@flowrail/init 0.0.7
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/README.md +27 -0
- package/dist/claude-settings.js +75 -0
- package/dist/connectivity.js +124 -0
- package/dist/doctor-script.js +65 -0
- package/dist/env.js +59 -0
- package/dist/flowrail-yaml.js +49 -0
- package/dist/git-check.js +17 -0
- package/dist/gitignore.js +63 -0
- package/dist/hook-install.js +99 -0
- package/dist/index.js +20 -0
- package/dist/init.js +118 -0
- package/dist/json-config.js +37 -0
- package/dist/mcp-config.js +53 -0
- package/dist/npm-hooks.js +53 -0
- package/dist/preflight.js +48 -0
- package/dist/skills.js +53 -0
- package/dist/verify-write.js +148 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @flowrail/init
|
|
2
|
+
|
|
3
|
+
One-shot installer that wires FlowRail's PreToolUse hooks, MCP server config, and skill markdown into a Claude Code project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
export FLOWRAIL_API_KEY=fs_poc_<slug>_<random> # from your FlowRail operator
|
|
7
|
+
cd ~/code/your-repo
|
|
8
|
+
npx @flowrail/init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
What it writes:
|
|
12
|
+
|
|
13
|
+
- `.claude/settings.json` — PreToolUse hooks for Write/Edit/MultiEdit + Bash. Tracked. No credentials.
|
|
14
|
+
- `.mcp.json` — Claude Code MCP wiring. Tracked. Carries `Bearer ${FLOWRAIL_API_KEY}` env-reference, never the literal.
|
|
15
|
+
- `.claude/skills/flowrail-*/SKILL.md` — four agent-facing skills (design-review, verify, lineage, dep-check).
|
|
16
|
+
- `flowrail.yaml` — placeholder project config; v1 hooks scanner orchestration + org defaults here.
|
|
17
|
+
- `.gitignore` — appends `.flowrail/` so per-session runtime state (active design_review_id) stays local.
|
|
18
|
+
|
|
19
|
+
The installer also probes `https://api.flowrail.ai/mcp` with the env-supplied bearer to confirm end-to-end auth before exiting. A 401 gets a tester-actionable message; transport failures get a different one.
|
|
20
|
+
|
|
21
|
+
**Full install guide**: <https://github.com/anshumanbh/flowstate/blob/main/docs/INSTALL.md>
|
|
22
|
+
|
|
23
|
+
**Troubleshooting**: <https://github.com/anshumanbh/flowstate/blob/main/docs/TROUBLESHOOTING.md>
|
|
24
|
+
|
|
25
|
+
**Honest limits**: <https://github.com/anshumanbh/flowstate/blob/main/docs/LIMITS.md>
|
|
26
|
+
|
|
27
|
+
This is a POC — eval-only, not for production. Source: <https://github.com/anshumanbh/flowstate>.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Read / merge / write ``.claude/settings.json``.
|
|
4
|
+
*
|
|
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.
|
|
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.PRE_BASH_MATCHER = exports.PRE_WRITE_MATCHER = void 0;
|
|
15
|
+
exports.buildHookCommand = buildHookCommand;
|
|
16
|
+
exports.mergeClaudeSettings = mergeClaudeSettings;
|
|
17
|
+
exports.writeClaudeSettings = writeClaudeSettings;
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const json_config_1 = require("./json-config");
|
|
20
|
+
// Shared as a constant so the idempotency check below ("is this group
|
|
21
|
+
// ours?") can derive its match marker from the same source — without
|
|
22
|
+
// that, a renamed hook bin silently breaks re-run idempotency.
|
|
23
|
+
const HOOK_BIN_NAME = 'flowrail-hook';
|
|
24
|
+
// --yes skips the interactive install prompt so the hook works in fresh
|
|
25
|
+
// worktrees (created by start_code_task) where node_modules may not exist.
|
|
26
|
+
const HOOK_BIN = `npx --yes -p @flowrail/hook ${HOOK_BIN_NAME}`;
|
|
27
|
+
exports.PRE_WRITE_MATCHER = 'Write|Edit|MultiEdit';
|
|
28
|
+
exports.PRE_BASH_MATCHER = 'Bash';
|
|
29
|
+
/**
|
|
30
|
+
* Build the hook command for a sub-dispatcher. We MUST bake
|
|
31
|
+
* ``FLOWRAIL_MCP_URL=<base>`` in front of the npx invocation —
|
|
32
|
+
* @flowrail/hook's ``resolveEndpoint`` reads that env var and falls
|
|
33
|
+
* back to ``http://localhost:8787``. Without the prefix, the hook
|
|
34
|
+
* silently calls localhost on every Write/Edit/Bash and every gate
|
|
35
|
+
* fails open — the install would probe api.flowrail.ai successfully
|
|
36
|
+
* and the runtime hooks would never reach it. Pinned by the
|
|
37
|
+
* "hook command embeds the install-time MCP base URL" tests.
|
|
38
|
+
*/
|
|
39
|
+
function buildHookCommand(subcommand, mcpBaseUrl) {
|
|
40
|
+
return `FLOWRAIL_MCP_URL=${mcpBaseUrl} ${HOOK_BIN} ${subcommand}`;
|
|
41
|
+
}
|
|
42
|
+
function flowrailHookGroups(mcpBaseUrl) {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
matcher: exports.PRE_WRITE_MATCHER,
|
|
46
|
+
hooks: [{ type: 'command', command: buildHookCommand('pre-write', mcpBaseUrl) }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
matcher: exports.PRE_BASH_MATCHER,
|
|
50
|
+
hooks: [{ type: 'command', command: buildHookCommand('pre-bash', mcpBaseUrl) }],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
function isFlowrailGroup(group) {
|
|
55
|
+
// Identify a group as "ours" if any inner hook command points at the
|
|
56
|
+
// flowrail-hook binary, regardless of which sub-command. Conservative
|
|
57
|
+
// match so a tester who renames their shell wrapper doesn't end up
|
|
58
|
+
// with two flowrail PreToolUse entries every time they re-run init.
|
|
59
|
+
return group.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_BIN_NAME));
|
|
60
|
+
}
|
|
61
|
+
function mergeClaudeSettings(existing, mcpBaseUrl) {
|
|
62
|
+
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
|
+
return {
|
|
69
|
+
...existing,
|
|
70
|
+
hooks: { ...existingHooks, PreToolUse },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function writeClaudeSettings(projectRoot, mcpBaseUrl) {
|
|
74
|
+
(0, json_config_1.readMergeWriteJson)(path_1.default.join(projectRoot, '.claude', 'settings.json'), (existing) => mergeClaudeSettings(existing, mcpBaseUrl));
|
|
75
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Connectivity check against ``api.flowrail.ai/mcp``.
|
|
4
|
+
*
|
|
5
|
+
* Sends a single ``tools/list`` JSON-RPC call carrying the bearer.
|
|
6
|
+
* 200 + ``result.tools`` non-empty → pass. 401 → bearer is wrong.
|
|
7
|
+
* Any other shape is a transient error the tester should retry. The
|
|
8
|
+
* signal we actually care about: "the env-supplied bearer authenticates
|
|
9
|
+
* end-to-end, including DNS + TLS + Fly + auth + tool registry." If
|
|
10
|
+
* any rung fails the install is broken regardless of the file writes.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.checkConnectivity = checkConnectivity;
|
|
17
|
+
const http_1 = __importDefault(require("http"));
|
|
18
|
+
const https_1 = __importDefault(require("https"));
|
|
19
|
+
const url_1 = require("url");
|
|
20
|
+
const env_1 = require("./env");
|
|
21
|
+
const mcp_config_1 = require("./mcp-config");
|
|
22
|
+
// Hard wall-clock deadline for the whole probe — Node's per-request
|
|
23
|
+
// ``timeout`` option only fires on socket inactivity, so a server that
|
|
24
|
+
// drips one byte every few seconds during the response body would
|
|
25
|
+
// otherwise hang the install indefinitely. 15s is generous against
|
|
26
|
+
// real RTT to Fly + cold-start, tight enough to fail an obvious hang.
|
|
27
|
+
const PROBE_DEADLINE_MS = 15000;
|
|
28
|
+
const defaultFetcher = (url, init) => new Promise((resolve, reject) => {
|
|
29
|
+
const parsed = new url_1.URL(url);
|
|
30
|
+
// Pick the right module by scheme. Prod traffic is https; local
|
|
31
|
+
// dev / staging probes via FLOWRAIL_MCP_URL may be http.
|
|
32
|
+
const transport = parsed.protocol === 'http:' ? http_1.default : https_1.default;
|
|
33
|
+
let settled = false;
|
|
34
|
+
const settleReject = (err) => {
|
|
35
|
+
if (settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(deadline);
|
|
39
|
+
reject(err);
|
|
40
|
+
};
|
|
41
|
+
const settleResolve = (value) => {
|
|
42
|
+
if (settled)
|
|
43
|
+
return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(deadline);
|
|
46
|
+
resolve(value);
|
|
47
|
+
};
|
|
48
|
+
const req = transport.request({
|
|
49
|
+
method: init.method,
|
|
50
|
+
host: parsed.hostname,
|
|
51
|
+
port: parsed.port || undefined,
|
|
52
|
+
path: parsed.pathname + parsed.search,
|
|
53
|
+
headers: { ...init.headers, 'content-length': Buffer.byteLength(init.body) },
|
|
54
|
+
timeout: 10000,
|
|
55
|
+
}, (res) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
res.on('data', (c) => chunks.push(c));
|
|
58
|
+
res.on('end', () => settleResolve({
|
|
59
|
+
status: res.statusCode ?? 0,
|
|
60
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
61
|
+
}));
|
|
62
|
+
res.on('error', settleReject);
|
|
63
|
+
});
|
|
64
|
+
const deadline = setTimeout(() => {
|
|
65
|
+
req.destroy(new Error(`connectivity probe exceeded ${PROBE_DEADLINE_MS / 1000}s wall-clock deadline`));
|
|
66
|
+
}, PROBE_DEADLINE_MS);
|
|
67
|
+
req.on('error', settleReject);
|
|
68
|
+
req.on('timeout', () => {
|
|
69
|
+
req.destroy(new Error('connectivity probe socket idle for 10s'));
|
|
70
|
+
});
|
|
71
|
+
req.write(init.body);
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
async function checkConnectivity(opts) {
|
|
75
|
+
const url = opts.url ?? (0, mcp_config_1.mcpUrlFromBase)(mcp_config_1.MCP_BASE_URL_DEFAULT);
|
|
76
|
+
const fetcher = opts.fetcher ?? defaultFetcher;
|
|
77
|
+
const body = JSON.stringify({
|
|
78
|
+
jsonrpc: '2.0',
|
|
79
|
+
id: 1,
|
|
80
|
+
method: 'tools/list',
|
|
81
|
+
});
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetcher(url, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'content-type': 'application/json',
|
|
88
|
+
authorization: `Bearer ${opts.apiKey}`,
|
|
89
|
+
},
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw new env_1.InitError(`Connectivity probe to ${url} failed: ${err.message}. ` +
|
|
95
|
+
`Check your network, that the URL is reachable, and that DNS for ` +
|
|
96
|
+
`api.flowrail.ai resolves.`);
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 401) {
|
|
99
|
+
throw new env_1.InitError(`Connectivity probe got 401 from ${url}. The bearer in ` +
|
|
100
|
+
`$FLOWRAIL_API_KEY isn't recognized. Confirm the value with your ` +
|
|
101
|
+
`operator — most likely it was revoked or never registered.`);
|
|
102
|
+
}
|
|
103
|
+
if (response.status !== 200) {
|
|
104
|
+
throw new env_1.InitError(`Connectivity probe got HTTP ${response.status} from ${url}. ` +
|
|
105
|
+
`Server may be unhealthy; check https://api.flowrail.ai/health and retry.`);
|
|
106
|
+
}
|
|
107
|
+
// Confirm the response shape — a 200 with an unexpected body would
|
|
108
|
+
// pass the status check but mean the URL is pointing at the wrong
|
|
109
|
+
// server (e.g. a captive portal).
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(response.body);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new env_1.InitError(`Connectivity probe to ${url} returned non-JSON. The endpoint ` +
|
|
116
|
+
`may be a proxy or captive portal rather than the FlowRail server.`);
|
|
117
|
+
}
|
|
118
|
+
const tools = parsed.result?.tools;
|
|
119
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
120
|
+
throw new env_1.InitError(`Connectivity probe got a JSON-RPC response from ${url} but no ` +
|
|
121
|
+
`tools were registered. The server is likely misconfigured — ` +
|
|
122
|
+
`notify the operator.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DOCTOR_SCRIPT_FILENAME = void 0;
|
|
7
|
+
exports.writeDoctorScript = writeDoctorScript;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
exports.DOCTOR_SCRIPT_FILENAME = 'scripts/check-flowrail.mjs';
|
|
11
|
+
/**
|
|
12
|
+
* The content of the doctor script dropped into the target repo.
|
|
13
|
+
* It is a self-contained ES module — no deps outside node:fs.
|
|
14
|
+
*/
|
|
15
|
+
const DOCTOR_SCRIPT_CONTENT = `#!/usr/bin/env node
|
|
16
|
+
/**
|
|
17
|
+
* FlowRail doctor script — checks that .mcp.json is present and wired.
|
|
18
|
+
* Generated by @flowrail/init. Safe to commit to the repo.
|
|
19
|
+
* Re-run the installer if this script fails: npx @flowrail/init@latest
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
|
|
23
|
+
const mcpPath = '.mcp.json';
|
|
24
|
+
let ok = true;
|
|
25
|
+
|
|
26
|
+
if (!existsSync(mcpPath)) {
|
|
27
|
+
console.error(
|
|
28
|
+
'FlowRail: .mcp.json is missing — Claude Code MCP tools ' +
|
|
29
|
+
'(mcp__flowrail__*) will not be available.\\n' +
|
|
30
|
+
'Fix: npx @flowrail/init@latest',
|
|
31
|
+
);
|
|
32
|
+
ok = false;
|
|
33
|
+
} else {
|
|
34
|
+
try {
|
|
35
|
+
const cfg = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
36
|
+
if (!cfg?.mcpServers?.flowrail) {
|
|
37
|
+
console.error(
|
|
38
|
+
'FlowRail: .mcp.json has no mcpServers.flowrail entry — ' +
|
|
39
|
+
'Claude Code MCP tools (mcp__flowrail__*) will not be available.\\n' +
|
|
40
|
+
'Fix: npx @flowrail/init@latest',
|
|
41
|
+
);
|
|
42
|
+
ok = false;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(
|
|
46
|
+
'FlowRail: .mcp.json is malformed JSON.\\n' +
|
|
47
|
+
'Fix: delete it and re-run: npx @flowrail/init@latest',
|
|
48
|
+
);
|
|
49
|
+
ok = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!ok) process.exit(1);
|
|
54
|
+
`;
|
|
55
|
+
/**
|
|
56
|
+
* Writes (or overwrites) the FlowRail doctor script into `scripts/` inside
|
|
57
|
+
* the target project. The script exits 1 when `.mcp.json` is absent or
|
|
58
|
+
* missing the `mcpServers.flowrail` entry. Wire it to `predev`/`prebuild`
|
|
59
|
+
* so contributors see the failure before their first dev-server start.
|
|
60
|
+
*/
|
|
61
|
+
function writeDoctorScript(projectRoot) {
|
|
62
|
+
const target = path_1.default.join(projectRoot, exports.DOCTOR_SCRIPT_FILENAME);
|
|
63
|
+
fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
|
|
64
|
+
fs_1.default.writeFileSync(target, DOCTOR_SCRIPT_CONTENT, 'utf8');
|
|
65
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Env + argv validation for the FlowRail init CLI.
|
|
4
|
+
*
|
|
5
|
+
* The bearer token MUST come from $FLOWRAIL_API_KEY, never from argv.
|
|
6
|
+
* --key= would land the credential in shell history, ps output, and
|
|
7
|
+
* any other place the kernel exposes argv to other UIDs. Rejecting it
|
|
8
|
+
* up front and pointing the user at the env path keeps the installer
|
|
9
|
+
* one-shot and avoids documenting a foot-gun.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.InitError = exports.API_KEY_PREFIX = exports.API_KEY_ENV = void 0;
|
|
13
|
+
exports.rejectKeyArgv = rejectKeyArgv;
|
|
14
|
+
exports.readApiKeyFromEnv = readApiKeyFromEnv;
|
|
15
|
+
exports.API_KEY_ENV = 'FLOWRAIL_API_KEY';
|
|
16
|
+
exports.API_KEY_PREFIX = 'fs_poc_';
|
|
17
|
+
class InitError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(...arguments);
|
|
20
|
+
// Tag so the CLI entry point can distinguish "expected" config errors
|
|
21
|
+
// (print message, exit 2) from genuine bugs (print stack, exit 1).
|
|
22
|
+
this.kind = 'init-error';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.InitError = InitError;
|
|
26
|
+
function rejectKeyArgv(argv) {
|
|
27
|
+
for (const arg of argv) {
|
|
28
|
+
// Match --key=, --key SPACE, --api-key=, etc. We don't need a
|
|
29
|
+
// permissive parser — any flag-shaped token mentioning "key" is
|
|
30
|
+
// worth rejecting because the credential never belongs in argv.
|
|
31
|
+
if (/^--?(api[-_]?)?key([=\s]|$)/i.test(arg)) {
|
|
32
|
+
throw new InitError(`${arg} is not a supported flag. Set the bearer in the ` +
|
|
33
|
+
`${exports.API_KEY_ENV} env var instead — argv is visible in shell ` +
|
|
34
|
+
`history and process listings.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function readApiKeyFromEnv(env) {
|
|
39
|
+
const raw = env[exports.API_KEY_ENV];
|
|
40
|
+
if (raw === undefined || raw === '') {
|
|
41
|
+
throw new InitError(`${exports.API_KEY_ENV} is not set. Export the bearer your operator emailed ` +
|
|
42
|
+
`you (e.g. \`export ${exports.API_KEY_ENV}=fs_poc_...\`) and re-run.`);
|
|
43
|
+
}
|
|
44
|
+
if (!raw.startsWith(exports.API_KEY_PREFIX)) {
|
|
45
|
+
throw new InitError(`${exports.API_KEY_ENV} doesn't look like a FlowRail API key (expected prefix ` +
|
|
46
|
+
`"${exports.API_KEY_PREFIX}"). Double-check the value the operator sent — ` +
|
|
47
|
+
`dashboard tokens (fs_dash_*) and scratch values won't authenticate ` +
|
|
48
|
+
`against the MCP surface.`);
|
|
49
|
+
}
|
|
50
|
+
// Reject any control / whitespace contamination — a stray \r\n from
|
|
51
|
+
// a copy-paste would otherwise become a header-injection vector when
|
|
52
|
+
// the connectivity probe puts the value into ``Authorization: Bearer``.
|
|
53
|
+
if (/[\s\x00-\x1f\x7f]/.test(raw)) {
|
|
54
|
+
throw new InitError(`${exports.API_KEY_ENV} contains whitespace or control characters. ` +
|
|
55
|
+
`Re-copy the value from the operator's email — most likely a ` +
|
|
56
|
+
`trailing newline slipped in.`);
|
|
57
|
+
}
|
|
58
|
+
return { apiKey: raw };
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Write the project-level ``flowrail.yaml`` (POC.md §4.3 C1, SPEC.md §4.5.1).
|
|
4
|
+
*
|
|
5
|
+
* The committed config is the future hook for tester-side scanner
|
|
6
|
+
* orchestration + allowlist overrides (SPEC.md §4.5.1 §3 — version,
|
|
7
|
+
* org, scanners[]). For the POC scope none of those layers are wired
|
|
8
|
+
* server-side yet, so the shipped contents are a ``version: 1`` stub
|
|
9
|
+
* with explanatory comments. The file existing at all is what
|
|
10
|
+
* downstream features (B+/v1 scanners, org-default inheritance) need —
|
|
11
|
+
* the contents themselves can stay minimal until those land.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: if the tester has already customised the file, leave it
|
|
14
|
+
* alone. Clobbering would silently destroy a hand-curated allowlist.
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.FLOWRAIL_YAML_FILENAME = void 0;
|
|
21
|
+
exports.writeFlowrailYaml = writeFlowrailYaml;
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
exports.FLOWRAIL_YAML_FILENAME = 'flowrail.yaml';
|
|
25
|
+
const DEFAULT_FLOWRAIL_YAML = `# FlowRail project config (POC). Committed to the repo so every
|
|
26
|
+
# clone of this project picks up the same gating shape.
|
|
27
|
+
#
|
|
28
|
+
# For the POC, this file is a placeholder — the hook + MCP server
|
|
29
|
+
# enforce a fixed set of guardrails today, with no per-repo tuning
|
|
30
|
+
# beyond the design-review-derived allowlist. v1 wires this file to:
|
|
31
|
+
#
|
|
32
|
+
# * scanner orchestration (run semgrep / trufflehog / bandit /
|
|
33
|
+
# custom commands and feed verdicts back to the verifier)
|
|
34
|
+
# * org-wide guardrail defaults via "org: <name>"
|
|
35
|
+
# * per-repo overrides on the design-review approved-deps list
|
|
36
|
+
#
|
|
37
|
+
# Until then, leave this as-is or add an "org:" line if your operator
|
|
38
|
+
# pointed you at one.
|
|
39
|
+
version: 1
|
|
40
|
+
`;
|
|
41
|
+
function writeFlowrailYaml(projectRoot) {
|
|
42
|
+
const target = path_1.default.join(projectRoot, exports.FLOWRAIL_YAML_FILENAME);
|
|
43
|
+
if (fs_1.default.existsSync(target)) {
|
|
44
|
+
// Don't clobber a hand-edited file. The init's job is one-shot
|
|
45
|
+
// bootstrap; ongoing curation belongs to the tester.
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
fs_1.default.writeFileSync(target, DEFAULT_FLOWRAIL_YAML, 'utf8');
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isMcpJsonGitTracked = isMcpJsonGitTracked;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
/**
|
|
6
|
+
* Returns true if `.mcp.json` is currently tracked by git in the given
|
|
7
|
+
* project root. Returns false if the file is untracked, gitignored, the
|
|
8
|
+
* directory is not a git repo, or git is not available on PATH.
|
|
9
|
+
*/
|
|
10
|
+
function isMcpJsonGitTracked(projectRoot) {
|
|
11
|
+
const result = (0, child_process_1.spawnSync)('git', ['ls-files', '--error-unmatch', '.mcp.json'], {
|
|
12
|
+
cwd: projectRoot,
|
|
13
|
+
stdio: 'pipe',
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
});
|
|
16
|
+
return result.status === 0;
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Append gitignore entries idempotently.
|
|
4
|
+
*
|
|
5
|
+
* `.flowrail/` — per-tester runtime state; local-only, never committed.
|
|
6
|
+
* `.mcp.json` — per-contributor MCP config; also local-only. Including
|
|
7
|
+
* a regeneration comment inline in `.gitignore` surfaces
|
|
8
|
+
* the `npx @flowrail/init@latest` fix command to
|
|
9
|
+
* anyone who hits the "silent missing MCP" trap (issue #113).
|
|
10
|
+
*/
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.MCP_JSON_GITIGNORE_COMMENT = exports.MCP_JSON_GITIGNORE_ENTRY = exports.FLOWRAIL_GITIGNORE_ENTRY = void 0;
|
|
16
|
+
exports.appendFlowrailToGitignore = appendFlowrailToGitignore;
|
|
17
|
+
exports.appendMcpJsonToGitignore = appendMcpJsonToGitignore;
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
exports.FLOWRAIL_GITIGNORE_ENTRY = '.flowrail/';
|
|
21
|
+
exports.MCP_JSON_GITIGNORE_ENTRY = '.mcp.json';
|
|
22
|
+
/**
|
|
23
|
+
* Appended above the `.mcp.json` line so any contributor reading
|
|
24
|
+
* `.gitignore` (or diffing `f246a8c`-style untrack commits) sees how
|
|
25
|
+
* to regenerate the file without hunting docs.
|
|
26
|
+
*/
|
|
27
|
+
exports.MCP_JSON_GITIGNORE_COMMENT = '# Per-contributor MCP config — regenerate with: npx @flowrail/init@latest';
|
|
28
|
+
function appendFlowrailToGitignore(projectRoot) {
|
|
29
|
+
const target = path_1.default.join(projectRoot, '.gitignore');
|
|
30
|
+
let body = '';
|
|
31
|
+
if (fs_1.default.existsSync(target)) {
|
|
32
|
+
body = fs_1.default.readFileSync(target, 'utf8');
|
|
33
|
+
// Already present (with or without a trailing slash) — nothing
|
|
34
|
+
// to do. Match line-anchored so a user-supplied negation like
|
|
35
|
+
// ``!.flowrail/keep`` doesn't trick us.
|
|
36
|
+
const pattern = /^\s*\.flowrail\/?\s*$/m;
|
|
37
|
+
if (pattern.test(body)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const prefix = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
42
|
+
const block = `${prefix}# FlowRail per-tester runtime state (active design_review_id, etc).\n` +
|
|
43
|
+
`${exports.FLOWRAIL_GITIGNORE_ENTRY}\n`;
|
|
44
|
+
fs_1.default.writeFileSync(target, body + block, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Append `.mcp.json` to `.gitignore` with a regeneration comment above it.
|
|
48
|
+
* Idempotent: no-ops if `.mcp.json` is already present on its own line
|
|
49
|
+
* (with or without a leading comment).
|
|
50
|
+
*/
|
|
51
|
+
function appendMcpJsonToGitignore(projectRoot) {
|
|
52
|
+
const target = path_1.default.join(projectRoot, '.gitignore');
|
|
53
|
+
let body = '';
|
|
54
|
+
if (fs_1.default.existsSync(target)) {
|
|
55
|
+
body = fs_1.default.readFileSync(target, 'utf8');
|
|
56
|
+
if (/^\s*\.mcp\.json\s*$/m.test(body)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const prefix = body.length === 0 || body.endsWith('\n') ? '' : '\n';
|
|
61
|
+
const block = `${prefix}${exports.MCP_JSON_GITIGNORE_COMMENT}\n${exports.MCP_JSON_GITIGNORE_ENTRY}\n`;
|
|
62
|
+
fs_1.default.writeFileSync(target, body + block, 'utf8');
|
|
63
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Install @flowrail/hook into the tester's project and verify
|
|
4
|
+
* the binary is runnable before declaring success.
|
|
5
|
+
*
|
|
6
|
+
* The runtime hook commands use `npx --yes -p @flowrail/hook` so they
|
|
7
|
+
* auto-install in fresh worktrees (e.g. start_code_task). This install
|
|
8
|
+
* step is still valuable: it records the dependency in devDependencies,
|
|
9
|
+
* ensures a working binary on the original checkout (no first-run
|
|
10
|
+
* latency), and catches broken packages at setup time with a clear error
|
|
11
|
+
* rather than a silent hook miss during a Write/Edit.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.HOOK_BIN = exports.HOOK_PACKAGE = void 0;
|
|
15
|
+
exports.buildVerifyNodeScript = buildVerifyNodeScript;
|
|
16
|
+
exports.installAndVerifyHook = installAndVerifyHook;
|
|
17
|
+
const child_process_1 = require("child_process");
|
|
18
|
+
const env_1 = require("./env");
|
|
19
|
+
exports.HOOK_PACKAGE = '@flowrail/hook';
|
|
20
|
+
exports.HOOK_BIN = 'flowrail-hook';
|
|
21
|
+
const INSTALL_NPM_ARGS = ['install', '--save-dev', exports.HOOK_PACKAGE];
|
|
22
|
+
/**
|
|
23
|
+
* The runtime hook command is `npx --yes -p @flowrail/hook flowrail-hook
|
|
24
|
+
* pre-{write,bash}` (see ``buildHookCommand`` in claude-settings.ts).
|
|
25
|
+
* The binary itself only accepts those two subcommands and exits 2 on
|
|
26
|
+
* anything else — so we cannot probe it with `--version`. Instead the
|
|
27
|
+
* verifier:
|
|
28
|
+
*
|
|
29
|
+
* 1. Resolves `@flowrail/hook/package.json` from the project root —
|
|
30
|
+
* proves npm install populated node_modules.
|
|
31
|
+
* 2. Reads `pkg.bin[HOOK_BIN]` and requires object-form. String-form
|
|
32
|
+
* `bin` on a scoped package derives the binary name from the
|
|
33
|
+
* package name (so `@flowrail/hook` with `"bin": "x.js"` would
|
|
34
|
+
* install as `hook`, NOT `flowrail-hook`) — accepting that would
|
|
35
|
+
* be a false positive.
|
|
36
|
+
* 3. Spawns the bin file via `node` with no args. The hook entry
|
|
37
|
+
* point writes `flowrail-hook: unknown subcommand ...` to stderr
|
|
38
|
+
* and exits 2 when invoked without `pre-write`/`pre-bash`. We
|
|
39
|
+
* require both signals. This proves the file is parseable, all
|
|
40
|
+
* imports resolve at runtime, and the entry-point code runs to
|
|
41
|
+
* completion — catching broken shims, syntax errors, missing
|
|
42
|
+
* runtime deps, or a wrong binary masquerading at the bin path.
|
|
43
|
+
*/
|
|
44
|
+
function buildVerifyNodeScript(pkg, bin) {
|
|
45
|
+
const pkgJson = JSON.stringify(`${pkg}/package.json`);
|
|
46
|
+
const binName = JSON.stringify(bin);
|
|
47
|
+
return [
|
|
48
|
+
"const path = require('path');",
|
|
49
|
+
"const cp = require('child_process');",
|
|
50
|
+
`const pkgPath = require.resolve(${pkgJson}, { paths: [process.cwd()] });`,
|
|
51
|
+
'const pkg = require(pkgPath);',
|
|
52
|
+
`const binEntry = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin[${binName}] : null;`,
|
|
53
|
+
`if (!binEntry) {`,
|
|
54
|
+
` console.error('package.json bin must be an object with a ' + ${binName} + ' entry');`,
|
|
55
|
+
' process.exit(2);',
|
|
56
|
+
'}',
|
|
57
|
+
'const binPath = path.resolve(path.dirname(pkgPath), binEntry);',
|
|
58
|
+
"const result = cp.spawnSync(process.execPath, [binPath], { encoding: 'utf8' });",
|
|
59
|
+
`if (result.status !== 2 || !/unknown subcommand/.test(result.stderr || '') || !new RegExp(${binName}).test(result.stderr || '')) {`,
|
|
60
|
+
" console.error('binary self-test failed: status=' + result.status + ' stderr=' + (result.stderr || '').slice(0, 300));",
|
|
61
|
+
' process.exit(2);',
|
|
62
|
+
'}',
|
|
63
|
+
"process.stdout.write(pkg.version || '');",
|
|
64
|
+
].join('');
|
|
65
|
+
}
|
|
66
|
+
const VERIFY_NODE_ARGS = ['-e', buildVerifyNodeScript(exports.HOOK_PACKAGE, exports.HOOK_BIN)];
|
|
67
|
+
const defaultExec = (file, args, options) => (0, child_process_1.execFileSync)(file, args, {
|
|
68
|
+
cwd: options.cwd,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
})
|
|
71
|
+
.toString()
|
|
72
|
+
.trim();
|
|
73
|
+
/**
|
|
74
|
+
* Installs @flowrail/hook as a devDependency and verifies the binary
|
|
75
|
+
* is runnable. Throws InitError with an actionable message on failure.
|
|
76
|
+
*
|
|
77
|
+
* The `exec` parameter is a test seam — production callers omit it.
|
|
78
|
+
*/
|
|
79
|
+
function installAndVerifyHook(projectRoot, exec = defaultExec) {
|
|
80
|
+
let installOutput;
|
|
81
|
+
try {
|
|
82
|
+
installOutput = exec('npm', INSTALL_NPM_ARGS, { cwd: projectRoot });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
86
|
+
throw new env_1.InitError(`failed to install ${exports.HOOK_PACKAGE}: ${detail}\n` +
|
|
87
|
+
`Ensure npm is available and your project has a package.json.`);
|
|
88
|
+
}
|
|
89
|
+
let version;
|
|
90
|
+
try {
|
|
91
|
+
version = exec('node', VERIFY_NODE_ARGS, { cwd: projectRoot });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
95
|
+
throw new env_1.InitError(`${exports.HOOK_PACKAGE} was installed but its "${exports.HOOK_BIN}" binary could not be resolved: ${detail}\n` +
|
|
96
|
+
`Check that node_modules/${exports.HOOK_PACKAGE}/ exists and its package.json "bin" entry points at a real file.`);
|
|
97
|
+
}
|
|
98
|
+
return { installOutput, version };
|
|
99
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* @flowrail/init — one-shot installer for FlowRail POC.
|
|
5
|
+
* See ``init.ts`` for the orchestrator.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const env_1 = require("./env");
|
|
9
|
+
const init_1 = require("./init");
|
|
10
|
+
(0, init_1.runInit)().catch((err) => {
|
|
11
|
+
if (err instanceof env_1.InitError) {
|
|
12
|
+
// Expected configuration errors get a clean message + exit 2.
|
|
13
|
+
process.stderr.write(`flowrail-init: ${err.message}\n`);
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
// Anything else is a genuine bug — keep the stack so we can debug it.
|
|
17
|
+
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
18
|
+
process.stderr.write(`flowrail-init: unexpected error\n${message}\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @flowrail/init — one-shot installer (POC.md §4.3 C1).
|
|
4
|
+
*
|
|
5
|
+
* Order of operations matters:
|
|
6
|
+
*
|
|
7
|
+
* 1. ``rejectKeyArgv`` first so a ``--key=`` invocation never even
|
|
8
|
+
* reaches the env / write stages — bails out before any side effect.
|
|
9
|
+
* 2. ``readApiKeyFromEnv`` second so a missing/wrong-prefix bearer
|
|
10
|
+
* surfaces before we touch the filesystem.
|
|
11
|
+
* 3. ``preflightNoLiteralTokens`` third — refuses to clobber a tracked
|
|
12
|
+
* config that already has a real credential baked in (operator
|
|
13
|
+
* error from a prior tool / hand-edit).
|
|
14
|
+
* 4. File writes (settings.json, .mcp.json, .gitignore, skills) happen
|
|
15
|
+
* in dependency order: skill files come from a known-good source,
|
|
16
|
+
* then claude settings reference them, then mcp config references
|
|
17
|
+
* the env var, then .gitignore catches the runtime-state directory.
|
|
18
|
+
* 5. ``installAndVerifyHook`` installs and verifies the @flowrail/hook
|
|
19
|
+
* binary locally. The runtime hook commands use `npx --yes` so they
|
|
20
|
+
* auto-install in fresh worktrees, but the install step here catches
|
|
21
|
+
* broken packages at setup time and records it in devDependencies.
|
|
22
|
+
* Runs after file writes so config is consistent even if npm is slow;
|
|
23
|
+
* errors here abort before the success message.
|
|
24
|
+
* 6. ``checkConnectivity`` last so the success message accurately says
|
|
25
|
+
* "your laptop reached our server with this bearer." A failure here
|
|
26
|
+
* leaves the config files in place — the tester can fix the network
|
|
27
|
+
* and the install is already complete on their side.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.runInit = runInit;
|
|
31
|
+
const claude_settings_1 = require("./claude-settings");
|
|
32
|
+
const connectivity_1 = require("./connectivity");
|
|
33
|
+
const doctor_script_1 = require("./doctor-script");
|
|
34
|
+
const env_1 = require("./env");
|
|
35
|
+
const flowrail_yaml_1 = require("./flowrail-yaml");
|
|
36
|
+
const gitignore_1 = require("./gitignore");
|
|
37
|
+
const git_check_1 = require("./git-check");
|
|
38
|
+
const hook_install_1 = require("./hook-install");
|
|
39
|
+
const mcp_config_1 = require("./mcp-config");
|
|
40
|
+
const npm_hooks_1 = require("./npm-hooks");
|
|
41
|
+
const preflight_1 = require("./preflight");
|
|
42
|
+
const skills_1 = require("./skills");
|
|
43
|
+
const verify_write_1 = require("./verify-write");
|
|
44
|
+
async function runInit(options = {}) {
|
|
45
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
46
|
+
const argv = options.argv ?? process.argv.slice(2);
|
|
47
|
+
const env = options.env ?? process.env;
|
|
48
|
+
const log = options.log ?? ((line) => process.stdout.write(line + '\n'));
|
|
49
|
+
const gitChecker = options.gitChecker ?? git_check_1.isMcpJsonGitTracked;
|
|
50
|
+
(0, env_1.rejectKeyArgv)(argv);
|
|
51
|
+
const { apiKey } = (0, env_1.readApiKeyFromEnv)(env);
|
|
52
|
+
// ``FLOWRAIL_MCP_URL`` is the BASE URL — same shape @flowrail/hook
|
|
53
|
+
// reads at runtime. Default to the prod base; staging / local-dev
|
|
54
|
+
// overrides set the env var before running init. The base flows into
|
|
55
|
+
// (a) the connectivity probe, (b) the .mcp.json url field, (c) the
|
|
56
|
+
// FLOWRAIL_MCP_URL=<base> env prefix on the hook command lines so
|
|
57
|
+
// the hooks call the same server the install probed against.
|
|
58
|
+
const mcpBaseUrl = env['FLOWRAIL_MCP_URL'] ?? mcp_config_1.MCP_BASE_URL_DEFAULT;
|
|
59
|
+
const connectivityUrl = options.connectivityUrl ?? (0, mcp_config_1.mcpUrlFromBase)(mcpBaseUrl);
|
|
60
|
+
(0, preflight_1.preflightNoLiteralTokens)(projectRoot);
|
|
61
|
+
const skillsSourceDir = options.skillsSourceDir ?? (0, skills_1.defaultSkillsSourceDir)();
|
|
62
|
+
const copyOpts = { projectRoot, skillsSourceDir };
|
|
63
|
+
(0, skills_1.copySkills)(copyOpts);
|
|
64
|
+
log(`✓ wrote ${skills_1.FLOWRAIL_SKILLS.length} skill(s) to .claude/skills/`);
|
|
65
|
+
(0, claude_settings_1.writeClaudeSettings)(projectRoot, mcpBaseUrl);
|
|
66
|
+
log('✓ wrote .claude/settings.json');
|
|
67
|
+
log(` Write/Edit/MultiEdit → ${(0, claude_settings_1.buildHookCommand)('pre-write', mcpBaseUrl)}`);
|
|
68
|
+
log(` Bash → ${(0, claude_settings_1.buildHookCommand)('pre-bash', mcpBaseUrl)}`);
|
|
69
|
+
(0, mcp_config_1.writeMcpConfig)(projectRoot, mcpBaseUrl);
|
|
70
|
+
log(`✓ wrote .mcp.json (mcpServers.flowrail → ${(0, mcp_config_1.mcpUrlFromBase)(mcpBaseUrl)}, ${mcp_config_1.MCP_AUTH_REFERENCE})`);
|
|
71
|
+
if (gitChecker(projectRoot)) {
|
|
72
|
+
log('⚠ .mcp.json is currently tracked by git. Contributors who pull a commit');
|
|
73
|
+
log(' that removes it (e.g. "untrack dev artifacts") will silently lose MCP');
|
|
74
|
+
log(' tools. Untrack it now:');
|
|
75
|
+
log(' git rm --cached .mcp.json && git commit -m "chore: untrack per-contributor .mcp.json"');
|
|
76
|
+
}
|
|
77
|
+
(0, flowrail_yaml_1.writeFlowrailYaml)(projectRoot);
|
|
78
|
+
log('✓ wrote flowrail.yaml');
|
|
79
|
+
(0, gitignore_1.appendFlowrailToGitignore)(projectRoot);
|
|
80
|
+
log('✓ ensured .flowrail/ is in .gitignore');
|
|
81
|
+
(0, gitignore_1.appendMcpJsonToGitignore)(projectRoot);
|
|
82
|
+
log('✓ ensured .mcp.json is in .gitignore (with regeneration comment)');
|
|
83
|
+
(0, doctor_script_1.writeDoctorScript)(projectRoot);
|
|
84
|
+
log('✓ wrote scripts/check-flowrail.mjs (doctor script)');
|
|
85
|
+
const npmPatched = (0, npm_hooks_1.patchNpmScripts)(projectRoot);
|
|
86
|
+
if (npmPatched) {
|
|
87
|
+
log('✓ wired FlowRail doctor into predev/prebuild npm scripts');
|
|
88
|
+
}
|
|
89
|
+
log(`→ installing ${hook_install_1.HOOK_PACKAGE}…`);
|
|
90
|
+
const hookResult = options.hookInstaller != null
|
|
91
|
+
? options.hookInstaller()
|
|
92
|
+
: (0, hook_install_1.installAndVerifyHook)(projectRoot);
|
|
93
|
+
log(`✓ installed @flowrail/hook ${hookResult.version} (hook binary verified)`);
|
|
94
|
+
// Self-test: prove the installed hook actually *fires* on a write, not
|
|
95
|
+
// just that the binary resolves. Runs before the connectivity probe so
|
|
96
|
+
// the more fundamental "is FlowRail guarding writes at all?" failure
|
|
97
|
+
// surfaces first. A failure here throws InitError and aborts the
|
|
98
|
+
// install — a silent FlowRail is worse than a loud failure (issue #133).
|
|
99
|
+
log('→ self-testing the hook (a known test secret must be blocked)…');
|
|
100
|
+
const runSelfTest = options.hookSelfTest ??
|
|
101
|
+
(() => (0, verify_write_1.runHookSelfTest)({
|
|
102
|
+
resolveHookBinPath: () => (0, verify_write_1.resolveInstalledHookBinPath)(projectRoot),
|
|
103
|
+
runHook: verify_write_1.spawnHookPreWrite,
|
|
104
|
+
env,
|
|
105
|
+
}));
|
|
106
|
+
runSelfTest();
|
|
107
|
+
log('✓ hook self-test passed — write carrying a test secret was blocked');
|
|
108
|
+
log(`→ checking connectivity to ${connectivityUrl}…`);
|
|
109
|
+
await (0, connectivity_1.checkConnectivity)({
|
|
110
|
+
apiKey,
|
|
111
|
+
url: connectivityUrl,
|
|
112
|
+
fetcher: options.connectivityFetcher,
|
|
113
|
+
});
|
|
114
|
+
log('✓ connectivity check passed');
|
|
115
|
+
log('');
|
|
116
|
+
log('FlowRail is installed. Open this repo in Claude Code and start coding —');
|
|
117
|
+
log('design-review, verify, and dep-check skills are wired into the agent.');
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared "read existing JSON, merge, write back" helper used by
|
|
4
|
+
* ``mcp-config`` and ``claude-settings``. Both files do the same
|
|
5
|
+
* three-step dance with the same error message on malformed input,
|
|
6
|
+
* so consolidating here means the two writers can't drift on what
|
|
7
|
+
* "malformed" means or how to surface it.
|
|
8
|
+
*/
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.readMergeWriteJson = readMergeWriteJson;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
function readMergeWriteJson(target, merge) {
|
|
17
|
+
let existing = {};
|
|
18
|
+
if (fs_1.default.existsSync(target)) {
|
|
19
|
+
const raw = fs_1.default.readFileSync(target, 'utf8');
|
|
20
|
+
try {
|
|
21
|
+
existing = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Malformed input is the operator's problem; refuse to clobber
|
|
25
|
+
// silently so the caller-provided merge logic can't accidentally
|
|
26
|
+
// overwrite the only copy of a hand-edited config.
|
|
27
|
+
throw new Error(`${target} is not valid JSON. Fix it or delete it before re-running.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Create the parent dir lazily so callers don't have to think
|
|
32
|
+
// about whether ``.claude/`` exists.
|
|
33
|
+
fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
const merged = merge(existing);
|
|
36
|
+
fs_1.default.writeFileSync(target, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Read / merge / write ``.mcp.json``.
|
|
4
|
+
*
|
|
5
|
+
* The tracked file holds the ``${FLOWRAIL_API_KEY}`` env-reference, NOT
|
|
6
|
+
* the resolved literal — without that distinction every committed
|
|
7
|
+
* .mcp.json would be a credential dump. Claude Code resolves the
|
|
8
|
+
* reference at runtime from the user's shell env.
|
|
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.MCP_AUTH_REFERENCE = exports.MCP_PATH = exports.MCP_BASE_URL_DEFAULT = exports.MCP_SERVER_NAME = void 0;
|
|
15
|
+
exports.mcpUrlFromBase = mcpUrlFromBase;
|
|
16
|
+
exports.flowrailMcpEntry = flowrailMcpEntry;
|
|
17
|
+
exports.mergeMcpConfig = mergeMcpConfig;
|
|
18
|
+
exports.writeMcpConfig = writeMcpConfig;
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const env_1 = require("./env");
|
|
21
|
+
const json_config_1 = require("./json-config");
|
|
22
|
+
exports.MCP_SERVER_NAME = 'flowrail';
|
|
23
|
+
// Base URL — the form ``@flowrail/hook`` reads from
|
|
24
|
+
// ``FLOWRAIL_MCP_URL`` (it appends ``/mcp`` itself). Init writes this
|
|
25
|
+
// onto the hook command line as an env prefix and also uses it as the
|
|
26
|
+
// source of truth for ``.mcp.json`` + the connectivity probe.
|
|
27
|
+
exports.MCP_BASE_URL_DEFAULT = 'https://api.flowrail.ai';
|
|
28
|
+
exports.MCP_PATH = '/mcp';
|
|
29
|
+
exports.MCP_AUTH_REFERENCE = `Bearer \${${env_1.API_KEY_ENV}}`;
|
|
30
|
+
function mcpUrlFromBase(base) {
|
|
31
|
+
// Strip a trailing slash so an operator pasting "https://host/" gets
|
|
32
|
+
// a clean "https://host/mcp" rather than "https://host//mcp".
|
|
33
|
+
return `${base.replace(/\/+$/, '')}${exports.MCP_PATH}`;
|
|
34
|
+
}
|
|
35
|
+
function flowrailMcpEntry(baseUrl) {
|
|
36
|
+
return {
|
|
37
|
+
type: 'http',
|
|
38
|
+
url: mcpUrlFromBase(baseUrl),
|
|
39
|
+
headers: { Authorization: exports.MCP_AUTH_REFERENCE },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function mergeMcpConfig(existing, baseUrl) {
|
|
43
|
+
// Preserve any pre-existing mcpServers entries (the tester might
|
|
44
|
+
// already have other MCP servers wired). Overwrite ours specifically
|
|
45
|
+
// so a stale URL or header reference gets refreshed without
|
|
46
|
+
// surprising the user with duplicate keys.
|
|
47
|
+
const servers = { ...(existing.mcpServers ?? {}) };
|
|
48
|
+
servers[exports.MCP_SERVER_NAME] = flowrailMcpEntry(baseUrl);
|
|
49
|
+
return { ...existing, mcpServers: servers };
|
|
50
|
+
}
|
|
51
|
+
function writeMcpConfig(projectRoot, baseUrl) {
|
|
52
|
+
(0, json_config_1.readMergeWriteJson)(path_1.default.join(projectRoot, '.mcp.json'), (existing) => mergeMcpConfig(existing, baseUrl));
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.patchNpmScripts = patchNpmScripts;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const doctor_script_1 = require("./doctor-script");
|
|
10
|
+
const DOCTOR_COMMAND = `node ${doctor_script_1.DOCTOR_SCRIPT_FILENAME}`;
|
|
11
|
+
const HOOKS_TO_PATCH = ['predev', 'prebuild'];
|
|
12
|
+
/**
|
|
13
|
+
* Appends the FlowRail doctor command to `predev` and `prebuild` npm
|
|
14
|
+
* lifecycle scripts in the target repo's `package.json`. If either hook
|
|
15
|
+
* already exists the doctor command is appended with `&&`; if it doesn't
|
|
16
|
+
* exist a new entry is created. No-ops when there is no `package.json`,
|
|
17
|
+
* when `package.json` is malformed, or when the command is already present.
|
|
18
|
+
*
|
|
19
|
+
* Returns true when at least one script was modified and the file was
|
|
20
|
+
* rewritten, false otherwise.
|
|
21
|
+
*/
|
|
22
|
+
function patchNpmScripts(projectRoot) {
|
|
23
|
+
const pkgPath = path_1.default.join(projectRoot, 'package.json');
|
|
24
|
+
if (!fs_1.default.existsSync(pkgPath))
|
|
25
|
+
return false;
|
|
26
|
+
let pkg;
|
|
27
|
+
try {
|
|
28
|
+
pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const scripts = (typeof pkg.scripts === 'object' && pkg.scripts !== null
|
|
34
|
+
? pkg.scripts
|
|
35
|
+
: {});
|
|
36
|
+
let changed = false;
|
|
37
|
+
for (const hook of HOOKS_TO_PATCH) {
|
|
38
|
+
const existing = scripts[hook];
|
|
39
|
+
if (existing === undefined) {
|
|
40
|
+
scripts[hook] = DOCTOR_COMMAND;
|
|
41
|
+
changed = true;
|
|
42
|
+
}
|
|
43
|
+
else if (!existing.includes(doctor_script_1.DOCTOR_SCRIPT_FILENAME)) {
|
|
44
|
+
scripts[hook] = `${existing} && ${DOCTOR_COMMAND}`;
|
|
45
|
+
changed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (changed) {
|
|
49
|
+
pkg.scripts = scripts;
|
|
50
|
+
fs_1.default.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
|
51
|
+
}
|
|
52
|
+
return changed;
|
|
53
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Preflight: refuse to proceed if any tracked Claude Code config
|
|
4
|
+
* already contains a literal ``fs_poc_*`` token.
|
|
5
|
+
*
|
|
6
|
+
* The ``${FLOWRAIL_API_KEY}`` env-reference is the contract. A literal
|
|
7
|
+
* in ``.claude/settings.json`` / ``.mcp.json`` / ``.claude/settings.local.json``
|
|
8
|
+
* means the operator (or a previous tool) baked a real credential
|
|
9
|
+
* into a tracked file — exactly the failure mode the env-reference
|
|
10
|
+
* design exists to prevent. Catch it before init writes anything else.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.preflightNoLiteralTokens = preflightNoLiteralTokens;
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const env_1 = require("./env");
|
|
20
|
+
const env_2 = require("./env");
|
|
21
|
+
const TRACKED_CONFIGS = [
|
|
22
|
+
'.claude/settings.json',
|
|
23
|
+
'.mcp.json',
|
|
24
|
+
'.claude/settings.local.json',
|
|
25
|
+
];
|
|
26
|
+
// Match ``fs_poc_<slug>_<random>`` literals — slug is alnum+dash+
|
|
27
|
+
// underscore, suffix is hex-shaped. Loose enough to catch any plausible
|
|
28
|
+
// real key; strict enough to skip the literal "fs_poc_" string in our
|
|
29
|
+
// own documentation / comments.
|
|
30
|
+
const LITERAL_TOKEN_RE = /fs_poc_[A-Za-z0-9_-]{1,40}_[A-Fa-f0-9]{8,}/;
|
|
31
|
+
function preflightNoLiteralTokens(projectRoot) {
|
|
32
|
+
const offenders = [];
|
|
33
|
+
for (const rel of TRACKED_CONFIGS) {
|
|
34
|
+
const absolute = path_1.default.join(projectRoot, rel);
|
|
35
|
+
if (!fs_1.default.existsSync(absolute))
|
|
36
|
+
continue;
|
|
37
|
+
const body = fs_1.default.readFileSync(absolute, 'utf8');
|
|
38
|
+
if (LITERAL_TOKEN_RE.test(body)) {
|
|
39
|
+
offenders.push(rel);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (offenders.length > 0) {
|
|
43
|
+
throw new env_2.InitError(`Refusing to proceed: literal "${env_1.API_KEY_PREFIX}*" token found in ` +
|
|
44
|
+
`tracked config: ${offenders.join(', ')}. Remove the literal — ` +
|
|
45
|
+
`the bearer must live in $FLOWRAIL_API_KEY and the config file ` +
|
|
46
|
+
`holds only the \${FLOWRAIL_API_KEY} reference.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copy skill markdown into ``.claude/skills/``.
|
|
4
|
+
*
|
|
5
|
+
* The four skills (design-review, verify, lineage, dep-check) live in
|
|
6
|
+
* ``packages/skills/`` as the canonical source. The init copies them
|
|
7
|
+
* under the tester's ``.claude/skills/`` so Claude Code can resolve
|
|
8
|
+
* them locally — no runtime fetch, no wiggle between server and client.
|
|
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.FLOWRAIL_SKILLS = void 0;
|
|
15
|
+
exports.copySkills = copySkills;
|
|
16
|
+
exports.defaultSkillsSourceDir = defaultSkillsSourceDir;
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
// Single source of truth for the four shipped skill names. Lives in
|
|
20
|
+
// ``@flowrail/skills/manifest.json`` so the init copier and the
|
|
21
|
+
// skills package's own integrity tests can't drift on which skills
|
|
22
|
+
// are required.
|
|
23
|
+
const manifest = require('@flowrail/skills/manifest.json');
|
|
24
|
+
exports.FLOWRAIL_SKILLS = manifest.skills;
|
|
25
|
+
function copySkills(opts) {
|
|
26
|
+
const targetRoot = path_1.default.join(opts.projectRoot, '.claude', 'skills');
|
|
27
|
+
fs_1.default.mkdirSync(targetRoot, { recursive: true });
|
|
28
|
+
for (const skill of exports.FLOWRAIL_SKILLS) {
|
|
29
|
+
const src = path_1.default.join(opts.skillsSourceDir, skill, 'SKILL.md');
|
|
30
|
+
if (!fs_1.default.existsSync(src)) {
|
|
31
|
+
// Fail loud — a missing skill at install time is a packaging bug,
|
|
32
|
+
// not a tester problem. Without this check, the agent would
|
|
33
|
+
// silently never invoke the missing skill and we'd debug it via
|
|
34
|
+
// a tester report instead of at install.
|
|
35
|
+
throw new Error(`expected SKILL.md at ${src} (workspace skills source missing)`);
|
|
36
|
+
}
|
|
37
|
+
const dstDir = path_1.default.join(targetRoot, skill);
|
|
38
|
+
fs_1.default.mkdirSync(dstDir, { recursive: true });
|
|
39
|
+
fs_1.default.copyFileSync(src, path_1.default.join(dstDir, 'SKILL.md'));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Default skills source — the sibling ``packages/skills/`` in the
|
|
44
|
+
* monorepo. ``require.resolve`` walks the resolution graph so it works
|
|
45
|
+
* even when the init package is symlinked into a tester's repo via
|
|
46
|
+
* ``npm link``.
|
|
47
|
+
*/
|
|
48
|
+
function defaultSkillsSourceDir() {
|
|
49
|
+
// The skills package's own ``package.json`` is the resolution
|
|
50
|
+
// anchor. Take its dirname to land on the package root.
|
|
51
|
+
const pkgJson = require.resolve('@flowrail/skills/package.json');
|
|
52
|
+
return path_1.default.dirname(pkgJson);
|
|
53
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Post-install hook self-test (issue #133).
|
|
4
|
+
*
|
|
5
|
+
* Connectivity (``connectivity.ts``) proves the laptop can reach the MCP
|
|
6
|
+
* server with this bearer. It does NOT prove the locally-installed hook
|
|
7
|
+
* actually *fires* on a write — a broken shim, a mis-wired
|
|
8
|
+
* ``.claude/settings.json``, or a hook that silently fails open would all
|
|
9
|
+
* pass connectivity while leaving the developer completely unguarded.
|
|
10
|
+
* That is the exact silent-failure this self-test closes: it drives the
|
|
11
|
+
* real installed ``flowrail-hook pre-write`` with a synthetic Write whose
|
|
12
|
+
* content carries a known test secret, and asserts the hook hard-denies.
|
|
13
|
+
*
|
|
14
|
+
* No file is written to disk — the Write payload carries the content
|
|
15
|
+
* inline and the hook reconstructs it from the payload, so the probe
|
|
16
|
+
* secret never lands on the filesystem and there is nothing to clean up.
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.spawnHookPreWrite = exports.PROBE_PATH = exports.PROBE_SECRET = void 0;
|
|
23
|
+
exports.buildProbePayload = buildProbePayload;
|
|
24
|
+
exports.runHookSelfTest = runHookSelfTest;
|
|
25
|
+
exports.resolveInstalledHookBinPath = resolveInstalledHookBinPath;
|
|
26
|
+
const path_1 = __importDefault(require("path"));
|
|
27
|
+
const child_process_1 = require("child_process");
|
|
28
|
+
const env_1 = require("./env");
|
|
29
|
+
const hook_install_1 = require("./hook-install");
|
|
30
|
+
// AWS's published example access key (a well-known non-credential).
|
|
31
|
+
// Assembled from fragments so the 20-char literal never appears verbatim
|
|
32
|
+
// in this source file — otherwise FlowRail's own secret net (and any
|
|
33
|
+
// other scanner) would flag this very file. At runtime the fragments
|
|
34
|
+
// join into the full AKIA… string that the hook's ``/AKIA[0-9A-Z]{16}/``
|
|
35
|
+
// net matches when it scans the probe payload.
|
|
36
|
+
exports.PROBE_SECRET = ['AKIA', 'IOSFODNN7', 'EXAMPLE'].join('');
|
|
37
|
+
// A plain, non-allowlisted, non-spec path so the hook's secret net runs:
|
|
38
|
+
// paths under docs/ tests/ fixtures/ and ``*.spec.md`` short-circuit the
|
|
39
|
+
// pipeline. The file is never created (see module docstring).
|
|
40
|
+
exports.PROBE_PATH = 'flowrail-install-selftest.ts';
|
|
41
|
+
/** Build the synthetic PreToolUse stdin the hook expects for a Write. */
|
|
42
|
+
function buildProbePayload() {
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
tool_name: 'Write',
|
|
45
|
+
tool_input: {
|
|
46
|
+
file_path: exports.PROBE_PATH,
|
|
47
|
+
content: `const awsKey = "${exports.PROBE_SECRET}";\n`,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Drive the installed hook against the probe and throw ``InitError`` if it
|
|
53
|
+
* does not hard-deny. A non-firing guardrail is treated as an install
|
|
54
|
+
* failure: a silent FlowRail is worse than a loud failure (issue #133).
|
|
55
|
+
*/
|
|
56
|
+
function runHookSelfTest(deps) {
|
|
57
|
+
const binPath = deps.resolveHookBinPath();
|
|
58
|
+
// Point the spawned hook's telemetry at an unroutable loopback so its
|
|
59
|
+
// fire-and-forget secret_detected POST refuses instantly (fail-open)
|
|
60
|
+
// instead of writing a fake-secret event into the tester's real
|
|
61
|
+
// dashboard. The hard-deny under test is purely local and unaffected.
|
|
62
|
+
//
|
|
63
|
+
// FLOWRAIL_API_KEY must stay non-empty: the hook's env guard fails
|
|
64
|
+
// OPEN (exit 0, allow) when the key is absent, *before* the secret net
|
|
65
|
+
// runs — which would make a keyless run look like a self-test failure
|
|
66
|
+
// for the wrong reason. Any non-empty value works; the deny path never
|
|
67
|
+
// authenticates. Always inject a dummy — never forward the real key —
|
|
68
|
+
// so the doomed telemetry POST cannot exfiltrate the tester's bearer
|
|
69
|
+
// (the secret_detected emit puts $FLOWRAIL_API_KEY in the Authorization
|
|
70
|
+
// header before the loopback rejects the connection, and even an
|
|
71
|
+
// unroutable target shouldn't see real credentials in its handshake).
|
|
72
|
+
const env = {
|
|
73
|
+
...deps.env,
|
|
74
|
+
FLOWRAIL_API_KEY: 'flowrail-selftest',
|
|
75
|
+
FLOWRAIL_MCP_URL: 'http://127.0.0.1:1',
|
|
76
|
+
};
|
|
77
|
+
const { status, stderr } = deps.runHook(binPath, buildProbePayload(), env);
|
|
78
|
+
const blocked = status === 2 && /secret pattern detected/i.test(stderr);
|
|
79
|
+
if (!blocked) {
|
|
80
|
+
// Echo a truncated stderr excerpt (same diagnostic pattern as
|
|
81
|
+
// hook-install.ts's binary self-test): the common real failure is the
|
|
82
|
+
// hook exiting 2 for a *different* reason (parse error, internal
|
|
83
|
+
// error), and "got exit 2" alone tells the tester nothing.
|
|
84
|
+
const excerpt = stderr.trim().slice(0, 300);
|
|
85
|
+
throw new env_1.InitError('hook self-test FAILED — the installed flowrail-hook did NOT block a ' +
|
|
86
|
+
'write containing a known test secret ' +
|
|
87
|
+
`(expected exit 2 + "secret pattern detected", got exit ${status}).\n` +
|
|
88
|
+
'FlowRail is installed but not actually guarding writes; a silent ' +
|
|
89
|
+
'FlowRail is worse than a failed install. Check that @flowrail/hook ' +
|
|
90
|
+
'installed cleanly and that .claude/settings.json wires the ' +
|
|
91
|
+
'flowrail-hook PreToolUse command.' +
|
|
92
|
+
(excerpt ? `\nHook stderr: ${excerpt}` : ''));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Production wiring
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
/**
|
|
99
|
+
* Resolve the installed hook's bin path from ``projectRoot``. Mirrors the
|
|
100
|
+
* resolution in ``hook-install.ts``'s verifier: require the package.json,
|
|
101
|
+
* read the object-form ``bin`` entry, resolve it against the package dir.
|
|
102
|
+
*
|
|
103
|
+
* Resolution relies on ``installAndVerifyHook`` having run first against
|
|
104
|
+
* the same ``projectRoot`` (it ``npm install``s @flowrail/hook there), so
|
|
105
|
+
* @flowrail/hook need not be a declared dependency of @flowrail/init.
|
|
106
|
+
* Out of that ordering, resolution throws a clear InitError rather than a
|
|
107
|
+
* confusing crash.
|
|
108
|
+
*/
|
|
109
|
+
function resolveInstalledHookBinPath(projectRoot) {
|
|
110
|
+
let pkgPath;
|
|
111
|
+
try {
|
|
112
|
+
pkgPath = require.resolve(`${hook_install_1.HOOK_PACKAGE}/package.json`, {
|
|
113
|
+
paths: [projectRoot],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
118
|
+
throw new env_1.InitError(`could not resolve ${hook_install_1.HOOK_PACKAGE} for the self-test: ${detail}`);
|
|
119
|
+
}
|
|
120
|
+
let pkg;
|
|
121
|
+
try {
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
123
|
+
pkg = require(pkgPath);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// A resolvable-but-unparseable package.json (truncated download, disk
|
|
127
|
+
// corruption mid-install) must surface as an actionable InitError, not
|
|
128
|
+
// a bare SyntaxError escaping into the top-level handler.
|
|
129
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
130
|
+
throw new env_1.InitError(`${hook_install_1.HOOK_PACKAGE} package.json could not be loaded for the self-test: ${detail}`);
|
|
131
|
+
}
|
|
132
|
+
const binEntry = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin[hook_install_1.HOOK_BIN] : undefined;
|
|
133
|
+
if (!binEntry) {
|
|
134
|
+
throw new env_1.InitError(`${hook_install_1.HOOK_PACKAGE} package.json has no "${hook_install_1.HOOK_BIN}" bin entry; ` +
|
|
135
|
+
'cannot run the hook self-test.');
|
|
136
|
+
}
|
|
137
|
+
return path_1.default.resolve(path_1.default.dirname(pkgPath), binEntry);
|
|
138
|
+
}
|
|
139
|
+
/** Production ``HookRunner`` — spawns the hook via the current node. */
|
|
140
|
+
const spawnHookPreWrite = (binPath, stdin, env) => {
|
|
141
|
+
const result = (0, child_process_1.spawnSync)(process.execPath, [binPath, 'pre-write'], {
|
|
142
|
+
input: stdin,
|
|
143
|
+
env,
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
});
|
|
146
|
+
return { status: result.status, stderr: result.stderr ?? '' };
|
|
147
|
+
};
|
|
148
|
+
exports.spawnHookPreWrite = spawnHookPreWrite;
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flowrail/init",
|
|
3
|
+
"version": "0.0.7",
|
|
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
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/anshumanbh/flowstate.git",
|
|
13
|
+
"directory": "packages/init"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/anshumanbh/flowstate#readme",
|
|
16
|
+
"bugs": "https://github.com/anshumanbh/flowstate/issues",
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"bin": {
|
|
19
|
+
"flowrail-init": "dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist/**/*.js",
|
|
23
|
+
"dist/**/*.d.ts"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"test": "jest",
|
|
31
|
+
"prepublishOnly": "npm run build && npm test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@flowrail/skills": "0.0.3"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/jest": "^29.5.12",
|
|
38
|
+
"@types/node": "^20.14.0",
|
|
39
|
+
"jest": "^29.7.0",
|
|
40
|
+
"ts-jest": "^29.1.5",
|
|
41
|
+
"typescript": "^5.4.5"
|
|
42
|
+
}
|
|
43
|
+
}
|