preflight-codex-overlap.ts
233 lines · scripts/preflight-codex-overlap.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2import * as fs from 'node:fs';
3import * as path from 'node:path';
4import { discoverTemplates, includesSkill } from './discover-skills';
5import { externalSkillName, extractNameAndDescription } from './external-skill-names';
6import { getHostConfig } from '../hosts';
7import { CLAUDE_CODE_RUNTIME_FILES } from '../lib/claude-code-migration';
9const args = process.argv.slice(2);
10const value = (flag: string): string => {
11 const index = args.indexOf(flag);
12 if (index < 0 || !args[index + 1]) throw new Error(`missing ${flag}`);
13 return args[index + 1];
14};
15const exists = (file: string) => fs.lstatSync(file, { throwIfNoEntry: false });
16const inside = (file: string, root: string) => file === root || file.startsWith(`${root}${path.sep}`);
17const refuse = (operation: string, target: string) => {
18 throw new Error(`Refusing: Codex ${operation} overlaps source or escapes its namespace: ${target}`);
19};
20const physical = (file: string): string => {
21 let ancestor = path.resolve(file);
22 const suffix: string[] = [];
23 const visited = new Set<string>();
24 while (true) {
25 if (exists(ancestor)) {
26 try { return path.join(fs.realpathSync(ancestor), ...suffix); }
27 catch (error) {
28 if (!exists(ancestor)?.isSymbolicLink() || visited.has(ancestor)) throw error;
29 visited.add(ancestor);
30 ancestor = path.resolve(path.dirname(ancestor), fs.readlinkSync(ancestor));
31 continue;
32 }
33 }
34 const parent = path.dirname(ancestor);
35 if (parent === ancestor) throw new Error(`unresolvable path: ${file}`);
36 suffix.unshift(path.basename(ancestor));
37 ancestor = parent;
38 }
39};
40const source = fs.realpathSync(value('--source'));
41const generation = path.join(source, '.agents/skills');
42const namespace = value('--namespace');
43const selected = value('--selected') === '1';
44const windows = value('--windows') === '1';
45const local = value('--local') === '1';
46const runtime = path.join(namespace, 'gstack');
47const runtimeStat = exists(runtime);
48const migrating = selected && !local && !!runtimeStat && runtimeStat.isDirectory()
49 && !runtimeStat.isSymbolicLink() && physical(runtime) === source;
50const relocated = migrating ? value('--relocation') : source;
52if (migrating && (exists(relocated) || inside(physical(relocated), source))) refuse('checkout relocation', relocated);
53const generationRoot = physical(generation);
54if (!inside(generationRoot, source) || generationRoot === source) refuse('generation namespace', generation);
55const physicalNamespace = physical(namespace);
56if (selected && inside(physicalNamespace, source) && (!local || physicalNamespace !== generationRoot)) refuse('host namespace', namespace);
58const checkOutput = (file: string, root: string, operation: string) => {
59 if (!inside(physical(file), root)) refuse(operation, file);
60};
61const checkAtomicCopy = (file: string, root: string, operation: string, detachesParent = false) => {
62 const parent = path.dirname(file);
63 const writeParent = detachesParent && exists(parent)?.isSymbolicLink() ? path.dirname(parent) : parent;
64 const resolved = physical(writeParent);
65 if (!inside(resolved, root) || (inside(resolved, source) && !inside(resolved, generationRoot))) {
66 refuse(operation, file);
67 }
68};
69const checkPostRelocationAlias = (file: string) => {
70 if (!migrating) return;
71 let entry = file;
72 while (inside(entry, source) && entry !== source) {
73 if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
74 refuse(entry === path.join(source, '.agents') || entry === generation
75 ? 'post-relocation generation namespace' : 'post-relocation generated alias', entry);
76 }
77 entry = path.dirname(entry);
78 }
79};
80const checkReplace = (file: string, operation: string) => {
81 const stat = exists(file);
82 if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return;
83 if (inside(source, physical(file))) {
84 if ((migrating || local) && file === runtime && physical(file) === source) return;
85 refuse(operation, file);
86 }
87};
88const userOwnedRoot = (root: string): boolean => {
89 const stat = exists(root);
90 const skill = path.join(root, 'SKILL.md');
91 return !!stat && stat.isDirectory() && !stat.isSymbolicLink()
92 && !!fs.statSync(skill, { throwIfNoEntry: false })?.isFile()
93 && !fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from');
94};
95const linkIsOurs = (file: string): boolean => {
96 try { return inside(physical(file), source); }
97 catch { return false; }
98};
99const owned = (file: string): boolean => {
100 const stat = exists(file);
101 if (!stat) return false;
102 if (stat.isSymbolicLink()) return linkIsOurs(file);
103 if (!stat.isDirectory()) return false;
104 if (exists(path.join(file, '.gstack-owned'))) return true;
105 const skill = path.join(file, 'SKILL.md');
106 if (exists(skill)?.isSymbolicLink() && linkIsOurs(skill)) return true;
107 try { return fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from'); }
108 catch { return false; }
109};
111const config = getHostConfig('codex');
112const names = new Set<string>();
113const sourceNames = new Set<string>();
114for (const template of discoverTemplates(source)) {
115 const dir = path.dirname(template.tmpl);
116 const name = externalSkillName(dir, extractNameAndDescription(fs.readFileSync(path.join(source, template.tmpl), 'utf8')).name);
117 sourceNames.add(name);
118 if (!includesSkill(config, dir)) continue;
119 names.add(name);
120 const skill = path.join(generation, name, 'SKILL.md');
121 let loop = false;
122 try {
123 loop = fs.realpathSync(path.join(source, template.output)) === path.join(fs.realpathSync(path.dirname(skill)), 'SKILL.md');
124 } catch {}
125 if (loop) continue;
126 checkPostRelocationAlias(skill);
127 checkOutput(skill, generationRoot, 'generated skill write');
128 if (config.generation.generateMetadata) {
129 const metadata = path.join(generation, name, 'agents/openai.yaml');
130 checkPostRelocationAlias(metadata);
131 checkOutput(metadata, generationRoot, 'generated metadata write');
132 }
133}
135const generatedEntries = exists(generation)?.isDirectory() ? fs.readdirSync(generation) : [];
136for (const name of generatedEntries.filter(name => name.startsWith('gstack-') && !names.has(name))) {
137 const entry = path.join(generation, name);
138 if (exists(entry)?.isDirectory() && !exists(entry)?.isSymbolicLink()) checkReplace(entry, 'stale render pruning');
139}
141const hostEntries = exists(namespace)?.isDirectory() ? fs.readdirSync(namespace) : [];
142for (const name of hostEntries.filter(name => name.startsWith('gstack-') && !sourceNames.has(name))) {
143 const entry = path.join(namespace, name);
144 const stat = exists(entry);
145 if (!stat || stat.isSymbolicLink()) continue;
146 if (stat.isDirectory()) {
147 const skill = path.join(entry, 'SKILL.md');
148 if (!fs.existsSync(skill) || !fs.readFileSync(skill, 'utf8').includes('<!-- AUTO-GENERATED from')) continue;
149 if (!exists(skill)?.isSymbolicLink() && inside(physical(skill), source) && !inside(physical(skill), generationRoot)) {
150 refuse('stale host cleanup', skill);
151 }
152 } else if (inside(physical(entry), source) && !inside(physical(entry), generationRoot)) {
153 refuse('stale host cleanup', entry);
154 }
155}
157const old = path.join(namespace, 'gstack-claude');
158const nextSkill = path.join(namespace, 'gstack-claude-code');
159const runtimeSkill = path.join(runtime, 'SKILL.md');
160const runtimeBanner = !fs.existsSync(runtimeSkill) || fs.readFileSync(runtimeSkill, 'utf8').includes('<!-- AUTO-GENERATED from');
161const rename = owned(old) && (!exists(nextSkill) || owned(nextSkill))
162 && (!exists(runtime)?.isSymbolicLink() || linkIsOurs(runtime))
163 && runtimeBanner && CLAUDE_CODE_RUNTIME_FILES.every(rel => {
164 const parent = path.join(runtime, path.dirname(rel));
165 return exists(path.join(source, rel)) && (!exists(parent)?.isSymbolicLink() || linkIsOurs(parent));
166 });
167if (rename) {
168 const next = nextSkill;
169 checkReplace(runtime, 'rename runtime replacement');
170 checkReplace(next, 'rename replacement');
171 if (exists(next)?.isDirectory() && !exists(next)?.isSymbolicLink()) {
172 for (const rel of ['SKILL.md', 'agents/openai.yaml']) checkAtomicCopy(path.join(next, rel), physicalNamespace, 'rename skill write', true);
173 }
174 if (exists(runtime) && physical(runtime) !== source) {
175 for (const rel of CLAUDE_CODE_RUNTIME_FILES) {
176 const dest = path.join(runtime, rel);
177 if (exists(dest) && physical(dest) === physical(path.join(source, rel))) continue;
178 checkAtomicCopy(dest, physicalNamespace, 'rename runtime write');
179 }
180 }
181 for (const name of names) {
182 if (name === 'gstack' || name === 'gstack-claude' || name === 'gstack-claude-code') continue;
183 const installed = path.join(namespace, name);
184 if (owned(installed) && !exists(installed)?.isSymbolicLink()) {
185 checkAtomicCopy(path.join(installed, 'SKILL.md'), physicalNamespace, 'rename workflow write', true);
186 if (config.generation.generateMetadata) checkAtomicCopy(path.join(installed, 'agents/openai.yaml'), physicalNamespace, 'rename metadata write', true);
187 }
188 }
189}
191if (selected) {
192 checkReplace(runtime, 'runtime replacement');
193 if (!local && !migrating && userOwnedRoot(runtime)) {
194 throw new Error(`Refusing: global Codex runtime ${runtime} is a real user-owned skill with a handwritten SKILL.md. Move it aside or choose another CODEX_HOME; setup will not replace it.`);
195 }
196 if (windows) for (const name of names) {
197 if (name !== 'gstack' && !(local && name === 'gstack-claude')) checkReplace(path.join(namespace, name), 'skill copy replacement');
198 }
200 const sidecar = path.join(generation, 'gstack');
201 if (!userOwnedRoot(sidecar)) {
202 checkOutput(sidecar, generationRoot, 'sidecar root write');
203 for (const rel of ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md']) {
204 const src = path.join(source, rel), dest = path.join(sidecar, rel);
205 if (!exists(src) || (!windows && exists(dest) && !exists(dest)?.isSymbolicLink())) continue;
206 if (exists(dest)?.isSymbolicLink()) checkOutput(path.dirname(dest), generationRoot, 'sidecar asset write');
207 else {
208 checkReplace(dest, 'sidecar asset replacement');
209 checkOutput(dest, generationRoot, 'sidecar asset write');
210 }
211 }
212 const configFile = path.join(source, 'supabase/config.sh');
213 if (exists(configFile)) {
214 const dest = path.join(sidecar, 'supabase/config.sh');
215 if (exists(dest)?.isSymbolicLink()) checkOutput(path.dirname(dest), generationRoot, 'sidecar config write');
216 else checkOutput(dest, generationRoot, 'sidecar config write');
217 }
218 }
219}
221if (migrating) {
222 for (const entry of [path.join(source, '.agents'), generation]) {
223 if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
224 refuse('post-relocation generation namespace', entry);
225 }
226 }
227 for (const name of generatedEntries.filter(name => name.startsWith('gstack'))) {
228 const entry = path.join(generation, name);
229 if (exists(entry)?.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(entry)) && inside(physical(entry), source)) {
230 refuse('post-relocation generated alias', entry);
231 }
232 }
233}