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.
- package/README.md +23 -7
- package/dist/bin/redline.js +338 -43
- package/dist/bin/redline.js.map +1 -1
- package/dist/commands/init.js +353 -34
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/remove.js +43 -1
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/status.js +94 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/verify.js +82 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/config/redline-json.js +63 -2
- package/dist/config/redline-json.js.map +1 -1
- package/dist/core/git.js +50 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/version.js +6 -0
- package/dist/core/version.js.map +1 -1
- package/dist/detect/existing.js +151 -0
- package/dist/detect/existing.js.map +1 -0
- package/dist/detect/setup.js +200 -0
- package/dist/detect/setup.js.map +1 -0
- package/dist/detect/stack.js +56 -17
- package/dist/detect/stack.js.map +1 -1
- package/dist/exempt/parse.js +8 -1
- package/dist/exempt/parse.js.map +1 -1
- package/dist/metrics/options.js +0 -9
- package/dist/metrics/options.js.map +1 -1
- package/dist/platforms/azure/install.js +9 -9
- package/dist/platforms/azure/install.js.map +1 -1
- package/dist/platforms/azure/verify.js +10 -2
- package/dist/platforms/azure/verify.js.map +1 -1
- package/dist/platforms/github/install.js +192 -26
- package/dist/platforms/github/install.js.map +1 -1
- package/dist/platforms/github/preflight.js +63 -0
- package/dist/platforms/github/preflight.js.map +1 -0
- package/dist/platforms/github/vendor.js +81 -0
- package/dist/platforms/github/vendor.js.map +1 -0
- package/dist/platforms/github/verify.js +79 -7
- package/dist/platforms/github/verify.js.map +1 -1
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/types.js.map +1 -1
- package/dist/policy/diff.js +26 -9
- package/dist/policy/diff.js.map +1 -1
- package/dist/render/contexts.js +39 -0
- package/dist/render/contexts.js.map +1 -0
- package/dist/render/profile.js +44 -10
- package/dist/render/profile.js.map +1 -1
- package/dist/render/standards.js +10 -1
- package/dist/render/standards.js.map +1 -1
- package/dist/render/vendors.js +14 -53
- package/dist/render/vendors.js.map +1 -1
- package/dist/rules/catalogue.js +127 -0
- package/dist/rules/catalogue.js.map +1 -0
- package/dist/ui/facts.js +111 -0
- package/dist/ui/facts.js.map +1 -0
- package/dist/ui/prompt.js +330 -0
- package/dist/ui/prompt.js.map +1 -0
- package/dist/ui/report.js +214 -0
- package/dist/ui/report.js.map +1 -0
- package/dist/ui/tty.js +576 -0
- package/dist/ui/tty.js.map +1 -0
- package/dist/ui/wizard.js +293 -0
- package/dist/ui/wizard.js.map +1 -0
- package/dist/verify/remote.js +33 -0
- package/dist/verify/remote.js.map +1 -1
- package/package.json +13 -1
- package/platforms/azure/gate-template-github.yml +152 -0
- package/platforms/azure/gate-template.yml +59 -6
- package/scripts/check-pins.mjs +84 -1
- package/scripts/fetch-stars.mjs +92 -0
- package/scripts/lib/rules.d.mts +18 -0
- package/scripts/publish-local.mjs +183 -0
- package/standards/contexts/speckit.md +22 -0
- package/standards/contexts/tmf.md +25 -0
- package/standards/manifest.json +78 -7
- package/standards/stacks/angular.md +57 -0
- package/standards/stacks/dom.md +61 -0
- package/standards/stacks/svelte.md +45 -0
- package/standards/stacks/vue.md +54 -0
- package/templates/redline.yml +9 -9
- package/workflows/dashboard.yml +1 -1
- package/workflows/redline-collect.yml +1 -1
- package/workflows/redline-gate.yml +38 -7
- package/workflows/seed-canary.yml +2 -2
- package/workflows/weekly-digest.yml +1 -1
- 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 (
|
|
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.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
127
|
-
| **Standards version** | `standards/manifest.json` → `version` | A human, in the same PR as the rule change (see above) | `0.0.
|
|
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
|
package/dist/bin/redline.js
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
189
|
-
//
|
|
190
|
-
|
|
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
|
-
...(
|
|
195
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
219
|
-
log.info(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
228
|
-
log.info(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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: {
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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;
|