@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.
- package/dist/app-instance.js +287 -0
- package/dist/claude-settings.js +39 -10
- package/dist/doctor-script.js +165 -0
- package/dist/index.js +0 -0
- package/dist/init.js +21 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/claude-settings.js
CHANGED
|
@@ -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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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: {
|
|
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) {
|
package/dist/doctor-script.js
CHANGED
|
@@ -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.
|
|
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.
|
|
34
|
+
"@flowrail/skills": "0.0.5"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/jest": "^29.5.12",
|