mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 18:41:42 +08:00
bb2cc54140
Use Claude Code from Codex and Codex from other supported hosts, with shared invocation rendering, positive gate validation and per-phase provenance. Rename /claude to /claude-code, repair managed shared and copied installations safely, and generate native Kiro skills. Add installed-workflow, failure-injection and live cross-harness regression coverage.
265 lines
14 KiB
TypeScript
265 lines
14 KiB
TypeScript
/** One rename, shared by setup and the v1.82 upgrade migration. No model calls. */
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const OLD = 'gstack-claude';
|
|
const NEXT = 'gstack-claude-code';
|
|
const BANNER = '<!-- AUTO-GENERATED from';
|
|
const RUNTIME_FILES = ['bin/gstack-claude-code', 'lib/claude-code.ts', 'lib/claude-code-windows-job.ts', 'lib/claude-bin.ts', 'lib/outside-review-result.ts'];
|
|
|
|
export interface RenameOptions {
|
|
installDir: string;
|
|
home?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
copy?: boolean;
|
|
/** The source checkout can itself live inside a repo-local skills directory. */
|
|
skillsDir?: string;
|
|
log?: (line: string) => void;
|
|
/** Tests render fixtures here; production invokes only gen-skill-docs. */
|
|
render?: (host: string, output: string) => void;
|
|
}
|
|
|
|
function exists(file: string): boolean { return fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined; }
|
|
function generated(file: string): boolean {
|
|
try {
|
|
const header = fs.readFileSync(file, 'utf8').slice(0, 8192);
|
|
return header.includes(BANNER) && header.includes('<!-- Regenerate: bun run gen:skill-docs -->');
|
|
} catch { return false; }
|
|
}
|
|
function inside(file: string, root: string): boolean {
|
|
const rel = path.relative(root, file);
|
|
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
}
|
|
function linkIsOurs(file: string, root: string): boolean {
|
|
try {
|
|
const target = path.resolve(path.dirname(file), fs.readlinkSync(file));
|
|
// Resolve every existing component before accepting ownership: a path
|
|
// lexically inside the checkout can still escape through a directory link.
|
|
// Missing suffixes cover dangling links after standalone generation prunes.
|
|
let existing = target;
|
|
const missing: string[] = [];
|
|
while (!exists(existing)) {
|
|
missing.unshift(path.basename(existing));
|
|
const parent = path.dirname(existing);
|
|
if (parent === existing) return false;
|
|
existing = parent;
|
|
}
|
|
return inside(path.join(fs.realpathSync(existing), ...missing), root);
|
|
} catch { return false; }
|
|
}
|
|
function owned(entry: string, root: string): boolean {
|
|
const stat = fs.lstatSync(entry, { throwIfNoEntry: false });
|
|
if (!stat) return false;
|
|
if (stat.isSymbolicLink()) return linkIsOurs(entry, root);
|
|
if (!stat.isDirectory()) return false;
|
|
if (fs.lstatSync(path.join(entry, 'SKILL.md'), { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
return linkIsOurs(path.join(entry, 'SKILL.md'), root);
|
|
}
|
|
return generated(path.join(entry, 'SKILL.md')) || fs.existsSync(path.join(entry, '.gstack-owned'));
|
|
}
|
|
function atomicCopy(source: string, target: string): void {
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
const tmp = `${target}.rename-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
try {
|
|
fs.copyFileSync(source, tmp);
|
|
fs.chmodSync(tmp, fs.statSync(source).mode);
|
|
fs.renameSync(tmp, target);
|
|
} finally { fs.rmSync(tmp, { force: true }); }
|
|
}
|
|
function preserveCopy(file: string): void {
|
|
let backup = `${file}.before-claude-code`;
|
|
for (let n = 1; exists(backup); n++) backup = `${file}.before-claude-code.${n}`;
|
|
fs.renameSync(file, backup);
|
|
}
|
|
function copySkill(source: string, target: string, root: string, preserve = false): void {
|
|
fs.mkdirSync(target, { recursive: true });
|
|
const files = ['SKILL.md', 'agents/openai.yaml'];
|
|
if (fs.existsSync(path.join(source, 'sections'))) {
|
|
for (const name of fs.readdirSync(path.join(source, 'sections'))) {
|
|
if (fs.statSync(path.join(source, 'sections', name)).isFile()) files.push(`sections/${name}`);
|
|
}
|
|
}
|
|
for (const rel of files) {
|
|
const src = path.join(source, rel);
|
|
if (!fs.existsSync(src)) continue;
|
|
const dest = path.join(target, rel);
|
|
// Never traverse a user-owned metadata directory link.
|
|
const parent = path.dirname(dest);
|
|
if (fs.lstatSync(parent, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
if (!linkIsOurs(parent, root)) throw new Error(`foreign metadata directory: ${parent}`);
|
|
if (preserve) {
|
|
// A copied host can carry legacy section links into another host's
|
|
// render. Replace our link before writing, never mutate that source.
|
|
fs.unlinkSync(parent);
|
|
fs.mkdirSync(parent, { recursive: true });
|
|
}
|
|
}
|
|
if (preserve && fs.lstatSync(dest, { throwIfNoEntry: false })?.isFile() && !fs.readFileSync(src).equals(fs.readFileSync(dest))) {
|
|
preserveCopy(dest);
|
|
}
|
|
atomicCopy(src, dest);
|
|
}
|
|
}
|
|
function retire(entry: string, root: string, oldSources: string[]): void {
|
|
if (!owned(entry, root)) return;
|
|
if (fs.lstatSync(entry).isSymbolicLink()) { fs.unlinkSync(entry); return; }
|
|
// Weak ownership proves the skill file, never the user's adjacent notes/assets.
|
|
// A customized copy (or one whose old render is already gone) is saved beside
|
|
// the old skill, without a SKILL.md that could keep the retired command alive.
|
|
const file = path.join(entry, 'SKILL.md');
|
|
if (fs.lstatSync(file, { throwIfNoEntry: false })?.isFile() && !oldSources.some(source => {
|
|
try { return fs.readFileSync(source).equals(fs.readFileSync(file)); } catch { return false; }
|
|
})) {
|
|
preserveCopy(file);
|
|
} else {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
fs.rmSync(path.join(entry, '.gstack-owned'), { force: true });
|
|
for (const name of fs.readdirSync(entry)) {
|
|
const file = path.join(entry, name);
|
|
if (fs.lstatSync(file).isSymbolicLink() && linkIsOurs(file, root)) fs.unlinkSync(file);
|
|
}
|
|
try { fs.rmdirSync(entry); } catch { /* user files and metadata stay */ }
|
|
}
|
|
|
|
export function migrateClaudeCodeSkills(opts: RenameOptions): { migrated: number; pending: string[] } {
|
|
const root = fs.realpathSync(opts.installDir);
|
|
const env = { ...process.env, ...opts.env };
|
|
const home = opts.home ?? env.HOME ?? os.homedir();
|
|
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
const targets = [
|
|
{ host: 'codex', subdir: '.agents', dir: path.join(env.CODEX_HOME ?? path.join(home, '.codex'), 'skills') },
|
|
{ host: 'kiro', subdir: '.kiro', dir: path.join(home, '.kiro', 'skills') },
|
|
{ host: 'factory', subdir: '.factory', dir: path.join(home, '.factory', 'skills') },
|
|
{ host: 'opencode', subdir: '.opencode', dir: path.join(home, '.config', 'opencode', 'skills') },
|
|
{ host: 'cursor', subdir: '.cursor', dir: path.join(home, '.cursor', 'skills') },
|
|
];
|
|
if (opts.skillsDir) {
|
|
const local = targets.find(t => t.subdir === path.basename(path.dirname(opts.skillsDir!)));
|
|
if (local && !targets.some(t => path.resolve(t.dir) === path.resolve(opts.skillsDir!))) {
|
|
targets.push({ ...local, dir: path.resolve(opts.skillsDir) });
|
|
}
|
|
}
|
|
// Capture before generation: a build can otherwise erase a symlink's source.
|
|
const candidates = targets.filter(t => owned(path.join(t.dir, OLD), root));
|
|
const oldSources = targets.map(t => path.join(root, t.subdir, 'skills', OLD, 'SKILL.md'));
|
|
const result = { migrated: 0, pending: [] as string[] };
|
|
const render = opts.render ?? ((host: string, output: string) => {
|
|
const args = ['run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', output];
|
|
if (host === 'codex') {
|
|
let model = env.GSTACK_CODEX_GENERATION_MODEL;
|
|
if (!model) {
|
|
const probe = spawnSync(process.execPath, ['run', 'scripts/resolve-codex-generation-model.ts'], { cwd: root, env, encoding: 'utf8', timeout: 30_000 });
|
|
if (probe.status !== 0) throw new Error('could not resolve the Codex model profile');
|
|
model = probe.stdout.trim().split('\t')[0];
|
|
}
|
|
if (model) args.push('--model', model);
|
|
}
|
|
const rendered = spawnSync(process.execPath, args, { cwd: root, env, encoding: 'utf8', timeout: 120_000 });
|
|
if (rendered.status !== 0) throw new Error(`replacement generation failed for ${host}: ${rendered.stderr.trim().slice(-500)}`);
|
|
});
|
|
for (const target of candidates) {
|
|
const old = path.join(target.dir, OLD);
|
|
const next = path.join(target.dir, NEXT);
|
|
let temporary: string | undefined;
|
|
try {
|
|
if (exists(next) && !owned(next, root)) throw new Error(`replacement is a foreign skill: ${next}`);
|
|
const runtime = path.join(target.dir, 'gstack');
|
|
if (exists(runtime) && (
|
|
(fs.lstatSync(runtime).isSymbolicLink() && !linkIsOurs(runtime, root)) ||
|
|
(fs.existsSync(path.join(runtime, 'SKILL.md')) && !generated(path.join(runtime, 'SKILL.md')))
|
|
)) throw new Error(`runtime root is not gstack-managed: ${runtime}`);
|
|
for (const rel of RUNTIME_FILES) {
|
|
if (!fs.existsSync(path.join(root, rel))) throw new Error(`replacement runtime is missing ${rel}`);
|
|
const parent = path.join(runtime, path.dirname(rel));
|
|
if (fs.lstatSync(parent, { throwIfNoEntry: false })?.isSymbolicLink() && !linkIsOurs(parent, root)) {
|
|
throw new Error(`runtime asset directory is foreign: ${parent}`);
|
|
}
|
|
}
|
|
temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-claude-rename-'));
|
|
render(target.host, temporary);
|
|
const skill = path.join(temporary, target.subdir, 'skills', NEXT);
|
|
const content = fs.readFileSync(path.join(skill, 'SKILL.md'), 'utf8');
|
|
if (!generated(path.join(skill, 'SKILL.md')) || !/^name:\s*(?:gstack-)?claude-code\s*$/m.test(content)) {
|
|
throw new Error('replacement render has an unexpected skill name or no generated banner');
|
|
}
|
|
const canonical = path.join(root, target.subdir, 'skills', NEXT);
|
|
if (exists(canonical) && (fs.lstatSync(canonical).isSymbolicLink() || !owned(canonical, root))) {
|
|
throw new Error(`replacement render is not a managed directory: ${canonical}`);
|
|
}
|
|
copySkill(skill, canonical, root);
|
|
for (const rel of RUNTIME_FILES) {
|
|
const src = path.join(root, rel);
|
|
const dst = path.join(runtime, rel);
|
|
if (fs.existsSync(dst) && fs.realpathSync(dst) === fs.realpathSync(src)) continue;
|
|
atomicCopy(src, dst);
|
|
}
|
|
if (exists(next) && fs.lstatSync(next).isSymbolicLink()) fs.unlinkSync(next);
|
|
if (opts.copy || process.platform === 'win32' || exists(next)) {
|
|
copySkill(skill, next, root, true);
|
|
} else {
|
|
fs.mkdirSync(target.dir, { recursive: true });
|
|
fs.symlinkSync(canonical, next, 'dir');
|
|
}
|
|
if (!/^name:\s*(?:gstack-)?claude-code\s*$/m.test(fs.readFileSync(path.join(next, 'SKILL.md'), 'utf8'))) {
|
|
throw new Error('installed replacement could not be verified');
|
|
}
|
|
for (const rel of RUNTIME_FILES) fs.accessSync(path.join(runtime, rel), fs.constants.R_OK);
|
|
// Existing copied workflows must receive the same native host routing as
|
|
// the new wrapper, including when setup selected a different host. Only
|
|
// refresh installed managed entries; this never installs another host or
|
|
// a skill the user has removed. Links use the newly published render.
|
|
const renderedSkills = path.join(temporary, target.subdir, 'skills');
|
|
for (const name of fs.readdirSync(renderedSkills)) {
|
|
if (name === NEXT || name === OLD) continue;
|
|
const installed = path.join(target.dir, name);
|
|
if (!owned(installed, root) && !(name === 'gstack' && !fs.existsSync(path.join(installed, 'SKILL.md')))) continue;
|
|
const rendered = path.join(renderedSkills, name);
|
|
if (!generated(path.join(rendered, 'SKILL.md'))) continue;
|
|
// The root sidecar mixes runtime assets with SKILL.md; it is not a
|
|
// generated skill directory and must never be replaced as a whole.
|
|
if (name === 'gstack') {
|
|
// Legacy whole-repository runtime links share the tracked source
|
|
// SKILL.md. A host render must never replace that shared source file.
|
|
if (fs.realpathSync(installed) !== root) copySkill(rendered, installed, root, true);
|
|
continue;
|
|
}
|
|
const live = path.join(root, target.subdir, 'skills', name);
|
|
if (exists(live) && (fs.lstatSync(live).isSymbolicLink() || !owned(live, root))) {
|
|
throw new Error(`workflow render is not a managed directory: ${live}`);
|
|
}
|
|
copySkill(rendered, live, root);
|
|
if (!fs.lstatSync(installed).isSymbolicLink()) copySkill(rendered, installed, root, true);
|
|
}
|
|
if (fs.realpathSync(runtime) !== root) {
|
|
for (const [name, alias] of [['gstack-office-hours', 'office-hours'], ['gstack-upgrade', 'gstack-upgrade']]) {
|
|
const rendered = path.join(renderedSkills, name);
|
|
const installed = path.join(runtime, alias);
|
|
if (owned(installed, root) && generated(path.join(rendered, 'SKILL.md'))) copySkill(rendered, installed, root, true);
|
|
}
|
|
}
|
|
retire(old, root, oldSources);
|
|
result.migrated++;
|
|
} catch (error) {
|
|
result.pending.push(target.dir);
|
|
log(` kept ${old}: ${error instanceof Error ? error.message : String(error)}. Re-run ./setup to retry the rename.`);
|
|
} finally {
|
|
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
|
}
|
|
}
|
|
// A failed/colliding host can still depend on a shared old render. Keep all
|
|
// old renders until every known dependent installation has its replacement.
|
|
if (result.pending.length === 0 && candidates.length > 0) {
|
|
for (const subdir of new Set(targets.map(t => t.subdir))) {
|
|
const oldRender = path.join(root, subdir, 'skills', OLD);
|
|
if (fs.lstatSync(oldRender, { throwIfNoEntry: false })?.isDirectory() && generated(path.join(oldRender, 'SKILL.md'))) {
|
|
fs.rmSync(oldRender, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
if (result.migrated) log(` /claude is now /claude-code: migrated ${result.migrated} installed skill${result.migrated === 1 ? '' : 's'}. Consult sessions are preserved.`);
|
|
return result;
|
|
}
|