redlinegate 0.0.1 → 0.0.3

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.
Files changed (86) hide show
  1. package/README.md +23 -7
  2. package/dist/bin/redline.js +338 -43
  3. package/dist/bin/redline.js.map +1 -1
  4. package/dist/commands/init.js +353 -34
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/remove.js +43 -1
  7. package/dist/commands/remove.js.map +1 -1
  8. package/dist/commands/status.js +94 -0
  9. package/dist/commands/status.js.map +1 -0
  10. package/dist/commands/verify.js +82 -2
  11. package/dist/commands/verify.js.map +1 -1
  12. package/dist/config/redline-json.js +63 -2
  13. package/dist/config/redline-json.js.map +1 -1
  14. package/dist/core/git.js +50 -3
  15. package/dist/core/git.js.map +1 -1
  16. package/dist/core/version.js +6 -0
  17. package/dist/core/version.js.map +1 -1
  18. package/dist/detect/existing.js +151 -0
  19. package/dist/detect/existing.js.map +1 -0
  20. package/dist/detect/setup.js +200 -0
  21. package/dist/detect/setup.js.map +1 -0
  22. package/dist/detect/stack.js +56 -17
  23. package/dist/detect/stack.js.map +1 -1
  24. package/dist/exempt/parse.js +8 -1
  25. package/dist/exempt/parse.js.map +1 -1
  26. package/dist/metrics/options.js +0 -9
  27. package/dist/metrics/options.js.map +1 -1
  28. package/dist/platforms/azure/install.js +9 -9
  29. package/dist/platforms/azure/install.js.map +1 -1
  30. package/dist/platforms/azure/verify.js +10 -2
  31. package/dist/platforms/azure/verify.js.map +1 -1
  32. package/dist/platforms/github/install.js +192 -26
  33. package/dist/platforms/github/install.js.map +1 -1
  34. package/dist/platforms/github/preflight.js +63 -0
  35. package/dist/platforms/github/preflight.js.map +1 -0
  36. package/dist/platforms/github/vendor.js +81 -0
  37. package/dist/platforms/github/vendor.js.map +1 -0
  38. package/dist/platforms/github/verify.js +79 -7
  39. package/dist/platforms/github/verify.js.map +1 -1
  40. package/dist/platforms/types.js +8 -0
  41. package/dist/platforms/types.js.map +1 -1
  42. package/dist/policy/diff.js +26 -9
  43. package/dist/policy/diff.js.map +1 -1
  44. package/dist/render/contexts.js +39 -0
  45. package/dist/render/contexts.js.map +1 -0
  46. package/dist/render/profile.js +44 -10
  47. package/dist/render/profile.js.map +1 -1
  48. package/dist/render/standards.js +10 -1
  49. package/dist/render/standards.js.map +1 -1
  50. package/dist/render/vendors.js +14 -53
  51. package/dist/render/vendors.js.map +1 -1
  52. package/dist/rules/catalogue.js +127 -0
  53. package/dist/rules/catalogue.js.map +1 -0
  54. package/dist/ui/facts.js +111 -0
  55. package/dist/ui/facts.js.map +1 -0
  56. package/dist/ui/prompt.js +330 -0
  57. package/dist/ui/prompt.js.map +1 -0
  58. package/dist/ui/report.js +214 -0
  59. package/dist/ui/report.js.map +1 -0
  60. package/dist/ui/tty.js +576 -0
  61. package/dist/ui/tty.js.map +1 -0
  62. package/dist/ui/wizard.js +293 -0
  63. package/dist/ui/wizard.js.map +1 -0
  64. package/dist/verify/remote.js +33 -0
  65. package/dist/verify/remote.js.map +1 -1
  66. package/package.json +13 -1
  67. package/platforms/azure/gate-template-github.yml +152 -0
  68. package/platforms/azure/gate-template.yml +59 -6
  69. package/scripts/check-pins.mjs +84 -1
  70. package/scripts/fetch-stars.mjs +92 -0
  71. package/scripts/lib/rules.d.mts +18 -0
  72. package/scripts/publish-local.mjs +183 -0
  73. package/standards/contexts/speckit.md +22 -0
  74. package/standards/contexts/tmf.md +25 -0
  75. package/standards/manifest.json +78 -7
  76. package/standards/stacks/angular.md +57 -0
  77. package/standards/stacks/dom.md +61 -0
  78. package/standards/stacks/svelte.md +45 -0
  79. package/standards/stacks/vue.md +54 -0
  80. package/templates/redline.yml +9 -9
  81. package/workflows/dashboard.yml +1 -1
  82. package/workflows/redline-collect.yml +1 -1
  83. package/workflows/redline-gate.yml +38 -7
  84. package/workflows/seed-canary.yml +2 -2
  85. package/workflows/weekly-digest.yml +1 -1
  86. package/scripts/measure-context.mjs +0 -101
package/README.md CHANGED
@@ -39,7 +39,7 @@ OpenAI Codex / `AGENTS.md`, and Claude. A Cursor adapter exists but ships disabl
39
39
  | `scripts/validate.mjs` | Bundle self-check, run by this repo's CI | this repo |
40
40
  | `scripts/check-pins.mjs` | Re-resolves SHA-pinned actions against their upstream tag | this repo's CI |
41
41
  | `scripts/assign-rule-ids.mjs` | Assigns and verifies the stable `<stack>/<slug>` id on every rule | this repo |
42
- | `seeded/` | Recall corpus (12 stacks, 82 BLOCKER seeds) + precision corpus, scored by `scripts/score-seeds.mjs` | validation only, never merged |
42
+ | `seeded/` | Recall corpus (16 stacks, 117 BLOCKER seeds) + precision corpus, scored by `scripts/score-seeds.mjs` | validation only, never merged |
43
43
 
44
44
  ## Language coverage vs org reality
45
45
 
@@ -56,10 +56,26 @@ cd your-repo
56
56
  npx redlinegate init
57
57
  ```
58
58
 
59
- That is the whole procedure. It detects your stack, renders the standards for it, installs
60
- the merge-readiness template and the gate (advisory it reports, it does not block),
61
- turns on the security floor, and opens a pull request. Anything that needed repository
62
- admin rights you do not have is listed at the end for an administrator to run.
59
+ That is the whole procedure. With no flags, at a terminal, it asks before it writes:
60
+ which standards apply, where the repository lives, what runs your pull request checks,
61
+ which assistants should read the rules, what to install, and how hard the gate should
62
+ bite. Whatever it detected is preselected, so pressing enter through it accepts detection,
63
+ and the last question offers **Dry run** before Apply.
64
+
65
+ Choose Apply and it renders the standards, installs the merge-readiness template and the
66
+ gate (advisory — it reports, it does not block), turns on the security floor, and opens a
67
+ pull request on `redline/onboard`. It never pushes to your default branch, so `git status`
68
+ stays clean. Anything that needed repository admin rights you do not have is listed at the
69
+ end for an administrator to run.
70
+
71
+ In CI, in a pipe, or with any flag present it prompts for nothing and takes the scripted
72
+ path — a prompt in a pipeline is a hang with nobody there to answer it:
73
+
74
+ ```sh
75
+ npx redlinegate init --dry-run # the plan; writes nothing, contacts no host
76
+ npx redlinegate init --profile web-react,infra # a React app with its own Terraform beside it
77
+ npx redlinegate init --pipeline azure-pipelines # on GitHub, but built by Azure Pipelines
78
+ ```
63
79
 
64
80
  Install it once and the everyday command is shorter:
65
81
 
@@ -123,8 +139,8 @@ Redline carries two versions that move independently. Do not conflate them.
123
139
 
124
140
  | Axis | Lives in | Bumped by | Example |
125
141
  | --- | --- | --- | --- |
126
- | **CLI version** | `redlinegate` on npm (git `v*` tags) | semantic-release, from conventional commits on `main` | `0.0.1` |
127
- | **Standards version** | `standards/manifest.json` → `version` | A human, in the same PR as the rule change (see above) | `0.0.1` |
142
+ | **CLI version** | `redlinegate` on npm (git `v*` tags) | semantic-release, from conventional commits on `main` | `0.0.2` |
143
+ | **Standards version** | `standards/manifest.json` → `version` | A human, in the same PR as the rule change (see above) | `0.0.2` |
128
144
 
129
145
  The CLI version is the tool's release line: [CHANGELOG.md](CHANGELOG.md) tracks it, and
130
146
  semantic-release computes the next one from commit messages — never edit
@@ -9,6 +9,15 @@ import { isSeverity } from "../core/severity.js";
9
9
  import { isRung, RUNGS } from "../enforce/ladder.js";
10
10
  import { resolvePlatform as defaultResolvePlatform, } from "../platforms/resolve.js";
11
11
  import { init, ONBOARD_BRANCH } from "../commands/init.js";
12
+ import { explain, loadRules } from "../rules/catalogue.js";
13
+ import { formatStatus, status } from "../commands/status.js";
14
+ import { createGit } from "../core/git.js";
15
+ import { parseRemote } from "../platforms/detect.js";
16
+ import { Cancelled, createPrompter, isInteractive } from "../ui/prompt.js";
17
+ import { renderReport } from "../ui/report.js";
18
+ import { colorDepth, colorEnabled, glyphs, palette } from "../ui/tty.js";
19
+ import { gatherFacts } from "../ui/facts.js";
20
+ import { runWizard } from "../ui/wizard.js";
12
21
  import { capabilitySelection, OPTIONAL_CAPABILITIES, } from "../config/redline-json.js";
13
22
  import { remove, REMOVE_BRANCH } from "../commands/remove.js";
14
23
  import { createHostWithdrawal } from "../remove/host.js";
@@ -26,19 +35,35 @@ import { createSyncHost } from "../sync/host.js";
26
35
  import { verifyRemote } from "../verify/remote.js";
27
36
  import { createRemoteVerifyHost } from "../verify/host.js";
28
37
  import { standardsVersion } from "../commands/sync.js";
38
+ import { isGatePipeline, isGateSource } from "../platforms/types.js";
29
39
  const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
30
40
  const USAGE = [
31
41
  'redline — engineering control plane',
32
42
  '',
33
- ' redline init [--profile <name>] [--vendors <list>] [--blocking] [--no-a11y] [--speckit] [--dry-run] [--repair]',
34
- ' [--adopt-caller] [--skip <list>] [--with <list>]',
43
+ ' redline init [--profile <list>] [--vendors <list>] [--blocking] [--no-a11y] [--dry-run] [--repair]',
44
+ ' [--adopt-caller] [--skip <list>] [--with <list>] [--pipeline <name>]',
45
+ ' [--gate-source org|local] [--no-commit]',
35
46
  ' onboard this repository: standards, security floor, merge gate (advisory), registration',
36
47
  ' --dry-run print the plan; writes nothing, needs no credential, contacts no host',
48
+ ' --no-commit write the files into the working tree and stop: no repository setting is',
49
+ ' changed, no branch is made, nothing is committed and no pull request is',
50
+ ' opened. Needs no credential and contacts no host, so it works offline — and',
51
+ ' so an org-sourced caller is written without checking the organisation',
52
+ ' publishes the gate it references. Run redline verify once you have committed',
53
+ ' --profile <list> one profile, or several separated by commas, whose stacks are',
54
+ ' rendered together — a React app with its own Terraform beside it is',
55
+ ' web,infra. The recorded name is sorted, so the order you type cannot',
56
+ ' change the artifacts. Omitted, the stack is detected from the checkout',
37
57
  ' --vendors <list> comma-separated vendor ids (copilot,agents,claude,cursor) to render for —',
38
58
  ' overrides both detection and whatever .redline.json already recorded; a vendor',
39
59
  ' the org has not enabled never renders no matter what this list names',
40
60
  ' --blocking promote the merge gate from advisory to blocking',
41
- ' --no-a11y, --speckit recorded in .redline.json for later phases; changes nothing in Phase 1',
61
+ ' --no-a11y recorded in .redline.json for a later phase; changes nothing in Phase 1',
62
+ ' --speckit / --no-speckit, --tmf / --no-tmf the optional context sections rendered',
63
+ ' into the standards artifacts beside the rules. speckit is on by default',
64
+ ' and is dropped automatically where the repository already runs Spec Kit;',
65
+ ' tmf is off unless asked for. Passing the negative on a later run removes',
66
+ ' a section already rendered — the block is regenerated, not appended to',
42
67
  ' --repair re-apply every capability even if this repository looks already onboarded — for',
43
68
  ' labels, review-ownership, repo-property, gate and merge-policy, whose recorded',
44
69
  ' pendingAdmin entry a plain re-run can never clear on its own; composes with --dry-run',
@@ -49,9 +74,34 @@ const USAGE = [
49
74
  ' organisation-wide minimum and is refused by name rather than deselected',
50
75
  ' --with <list> the same names, selected again — how a deselection recorded in .redline.json is',
51
76
  ' reversed',
77
+ ' --gate-source org|local where the gate machinery lives. org (the default) references the',
78
+ ' reusable workflow published at <org>/.github; local vendors a copy into this',
79
+ ' repository at .github/workflows/redline-gate.yml, for a repository whose',
80
+ ' organisation has no shared .github repo yet. local is the WEAKER control: the',
81
+ ' workflow runs from the pull request\'s own head commit, so a pull request can',
82
+ ' edit the gate that is judging it — protect .github/workflows/ with CODEOWNERS.',
83
+ ' Omitting the flag keeps whatever the repository already recorded',
52
84
  ` --rung <name> the enforcement rung: ${RUNGS.join(', ')}. A promotion needs recorded`,
53
85
  ' evidence and is refused without it; a demotion is always allowed. Omitting',
54
86
  ' the flag keeps whatever the repository already recorded',
87
+ ' --branches <patterns> which branches the merge policy governs, comma separated, in the',
88
+ ' host\'s own syntax (~DEFAULT_BRANCH, refs/heads/release/*). Default: the default branch',
89
+ ' alone. Widening this widens an enforcement boundary, so it is never detected for you.',
90
+ ' --review-owners <list> who owns the paths seeded into CODEOWNERS — a team, a user or',
91
+ ' an email, several separated by commas. Defaults to the platform team, which may not',
92
+ ' exist in your organisation: GitHub ignores an owner it cannot resolve, so the file',
93
+ ' would install and enforce nothing.',
94
+ ' --setup <list> controls to install alongside Redline: dependabot, renovate, codeql.',
95
+ ' Only what works with no account and no token is offered — writes .github/dependabot.yml,',
96
+ ' renovate.json, .github/workflows/codeql.yml. An existing file is never overwritten.',
97
+ ' --integrations <list> comma-separated ids of controls this repository already runs',
98
+ ' (sonarqube,snyk,mend,dependabot,renovate,gitleaks,trufflehog,codeql). Overrides what',
99
+ ' detection found — it reads a checkout, so it cannot see a scanner wired through a',
100
+ ' shared pipeline template. Recorded, so the correction is made once.',
101
+ ' --pipeline <name> github-actions or azure-pipelines — what actually runs this',
102
+ ' repository\'s pull request checks. Asked separately from the host because',
103
+ ' the two come apart: a repository on GitHub can be built entirely by Azure',
104
+ ' Pipelines, and installing an Actions workflow there gates nothing',
55
105
  ' --adopt-caller let Redline take over the gate machinery file (.github/workflows/redline.yml,',
56
106
  ' .azuredevops/redline-gate.yml) when what is already there carries nothing that',
57
107
  ' attributes it to Redline — a 2.1 caller, in practice. Without it the run refuses',
@@ -67,7 +117,8 @@ const USAGE = [
67
117
  ' the security floor (secret scanning, push protection, dependency alerts) is the',
68
118
  ' organisation\'s minimum, not Redline\'s state — no flag here turns it off',
69
119
  '',
70
- ' redline verify [--gate] [--repo <owner/name>]',
120
+ ' redline verify [--gate] [--repo <owner/name>] [--json]',
121
+ ' --json the whole report as JSON, for a wrapper that has to act on it',
71
122
  ' check this repository still matches what .redline.json claims',
72
123
  ' --repo <owner/name> check a repository over the API, with no checkout — a check',
73
124
  ' that genuinely needs a working tree reports ?? rather than passing',
@@ -81,6 +132,13 @@ const USAGE = [
81
132
  ' redline policy --diff-file <path>',
82
133
  ' evaluate the rules a checker can decide, with no model call. Exit 1 on a BLOCKER',
83
134
  '',
135
+ ' redline status [--json]',
136
+ ' what is installed here, how hard it bites, what an administrator still owes',
137
+ ' you and whether the standards have moved on. Reads the checkout only.',
138
+ ' redline explain <rule-id> [--json]',
139
+ ' what a rule means, who decided it, which files it is scoped to and which',
140
+ ' profiles receive it. The id is the bracketed part of a finding.',
141
+ ' --list every rule id in the standards, with its severity',
84
142
  ' redline exempt --body-file <path> [--scope <check>]',
85
143
  ' decide whether a pull request carries a valid exemption for a failing process',
86
144
  ' check — a reason, an actor and an expiry, not a bare label. Exit 0 if it applies',
@@ -118,6 +176,37 @@ export async function run(argv, deps = {}) {
118
176
  const cwd = deps.cwd ?? process.cwd();
119
177
  const root = deps.root ?? PACKAGE_ROOT;
120
178
  const log = createLog(deps.sink);
179
+ const interactive = deps.isInteractive ?? (() => isInteractive());
180
+ // The host is read from the git remote alone, with no credential and no
181
+ // request — the menu's first question has to render before either exists,
182
+ // because "Dry run" is one of its answers. A remote this cannot classify is
183
+ // `null`, which is exactly the case the host question is there to settle.
184
+ const detectHost = () => {
185
+ try {
186
+ return parseRemote(createGit(cwd).remoteUrl()).host;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ };
192
+ const runInitWizard = async (dir, packageRoot) => {
193
+ const prompter = deps.prompter?.() ?? createPrompter();
194
+ const answers = await runWizard(prompter, gatherFacts({ cwd: dir, root: packageRoot, detectedHost: detectHost() }));
195
+ // The prompter comes back with the answers because the work starts the
196
+ // moment the last question is answered, and the operator has to be able to
197
+ // see that it did. Building a second one here would mean a second set of
198
+ // signal handlers on the same terminal.
199
+ return { answers, prompter };
200
+ };
201
+ // Report colour is decided the same way the prompts decide it, and from the
202
+ // same env: NO_COLOR, FORCE_COLOR, a dumb terminal and a pipe all have to
203
+ // mean the same thing in both halves of one run. An injected sink is a test
204
+ // or a pipe, so it is never painted.
205
+ const reportTheme = {
206
+ palette: palette(deps.sink === undefined && colorEnabled(process.env, process.stdout.isTTY === true), colorDepth(process.env)),
207
+ glyphs: glyphs(process.env),
208
+ width: process.stdout.columns ?? 80,
209
+ };
121
210
  const resolve = deps.resolvePlatform ??
122
211
  ((dir, options) => defaultResolvePlatform(dir, options));
123
212
  const [command, ...rest] = argv;
@@ -144,15 +233,31 @@ export async function run(argv, deps = {}) {
144
233
  blocking: { type: 'boolean' },
145
234
  'no-a11y': { type: 'boolean' },
146
235
  speckit: { type: 'boolean' },
236
+ 'no-speckit': { type: 'boolean' },
237
+ tmf: { type: 'boolean' },
238
+ 'no-tmf': { type: 'boolean' },
147
239
  'dry-run': { type: 'boolean' },
240
+ 'no-commit': { type: 'boolean' },
148
241
  repair: { type: 'boolean' },
149
242
  'adopt-caller': { type: 'boolean' },
150
243
  skip: { type: 'string' },
151
244
  with: { type: 'string' },
152
245
  rung: { type: 'string' },
246
+ pipeline: { type: 'string' },
247
+ 'gate-source': { type: 'string' },
248
+ integrations: { type: 'string' },
249
+ 'review-owners': { type: 'string' },
250
+ setup: { type: 'string' },
251
+ branches: { type: 'string' },
153
252
  },
154
253
  allowPositionals: false,
155
254
  }));
255
+ // `redline init` with nothing after it, at a terminal, is a person asking
256
+ // to be walked through onboarding — so walk them through it. Any flag at
257
+ // all means the caller has already decided, and the menu would be in the
258
+ // way; CI and pipes never see it (isInteractive), so the scripted path is
259
+ // byte-identical to what it was before the menu existed.
260
+ const wizard = rest.length === 0 && interactive() ? await runInitWizard(cwd, root) : null;
156
261
  const names = (list) => list === undefined
157
262
  ? []
158
263
  : list
@@ -161,7 +266,14 @@ export async function run(argv, deps = {}) {
161
266
  .filter((name) => name !== '');
162
267
  // Throws a usage RedlineError on an unknown or non-optional name, before
163
268
  // a platform is resolved or a byte is written.
164
- const selection = capabilitySelection(names(values.skip), names(values.with));
269
+ //
270
+ // The menu answers arrive here as the same two lists the flags produce,
271
+ // rather than as a parallel set of options: a capability the operator
272
+ // deselected in the wizard is a `--skip`, and there is exactly one place
273
+ // that decides what a skip means.
274
+ const selection = wizard
275
+ ? capabilitySelection(OPTIONAL_CAPABILITIES.filter((name) => !wizard.answers.capabilities.includes(name)), [...wizard.answers.capabilities])
276
+ : capabilitySelection(names(values.skip), names(values.with));
165
277
  const menu = { ...selection.menu };
166
278
  if (values.blocking !== undefined)
167
279
  menu.blockingGate = values.blocking;
@@ -169,6 +281,16 @@ export async function run(argv, deps = {}) {
169
281
  menu.accessibility = !values['no-a11y'];
170
282
  if (values.speckit !== undefined)
171
283
  menu.speckit = values.speckit;
284
+ if (values['no-speckit'] === true)
285
+ menu.speckit = false;
286
+ if (values.tmf !== undefined)
287
+ menu.tmf = values.tmf;
288
+ if (values['no-tmf'] === true)
289
+ menu.tmf = false;
290
+ if (wizard) {
291
+ menu.speckit = wizard.answers.speckit;
292
+ menu.tmf = wizard.answers.tmf;
293
+ }
172
294
  // Same "no default" reasoning as the menu flags above: undefined is how
173
295
  // init() tells "nothing typed, keep detection or the recorded
174
296
  // selection" from "the caller typed an empty list".
@@ -181,54 +303,136 @@ export async function run(argv, deps = {}) {
181
303
  if (values.rung !== undefined && !isRung(values.rung)) {
182
304
  throw new RedlineError('usage', `--rung must be one of ${RUNGS.join(', ')}, not "${values.rung}"`, 'observe reports and blocks nothing; block-blocker stops a merge on a BLOCKER');
183
305
  }
184
- const dryRun = values['dry-run'] === true;
306
+ if (values.pipeline !== undefined && !isGatePipeline(values.pipeline)) {
307
+ throw new RedlineError('usage', `--pipeline must be github-actions or azure-pipelines, not "${values.pipeline}"`, 'a repository on GitHub whose pull request checks are Azure Pipelines wants azure-pipelines');
308
+ }
309
+ if (values['gate-source'] !== undefined && !isGateSource(values['gate-source'])) {
310
+ throw new RedlineError('usage', `--gate-source must be org or local, not "${values['gate-source']}"`, 'org references the reusable gate in the organisation .github repository; local vendors a ' +
311
+ 'copy into this repository, which means a pull request can edit the gate judging it');
312
+ }
313
+ const pipelineChoice = wizard?.answers.pipeline ?? values.pipeline;
314
+ const gateSourceChoice = wizard?.answers.gateSource ?? values['gate-source'];
315
+ const dryRun = values['dry-run'] === true || wizard?.answers.action === 'dry-run';
316
+ const noCommit = values['no-commit'] === true || wizard?.answers.action === 'no-commit';
185
317
  const repair = values.repair === true;
186
318
  const adoptCaller = values['adopt-caller'] === true;
187
319
  // A dry run sends no request, so it must not require a credential —
188
- // see ResolvePlatformOptions.lazyCredentials. Every other path here
189
- // resolves one up front, exactly as before.
190
- const platform = await resolve(cwd, dryRun ? { lazyCredentials: true } : {});
320
+ // see ResolvePlatformOptions.lazyCredentials. --no-commit contacts no host
321
+ // either, so it must not demand a credential up front any more than a dry
322
+ // run does working offline is most of the reason it exists. Every other
323
+ // path here resolves one up front, exactly as before.
324
+ const platform = await resolve(cwd, dryRun || noCommit ? { lazyCredentials: true } : {});
325
+ const profileChoice = wizard?.answers.profile ?? values.profile;
326
+ const vendorChoice = wizard ? [...wizard.answers.vendors] : vendors;
327
+ const rungChoice = wizard?.answers.rung ?? values.rung;
328
+ const setupChoice = wizard
329
+ ? [...wizard.answers.setup]
330
+ : values.setup?.split(',').map((id) => id.trim()).filter((id) => id !== '');
331
+ const branchChoice = values.branches?.split(',').map((b) => b.trim()).filter((b) => b !== '');
332
+ const ownerChoice = wizard
333
+ ? [...wizard.answers.reviewOwners]
334
+ : values['review-owners']?.split(',').map((o) => o.trim()).filter((o) => o !== '');
335
+ const integrationChoice = wizard
336
+ ? [...wizard.answers.integrations]
337
+ : values.integrations?.split(',').map((id) => id.trim()).filter((id) => id !== '');
338
+ // Only the wizard path spins: a scripted `redline init` in CI has nobody
339
+ // watching, and an animated line in a build log is noise with no reader.
340
+ // Reassigned, not just stopped: the fallback prompt below has to take the
341
+ // terminal back from the spinner to be readable at all, and the run
342
+ // carries on afterwards — so it gets a fresh one rather than finishing in
343
+ // silence.
344
+ let task = wizard?.prompter.task('onboarding this repository') ?? null;
191
345
  const report = await init(platform, {
192
346
  cwd,
193
347
  root,
194
- ...(values.profile ? { profile: values.profile } : {}),
195
- ...(vendors ? { vendors } : {}),
348
+ ...(wizard ? { onStep: (label) => task?.update(label) } : {}),
349
+ ...(profileChoice ? { profile: profileChoice } : {}),
350
+ ...(vendorChoice ? { vendors: vendorChoice } : {}),
196
351
  ...(dryRun ? { dryRun: true } : {}),
352
+ ...(noCommit ? { noCommit: true } : {}),
197
353
  ...(repair ? { repair: true } : {}),
198
354
  ...(adoptCaller ? { adoptCaller: true } : {}),
199
- ...(values.rung ? { rung: values.rung } : {}),
355
+ ...(rungChoice ? { rung: rungChoice } : {}),
356
+ ...(integrationChoice ? { integrations: integrationChoice } : {}),
357
+ ...(ownerChoice && ownerChoice.length > 0 ? { reviewOwners: ownerChoice } : {}),
358
+ ...(branchChoice && branchChoice.length > 0 ? { branches: branchChoice } : {}),
359
+ ...(setupChoice && setupChoice.length > 0 ? { setup: setupChoice } : {}),
360
+ ...(pipelineChoice ? { pipeline: pipelineChoice } : {}),
361
+ ...(gateSourceChoice ? { gateSource: gateSourceChoice } : {}),
362
+ // Offered only at a terminal, and only when the planning pass finds the
363
+ // organisation publishes no gate. A scripted run gets the denial it has
364
+ // always got: vendoring the gate is a weaker control, and nothing
365
+ // unattended should be able to choose it on an operator's behalf.
366
+ ...(wizard
367
+ ? {
368
+ onGateFallback: async (detail) => {
369
+ task?.stop();
370
+ const vendor = await wizard.prompter.select(`${detail}. Install the gate where?`, [
371
+ {
372
+ value: false,
373
+ label: 'skip the gate for now',
374
+ hint: 'everything else installs; re-run once the org publishes one',
375
+ },
376
+ {
377
+ value: true,
378
+ label: 'vendor it into this repository',
379
+ hint: 'weaker: a pull request can edit the gate that judges it',
380
+ },
381
+ ], false);
382
+ task = wizard.prompter.task('onboarding this repository');
383
+ return vendor;
384
+ },
385
+ }
386
+ : {}),
200
387
  menu,
201
388
  capabilities: selection.capabilities,
202
- });
203
- log.info(`profile ${report.profile}`);
204
- if (report.migratedFrom)
205
- log.info(`migrated from ${report.migratedFrom}`);
206
- // Printed on every path, settled included: a report that falls silent
207
- // about what was never attempted cannot be told from one where it broke.
208
- if (report.optedOut.length > 0)
209
- log.info(`opted out: ${report.optedOut.join(', ')}`);
210
- for (const note of report.notes)
211
- log.info(note);
389
+ }).finally(() => task?.stop());
390
+ const summary = {
391
+ profile: report.profile,
392
+ migratedFrom: report.migratedFrom,
393
+ optedOut: report.optedOut,
394
+ notes: report.notes,
395
+ files: report.files,
396
+ removals: report.removals,
397
+ outcomes: report.outcomes,
398
+ pullRequestUrl: report.pullRequest?.url ?? null,
399
+ pendingAdmin: report.pendingAdmin,
400
+ dryRun: report.dryRun,
401
+ hostPlan: report.hostPlan,
402
+ };
212
403
  if (report.dryRun) {
213
- log.info('dry run — nothing was written, read or changed on the host');
214
404
  if (report.alreadyOnboarded) {
405
+ for (const line of renderReport({ ...summary, files: [], hostPlan: [] }, reportTheme)) {
406
+ log.info(line);
407
+ }
408
+ log.info('');
215
409
  log.info('already onboarded — no file would change (host settings were not read)');
216
410
  return 0;
217
411
  }
218
- for (const file of report.files) {
219
- log.info(` would ${report.removals.includes(file) ? 'remove' : 'write '} ${file}`);
220
- }
221
- for (const step of report.hostPlan)
222
- log.info(` would apply ${step}`);
223
- for (const [key, value] of Object.entries(report.menu))
224
- log.info(` menu ${key}: ${value}`);
412
+ for (const line of renderReport(summary, reportTheme))
413
+ log.info(line);
414
+ log.info('');
415
+ // The menu is the half of a dry run the file list cannot show: two of
416
+ // its answers move no file at all, and a preview that hid them would
417
+ // send an operator to `--blocking` to find out what `--blocking` did.
418
+ log.info(` menu: ${Object.entries(report.menu)
419
+ .map(([key, value]) => `${key}=${value}`)
420
+ .join(' ')}`);
421
+ log.info('');
422
+ log.info('dry run — nothing was written, read or changed on the host');
225
423
  return 0;
226
424
  }
227
- for (const file of report.files) {
228
- log.info(` ${report.removals.includes(file) ? 'remove' : 'write '} ${file}`);
229
- }
230
- for (const outcome of report.outcomes) {
231
- log.info(` ${outcome.status.padEnd(11)} ${outcome.capability} ${outcome.detail}`);
425
+ for (const line of renderReport(summary, reportTheme))
426
+ log.info(line);
427
+ log.info('');
428
+ // Said plainly, because the one thing an operator must not do after this
429
+ // is assume the repository is onboarded: the files are on disk, the host
430
+ // is untouched, and the branch a full run would have opened does not
431
+ // exist. The run that finishes the job is named rather than described.
432
+ if (report.noCommit === true) {
433
+ log.info(' not committed — the files are in your working tree. Review them, commit them, then ' +
434
+ 'run redline init to apply the repository settings and open the pull request');
435
+ return 0;
232
436
  }
233
437
  if (report.pendingAdmin.length > 0) {
234
438
  // On the already-onboarded path this run applied nothing, so the list
@@ -247,10 +451,21 @@ export async function run(argv, deps = {}) {
247
451
  log.error(`could not open the pull request: ${report.pullRequestError}`, `the Redline changes are on branch ${ONBOARD_BRANCH} — push it if it is not already on origin, then open the pull request manually`);
248
452
  return exitCodeFor('failed');
249
453
  }
250
- if (report.pullRequest)
251
- log.info(`pull request: ${report.pullRequest.url}`);
252
- else if (report.alreadyOnboarded)
253
- log.info('already onboarded nothing to change');
454
+ if (report.pullRequest) {
455
+ // The URL itself is printed by the report's own Pull request section.
456
+ // What that section cannot say is why the working tree looks untouched:
457
+ // onboarding commits to its own branch and pushes it, so `git status`
458
+ // here stays clean. Without this the run looked like it did nothing —
459
+ // the first real onboarding ended with the operator running `init` a
460
+ // second time and being told "already onboarded" by a repository they
461
+ // believed was not.
462
+ log.info(` the changes are committed on ${ONBOARD_BRANCH} and pushed, not in your working tree —`);
463
+ log.info(' `git status` here stays clean. Review and merge the pull request above.');
464
+ }
465
+ else if (report.alreadyOnboarded) {
466
+ log.info(`already onboarded — nothing to change (recorded in .redline.json; ` +
467
+ `re-run with --repair to re-apply every capability)`);
468
+ }
254
469
  return 0;
255
470
  }
256
471
  if (command === 'remove') {
@@ -309,7 +524,11 @@ export async function run(argv, deps = {}) {
309
524
  if (command === 'verify') {
310
525
  const { values } = parseCliArgs(() => parseArgs({
311
526
  args: rest,
312
- options: { gate: { type: 'boolean', default: false }, repo: { type: 'string' } },
527
+ options: {
528
+ gate: { type: 'boolean', default: false },
529
+ repo: { type: 'string' },
530
+ json: { type: 'boolean', default: false },
531
+ },
313
532
  allowPositionals: false,
314
533
  }));
315
534
  if (values.repo !== undefined) {
@@ -321,9 +540,15 @@ export async function run(argv, deps = {}) {
321
540
  const remoteReport = await verifyRemote(remoteHost, remoteRef, {
322
541
  root,
323
542
  standardsVersion: standardsVersion(root),
543
+ cliVersion: CLI_VERSION,
324
544
  });
325
- log.info(`${values.repo} (${remoteRef.defaultBranch})`);
326
- log.report(remoteReport.findings);
545
+ if (values.json === true) {
546
+ log.info(JSON.stringify({ repo: values.repo, ref: remoteRef.defaultBranch, ...remoteReport }, null, 2));
547
+ }
548
+ else {
549
+ log.info(`${values.repo} (${remoteRef.defaultBranch})`);
550
+ log.report(remoteReport.findings);
551
+ }
327
552
  // Same mapping as the local path: one finding means the repository was
328
553
  // never onboarded, which is a different thing to tell an operator than
329
554
  // onboarded-and-drifted.
@@ -333,7 +558,13 @@ export async function run(argv, deps = {}) {
333
558
  // resolve is passed unevaluated: verify() must be able to report "not
334
559
  // onboarded" without a host credential — see cli/commands/verify.ts.
335
560
  const report = await verify(() => resolve(cwd), { cwd, root, gate: values.gate === true });
336
- log.report(report.findings);
561
+ // The findings are the same object either way. `--json` exists because a
562
+ // tool whose whole claim is auditability was unreadable by anything but a
563
+ // human, and a wrapper had to scrape prose to learn what it already knew.
564
+ if (values.json === true)
565
+ log.info(JSON.stringify(report, null, 2));
566
+ else
567
+ log.report(report.findings);
337
568
  // verify() short-circuits to exactly one finding when .redline.json is
338
569
  // missing or corrupt (see cli/commands/verify.ts), precisely so this
339
570
  // mapping can tell "never onboarded" (usage, 2) apart from "onboarded
@@ -460,6 +691,63 @@ export async function run(argv, deps = {}) {
460
691
  }
461
692
  return report.ok ? 0 : exitCodeFor('failed');
462
693
  }
694
+ if (command === 'status') {
695
+ const { values } = parseCliArgs(() => parseArgs({
696
+ args: rest,
697
+ options: { json: { type: 'boolean', default: false } },
698
+ allowPositionals: false,
699
+ }));
700
+ const report = status(cwd, root);
701
+ if (values.json === true) {
702
+ log.info(JSON.stringify(report, null, 2));
703
+ }
704
+ else {
705
+ for (const line of formatStatus(report))
706
+ log.info(line);
707
+ }
708
+ // Not onboarded is an answer, not a failure: `status` is what somebody
709
+ // runs to find that out, and exiting non-zero would break the script that
710
+ // asked.
711
+ return 0;
712
+ }
713
+ if (command === 'explain') {
714
+ const { values, positionals } = parseCliArgs(() => parseArgs({
715
+ args: rest,
716
+ options: { list: { type: 'boolean', default: false }, json: { type: 'boolean', default: false } },
717
+ allowPositionals: true,
718
+ }));
719
+ const rules = loadRules(root);
720
+ if (values.list === true) {
721
+ for (const rule of rules.values()) {
722
+ log.info(`${rule.severity.padEnd(10)} ${rule.id}`);
723
+ }
724
+ log.info(`${rules.size} rule(s) across the core standard and every stack`);
725
+ return 0;
726
+ }
727
+ const id = positionals[0];
728
+ if (id === undefined) {
729
+ throw new RedlineError('usage', 'redline explain needs a rule id', 'the id is the part in brackets on a finding: redline explain core/hardcoded-secrets');
730
+ }
731
+ const found = explain(root, id);
732
+ if (values.json === true) {
733
+ log.info(JSON.stringify(found, null, 2));
734
+ return 0;
735
+ }
736
+ log.info(`${found.rule.severity} ${found.rule.id}`);
737
+ log.info('');
738
+ log.info(` ${found.rule.text}`);
739
+ log.info('');
740
+ log.info(` decided by ${found.deterministic ? 'a checker, with no model call' : 'review judgement'}`);
741
+ log.info(` defined in ${found.rule.source}:${found.rule.line}`);
742
+ if (found.globs.length > 0) {
743
+ log.info(` applies to ${found.globs.join(', ')}`);
744
+ }
745
+ else {
746
+ log.info(' applies to every file — the core standard is not scoped by stack');
747
+ }
748
+ log.info(` reaches ${found.profiles.join(', ')}`);
749
+ return 0;
750
+ }
463
751
  if (command === 'exempt') {
464
752
  const { values } = parseCliArgs(() => parseArgs({
465
753
  args: rest,
@@ -563,6 +851,13 @@ export async function run(argv, deps = {}) {
563
851
  return exitCodeFor('usage');
564
852
  }
565
853
  catch (error) {
854
+ // Cancelling at a prompt is a decision, not a failure. It must not print an
855
+ // `error` line: an operator who pressed Ctrl-C already knows what happened,
856
+ // and a wrapping script reading exit 1 would file it as onboarding broken.
857
+ if (error instanceof Cancelled) {
858
+ log.info('cancelled — nothing was written');
859
+ return error.exitCode;
860
+ }
566
861
  if (isRedlineError(error)) {
567
862
  log.error(error.message, error.hint);
568
863
  return error.exitCode;