合并远程主分支客户端收口
This commit is contained in:
@@ -90,6 +90,12 @@ export interface OpencodeSkillInfo {
|
||||
description?: string;
|
||||
location: string;
|
||||
content: string;
|
||||
entries?: OpencodeSkillEntry[];
|
||||
}
|
||||
|
||||
export interface OpencodeSkillEntry {
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
export interface RevertOpencodeSessionMessageInput {
|
||||
|
||||
115
electron/opencode/course-skills.ts
Normal file
115
electron/opencode/course-skills.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface BundledSkillsPathInput {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
}
|
||||
|
||||
export interface EnsureBundledCourseSkillsOptions {
|
||||
managedConfigDir: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'agent-browser',
|
||||
'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',
|
||||
'deploy-publish-check',
|
||||
'student-growth-logger',
|
||||
] as const;
|
||||
const LEGACY_SUPERPOWERS_ARTIFACTS = [
|
||||
'plugins/superpowers-niancode.js',
|
||||
'plugins/superpowers.js',
|
||||
'superpowers-active.json',
|
||||
'superpowers-bundles',
|
||||
] as const;
|
||||
|
||||
export function resolveBundledCourseSkillsDir(input: BundledSkillsPathInput): string {
|
||||
return input.isPackaged
|
||||
? join(input.resourcesPath, 'course-skills')
|
||||
: join(input.appPath, '.opencode', 'skills');
|
||||
}
|
||||
|
||||
export function resolveBundledAgentBrowserPluginPath(input: BundledSkillsPathInput): string {
|
||||
return join(
|
||||
resolveBundledCourseSkillsDir(input),
|
||||
'agent-browser',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'niancode-agent-browser.js',
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedOpencodeConfigDir(userDataDir: string): string {
|
||||
return join(userDataDir, 'opencode', 'niancode-config');
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function removeLegacySuperpowersArtifacts(managedConfigDir: string): void {
|
||||
for (const artifact of LEGACY_SUPERPOWERS_ARTIFACTS) {
|
||||
rmSync(join(managedConfigDir, artifact), { recursive: true, force: 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 ensureBundledAgentBrowserPlugin(options: {
|
||||
managedConfigDir: string;
|
||||
sourcePath?: string;
|
||||
}): boolean {
|
||||
const sourcePath = options.sourcePath?.trim();
|
||||
if (!sourcePath || !existsSync(sourcePath)) return false;
|
||||
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
|
||||
mkdirSync(targetPluginsDir, { recursive: true });
|
||||
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
|
||||
return true;
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
import {
|
||||
ensureBundledAgentBrowserPlugin,
|
||||
ensureBundledCourseSkills,
|
||||
ensureBundledSuperpowersPlugin,
|
||||
getManagedOpencodeConfigDir,
|
||||
} from './superpowers';
|
||||
removeLegacySuperpowersArtifacts,
|
||||
} from './course-skills';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
prependManagedRuntimesToPath,
|
||||
@@ -164,7 +164,6 @@ export interface OpencodeManagerOptions {
|
||||
port: number;
|
||||
binPath: string;
|
||||
userDataDir?: string;
|
||||
bundledSuperpowersDir?: string;
|
||||
bundledCourseSkillsDir?: string;
|
||||
bundledAgentBrowserPluginPath?: string;
|
||||
pythonRuntime?: PythonRuntime;
|
||||
@@ -1049,10 +1048,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
mkdirSync(dataHome, { recursive: true });
|
||||
mkdirSync(cacheHome, { recursive: true });
|
||||
mkdirSync(managedConfigDir, { recursive: true });
|
||||
ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledSuperpowersDir,
|
||||
});
|
||||
removeLegacySuperpowersArtifacts(managedConfigDir);
|
||||
ensureBundledCourseSkills({
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledCourseSkillsDir,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export interface RuntimePathInput {
|
||||
isPackaged: boolean;
|
||||
@@ -47,11 +48,31 @@ function getNativeOpencodeBinName(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'opencode.exe' : 'opencode';
|
||||
}
|
||||
|
||||
function resolveDevelopmentNativeBinPath(input: RuntimePathInput): string | undefined {
|
||||
function resolveNativePackageRoot(runtimeDir: string, packageName: string): string | undefined {
|
||||
try {
|
||||
const resolvedRuntimeDir = realpathSync(runtimeDir);
|
||||
const runtimeRequire = createRequire(join(resolvedRuntimeDir, 'package.json'));
|
||||
return dirname(runtimeRequire.resolve(`${packageName}/package.json`));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDevelopmentNativeBinPath(
|
||||
input: RuntimePathInput,
|
||||
runtimeDir: string,
|
||||
): string | undefined {
|
||||
const arch = input.arch ?? process.arch;
|
||||
for (const packageName of getDevelopmentNativePackageCandidates(input.platform, arch)) {
|
||||
const binPath = join(input.appPath, 'node_modules', packageName, 'bin', getNativeOpencodeBinName(input.platform));
|
||||
if (existsSync(binPath)) return binPath;
|
||||
const binaryName = getNativeOpencodeBinName(input.platform);
|
||||
const hoistedBinPath = join(input.appPath, 'node_modules', packageName, 'bin', binaryName);
|
||||
if (existsSync(hoistedBinPath)) return hoistedBinPath;
|
||||
|
||||
const nativePackageRoot = resolveNativePackageRoot(runtimeDir, packageName);
|
||||
if (!nativePackageRoot) continue;
|
||||
|
||||
const virtualStoreBinPath = join(nativePackageRoot, 'bin', binaryName);
|
||||
if (existsSync(virtualStoreBinPath)) return virtualStoreBinPath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -73,7 +94,7 @@ export function resolveOpencodeRuntimePaths(input: RuntimePathInput): OpencodeRu
|
||||
: join(input.appPath, 'node_modules', OPENCODE_RUNTIME_PACKAGE);
|
||||
const developmentNativeBinPath = input.isPackaged
|
||||
? undefined
|
||||
: resolveDevelopmentNativeBinPath(input);
|
||||
: resolveDevelopmentNativeBinPath(input, runtimeDir);
|
||||
|
||||
return {
|
||||
runtimeDir,
|
||||
|
||||
@@ -31,6 +31,21 @@ function normalizeProjectType(value: unknown): ProjectType {
|
||||
throw new Error('Invalid project type');
|
||||
}
|
||||
|
||||
function needsLegacyAgentModelMigration(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const raw = value as Partial<ProjectConfig>;
|
||||
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null;
|
||||
if (!defaultModel || !Array.isArray(raw.agents)) return false;
|
||||
return raw.agents.some((agent) => (
|
||||
Boolean(agent)
|
||||
&& typeof agent === 'object'
|
||||
&& !(typeof (agent as { model?: unknown }).model === 'string'
|
||||
&& (agent as { model?: string }).model?.trim())
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Partial<ProjectAgentConfig>;
|
||||
@@ -80,9 +95,16 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
}
|
||||
const raw = value as Partial<ProjectConfig>;
|
||||
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
|
||||
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null;
|
||||
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
|
||||
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
|
||||
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
|
||||
const normalizedAgents = agents
|
||||
.filter((item): item is ProjectAgentConfig => Boolean(item))
|
||||
.map((agent) => agent.model || !defaultModel
|
||||
? agent
|
||||
: { ...agent, model: defaultModel });
|
||||
if (new Set(normalizedAgents.map((item) => item.id)).size !== normalizedAgents.length) {
|
||||
throw new Error('Duplicate project Agent id');
|
||||
}
|
||||
@@ -97,10 +119,9 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
schemaVersion: 1,
|
||||
projectType: normalizeProjectType(raw.projectType),
|
||||
initialized,
|
||||
superpowersEnabled: raw.superpowersEnabled === true,
|
||||
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null,
|
||||
// Keep this legacy field readable for compatibility, but runtime model
|
||||
// selection is owned by each project Agent.
|
||||
defaultModel,
|
||||
agents: normalizedAgents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
createdAt,
|
||||
@@ -111,7 +132,12 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
|
||||
return { status: 'valid', config: normalizeProjectConfig(raw) };
|
||||
const config = normalizeProjectConfig(raw);
|
||||
if (needsLegacyAgentModelMigration(raw)) {
|
||||
if (config.initialized) await materializeAgents(projectPath, config);
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
return { status: 'valid', config };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -165,7 +191,7 @@ function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): s
|
||||
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
|
||||
: ' "*": deny';
|
||||
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
|
||||
const model = agent.model ?? config.defaultModel;
|
||||
const model = agent.model;
|
||||
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
|
||||
return `---
|
||||
description: ${yamlString(agent.name)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { OpencodeSkillInfo } from './client';
|
||||
import { basename, dirname, join, relative, sep } from 'node:path';
|
||||
import type { OpencodeSkillEntry, OpencodeSkillInfo } from './client';
|
||||
|
||||
const SKILL_ROOT_NAMES = ['skill', 'skills'] as const;
|
||||
|
||||
@@ -29,6 +29,36 @@ async function collectSkillFiles(root: string): Promise<string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function collectSkillEntries(root: string, current = root): Promise<OpencodeSkillEntry[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
entries.sort((left, right) => {
|
||||
const directoryOrder = Number(right.isDirectory()) - Number(left.isDirectory());
|
||||
return directoryOrder || left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
const result: OpencodeSkillEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(current, entry.name);
|
||||
const relativePath = relative(root, entryPath).split(sep).join('/');
|
||||
if (entry.isDirectory()) {
|
||||
result.push({ path: relativePath, type: 'directory' });
|
||||
result.push(...await collectSkillEntries(root, entryPath));
|
||||
} else if (entry.isFile()) {
|
||||
result.push({ path: relativePath, type: 'file' });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unquoteYamlScalar(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
@@ -73,6 +103,7 @@ export async function listInstalledOpencodeSkills(managedConfigDir?: string | nu
|
||||
description: readFrontmatterScalar(content, 'description'),
|
||||
location,
|
||||
content,
|
||||
entries: await collectSkillEntries(dirname(location)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
92
electron/opencode/startup-warmup.ts
Normal file
92
electron/opencode/startup-warmup.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OpencodeStatus } from './manager';
|
||||
|
||||
type ProjectConfigReadResult = {
|
||||
status: 'valid' | 'missing' | 'invalid';
|
||||
config?: { initialized?: boolean };
|
||||
};
|
||||
|
||||
export type OpencodeStartupWarmupSkipReason =
|
||||
| 'no-session'
|
||||
| 'runtime-active'
|
||||
| 'no-active-project'
|
||||
| 'project-not-ready'
|
||||
| 'no-provider'
|
||||
| 'eligibility-check-failed'
|
||||
| 'start-failed';
|
||||
|
||||
export type OpencodeStartupWarmupResult =
|
||||
| { started: true; status: OpencodeStatus }
|
||||
| { started: false; reason: OpencodeStartupWarmupSkipReason; error?: unknown };
|
||||
|
||||
export interface OpencodeStartupWarmupDependencies {
|
||||
hasAuthenticatedSession: () => boolean;
|
||||
getStatus: () => OpencodeStatus;
|
||||
getActiveProject: () => Promise<{ path: string } | null>;
|
||||
readProjectConfig: (projectPath: string) => Promise<ProjectConfigReadResult>;
|
||||
getConfiguredProviderCount: () => Promise<number>;
|
||||
start: () => Promise<OpencodeStatus>;
|
||||
onError?: (error: unknown, phase: 'eligibility' | 'start') => void;
|
||||
}
|
||||
|
||||
function reportError(
|
||||
dependencies: OpencodeStartupWarmupDependencies,
|
||||
error: unknown,
|
||||
phase: 'eligibility' | 'start',
|
||||
): void {
|
||||
try {
|
||||
dependencies.onError?.(error, phase);
|
||||
} catch {
|
||||
// Logging must never turn a background warmup into an unhandled rejection.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the local runtime in the background when the persisted app state is
|
||||
* ready for a Code session. This deliberately does not throw: startup
|
||||
* warmup is an optimization and the normal Chat-page lazy start remains the
|
||||
* recovery path when it is unavailable.
|
||||
*/
|
||||
export async function warmupOpencodeRuntime(
|
||||
dependencies: OpencodeStartupWarmupDependencies,
|
||||
): Promise<OpencodeStartupWarmupResult> {
|
||||
if (!dependencies.hasAuthenticatedSession()) {
|
||||
return { started: false, reason: 'no-session' };
|
||||
}
|
||||
|
||||
const status = dependencies.getStatus();
|
||||
if (status.state !== 'stopped') {
|
||||
return { started: false, reason: 'runtime-active' };
|
||||
}
|
||||
|
||||
let project: { path: string } | null;
|
||||
let projectConfig: ProjectConfigReadResult;
|
||||
let providerCount: number;
|
||||
try {
|
||||
project = await dependencies.getActiveProject();
|
||||
if (!project) {
|
||||
return { started: false, reason: 'no-active-project' };
|
||||
}
|
||||
|
||||
projectConfig = await dependencies.readProjectConfig(project.path);
|
||||
if (projectConfig.status !== 'valid' || projectConfig.config?.initialized !== true) {
|
||||
return { started: false, reason: 'project-not-ready' };
|
||||
}
|
||||
|
||||
providerCount = await dependencies.getConfiguredProviderCount();
|
||||
} catch (error) {
|
||||
reportError(dependencies, error, 'eligibility');
|
||||
return { started: false, reason: 'eligibility-check-failed', error };
|
||||
}
|
||||
|
||||
if (!Number.isFinite(providerCount) || providerCount <= 0) {
|
||||
return { started: false, reason: 'no-provider' };
|
||||
}
|
||||
|
||||
try {
|
||||
const startedStatus = await dependencies.start();
|
||||
return { started: true, status: startedStatus };
|
||||
} catch (error) {
|
||||
reportError(dependencies, error, 'start');
|
||||
return { started: false, reason: 'start-failed', error };
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
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 = [
|
||||
'agent-browser',
|
||||
'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',
|
||||
'deploy-publish-check',
|
||||
'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 resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string {
|
||||
return join(
|
||||
resolveBundledCourseSkillsDir(input),
|
||||
'agent-browser',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'niancode-agent-browser.js',
|
||||
);
|
||||
}
|
||||
|
||||
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 ensureBundledAgentBrowserPlugin(options: {
|
||||
managedConfigDir: string;
|
||||
sourcePath?: string;
|
||||
}): boolean {
|
||||
const sourcePath = options.sourcePath?.trim();
|
||||
if (!sourcePath || !existsSync(sourcePath)) return false;
|
||||
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
|
||||
mkdirSync(targetPluginsDir, { recursive: true });
|
||||
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user