293 lines
9.0 KiB
TypeScript
293 lines
9.0 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface EnsureBundledCourseAgentsOptions {
|
|
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 resolveBundledCourseAgentsDir(input: BundledSuperpowersPathInput): string {
|
|
return input.isPackaged
|
|
? join(input.resourcesPath, 'course-agents')
|
|
: join(input.appPath, '.opencode', 'agent');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function ensureBundledCourseAgents(options: EnsureBundledCourseAgentsOptions): boolean {
|
|
const sourceDir = options.sourceDir?.trim();
|
|
if (!sourceDir || !existsSync(sourceDir)) {
|
|
return false;
|
|
}
|
|
|
|
const targetAgentDir = join(options.managedConfigDir, 'agent');
|
|
mkdirSync(targetAgentDir, { recursive: true });
|
|
copyDirectorySync(sourceDir, targetAgentDir);
|
|
|
|
return true;
|
|
}
|