@flowrail/init 0.0.10 → 0.0.11

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,287 @@
1
+ "use strict";
2
+ /**
3
+ * ADR-019 M0' — durable app-instance identity (Decision 1).
4
+ *
5
+ * ``init`` mints an ``app_instance_id`` (mint-if-absent) into
6
+ * ``.flowrail/app.json`` and registers it with the server via the
7
+ * ``flowrail_register_app`` MCP tool. The identity is DURABLE:
8
+ *
9
+ * - it lives in its own file, NOT ``context.json`` — the design-review
10
+ * handoff file is rewritten by the skill on every review and carries
11
+ * a 7-day staleness rule, both fatal for an identity that must
12
+ * survive the life of the clone;
13
+ * - an existing file is never re-minted or clobbered with a different
14
+ * id (``writeAppIdentity`` throws rather than overwrite);
15
+ * - registration is fail-soft — a dead network still yields a local
16
+ * identity (``local_only``) and the next ``init`` re-registers it
17
+ * idempotently.
18
+ *
19
+ * ``repo_key`` follows the hook's repo-scan convention EXACTLY
20
+ * (sha256 hex of the RAW remote string, first 16 chars; path fallback)
21
+ * so the lineage hint agrees across scan and registration events. The
22
+ * ``normalized_remote`` (canonical https form) exists only for the
23
+ * server-side rebind lookup — it is never hashed into ``repo_key``.
24
+ */
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.mintAppInstanceId = mintAppInstanceId;
30
+ exports.normalizeRemoteUrl = normalizeRemoteUrl;
31
+ exports.readAppIdentity = readAppIdentity;
32
+ exports.writeAppIdentity = writeAppIdentity;
33
+ exports.resolveRepoIdentity = resolveRepoIdentity;
34
+ exports.ensureAppInstance = ensureAppInstance;
35
+ const child_process_1 = require("child_process");
36
+ const crypto_1 = __importDefault(require("crypto"));
37
+ const fs_1 = __importDefault(require("fs"));
38
+ const path_1 = __importDefault(require("path"));
39
+ const APP_JSON_DIR = '.flowrail';
40
+ const APP_JSON_FILE = 'app.json';
41
+ const REGISTER_TIMEOUT_MS = 10000;
42
+ function mintAppInstanceId() {
43
+ return 'app_' + crypto_1.default.randomBytes(16).toString('hex');
44
+ }
45
+ /**
46
+ * Canonical https form of a git remote, used ONLY for the server-side
47
+ * rebind lookup. Handles scp-style ssh (``git@host:path``), ``ssh://``,
48
+ * and https (with embedded credentials stripped). Host is lowercased;
49
+ * path case is PRESERVED (GitHub owner/repo lookups are case-preserving
50
+ * even though resolution is case-insensitive).
51
+ */
52
+ function normalizeRemoteUrl(raw) {
53
+ const trimmed = raw.trim();
54
+ let host;
55
+ let pathPart;
56
+ const scp = /^[A-Za-z0-9._-]+@([^:/]+):(.+)$/.exec(trimmed);
57
+ if (scp && !trimmed.includes('://')) {
58
+ host = scp[1];
59
+ pathPart = scp[2];
60
+ }
61
+ else {
62
+ const proto = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(trimmed);
63
+ let rest = proto ? proto[1] : trimmed;
64
+ // Strip credentials: anything before an ``@`` that precedes the
65
+ // first path slash belongs to userinfo, never the host.
66
+ const firstSlash = rest.indexOf('/');
67
+ const at = rest.indexOf('@');
68
+ if (at !== -1 && (firstSlash === -1 || at < firstSlash)) {
69
+ rest = rest.slice(at + 1);
70
+ }
71
+ const slash = rest.indexOf('/');
72
+ if (slash === -1) {
73
+ host = rest;
74
+ pathPart = '';
75
+ }
76
+ else {
77
+ host = rest.slice(0, slash);
78
+ pathPart = rest.slice(slash + 1);
79
+ }
80
+ }
81
+ pathPart = pathPart.replace(/\/+$/, '').replace(/\.git$/, '');
82
+ return `https://${host.toLowerCase()}${pathPart ? `/${pathPart}` : ''}`;
83
+ }
84
+ /** Read ``.flowrail/app.json``. Durable by design: NO staleness rule —
85
+ * contrast the hook's context.json reader, which drops 7-day-old
86
+ * contexts. Returns null on missing/malformed/typeless content. */
87
+ function readAppIdentity(projectRoot) {
88
+ let raw;
89
+ try {
90
+ raw = fs_1.default.readFileSync(path_1.default.join(projectRoot, APP_JSON_DIR, APP_JSON_FILE), 'utf8');
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(raw);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
103
+ return null;
104
+ }
105
+ const obj = parsed;
106
+ if (typeof obj['app_instance_id'] !== 'string')
107
+ return null;
108
+ return {
109
+ app_instance_id: obj['app_instance_id'],
110
+ created_at: typeof obj['created_at'] === 'string' ? obj['created_at'] : '',
111
+ };
112
+ }
113
+ /** Write ``.flowrail/app.json``. Never clobbers a DIFFERENT id — the
114
+ * mint is permanent for the life of the clone (re-mint only via
115
+ * delete/re-clone, per ADR-019 Decision 1). */
116
+ function writeAppIdentity(projectRoot, identity) {
117
+ const existing = readAppIdentity(projectRoot);
118
+ if (existing !== null) {
119
+ if (existing.app_instance_id !== identity.app_instance_id) {
120
+ throw new Error(`.flowrail/app.json already holds app_instance_id ${existing.app_instance_id}; ` +
121
+ 'refusing to overwrite a durable identity with a different one');
122
+ }
123
+ return; // idempotent: same id, keep the original file
124
+ }
125
+ const dir = path_1.default.join(projectRoot, APP_JSON_DIR);
126
+ fs_1.default.mkdirSync(dir, { recursive: true });
127
+ fs_1.default.writeFileSync(path_1.default.join(dir, APP_JSON_FILE), JSON.stringify(identity, null, 2) + '\n', 'utf8');
128
+ }
129
+ function defaultGitRemote(projectRoot) {
130
+ try {
131
+ const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 }).trim();
132
+ return out.length > 0 ? out : undefined;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ function shortHash(value) {
139
+ return crypto_1.default.createHash('sha256').update(value).digest('hex').slice(0, 16);
140
+ }
141
+ /**
142
+ * Resolve the repo identity triple. ``repoKey`` hashes the RAW remote
143
+ * string (never the normalized form) to stay byte-identical with the
144
+ * hook's repo-scan ``resolveRepoKey`` — the two surfaces must agree or
145
+ * lineage grouping by repo_key silently forks.
146
+ */
147
+ function resolveRepoIdentity(projectRoot, deps = {}) {
148
+ const gitRemote = deps.gitRemote ?? defaultGitRemote;
149
+ const raw = gitRemote(projectRoot);
150
+ if (raw !== undefined) {
151
+ return {
152
+ normalizedRemote: normalizeRemoteUrl(raw),
153
+ repoKey: shortHash(raw),
154
+ remotePresent: true,
155
+ };
156
+ }
157
+ return {
158
+ normalizedRemote: null,
159
+ repoKey: shortHash(path_1.default.resolve(projectRoot)),
160
+ remotePresent: false,
161
+ };
162
+ }
163
+ function buildDefaultRegisterApp(mcpBaseUrl, apiKey) {
164
+ const url = `${mcpBaseUrl.replace(/\/+$/, '')}/mcp`;
165
+ return async (args) => {
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), REGISTER_TIMEOUT_MS);
168
+ try {
169
+ const response = await fetch(url, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'content-type': 'application/json',
173
+ authorization: `Bearer ${apiKey}`,
174
+ },
175
+ body: JSON.stringify({
176
+ jsonrpc: '2.0',
177
+ id: 1,
178
+ method: 'tools/call',
179
+ params: { name: 'flowrail_register_app', arguments: args },
180
+ }),
181
+ signal: controller.signal,
182
+ });
183
+ if (!response.ok) {
184
+ return { error: `HTTP ${response.status}` };
185
+ }
186
+ const body = (await response.json());
187
+ if (body.error) {
188
+ return { error: body.error.message ?? 'JSON-RPC error' };
189
+ }
190
+ const structured = body.result?.structuredContent;
191
+ if (structured &&
192
+ typeof structured['status'] === 'string' &&
193
+ typeof structured['app_instance_id'] === 'string') {
194
+ return structured;
195
+ }
196
+ return { error: 'malformed flowrail_register_app response' };
197
+ }
198
+ catch (err) {
199
+ return { error: err instanceof Error ? err.message : String(err) };
200
+ }
201
+ finally {
202
+ clearTimeout(timer);
203
+ }
204
+ };
205
+ }
206
+ /**
207
+ * Mint-if-absent + register. Every path yields a durable local identity;
208
+ * only the registration round-trip is best-effort.
209
+ */
210
+ async function ensureAppInstance(opts) {
211
+ const registerApp = opts.registerApp ?? buildDefaultRegisterApp(opts.mcpBaseUrl, opts.apiKey);
212
+ const repo = resolveRepoIdentity(opts.projectRoot);
213
+ const registerArgs = (appInstanceId, extra = {}) => {
214
+ const args = {
215
+ app_instance_id: appInstanceId,
216
+ repo_key: repo.repoKey,
217
+ remote_present: repo.remotePresent,
218
+ ...extra,
219
+ };
220
+ if (repo.normalizedRemote !== null) {
221
+ args['normalized_remote'] = repo.normalizedRemote;
222
+ }
223
+ return args;
224
+ };
225
+ const callRegister = async (args) => {
226
+ try {
227
+ return await registerApp(args);
228
+ }
229
+ catch (err) {
230
+ return { error: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ };
233
+ const existing = readAppIdentity(opts.projectRoot);
234
+ if (existing !== null) {
235
+ // Idempotent re-register of the durable id. Any server-side outcome
236
+ // that acknowledges the id maps to 'ok'; failure is fail-soft.
237
+ const res = await callRegister(registerArgs(existing.app_instance_id));
238
+ if ('error' in res) {
239
+ opts.log(`FlowRail: app-instance registration deferred (${res.error}); ` +
240
+ 'the local identity is kept and re-registered on the next init.');
241
+ return { appInstanceId: existing.app_instance_id, status: 'local_only' };
242
+ }
243
+ return { appInstanceId: existing.app_instance_id, status: 'ok' };
244
+ }
245
+ const minted = mintAppInstanceId();
246
+ const identity = {
247
+ app_instance_id: minted,
248
+ created_at: new Date().toISOString(),
249
+ };
250
+ const first = await callRegister(registerArgs(minted));
251
+ if ('error' in first) {
252
+ writeAppIdentity(opts.projectRoot, identity);
253
+ opts.log(`FlowRail: app-instance registration deferred (${first.error}); ` +
254
+ 'minted a local identity — the next init re-registers it.');
255
+ return { appInstanceId: minted, status: 'local_only' };
256
+ }
257
+ if (first.status === 'existing') {
258
+ // The rebind offer: this remote is already registered under a
259
+ // different instance id (a prior clone).
260
+ if (opts.rebind) {
261
+ const adopted = {
262
+ app_instance_id: first.app_instance_id,
263
+ created_at: first.created_at ?? new Date().toISOString(),
264
+ };
265
+ writeAppIdentity(opts.projectRoot, adopted);
266
+ opts.log(`✓ rebound to existing app instance ${first.app_instance_id} for this remote`);
267
+ return {
268
+ appInstanceId: first.app_instance_id,
269
+ status: 'existing_adopted',
270
+ };
271
+ }
272
+ opts.log(`FlowRail: this remote is already registered as app instance ${first.app_instance_id}.`);
273
+ opts.log(' Minting a fresh instance for this clone (graphs are per-clone). To adopt the');
274
+ opts.log(' existing identity instead, re-run init with --rebind (or FLOWRAIL_APP_REBIND=1).');
275
+ const forced = await callRegister(registerArgs(minted, { force_new: true }));
276
+ writeAppIdentity(opts.projectRoot, identity);
277
+ if ('error' in forced) {
278
+ opts.log(`FlowRail: forced registration deferred (${forced.error}); ` +
279
+ 'the local identity is kept and re-registered on the next init.');
280
+ return { appInstanceId: minted, status: 'local_only' };
281
+ }
282
+ return { appInstanceId: minted, status: 'forced_new' };
283
+ }
284
+ // 'registered' (or an unexpectedly-lenient 'ok') — persist the mint.
285
+ writeAppIdentity(opts.projectRoot, identity);
286
+ return { appInstanceId: minted, status: 'registered' };
287
+ }
@@ -3,15 +3,16 @@
3
3
  * Read / merge / write ``.claude/settings.json``.
4
4
  *
5
5
  * Wires PreToolUse hooks for Write/Edit/MultiEdit (pre-write
6
- * dispatcher) and Bash (pre-bash dispatcher). Both invoke
7
- * ``flowrail-hook`` from the @flowrail/hook package the tester is
8
- * expected to have installed alongside this init.
6
+ * dispatcher) and Bash (pre-bash dispatcher), plus the ADR-019 Stop
7
+ * (turn-batched whole-app review) and SessionStart (coverage catch-up)
8
+ * hooks. All invoke ``flowrail-hook`` from the @flowrail/hook package
9
+ * the tester is expected to have installed alongside this init.
9
10
  */
10
11
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
12
13
  };
13
14
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.PRE_BASH_MATCHER = exports.PRE_WRITE_MATCHER = void 0;
15
+ exports.REVIEW_HOOK_TIMEOUT_SECONDS = exports.PRE_BASH_MATCHER = exports.PRE_WRITE_MATCHER = void 0;
15
16
  exports.buildHookCommand = buildHookCommand;
16
17
  exports.mergeClaudeSettings = mergeClaudeSettings;
17
18
  exports.writeClaudeSettings = writeClaudeSettings;
@@ -26,6 +27,12 @@ const HOOK_BIN_NAME = 'flowrail-hook';
26
27
  const HOOK_BIN = `npx --yes -p @flowrail/hook ${HOOK_BIN_NAME}`;
27
28
  exports.PRE_WRITE_MATCHER = 'Write|Edit|MultiEdit';
28
29
  exports.PRE_BASH_MATCHER = 'Bash';
30
+ // ADR-019 failure posture: the Stop/SessionStart hook timeout is sized
31
+ // to batched whole-app reality and set EXPLICITLY in the generated
32
+ // settings entry (seconds). It sits above the hook's own HTTP client
33
+ // timeout so the client, not Claude Code, decides how a slow review
34
+ // degrades (fail-open with telemetry, never a killed process).
35
+ exports.REVIEW_HOOK_TIMEOUT_SECONDS = 180;
29
36
  /**
30
37
  * Build the hook command for a sub-dispatcher. We MUST bake
31
38
  * ``FLOWRAIL_MCP_URL=<base>`` in front of the npx invocation —
@@ -51,6 +58,17 @@ function flowrailHookGroups(mcpBaseUrl) {
51
58
  },
52
59
  ];
53
60
  }
61
+ function flowrailReviewGroup(subcommand, mcpBaseUrl) {
62
+ return {
63
+ hooks: [
64
+ {
65
+ type: 'command',
66
+ command: buildHookCommand(subcommand, mcpBaseUrl),
67
+ timeout: exports.REVIEW_HOOK_TIMEOUT_SECONDS,
68
+ },
69
+ ],
70
+ };
71
+ }
54
72
  function isFlowrailGroup(group) {
55
73
  // Identify a group as "ours" if any inner hook command points at the
56
74
  // flowrail-hook binary, regardless of which sub-command. Conservative
@@ -58,16 +76,27 @@ function isFlowrailGroup(group) {
58
76
  // with two flowrail PreToolUse entries every time they re-run init.
59
77
  return group.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_BIN_NAME));
60
78
  }
79
+ function mergeGroups(existing, ours) {
80
+ // Drop any prior flowrail entries before re-inserting so re-running
81
+ // init is idempotent (no duplicate groups piling up); foreign groups
82
+ // are preserved verbatim.
83
+ const others = (existing ?? []).filter((g) => !isFlowrailGroup(g));
84
+ return [...others, ...ours];
85
+ }
61
86
  function mergeClaudeSettings(existing, mcpBaseUrl) {
62
87
  const existingHooks = existing.hooks ?? {};
63
- const existingPreToolUse = existingHooks.PreToolUse ?? [];
64
- // Drop any prior flowrail PreToolUse entries before re-inserting so
65
- // re-running init is idempotent (no duplicate matchers piling up).
66
- const otherGroups = existingPreToolUse.filter((g) => !isFlowrailGroup(g));
67
- const PreToolUse = [...otherGroups, ...flowrailHookGroups(mcpBaseUrl)];
68
88
  return {
69
89
  ...existing,
70
- hooks: { ...existingHooks, PreToolUse },
90
+ hooks: {
91
+ ...existingHooks,
92
+ PreToolUse: mergeGroups(existingHooks.PreToolUse, flowrailHookGroups(mcpBaseUrl)),
93
+ Stop: mergeGroups(existingHooks.Stop, [
94
+ flowrailReviewGroup('stop', mcpBaseUrl),
95
+ ]),
96
+ SessionStart: mergeGroups(existingHooks.SessionStart, [
97
+ flowrailReviewGroup('session-start', mcpBaseUrl),
98
+ ]),
99
+ },
71
100
  };
72
101
  }
73
102
  function writeClaudeSettings(projectRoot, mcpBaseUrl) {
@@ -19,6 +19,7 @@ const DOCTOR_SCRIPT_CONTENT = String.raw `#!/usr/bin/env node
19
19
  * Generated by @flowrail/init. Safe to commit to the repo.
20
20
  * Re-run the installer if this script fails: npx @flowrail/init@latest
21
21
  */
22
+ import { execFileSync } from 'node:child_process';
22
23
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
23
24
  import { builtinModules } from 'node:module';
24
25
  import path from 'node:path';
@@ -1170,6 +1171,170 @@ function checkUndeclaredRuntimeImports() {
1170
1171
 
1171
1172
  if (!checkUndeclaredRuntimeImports()) ok = false;
1172
1173
 
1174
+ // ---------------------------------------------------------------------------
1175
+ // ADR-019 Decision 6 — version-skew checks.
1176
+ //
1177
+ // Both checks DEGRADE GRACEFULLY: an inability to PROBE (offline npx,
1178
+ // unreachable server, missing API key) warns and skips — the doctor must
1179
+ // never convert its own probe failure into a hard build failure. Only a
1180
+ // SUCCESSFUL probe that reveals real skew fails the doctor.
1181
+ // ---------------------------------------------------------------------------
1182
+
1183
+ function readClaudeSettings() {
1184
+ try {
1185
+ return JSON.parse(readFileSync(path.join('.claude', 'settings.json'), 'utf8'));
1186
+ } catch {
1187
+ return null;
1188
+ }
1189
+ }
1190
+
1191
+ function flowrailHookCommands(settings) {
1192
+ const commands = [];
1193
+ const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
1194
+ if (!hooks || typeof hooks !== 'object') return commands;
1195
+ for (const groups of Object.values(hooks)) {
1196
+ if (!Array.isArray(groups)) continue;
1197
+ for (const group of groups) {
1198
+ const entries = group && Array.isArray(group.hooks) ? group.hooks : [];
1199
+ for (const hook of entries) {
1200
+ if (hook && typeof hook.command === 'string' && hook.command.includes('flowrail-hook')) {
1201
+ commands.push(hook.command);
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+ return commands;
1207
+ }
1208
+
1209
+ function referencedHookSubcommands(commands) {
1210
+ const subcommands = new Set();
1211
+ for (const command of commands) {
1212
+ const match = command.match(/flowrail-hook\s+([a-z][a-z-]*)/);
1213
+ if (match) subcommands.add(match[1]);
1214
+ }
1215
+ return subcommands;
1216
+ }
1217
+
1218
+ /**
1219
+ * (a) Every settings-referenced flowrail-hook subcommand must exist in the
1220
+ * RESOLVED hook version. A stale npx cache running an old hook against newer
1221
+ * wiring mis-fires silently at runtime (the hook's own unknown-subcommand
1222
+ * handling fails open outside PreToolUse) — the doctor is where that skew
1223
+ * surfaces loudly. Probes the hook's "capabilities" subcommand.
1224
+ */
1225
+ function checkHookSubcommandCapabilities(settings) {
1226
+ const referenced = referencedHookSubcommands(flowrailHookCommands(settings));
1227
+ if (referenced.size === 0) return true;
1228
+ let parsed;
1229
+ try {
1230
+ const out = execFileSync(
1231
+ 'npx',
1232
+ ['--yes', '-p', '@flowrail/hook', 'flowrail-hook', 'capabilities'],
1233
+ { encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] },
1234
+ );
1235
+ parsed = JSON.parse(out);
1236
+ } catch {
1237
+ console.warn(
1238
+ 'FlowRail: could not probe flowrail-hook capabilities (offline, npx ' +
1239
+ 'unavailable, or a pre-capabilities hook version); skipping the ' +
1240
+ 'subcommand version-skew check.',
1241
+ );
1242
+ return true;
1243
+ }
1244
+ const advertised = new Set(
1245
+ parsed && Array.isArray(parsed.subcommands) ? parsed.subcommands : [],
1246
+ );
1247
+ const missing = Array.from(referenced).filter((sub) => !advertised.has(sub));
1248
+ if (missing.length === 0) return true;
1249
+ console.error(
1250
+ 'FlowRail: .claude/settings.json references flowrail-hook subcommand(s) ' +
1251
+ 'missing from the resolved @flowrail/hook version: ' +
1252
+ missing.join(', ') +
1253
+ '\nA stale hook cannot serve these hooks. Fix: refresh the hook — ' +
1254
+ 'npx --yes @flowrail/init@latest',
1255
+ );
1256
+ return false;
1257
+ }
1258
+
1259
+ function settingsHasFlowrailStopWiring(settings) {
1260
+ const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
1261
+ const groups = hooks && Array.isArray(hooks.Stop) ? hooks.Stop : [];
1262
+ for (const group of groups) {
1263
+ const entries = group && Array.isArray(group.hooks) ? group.hooks : [];
1264
+ for (const hook of entries) {
1265
+ if (hook && typeof hook.command === 'string' && hook.command.includes('flowrail-hook')) {
1266
+ return true;
1267
+ }
1268
+ }
1269
+ }
1270
+ return false;
1271
+ }
1272
+
1273
+ /**
1274
+ * (b) When the server advertises flowrail_review_app (tools/list) but
1275
+ * .claude/settings.json has no FlowRail Stop wiring, whole-app reviews
1276
+ * never trigger — settings written by a pre-ADR-019 init against a newer
1277
+ * server. Needs the base URL (from the hook command's FLOWRAIL_MCP_URL
1278
+ * prefix) and FLOWRAIL_API_KEY; missing either skips silently.
1279
+ */
1280
+ async function checkStopWiringAgainstServer(settings) {
1281
+ let baseUrl = null;
1282
+ for (const command of flowrailHookCommands(settings)) {
1283
+ const match = command.match(/FLOWRAIL_MCP_URL=(\S+)/);
1284
+ if (match) {
1285
+ baseUrl = match[1];
1286
+ break;
1287
+ }
1288
+ }
1289
+ const apiKey = process.env.FLOWRAIL_API_KEY;
1290
+ if (!baseUrl || !apiKey) return true;
1291
+
1292
+ let tools;
1293
+ try {
1294
+ const controller = new AbortController();
1295
+ const timer = setTimeout(() => controller.abort(), 5000);
1296
+ const response = await fetch(baseUrl.replace(/\/+$/, '') + '/mcp', {
1297
+ method: 'POST',
1298
+ headers: {
1299
+ 'content-type': 'application/json',
1300
+ authorization: 'Bearer ' + apiKey,
1301
+ },
1302
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
1303
+ signal: controller.signal,
1304
+ });
1305
+ clearTimeout(timer);
1306
+ const body = await response.json();
1307
+ tools = body && body.result && Array.isArray(body.result.tools)
1308
+ ? body.result.tools
1309
+ : null;
1310
+ } catch {
1311
+ console.warn(
1312
+ 'FlowRail: could not reach the server for tools/list; skipping the ' +
1313
+ 'Stop-wiring check.',
1314
+ );
1315
+ return true;
1316
+ }
1317
+ if (!tools) return true;
1318
+ const advertisesReviewApp = tools.some(
1319
+ (tool) => tool && tool.name === 'flowrail_review_app',
1320
+ );
1321
+ if (!advertisesReviewApp) return true;
1322
+ if (settingsHasFlowrailStopWiring(settings)) return true;
1323
+ console.error(
1324
+ 'FlowRail: the server advertises flowrail_review_app but ' +
1325
+ '.claude/settings.json has no FlowRail Stop hook wiring — whole-app ' +
1326
+ 'composition reviews will never trigger.\n' +
1327
+ 'Fix: re-run the installer — npx @flowrail/init@latest',
1328
+ );
1329
+ return false;
1330
+ }
1331
+
1332
+ const claudeSettings = readClaudeSettings();
1333
+ if (claudeSettings !== null) {
1334
+ if (!checkHookSubcommandCapabilities(claudeSettings)) ok = false;
1335
+ if (!(await checkStopWiringAgainstServer(claudeSettings))) ok = false;
1336
+ }
1337
+
1173
1338
  if (!ok) process.exit(1);
1174
1339
  `;
1175
1340
  /**
package/dist/index.js CHANGED
File without changes
package/dist/init.js CHANGED
@@ -28,6 +28,7 @@
28
28
  */
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
30
  exports.runInit = runInit;
31
+ const app_instance_1 = require("./app-instance");
31
32
  const claude_settings_1 = require("./claude-settings");
32
33
  const connectivity_1 = require("./connectivity");
33
34
  const doctor_script_1 = require("./doctor-script");
@@ -114,6 +115,26 @@ async function runInit(options = {}) {
114
115
  fetcher: options.connectivityFetcher,
115
116
  });
116
117
  log('✓ connectivity check passed');
118
+ // ADR-019 M0': mint-if-absent app-instance identity + registration.
119
+ // Runs after connectivity (the register call needs the server) and is
120
+ // fail-soft — ensureAppInstance itself degrades to a local-only mint
121
+ // on any registration failure, and this belt-and-suspenders catch
122
+ // covers unexpected throws (e.g. an unwritable .flowrail/).
123
+ try {
124
+ const rebind = argv.includes('--rebind') || env['FLOWRAIL_APP_REBIND'] === '1';
125
+ const appResult = await (0, app_instance_1.ensureAppInstance)({
126
+ projectRoot,
127
+ mcpBaseUrl,
128
+ apiKey,
129
+ rebind,
130
+ log,
131
+ registerApp: options.appRegistrar,
132
+ });
133
+ log(`✓ app instance ${appResult.appInstanceId} (${appResult.status}) — .flowrail/app.json`);
134
+ }
135
+ catch (err) {
136
+ log(`FlowRail: app-instance identity step failed (${messageForError(err)}); continuing.`);
137
+ }
117
138
  if (!noScan) {
118
139
  log('→ scanning your existing dependencies for known CVEs…');
119
140
  const scanner = options.repoScanner ?? repo_scan_trigger_1.defaultRepoScanner;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowrail/init",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "One-shot FlowRail installer for Claude Code. Wires PreToolUse hooks, MCP server config, and skill markdown into a tester's repo, then probes the FlowRail server with their bearer.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -31,7 +31,7 @@
31
31
  "prepublishOnly": "npm run build && npm test"
32
32
  },
33
33
  "dependencies": {
34
- "@flowrail/skills": "0.0.4"
34
+ "@flowrail/skills": "0.0.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/jest": "^29.5.12",