@flowrail/init 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical copy-in CI gate workflow generated by @flowrail/init.
4
+ *
5
+ * This package cannot depend on the repository workflow at runtime, so the
6
+ * caller-ready form is embedded here. The drift test pins the gate-authority
7
+ * tail to the reusable workflow maintained at the repository root.
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.CI_WORKFLOW_RELATIVE_PATH = void 0;
14
+ exports.renderCiWorkflow = renderCiWorkflow;
15
+ exports.writeCiWorkflow = writeCiWorkflow;
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ exports.CI_WORKFLOW_RELATIVE_PATH = '.github/workflows/flowrail-verify.yml';
19
+ const PROJECT_ID_PATTERN = /^prj_[0-9a-f]{32}$/;
20
+ const PROJECT_ID_PLACEHOLDER = '__FLOWRAIL_PROJECT_ID__';
21
+ const CI_WORKFLOW_TEMPLATE = String.raw `# generated by @flowrail/init v0.0.16 (--ci-gate)
22
+ # workflow-protocol: 1
23
+ name: flowrail-verify
24
+
25
+ on:
26
+ pull_request:
27
+
28
+ permissions:
29
+ contents: read
30
+ pull-requests: write
31
+
32
+ jobs:
33
+ verify:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ with:
38
+ fetch-depth: 0
39
+ # PR #102 review round 4 (P1): without this, actions/checkout
40
+ # persists the workflow's GitHub token in .git/config. An
41
+ # attacker who lands a symlink such as \`\`leak -> .git/config\`\`
42
+ # in the PR would have that file read + uploaded to
43
+ # \`\`/api/verify\`\` by the payload builder below — leaking
44
+ # repository credentials to the FlowRail endpoint. The
45
+ # symlink skip below is the second layer; this is the first.
46
+ persist-credentials: false
47
+
48
+ - name: Build changed-file payload
49
+ env:
50
+ BASE_SHA: @@{{ github.event.pull_request.base.sha || github.event.before }}
51
+ HEAD_SHA: @@{{ github.event.pull_request.head.sha || github.sha }}
52
+ PR_NUMBER: @@{{ github.event.pull_request.number }}
53
+ run: |
54
+ python - <<'PY'
55
+ import json
56
+ import os
57
+ import subprocess
58
+ from pathlib import Path
59
+
60
+ base = os.environ.get("BASE_SHA") or "HEAD~1"
61
+ head = os.environ.get("HEAD_SHA") or "HEAD"
62
+ raw_names = subprocess.check_output(
63
+ [
64
+ "git",
65
+ "-c",
66
+ "core.quotepath=false",
67
+ "diff",
68
+ "-z",
69
+ "--name-only",
70
+ f"{base}...{head}",
71
+ ],
72
+ )
73
+ names = [
74
+ name
75
+ # \`\`surrogateescape\`\` so a legitimately non-UTF-8 filename in the
76
+ # diff cannot raise UnicodeDecodeError and fail the whole
77
+ # collector step; such names round-trip back to bytes for the
78
+ # symlink/is_file/read_text checks below.
79
+ for name in raw_names.decode("utf-8", "surrogateescape").split("\x00")
80
+ if name
81
+ ]
82
+ files = []
83
+ for name in names:
84
+ path = Path(name)
85
+ # PR #102 review round 4 (P1): skip symlinks. \`\`is_file\`\`
86
+ # follows the link, so a PR that adds \`\`leak -> /etc/passwd\`\`
87
+ # or \`\`leak -> .git/config\`\` would have the *target* read
88
+ # and uploaded. Defense paired with persist-credentials:
89
+ # false on the checkout step.
90
+ if path.is_symlink():
91
+ continue
92
+ if not path.is_file():
93
+ continue
94
+ try:
95
+ content = path.read_text(encoding="utf-8")
96
+ except UnicodeDecodeError:
97
+ continue
98
+ # The surrogateescaped \`\`name\`\` is correct for the filesystem
99
+ # checks above, but a lone surrogate must NOT go on the wire:
100
+ # the path is only a display label server-side, and strict-UTF-8
101
+ # response encoding would raise UnicodeEncodeError when a finding
102
+ # echoes it. Send a JSON-safe backslash-escaped display path.
103
+ display_path = os.fsencode(name).decode("utf-8", "backslashreplace")
104
+ files.append({"path": display_path, "content": content})
105
+ payload = {
106
+ "commit_sha": head,
107
+ "pr_number": int(os.environ["PR_NUMBER"]) if os.environ.get("PR_NUMBER") else None,
108
+ "files": files,
109
+ }
110
+ Path("flowrail-payload.json").write_text(json.dumps(payload), encoding="utf-8")
111
+ PY
112
+
113
+ - name: Call FlowRail verify
114
+ env:
115
+ FLOWSTATE_URL: https://api.flowrail.ai
116
+ PROJECT_ID: __FLOWRAIL_PROJECT_ID__
117
+ FLOWRAIL_TOKEN: @@{{ secrets.FLOWRAIL_PROJECT_TOKEN }}
118
+ run: |
119
+ # --fail-with-body: exit non-zero on 4xx/5xx but still write
120
+ # the error body to stdout so failures are debuggable. Without
121
+ # this flag a 401 from a wrong/unconfigured token would write
122
+ # a JSON error envelope to flowrail-results.json and the next
123
+ # step would publish a misleading "No custom guardrail findings"
124
+ # comment.
125
+ curl -sS --fail-with-body \
126
+ -H "Authorization: Bearer @@{FLOWRAIL_TOKEN}" \
127
+ -H "Content-Type: application/json" \
128
+ --data @flowrail-payload.json \
129
+ "@@{FLOWSTATE_URL}/api/verify?project_id=@@{PROJECT_ID}" \
130
+ > flowrail-results.json
131
+
132
+ - name: Extract PR comment
133
+ # Server renders the PR-comment markdown into
134
+ # \`\`pr_comment_markdown\`\` so the workflow does not have to ship
135
+ # FlowRail tooling into caller repos. \`\`actions/checkout\`\` above
136
+ # checked out the CALLER's repo, not flowstate, so
137
+ # \`\`tools/format_pr_comment.py\`\` is not available here.
138
+ run: |
139
+ python3 - <<'PY'
140
+ import json
141
+ import sys
142
+
143
+ # Degrade gracefully: a truncated/corrupt response must not crash the
144
+ # step with a raw traceback. The verdict-enforcement step below is the
145
+ # gate authority (it fails closed on malformed JSON); here we only
146
+ # render a comment, so fall back to a placeholder on any parse error.
147
+ try:
148
+ with open("flowrail-results.json", encoding="utf-8") as f:
149
+ markdown = json.load(f).get("pr_comment_markdown")
150
+ except Exception as exc:
151
+ markdown = None
152
+ print(f"FlowRail: could not parse results for the PR comment: {exc}", file=sys.stderr)
153
+
154
+ # Guard against a parseable-but-non-string field (e.g. 123): writing
155
+ # it would raise TypeError and fail the step before the gate runs.
156
+ if not isinstance(markdown, str) or not markdown:
157
+ markdown = (
158
+ "<!-- flowrail-verify-comment -->\n"
159
+ "## FlowRail verify\n\n"
160
+ "FlowRail could not render a PR comment (empty or unparseable "
161
+ "response). The gate result is reported by the FlowRail check "
162
+ "on this pull request."
163
+ )
164
+
165
+ # \`\`backslashreplace\`\` so a stray unpaired surrogate in the markdown
166
+ # (however it arose) becomes an escape rather than crashing the write
167
+ # and failing the job before the gate's verdict step runs.
168
+ with open("flowrail-comment.md", "w", encoding="utf-8", errors="backslashreplace") as f:
169
+ f.write(markdown)
170
+ PY
171
+
172
+ - name: Upsert PR comment
173
+ if: @@{{ github.event.pull_request.number }}
174
+ env:
175
+ GH_TOKEN: @@{{ github.token }}
176
+ PR_NUMBER: @@{{ github.event.pull_request.number }}
177
+ REPO: @@{{ github.repository }}
178
+ run: |
179
+ marker='<!-- flowrail-verify-comment -->'
180
+ existing=$(
181
+ gh api "repos/@@{REPO}/issues/@@{PR_NUMBER}/comments" \
182
+ --jq ".[] | select(.body | contains(\"@@{marker}\")) | .id" \
183
+ | head -1
184
+ )
185
+ if [ -n "$existing" ]; then
186
+ gh api "repos/@@{REPO}/issues/comments/@@{existing}" \
187
+ --method PATCH \
188
+ --field body="$(cat flowrail-comment.md)"
189
+ else
190
+ gh pr comment "$PR_NUMBER" --body-file flowrail-comment.md
191
+ fi
192
+
193
+ - name: Enforce verdict
194
+ run: |
195
+ python3 - <<'PY'
196
+ import json
197
+ import sys
198
+
199
+ try:
200
+ with open("flowrail-results.json", encoding="utf-8") as f:
201
+ result = json.load(f)
202
+ except Exception as exc:
203
+ print("FlowRail verdict: status=contract_error mode=enforce findings=0")
204
+ print(f"FlowRail contract mismatch: could not load results: {exc}", file=sys.stderr)
205
+ raise SystemExit(1)
206
+
207
+ if not isinstance(result, dict):
208
+ status = None
209
+ mode = "enforce"
210
+ findings = []
211
+ else:
212
+ status = result.get("status")
213
+ mode = result.get("mode")
214
+ findings = result.get("findings") or []
215
+ findings_count = len(findings) if isinstance(findings, list) else 0
216
+ print(f"FlowRail verdict: status={status} mode={mode} findings={findings_count}")
217
+ if status not in {"pass", "fail"}:
218
+ print(f"FlowRail contract mismatch: invalid status {status!r}", file=sys.stderr)
219
+ raise SystemExit(1)
220
+ if mode not in {"enforce", "advisory"}:
221
+ print(f"FlowRail contract mismatch: invalid mode {mode!r}", file=sys.stderr)
222
+ raise SystemExit(1)
223
+ if status == "fail" and mode != "advisory":
224
+ raise SystemExit(1)
225
+ PY
226
+ `
227
+ // String.raw preserves the escape needed to carry Markdown backticks
228
+ // through this TypeScript template. Remove only those deliberate escapes.
229
+ .split('\\`')
230
+ .join('`')
231
+ // Avoid TypeScript template interpolation while preserving GitHub syntax.
232
+ .split('@@{{')
233
+ .join('${{')
234
+ // Shell parameter expansion uses the same interpolation opener.
235
+ .split('@@{')
236
+ .join('${');
237
+ function renderCiWorkflow(projectId) {
238
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
239
+ throw new Error('projectId must match prj_<32 lowercase hex>');
240
+ }
241
+ return CI_WORKFLOW_TEMPLATE.replace(PROJECT_ID_PLACEHOLDER, projectId);
242
+ }
243
+ function writeCiWorkflow(projectRoot, projectId) {
244
+ const target = path_1.default.join(projectRoot, exports.CI_WORKFLOW_RELATIVE_PATH);
245
+ if (fs_1.default.existsSync(target)) {
246
+ return;
247
+ }
248
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
249
+ try {
250
+ fs_1.default.writeFileSync(target, renderCiWorkflow(projectId), { flag: 'wx' });
251
+ }
252
+ catch (error) {
253
+ if (error.code === 'EEXIST') {
254
+ return;
255
+ }
256
+ throw error;
257
+ }
258
+ }
package/dist/env.js CHANGED
@@ -25,6 +25,12 @@ class InitError extends Error {
25
25
  exports.InitError = InitError;
26
26
  function rejectKeyArgv(argv) {
27
27
  for (const arg of argv) {
28
+ // Project tokens are CI-plane credentials. They never belong on the init
29
+ // command line, including as the value of an otherwise unknown flag.
30
+ if (arg.includes('fs_prj_')) {
31
+ throw new InitError('A FlowRail project token was supplied on argv. Project tokens are ' +
32
+ 'credentials and must never be placed in shell history or process listings.');
33
+ }
28
34
  // Match --key=, --key SPACE, --api-key=, etc. We don't need a
29
35
  // permissive parser — any flag-shaped token mentioning "key" is
30
36
  // worth rejecting because the credential never belongs in argv.
package/dist/init.js CHANGED
@@ -31,6 +31,7 @@ exports.runInit = runInit;
31
31
  const app_instance_1 = require("./app-instance");
32
32
  const claude_settings_1 = require("./claude-settings");
33
33
  const connectivity_1 = require("./connectivity");
34
+ const ci_workflow_1 = require("./ci-workflow");
34
35
  const doctor_script_1 = require("./doctor-script");
35
36
  const env_1 = require("./env");
36
37
  const flowrail_yaml_1 = require("./flowrail-yaml");
@@ -43,6 +44,44 @@ const preflight_1 = require("./preflight");
43
44
  const repo_scan_trigger_1 = require("./repo-scan-trigger");
44
45
  const skills_1 = require("./skills");
45
46
  const verify_write_1 = require("./verify-write");
47
+ const PROJECT_ID_PATTERN = /^prj_[0-9a-f]{32}$/;
48
+ function parseCiGateArgs(argv) {
49
+ const enabled = argv.includes('--ci-gate');
50
+ if (!enabled)
51
+ return { enabled: false };
52
+ let projectId;
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const arg = argv[i];
55
+ let value;
56
+ if (arg === '--project-id') {
57
+ value = argv[i + 1];
58
+ if (value === undefined || value.startsWith('--')) {
59
+ throw new env_1.InitError('--ci-gate requires --project-id prj_<32 lowercase hex>.');
60
+ }
61
+ i++;
62
+ }
63
+ else if (arg.startsWith('--project-id=')) {
64
+ value = arg.slice('--project-id='.length);
65
+ }
66
+ else {
67
+ continue;
68
+ }
69
+ if (projectId !== undefined) {
70
+ throw new env_1.InitError('--project-id may be supplied only once.');
71
+ }
72
+ projectId = value;
73
+ }
74
+ if (projectId === undefined || projectId === '') {
75
+ throw new env_1.InitError('--ci-gate requires --project-id prj_<32 lowercase hex>.');
76
+ }
77
+ if (projectId.startsWith('fs_')) {
78
+ throw new env_1.InitError('--project-id accepts the public prj_ identifier, never a FlowRail token.');
79
+ }
80
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
81
+ throw new env_1.InitError('--project-id must match prj_<32 lowercase hex>.');
82
+ }
83
+ return { enabled: true, projectId };
84
+ }
46
85
  async function runInit(options = {}) {
47
86
  const projectRoot = options.projectRoot ?? process.cwd();
48
87
  const argv = options.argv ?? process.argv.slice(2);
@@ -51,6 +90,9 @@ async function runInit(options = {}) {
51
90
  const gitChecker = options.gitChecker ?? git_check_1.isMcpJsonGitTracked;
52
91
  const noScan = argv.includes('--no-scan') || argv.includes('--no-retro-scan');
53
92
  (0, env_1.rejectKeyArgv)(argv);
93
+ // Validate all CI-gate inputs before reading or writing project state. A
94
+ // placeholder or token-shaped value must never leave a partial install.
95
+ const ciGate = parseCiGateArgs(argv);
54
96
  const { apiKey } = (0, env_1.readApiKeyFromEnv)(env);
55
97
  // ``FLOWRAIL_MCP_URL`` is the BASE URL — same shape @flowrail/hook
56
98
  // reads at runtime. Default to the prod base; staging / local-dev
@@ -79,6 +121,10 @@ async function runInit(options = {}) {
79
121
  }
80
122
  (0, flowrail_yaml_1.writeFlowrailYaml)(projectRoot);
81
123
  log('✓ wrote flowrail.yaml');
124
+ if (ciGate.enabled) {
125
+ (0, ci_workflow_1.writeCiWorkflow)(projectRoot, ciGate.projectId);
126
+ log(`✓ wrote ${ci_workflow_1.CI_WORKFLOW_RELATIVE_PATH}`);
127
+ }
82
128
  (0, gitignore_1.appendFlowrailToGitignore)(projectRoot);
83
129
  log('✓ ensured .flowrail/ is in .gitignore');
84
130
  (0, gitignore_1.appendMcpJsonToGitignore)(projectRoot);
@@ -122,13 +168,19 @@ async function runInit(options = {}) {
122
168
  // covers unexpected throws (e.g. an unwritable .flowrail/).
123
169
  try {
124
170
  const rebind = argv.includes('--rebind') || env['FLOWRAIL_APP_REBIND'] === '1';
171
+ const appRegistrar = options.appRegistrar;
172
+ const registerApp = appRegistrar === undefined
173
+ ? undefined
174
+ : async (args) => (await appRegistrar(args)) ?? {
175
+ error: 'empty app registration response',
176
+ };
125
177
  const appResult = await (0, app_instance_1.ensureAppInstance)({
126
178
  projectRoot,
127
179
  mcpBaseUrl,
128
180
  apiKey,
129
181
  rebind,
130
182
  log,
131
- registerApp: options.appRegistrar,
183
+ registerApp,
132
184
  });
133
185
  log(`✓ app instance ${appResult.appInstanceId} (${appResult.status}) — .flowrail/app.json`);
134
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowrail/init",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "One-shot FlowRail installer for Claude Code. Wires PreToolUse hooks, MCP server config, and skill markdown into your repo, then probes the FlowRail server with your API key.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,