import { randomUUID } from 'node:crypto'; import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { retryTransientFilesystemOperation } from './filesystem-retry'; export interface BundledSuperpowersPathInput { isPackaged: boolean; resourcesPath: string; appPath: string; } export interface EnsureBundledSuperpowersOptions { managedConfigDir: string; sourceDir?: string; } export interface EnsureBundledCourseSkillsOptions { managedConfigDir: string; sourceDir?: string; } const WRAPPER_PLUGIN_NAME = 'superpowers-niancode.js'; const SUPERPOWERS_REPO_DIR = 'superpowers'; const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles'; const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json'; export const BUNDLED_COURSE_SKILL_IDS = [ 'deploy-publish-check', 'designer-design-spec', 'dev-build-test', 'game-assets', 'marketing-launch-story', 'nianxxgame-skill', 'partner-agent-showcase', 'pm-project-plan', 'product-demo-prototype', 'ui-ux-course-quality', 'youth-plain-language', 'youth-ai-product-course', ] as const; const RETIRED_COURSE_SKILL_IDS = ['course-stage-review', 'student-growth-logger'] as const; export function resolveBundledSuperpowersDir(input: BundledSuperpowersPathInput): string { const resourcesDir = input.isPackaged ? join(input.resourcesPath, 'resources') : join(input.appPath, 'resources'); return join(resourcesDir, 'skills', SUPERPOWERS_REPO_DIR); } export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput): string { return input.isPackaged ? join(input.resourcesPath, 'course-skills') : join(input.appPath, '.opencode', 'skills'); } export function getManagedOpencodeConfigDir(userDataDir: string): string { return join(userDataDir, 'opencode', 'niancode-config'); } function readBundledSuperpowersVersion(sourceDir: string): string { const packagePath = join(sourceDir, 'package.json'); const packageJson = JSON.parse(retryTransientFilesystemOperation( () => readFileSync(packagePath, 'utf8'), )) as { version?: unknown }; if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) { throw new Error(`Bundled Superpowers package has no valid version: ${packagePath}`); } return packageJson.version.trim(); } function toSafeBundleName(version: string): string { const safeVersion = version.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); if (!safeVersion) { throw new Error(`Bundled Superpowers version cannot form a safe directory name: ${version}`); } return safeVersion; } function isCompleteBundle(bundleDir: string, expectedVersion: string): boolean { try { if (!existsSync(join(bundleDir, '.opencode', 'plugins', 'superpowers.js')) || !existsSync(join(bundleDir, 'skills', 'using-superpowers', 'SKILL.md'))) { return false; } return readBundledSuperpowersVersion(bundleDir) === expectedVersion; } catch { return false; } } function resolveManifestBundle( managedConfigDir: string, bundlesDir: string, expectedVersion: string, ): string | null { try { const manifest = JSON.parse(retryTransientFilesystemOperation( () => readFileSync(join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST), 'utf8'), )) as { version?: unknown; directory?: unknown }; if (manifest.version !== expectedVersion || typeof manifest.directory !== 'string') { return null; } const bundlesRoot = resolve(bundlesDir); const bundleDir = resolve(bundlesDir, manifest.directory); const relativeBundlePath = relative(bundlesRoot, bundleDir); if (!relativeBundlePath || relativeBundlePath === '..' || relativeBundlePath.startsWith(`..${sep}`) || isAbsolute(relativeBundlePath)) { return null; } return isCompleteBundle(bundleDir, expectedVersion) ? bundleDir : null; } catch { return null; } } function copyDirectorySync(sourceDir: string, targetDir: string): void { cpSync(sourceDir, targetDir, { recursive: true, force: true, filter: (sourcePath, targetPath) => { // Node 24.14's Windows override path can corrupt non-ASCII destinations // when replacing a file. Public unlink keeps force-overwrite semantics. if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) { unlinkSync(targetPath); } return true; }, }); } function installImmutableBundle( sourceDir: string, bundlesDir: string, safeBundleName: string, expectedVersion: string, ): string { retryTransientFilesystemOperation(() => mkdirSync(bundlesDir, { recursive: true })); const stagingDir = join( bundlesDir, `.staging-${safeBundleName}-${process.pid}-${randomUUID()}`, ); try { retryTransientFilesystemOperation(() => copyDirectorySync(sourceDir, stagingDir)); if (!isCompleteBundle(stagingDir, expectedVersion)) { throw new Error(`Bundled Superpowers staging copy is incomplete: ${stagingDir}`); } const preferredDir = join(bundlesDir, safeBundleName); if (isCompleteBundle(preferredDir, expectedVersion)) { return preferredDir; } const selectedDir = existsSync(preferredDir) ? join(bundlesDir, `${safeBundleName}-${randomUUID()}`) : preferredDir; try { retryTransientFilesystemOperation(() => renameSync(stagingDir, selectedDir)); } catch (error) { if (!isCompleteBundle(selectedDir, expectedVersion)) { throw error; } } return selectedDir; } finally { if (existsSync(stagingDir)) { try { retryTransientFilesystemOperation(() => rmSync(stagingDir, { recursive: true, force: true, })); } catch { // Startup cleanup is best-effort; preserve the installation result or error. } } } } function writeSelectedBundleFiles( managedConfigDir: string, bundlesDir: string, bundleDir: string, version: string, ): void { const targetPluginsDir = join(managedConfigDir, 'plugins'); retryTransientFilesystemOperation(() => mkdirSync(targetPluginsDir, { recursive: true })); const pluginPath = join(bundleDir, '.opencode', 'plugins', 'superpowers.js'); let pluginImport = relative(targetPluginsDir, pluginPath).split(sep).join('/'); if (!pluginImport.startsWith('.')) pluginImport = `./${pluginImport}`; retryTransientFilesystemOperation(() => writeFileSync( join(targetPluginsDir, WRAPPER_PLUGIN_NAME), `export { SuperpowersPlugin } from '${pluginImport}';\n`, 'utf8', )); retryTransientFilesystemOperation(() => writeFileSync( join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST), `${JSON.stringify({ version, directory: relative(bundlesDir, bundleDir), }, null, 2)}\n`, 'utf8', )); } export function ensureBundledSuperpowersPlugin(options: EnsureBundledSuperpowersOptions): boolean { const sourceDir = options.sourceDir?.trim(); if (!sourceDir) { return false; } const sourcePluginPath = join(sourceDir, '.opencode', 'plugins', 'superpowers.js'); const sourceSkillsDir = join(sourceDir, 'skills'); if (!existsSync(sourcePluginPath) || !existsSync(sourceSkillsDir)) { return false; } const version = readBundledSuperpowersVersion(sourceDir); const safeBundleName = toSafeBundleName(version); const bundlesDir = join(options.managedConfigDir, SUPERPOWERS_BUNDLES_DIR); const preferredDir = join(bundlesDir, safeBundleName); const bundleDir = resolveManifestBundle( options.managedConfigDir, bundlesDir, version, ) ?? (isCompleteBundle(preferredDir, version) ? preferredDir : installImmutableBundle(sourceDir, bundlesDir, safeBundleName, version)); writeSelectedBundleFiles( options.managedConfigDir, bundlesDir, bundleDir, version, ); return true; } export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean { const sourceDir = options.sourceDir?.trim(); if (!sourceDir || !existsSync(sourceDir)) { return false; } const targetSkillsDir = join(options.managedConfigDir, 'skills'); mkdirSync(targetSkillsDir, { recursive: true }); for (const skillId of RETIRED_COURSE_SKILL_IDS) { rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true }); } copyDirectorySync(sourceDir, targetSkillsDir); return true; }