@flowrail/init 0.0.17 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ "use strict";
2
+ /**
3
+ * Does Codex LOAD this `hooks.json`? (ADR-050 D8; adversarial-review BLOCKER.)
4
+ *
5
+ * Codex deserializes the file with serde into `HooksFile` and, on ANY error,
6
+ * drops the WHOLE file with only a warning (codex-rs/hooks/src/engine/
7
+ * discovery.rs `load_hooks_json`). So an edit that keeps FlowRail's handlers
8
+ * byte-identical but adds one malformed sibling disables FlowRail just as
9
+ * surely as deleting it. This mirrors the serde contract of openai/codex
10
+ * rust-v0.156.0 `codex-rs/config/src/hook_config.rs`:
11
+ *
12
+ * HooksFile deny_unknown_fields { description?: string|null, hooks?: HookEventsToml }
13
+ * HookEventsToml known events → Vec<MatcherGroup> (unknown keys ignored; null is an error)
14
+ * MatcherGroup { matcher?: string|null, hooks?: Vec<HookHandlerConfig> } (unknown keys ignored)
15
+ * HookHandlerConfig internally tagged on "type":
16
+ * command { command: string, commandWindows|command_windows?: string|null,
17
+ * timeout?: u64|null, async?: bool, statusMessage?: string|null,
18
+ * additionalContextLimit?: usize|null }
19
+ * mcp_tool { server: string, tool: string, input?: object (TOML-representable),
20
+ * timeout?: u64|null, statusMessage?: string|null }
21
+ * prompt | agent {}
22
+ *
23
+ * JSON.parse is not enough. serde_json rejects, where JSON.parse accepts:
24
+ * - a duplicate KNOWN struct field (unknown ones are ignored);
25
+ * - a float (`5.0`) where an integer is required;
26
+ * - a lone UTF-16 surrogate escape (`"\ud800"`) in any string it decodes —
27
+ * every object key it identifies, every string value it deserializes,
28
+ * and everything inside a handler (buffered whole for the `type` tag) —
29
+ * but NOT inside a value it ignores (an unknown event or group field);
30
+ * - nesting deeper than its recursion limit (128) where it deserializes.
31
+ * Hence a small raw-token parser.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.CODEX_HOOK_EVENTS = void 0;
35
+ exports.parseHooksJsonExact = parseHooksJsonExact;
36
+ exports.codexWouldLoadHooksJson = codexWouldLoadHooksJson;
37
+ /** serde_json's default recursion limit. */
38
+ const SERDE_MAX_DEPTH = 128;
39
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
40
+ class RawJsonError extends Error {
41
+ }
42
+ function parseRaw(text) {
43
+ let i = 0;
44
+ let depth = 0;
45
+ const ws = () => {
46
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r'))
47
+ i++;
48
+ };
49
+ const value = () => {
50
+ ws();
51
+ const c = text[i];
52
+ if (c === '{') {
53
+ i++;
54
+ depth++;
55
+ const at = depth;
56
+ const entries = [];
57
+ let loneKey = false;
58
+ ws();
59
+ if (text[i] === '}') {
60
+ i++;
61
+ depth--;
62
+ return { t: 'object', entries, loneKey, depth: at };
63
+ }
64
+ for (;;) {
65
+ ws();
66
+ if (text[i] !== '"')
67
+ throw new RawJsonError('expected key');
68
+ const key = str();
69
+ if (key.lone)
70
+ loneKey = true;
71
+ ws();
72
+ if (text[i++] !== ':')
73
+ throw new RawJsonError('expected :');
74
+ entries.push([key.v, value()]);
75
+ ws();
76
+ if (text[i] === ',') {
77
+ i++;
78
+ continue;
79
+ }
80
+ if (text[i] === '}') {
81
+ i++;
82
+ depth--;
83
+ return { t: 'object', entries, loneKey, depth: at };
84
+ }
85
+ throw new RawJsonError('expected , or }');
86
+ }
87
+ }
88
+ if (c === '[') {
89
+ i++;
90
+ depth++;
91
+ const at = depth;
92
+ const items = [];
93
+ ws();
94
+ if (text[i] === ']') {
95
+ i++;
96
+ depth--;
97
+ return { t: 'array', items, depth: at };
98
+ }
99
+ for (;;) {
100
+ items.push(value());
101
+ ws();
102
+ if (text[i] === ',') {
103
+ i++;
104
+ continue;
105
+ }
106
+ if (text[i] === ']') {
107
+ i++;
108
+ depth--;
109
+ return { t: 'array', items, depth: at };
110
+ }
111
+ throw new RawJsonError('expected , or ]');
112
+ }
113
+ }
114
+ if (c === '"') {
115
+ const sv = str();
116
+ return { t: 'string', v: sv.v, lone: sv.lone };
117
+ }
118
+ if (text.startsWith('true', i)) {
119
+ i += 4;
120
+ return { t: 'bool', v: true };
121
+ }
122
+ if (text.startsWith('false', i)) {
123
+ i += 5;
124
+ return { t: 'bool', v: false };
125
+ }
126
+ if (text.startsWith('null', i)) {
127
+ i += 4;
128
+ return { t: 'null' };
129
+ }
130
+ const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
131
+ if (!m)
132
+ throw new RawJsonError('unexpected token');
133
+ i += m[0].length;
134
+ return { t: 'number', raw: m[0] };
135
+ };
136
+ const str = () => {
137
+ // Delegate escape handling to JSON.parse on the exact literal; it keeps
138
+ // a lone surrogate escape, which serde_json refuses to decode.
139
+ const start = i;
140
+ i++;
141
+ while (i < text.length && text[i] !== '"')
142
+ i += text[i] === '\\' ? 2 : 1;
143
+ if (i >= text.length)
144
+ throw new RawJsonError('unterminated string');
145
+ i++;
146
+ const v = JSON.parse(text.slice(start, i));
147
+ return { v, lone: LONE_SURROGATE.test(v) };
148
+ };
149
+ const v = value();
150
+ ws();
151
+ if (i !== text.length)
152
+ throw new RawJsonError('trailing data');
153
+ return v;
154
+ }
155
+ /** Would serde_json decode every string and container in ``v`` (a position
156
+ * it deserializes rather than ignores)? */
157
+ function decodable(v) {
158
+ switch (v.t) {
159
+ case 'string':
160
+ return !v.lone;
161
+ case 'array':
162
+ return v.depth < SERDE_MAX_DEPTH && v.items.every(decodable);
163
+ case 'object':
164
+ return v.depth < SERDE_MAX_DEPTH && !v.loneKey && v.entries.every(([, x]) => decodable(x));
165
+ default:
166
+ return true;
167
+ }
168
+ }
169
+ /** The hook events Codex 0.156 deserializes; any other key is ignored. */
170
+ exports.CODEX_HOOK_EVENTS = new Set([
171
+ 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'SessionStart',
172
+ 'SessionEnd', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt',
173
+ ]);
174
+ function fieldMap(obj, strictDuplicates) {
175
+ const out = new Map();
176
+ for (const [k, v] of obj.entries) {
177
+ if (out.has(k) && (strictDuplicates === 'all' || strictDuplicates.has(k))) {
178
+ throw new RawJsonError(`duplicate field ${k}`);
179
+ }
180
+ if (!out.has(k))
181
+ out.set(k, v);
182
+ }
183
+ return out;
184
+ }
185
+ const U64_MAX = BigInt('18446744073709551615');
186
+ const isUnsignedInt = (v) => v.t === 'number' && /^(0|[1-9]\d*)$/.test(v.raw) && BigInt(v.raw) <= U64_MAX;
187
+ const optString = (v) => v === undefined || v.t === 'null' || v.t === 'string';
188
+ const optUint = (v) => v === undefined || v.t === 'null' || isUnsignedInt(v);
189
+ function tomlRepresentable(v) {
190
+ if (v.t === 'null')
191
+ return false;
192
+ if (v.t === 'object')
193
+ return v.entries.every(([, x]) => tomlRepresentable(x));
194
+ if (v.t === 'array')
195
+ return v.items.every(tomlRepresentable);
196
+ return true;
197
+ }
198
+ const HANDLER_FIELDS = {
199
+ command: new Set(['command', 'commandWindows', 'command_windows', 'timeout', 'async', 'statusMessage', 'additionalContextLimit']),
200
+ mcp_tool: new Set(['server', 'tool', 'input', 'timeout', 'statusMessage']),
201
+ prompt: new Set(),
202
+ agent: new Set(),
203
+ };
204
+ function validateHandler(h) {
205
+ if (h.t !== 'object')
206
+ throw new RawJsonError('handler must be an object');
207
+ // Internally tagged: serde buffers the whole handler, decoding every string.
208
+ if (!decodable(h))
209
+ throw new RawJsonError('handler not decodable');
210
+ const tags = h.entries.filter(([k]) => k === 'type');
211
+ if (tags.length !== 1)
212
+ throw new RawJsonError(tags.length === 0 ? 'handler missing type' : 'duplicate field type');
213
+ const tag = tags[0][1];
214
+ if (tag.t !== 'string')
215
+ throw new RawJsonError('handler type must be a string');
216
+ const known = HANDLER_FIELDS[tag.v];
217
+ if (known === undefined)
218
+ throw new RawJsonError(`unknown handler type ${tag.v}`);
219
+ // Only KNOWN fields are rejected when repeated; unknown ones are ignored.
220
+ const f = fieldMap(h, known);
221
+ switch (tag.v) {
222
+ case 'command': {
223
+ const command = f.get('command');
224
+ if (!command || command.t !== 'string')
225
+ throw new RawJsonError('command must be a string');
226
+ if (f.has('commandWindows') && f.has('command_windows'))
227
+ throw new RawJsonError('duplicate field commandWindows');
228
+ if (!optString(f.get('commandWindows') ?? f.get('command_windows')))
229
+ throw new RawJsonError('commandWindows');
230
+ if (!optUint(f.get('timeout')))
231
+ throw new RawJsonError('timeout must be an unsigned integer');
232
+ const async = f.get('async');
233
+ if (async !== undefined && async.t !== 'bool')
234
+ throw new RawJsonError('async must be a boolean');
235
+ if (!optString(f.get('statusMessage')))
236
+ throw new RawJsonError('statusMessage');
237
+ if (!optUint(f.get('additionalContextLimit')))
238
+ throw new RawJsonError('additionalContextLimit');
239
+ return;
240
+ }
241
+ case 'mcp_tool': {
242
+ for (const k of ['server', 'tool']) {
243
+ const v = f.get(k);
244
+ if (!v || v.t !== 'string')
245
+ throw new RawJsonError(`${k} must be a string`);
246
+ }
247
+ const input = f.get('input');
248
+ if (input !== undefined && (input.t !== 'object' || !tomlRepresentable(input)))
249
+ throw new RawJsonError('input');
250
+ if (!optUint(f.get('timeout')))
251
+ throw new RawJsonError('timeout');
252
+ if (!optString(f.get('statusMessage')))
253
+ throw new RawJsonError('statusMessage');
254
+ return;
255
+ }
256
+ default:
257
+ return;
258
+ }
259
+ }
260
+ /**
261
+ * The file as JavaScript values with every integer EXACT (a ``bigint``), so
262
+ * two values Codex tells apart never compare equal: JSON.parse maps
263
+ * ``9007199254740992`` and ``9007199254740993`` to one number. Throws on text
264
+ * that is not JSON.
265
+ */
266
+ function parseHooksJsonExact(text) {
267
+ const toJs = (v) => {
268
+ switch (v.t) {
269
+ case 'object': {
270
+ // Own properties exactly as JSON.parse makes them (the last duplicate
271
+ // wins; `__proto__` is an ordinary key).
272
+ const out = {};
273
+ for (const [k, x] of v.entries) {
274
+ Object.defineProperty(out, k, { value: toJs(x), enumerable: true, writable: true, configurable: true });
275
+ }
276
+ return out;
277
+ }
278
+ case 'array':
279
+ return v.items.map(toJs);
280
+ case 'string':
281
+ return v.v;
282
+ case 'number':
283
+ return /^-?(0|[1-9]\d*)$/.test(v.raw) ? BigInt(v.raw) : Number(v.raw);
284
+ case 'bool':
285
+ return v.v;
286
+ case 'null':
287
+ return null;
288
+ }
289
+ };
290
+ try {
291
+ return toJs(parseRaw(text));
292
+ }
293
+ catch (err) {
294
+ throw new SyntaxError(err.message);
295
+ }
296
+ }
297
+ /** True iff Codex 0.156 would load this text as a hooks file. */
298
+ function codexWouldLoadHooksJson(text) {
299
+ try {
300
+ const root = parseRaw(text);
301
+ if (root.t !== 'object' || root.loneKey)
302
+ return false;
303
+ const top = fieldMap(root, 'all');
304
+ for (const k of top.keys())
305
+ if (k !== 'description' && k !== 'hooks')
306
+ return false;
307
+ const description = top.get('description');
308
+ if (!optString(description) || (description !== undefined && !decodable(description)))
309
+ return false;
310
+ const hooks = top.get('hooks');
311
+ if (hooks === undefined)
312
+ return true;
313
+ if (hooks.t !== 'object' || hooks.loneKey)
314
+ return false;
315
+ const events = fieldMap(hooks, exports.CODEX_HOOK_EVENTS);
316
+ for (const [event, groups] of events) {
317
+ // An unknown event's value is ignored, never decoded.
318
+ if (!exports.CODEX_HOOK_EVENTS.has(event))
319
+ continue;
320
+ if (groups.t !== 'array' || groups.depth >= SERDE_MAX_DEPTH)
321
+ return false;
322
+ for (const group of groups.items) {
323
+ if (group.t !== 'object' || group.loneKey || group.depth >= SERDE_MAX_DEPTH)
324
+ return false;
325
+ const g = fieldMap(group, new Set(['matcher', 'hooks']));
326
+ const matcher = g.get('matcher');
327
+ if (!optString(matcher) || (matcher !== undefined && !decodable(matcher)))
328
+ return false;
329
+ const handlers = g.get('hooks');
330
+ if (handlers === undefined)
331
+ continue;
332
+ if (handlers.t !== 'array' || handlers.depth >= SERDE_MAX_DEPTH)
333
+ return false;
334
+ handlers.items.forEach(validateHandler);
335
+ }
336
+ }
337
+ return true;
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ }
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+ /**
3
+ * `npx @flowrail/init --agent codex` (ADR-050 D9).
4
+ *
5
+ * Order is load-bearing: validate every file edit and install + self-test the
6
+ * EXACT hook release FIRST, and only then write Codex wiring. A wiring file
7
+ * pointing at a hook that cannot serve Codex would be worse than no wiring —
8
+ * Codex fails open on a broken hook.
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.runCodexInit = runCodexInit;
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ const app_instance_1 = require("./app-instance");
18
+ const connectivity_1 = require("./connectivity");
19
+ const ci_workflow_1 = require("./ci-workflow");
20
+ const codex_target_1 = require("./codex-target");
21
+ const codex_doctor_script_1 = require("./codex-doctor-script");
22
+ const codex_install_1 = require("./codex-install");
23
+ const destinations_1 = require("./destinations");
24
+ const env_1 = require("./env");
25
+ const flowrail_yaml_1 = require("./flowrail-yaml");
26
+ const gitignore_1 = require("./gitignore");
27
+ const npm_hooks_1 = require("./npm-hooks");
28
+ const preflight_1 = require("./preflight");
29
+ const repo_scan_trigger_1 = require("./repo-scan-trigger");
30
+ const skills_1 = require("./skills");
31
+ const LITERAL_TOKEN_RE = /fs_poc_[A-Za-z0-9_-]{1,40}_[A-Fa-f0-9]{8,}/;
32
+ function readIfExists(abs) {
33
+ return fs_1.default.existsSync(abs) ? fs_1.default.readFileSync(abs, 'utf8') : null;
34
+ }
35
+ function writeFile(abs, text) {
36
+ fs_1.default.mkdirSync(path_1.default.dirname(abs), { recursive: true });
37
+ fs_1.default.writeFileSync(abs, text, 'utf8');
38
+ }
39
+ async function runCodexInit(opts) {
40
+ const { projectRoot, log, mcpBaseUrl } = opts;
41
+ const { apiKey } = (0, env_1.readApiKeyFromEnv)(opts.env);
42
+ // The endpoint goes into every hook command; the hook only recognizes an
43
+ // http(s) URL there (ADR-050 D8), so anything else would be wiring that
44
+ // never counts as FlowRail — refuse it before touching anything.
45
+ if (!(0, codex_target_1.isValidEndpoint)(mcpBaseUrl)) {
46
+ throw new env_1.InitError('FLOWRAIL_MCP_URL must be a plain http(s) URL such as https://api.flowrail.ai — no credentials, ' +
47
+ 'spaces, backslashes, percent-encoding or line breaks (it is written into tracked hook commands). The value is ' +
48
+ 'not shown here because it may contain a credential.');
49
+ }
50
+ for (const phase of ['pre-write', 'pre-bash', 'stop', 'session-start']) {
51
+ if ((0, codex_target_1.parseCodexHookCommand)((0, codex_target_1.buildCodexHookCommand)(phase, mcpBaseUrl, codex_target_1.CODEX_HOOK_VERSION)) === null) {
52
+ throw new env_1.InitError(`internal error: the generated ${phase} hook command does not round-trip; nothing was changed.`);
53
+ }
54
+ }
55
+ // 1. Refuse a literal credential in any config init touches or reads.
56
+ (0, preflight_1.preflightNoLiteralTokens)(projectRoot);
57
+ const offenders = codex_target_1.CODEX_TRACKED_CONFIGS.filter((rel) => {
58
+ const text = readIfExists(path_1.default.join(projectRoot, rel));
59
+ return text !== null && LITERAL_TOKEN_RE.test(text);
60
+ });
61
+ if (offenders.length > 0) {
62
+ throw new env_1.InitError(`${offenders.join(', ')} contains a literal FlowRail API key. Remove it — Codex reads the key ` +
63
+ 'from the FLOWRAIL_API_KEY environment variable — and re-run.');
64
+ }
65
+ // 2. Compute every edit up front; any conflict throws before a single write.
66
+ const hooksAbs = path_1.default.join(projectRoot, codex_target_1.CODEX_HOOKS_PATH);
67
+ const tomlAbs = path_1.default.join(projectRoot, codex_target_1.CODEX_CONFIG_PATH);
68
+ const agentsAbs = path_1.default.join(projectRoot, codex_target_1.AGENTS_MD_PATH);
69
+ const hooksMerge = (0, codex_target_1.mergeCodexHooks)(readIfExists(hooksAbs), mcpBaseUrl, codex_target_1.CODEX_HOOK_VERSION);
70
+ const nextToml = (0, codex_target_1.upsertCodexConfigToml)(readIfExists(tomlAbs), mcpBaseUrl);
71
+ const nextAgents = (0, codex_target_1.upsertAgentsMd)(readIfExists(agentsAbs));
72
+ // Nothing FlowRail writes may carry the credential — however it got there.
73
+ for (const [rel, text] of [
74
+ [codex_target_1.CODEX_HOOKS_PATH, hooksMerge.text],
75
+ [codex_target_1.CODEX_CONFIG_PATH, nextToml],
76
+ [codex_target_1.AGENTS_MD_PATH, nextAgents],
77
+ ]) {
78
+ if (text !== null && (text.includes(apiKey) || LITERAL_TOKEN_RE.test(text))) {
79
+ throw new env_1.InitError(`the ${rel} FlowRail would write contains an API key; nothing was changed.`);
80
+ }
81
+ }
82
+ // Every destination, checked before anything is installed or written: it
83
+ // must resolve inside the repo and outside .claude/ (the Claude Code
84
+ // install's territory), no parent may be a file, and it must be writable.
85
+ // A skills directory that is a link into .claude/skills (a dual-agent
86
+ // repo sharing skills) is left to the Claude Code install.
87
+ const skillsShared = (0, destinations_1.resolvesIntoClaude)(projectRoot, codex_target_1.CODEX_SKILLS_DIR);
88
+ const destinations = [
89
+ ...(skillsShared ? [] : skills_1.FLOWRAIL_SKILLS.map((skill) => path_1.default.join(codex_target_1.CODEX_SKILLS_DIR, skill, 'SKILL.md'))),
90
+ ...(hooksMerge.text !== null ? [codex_target_1.CODEX_HOOKS_PATH] : []),
91
+ ...(nextToml !== null ? [codex_target_1.CODEX_CONFIG_PATH] : []),
92
+ ...(nextAgents !== null ? [codex_target_1.AGENTS_MD_PATH] : []),
93
+ 'flowrail.yaml',
94
+ ...(opts.ciGate.enabled ? [ci_workflow_1.CI_WORKFLOW_RELATIVE_PATH] : []),
95
+ '.gitignore',
96
+ codex_doctor_script_1.CODEX_DOCTOR_FILENAME,
97
+ ...(fs_1.default.existsSync(path_1.default.join(projectRoot, 'package.json')) ? ['package.json'] : []),
98
+ // Written later by the app-identity step; checked now all the same.
99
+ path_1.default.join('.flowrail', 'app.json'),
100
+ ];
101
+ (0, destinations_1.preflightDestinations)(projectRoot, destinations);
102
+ // 3. The exact hook release, proven able to serve Codex, BEFORE wiring.
103
+ log(`→ installing @flowrail/hook@${codex_target_1.CODEX_HOOK_VERSION}…`);
104
+ const hook = opts.hookInstaller
105
+ ? opts.hookInstaller()
106
+ : (0, codex_install_1.installAndVerifyCodexHook)(projectRoot, codex_target_1.CODEX_HOOK_VERSION);
107
+ log(`✓ installed @flowrail/hook ${hook.version} (Codex adapter verified)`);
108
+ log('→ self-testing the Codex hook (a patch adding a known test secret must be blocked)…');
109
+ (opts.hookSelfTest ?? (() => (0, codex_install_1.runCodexHookSelfTest)({ projectRoot, env: opts.env })))();
110
+ log('✓ Codex hook self-test passed — the patch carrying a test secret was blocked');
111
+ // 4. Wiring — all or nothing: a failed write restores every file it had
112
+ // already changed.
113
+ const snapshot = (0, destinations_1.snapshotDestinations)(projectRoot, destinations);
114
+ try {
115
+ writeCodexWiring();
116
+ }
117
+ catch (err) {
118
+ (0, destinations_1.restoreDestinations)(snapshot);
119
+ throw new env_1.InitError(`writing the Codex wiring failed (${err instanceof Error ? err.message : String(err)}); ` +
120
+ 'every wiring file FlowRail had changed was restored (the @flowrail/hook devDependency it ' +
121
+ 'installed stays in package.json).');
122
+ }
123
+ function writeCodexWiring() {
124
+ if (skillsShared) {
125
+ const missing = skills_1.FLOWRAIL_SKILLS.filter((skill) => !fs_1.default.existsSync(path_1.default.join(projectRoot, codex_target_1.CODEX_SKILLS_DIR, skill, 'SKILL.md')));
126
+ log(`✓ ${codex_target_1.CODEX_SKILLS_DIR}/ is a link into .claude/ — its skills are managed by the Claude Code install (unchanged)`);
127
+ if (missing.length > 0) {
128
+ log(`FlowRail: ${missing.length} FlowRail skill(s) are missing there (${missing.join(', ')}). Run ` +
129
+ '`npx --yes @flowrail/init@latest` for the Claude Code install, or replace the link, so Codex gets them.');
130
+ }
131
+ }
132
+ else {
133
+ (0, skills_1.copySkills)({
134
+ projectRoot,
135
+ skillsSourceDir: opts.skillsSourceDir ?? (0, skills_1.defaultSkillsSourceDir)(),
136
+ targetDir: codex_target_1.CODEX_SKILLS_DIR,
137
+ });
138
+ log(`✓ wrote ${skills_1.FLOWRAIL_SKILLS.length} skill(s) to ${codex_target_1.CODEX_SKILLS_DIR}/`);
139
+ }
140
+ if (hooksMerge.text !== null) {
141
+ writeFile(hooksAbs, hooksMerge.text);
142
+ log(`✓ wrote ${codex_target_1.CODEX_HOOKS_PATH} (${hooksMerge.actions.join('; ')})`);
143
+ }
144
+ else {
145
+ log(`✓ ${codex_target_1.CODEX_HOOKS_PATH} already wires FlowRail @flowrail/hook@${codex_target_1.CODEX_HOOK_VERSION} (unchanged)`);
146
+ }
147
+ if (nextToml !== null) {
148
+ writeFile(tomlAbs, nextToml);
149
+ log(`✓ wrote ${codex_target_1.CODEX_CONFIG_PATH} (mcp_servers.flowrail → ${opts.connectivityUrl}, bearer from FLOWRAIL_API_KEY)`);
150
+ }
151
+ else {
152
+ log(`✓ ${codex_target_1.CODEX_CONFIG_PATH} already defines the FlowRail MCP server (unchanged)`);
153
+ }
154
+ if (nextAgents !== null) {
155
+ writeFile(agentsAbs, nextAgents);
156
+ log(`✓ wrote the FlowRail section of ${codex_target_1.AGENTS_MD_PATH}`);
157
+ }
158
+ (0, flowrail_yaml_1.writeFlowrailYaml)(projectRoot);
159
+ log('✓ wrote flowrail.yaml');
160
+ if (opts.ciGate.enabled) {
161
+ (0, ci_workflow_1.writeCiWorkflow)(projectRoot, opts.ciGate.projectId);
162
+ log(`✓ wrote ${ci_workflow_1.CI_WORKFLOW_RELATIVE_PATH}`);
163
+ }
164
+ (0, gitignore_1.appendFlowrailToGitignore)(projectRoot);
165
+ log('✓ ensured .flowrail/ is in .gitignore');
166
+ (0, codex_doctor_script_1.writeCodexDoctorScript)(projectRoot, codex_target_1.CODEX_HOOK_VERSION);
167
+ log(`✓ wrote ${codex_doctor_script_1.CODEX_DOCTOR_FILENAME} (Codex readiness check)`);
168
+ const skipped = (hook) => log(`! left the ${hook} npm script unchanged (its ending would not combine safely); add \`&& node ${codex_doctor_script_1.CODEX_DOCTOR_FILENAME}\` to it yourself`);
169
+ if ((0, npm_hooks_1.patchNpmScripts)(projectRoot, codex_doctor_script_1.CODEX_DOCTOR_FILENAME, skipped)) {
170
+ log('✓ wired the Codex readiness check into predev/prebuild npm scripts');
171
+ }
172
+ }
173
+ // 5. Server reachability, app identity, retroactive scan — as for Claude Code.
174
+ log(`→ checking connectivity to ${opts.connectivityUrl}…`);
175
+ await (0, connectivity_1.checkConnectivity)({ apiKey, url: opts.connectivityUrl, fetcher: opts.connectivityFetcher });
176
+ log('✓ connectivity check passed');
177
+ try {
178
+ const rebind = opts.argv.includes('--rebind') || opts.env['FLOWRAIL_APP_REBIND'] === '1';
179
+ const appRegistrar = opts.appRegistrar;
180
+ const appResult = await (0, app_instance_1.ensureAppInstance)({
181
+ projectRoot,
182
+ mcpBaseUrl,
183
+ apiKey,
184
+ rebind,
185
+ log,
186
+ registerApp: appRegistrar === undefined
187
+ ? undefined
188
+ : async (args) => (await appRegistrar(args)) ?? { error: 'empty app registration response' },
189
+ });
190
+ log(`✓ app instance ${appResult.appInstanceId} (${appResult.status}) — .flowrail/app.json`);
191
+ }
192
+ catch (err) {
193
+ log(`FlowRail: app-instance identity step failed (${err instanceof Error ? err.message : String(err)}); continuing.`);
194
+ }
195
+ if (!(opts.argv.includes('--no-scan') || opts.argv.includes('--no-retro-scan'))) {
196
+ log('→ scanning your existing dependencies for known CVEs…');
197
+ try {
198
+ await (opts.repoScanner ?? repo_scan_trigger_1.defaultRepoScanner)({ projectRoot, mcpBaseUrl, apiKey, log });
199
+ }
200
+ catch (err) {
201
+ log(`FlowRail: retroactive scan could not complete (${err instanceof Error ? err.message : String(err)}); skipping.`);
202
+ }
203
+ }
204
+ // 6. Configured is not active: say exactly what Codex still needs.
205
+ log('');
206
+ log('FlowRail is configured for Codex (hooks, MCP server, skills, AGENTS.md).');
207
+ log('It becomes active only after two approvals in Codex, which FlowRail cannot grant:');
208
+ log(' 1. Open this repo in Codex and trust the project when asked.');
209
+ log(' 2. Run /hooks in Codex and approve the four FlowRail hooks.');
210
+ log(`Then run \`node ${codex_doctor_script_1.CODEX_DOCTOR_FILENAME}\` — it reports "readiness: active" once Codex runs them.`);
211
+ log('Start Codex from a shell where FLOWRAIL_API_KEY is set.');
212
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ /**
3
+ * Install the EXACT @flowrail/hook release the Codex wiring pins, prove it
4
+ * implements the Codex adapter, and prove it blocks a Codex patch carrying a
5
+ * test secret — all BEFORE any Codex wiring is written (ADR-050 D9, D11).
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.spawnCodexHookPreWrite = void 0;
9
+ exports.buildCodexVerifyScript = buildCodexVerifyScript;
10
+ exports.installAndVerifyCodexHook = installAndVerifyCodexHook;
11
+ exports.buildCodexProbePayload = buildCodexProbePayload;
12
+ exports.runCodexHookSelfTest = runCodexHookSelfTest;
13
+ const child_process_1 = require("child_process");
14
+ const env_1 = require("./env");
15
+ const hook_install_1 = require("./hook-install");
16
+ const verify_write_1 = require("./verify-write");
17
+ const defaultExec = (file, args, options) => (0, child_process_1.execFileSync)(file, args, { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'] })
18
+ .toString()
19
+ .trim();
20
+ /** Node one-liner run in the project: the installed package must be exactly
21
+ * `version`, expose the `flowrail-hook` bin, and advertise the Codex agent in
22
+ * `capabilities`. Prints the version on success. */
23
+ function buildCodexVerifyScript(version) {
24
+ return [
25
+ "const path = require('path');",
26
+ "const cp = require('child_process');",
27
+ `const pkgPath = require.resolve(${JSON.stringify(`${hook_install_1.HOOK_PACKAGE}/package.json`)}, { paths: [process.cwd()] });`,
28
+ 'const pkg = require(pkgPath);',
29
+ `if (pkg.version !== ${JSON.stringify(version)}) { console.error('installed ' + pkg.version + ', expected ${version}'); process.exit(2); }`,
30
+ `const binEntry = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin[${JSON.stringify(hook_install_1.HOOK_BIN)}] : null;`,
31
+ "if (!binEntry) { console.error('package.json bin must name flowrail-hook'); process.exit(2); }",
32
+ "const r = cp.spawnSync(process.execPath, [path.resolve(path.dirname(pkgPath), binEntry), 'capabilities'], { encoding: 'utf8' });",
33
+ 'let caps = null; try { caps = JSON.parse(r.stdout); } catch (e) {}',
34
+ "if (r.status !== 0 || !caps || !caps.agents || !caps.agents.codex || !(caps.adapter_protocol >= 1)) {",
35
+ " console.error('this @flowrail/hook does not implement the Codex adapter: ' + (r.stdout || r.stderr || '').slice(0, 300));",
36
+ ' process.exit(2);',
37
+ '}',
38
+ 'process.stdout.write(pkg.version);',
39
+ ].join('');
40
+ }
41
+ function installAndVerifyCodexHook(projectRoot, version, exec = defaultExec) {
42
+ let installOutput;
43
+ try {
44
+ installOutput = exec('npm', ['install', '--save-dev', '--save-exact', `${hook_install_1.HOOK_PACKAGE}@${version}`], {
45
+ cwd: projectRoot,
46
+ });
47
+ }
48
+ catch (err) {
49
+ throw new env_1.InitError(`npm install ${hook_install_1.HOOK_PACKAGE}@${version} failed: ${err instanceof Error ? err.message : String(err)}`);
50
+ }
51
+ let verified;
52
+ try {
53
+ verified = exec('node', ['-e', buildCodexVerifyScript(version)], { cwd: projectRoot });
54
+ }
55
+ catch (err) {
56
+ throw new env_1.InitError(`${hook_install_1.HOOK_PACKAGE}@${version} is installed but cannot serve Codex: ` +
57
+ `${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ return { installOutput, version: verified || version };
60
+ }
61
+ /** A Codex `apply_patch` PreToolUse payload that adds the probe secret. */
62
+ function buildCodexProbePayload(cwd) {
63
+ return JSON.stringify({
64
+ session_id: '',
65
+ hook_event_name: 'PreToolUse',
66
+ cwd,
67
+ tool_name: 'apply_patch',
68
+ tool_input: {
69
+ command: `*** Begin Patch\n*** Add File: ${verify_write_1.PROBE_PATH}\n+const awsKey = "${verify_write_1.PROBE_SECRET}";\n*** End Patch\n`,
70
+ },
71
+ });
72
+ }
73
+ const spawnCodexHookPreWrite = (binPath, stdin, env, cwd) => {
74
+ const r = (0, child_process_1.spawnSync)(process.execPath, [binPath, 'agent', 'codex', 'pre-write'], {
75
+ input: stdin,
76
+ env,
77
+ cwd,
78
+ encoding: 'utf8',
79
+ timeout: 30000,
80
+ });
81
+ return { status: r.status, stderr: r.stderr ?? '' };
82
+ };
83
+ exports.spawnCodexHookPreWrite = spawnCodexHookPreWrite;
84
+ /**
85
+ * Prove the installed hook blocks a Codex patch that adds a known test
86
+ * secret. Like the Claude Code self-test it never sees the real key and its
87
+ * telemetry targets an unroutable loopback. This proves hook logic only —
88
+ * whether Codex LOADS and TRUSTS the hooks is the doctor's readiness check.
89
+ */
90
+ function runCodexHookSelfTest(opts) {
91
+ const bin = (opts.resolveHookBinPath ?? verify_write_1.resolveInstalledHookBinPath)(opts.projectRoot);
92
+ const env = {
93
+ ...opts.env,
94
+ FLOWRAIL_API_KEY: 'flowrail-selftest',
95
+ FLOWRAIL_MCP_URL: 'http://127.0.0.1:1',
96
+ };
97
+ const { status, stderr } = (opts.runHook ?? exports.spawnCodexHookPreWrite)(bin, buildCodexProbePayload(opts.projectRoot), env, opts.projectRoot);
98
+ if (!(status === 2 && /secret pattern detected/i.test(stderr))) {
99
+ throw new env_1.InitError(`the Codex hook self-test did not block a test secret (exit ${String(status)}; ` +
100
+ `stderr: ${stderr.trim().slice(0, 200) || '<empty>'}). FlowRail would not protect Codex writes — aborting.`);
101
+ }
102
+ }