Skip to content

Commit 661550c

Browse files
committed
fix(snapshot): follow main's CLI with a snapshot subcommand over the shared parser
Main's CLI (#152) is total over the parsed modes: its dispatcher and per-mode flag split are typed over RunConfig and MODES, so the snapshot arm needs a home. The dispatcher concludes a snapshot config the way the action does, inputsForMode derives each mode's flags from the exported per-mode input lists (MERGE_ONLY_INPUTS and SNAPSHOT_ONLY_INPUTS now exported), and the program gains the snapshot subcommand with its guide row.
1 parent ce4b2d0 commit 661550c

9 files changed

Lines changed: 224 additions & 25 deletions

File tree

‎docs/start/cli.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The package needs Node 22.14 or newer; `bunx` fetches it the same way and honors
3333
| `check` | Report drift between a settings file and the live repository; exits 1 on any drift | yes |
3434
| `apply` | Apply a settings file to the repository | yes |
3535
| `merge` | Fold an ordered list of settings files into one document | no |
36+
| `snapshot` | Write a repository's live settings as a settings file, or one file per multi-repo target | yes |
3637
| `validate <file>` | Validate a settings file against the schema | no |
3738
| `permissions <file>` | Print the PAT grant each section the file declares needs | no |
3839

@@ -65,6 +66,15 @@ gsac merge --settings-file fleet.yml --settings-file team.yml --merged-file merg
6566

6667
A repeated `--settings-file` builds the layer list, lowest first; a comma-separated value does the same. The merged file is exactly what `apply` would run ([layering](../operate/layering.md)).
6768

69+
### snapshot
70+
71+
```bash
72+
gsac snapshot --token "$ADMIN_TOKEN" --repository octocat/hello-world --snapshot-file settings.snapshot.yml
73+
gsac snapshot --token "$FLEET_TOKEN" --repos "*" --snapshot-dir snapshots
74+
```
75+
76+
Exactly one of `--snapshot-file` (one repository) and `--snapshot-dir` (one `<owner>/<name>.yml` per target) is required; the fleet flags are the multi-repo ones. The written file is what `check` reads clean against the repository it came from ([snapshot mode](../operate/snapshot.md)).
77+
6878
### validate
6979

7080
```bash
@@ -114,7 +124,7 @@ With `--json` the outputs are one object instead, and stdout carries nothing els
114124
{"skipped-sections":"","result":"clean"}
115125
```
116126

117-
The exit codes are the action's: `check` exits 1 on drift or failure, `apply` and `merge` exit 1 on failure only, `validate` exits 1 on an invalid file, `permissions` exits 0 for a valid file.
127+
The exit codes are the action's: `check` exits 1 on drift or failure, `apply`, `merge`, and `snapshot` exit 1 on failure only, `validate` exits 1 on an invalid file, `permissions` exits 0 for a valid file.
118128
A flag the mode does not read is unknown to that subcommand and exits 1 with the parser's message, where the action rejects the same input by name;
119129
a value the mode refuses (a filter without `--repos "*"`, a `--repository` that is not a slug) fails with the action's own message, reworded where the action's remedy names the workflow step.
120130
The token passed on the command line is masked as `***` wherever a line would echo it.

‎src/cli/commands.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import {
99
concludeMerge,
1010
concludeRun,
11+
concludeSnapshot,
1112
describeProblem,
1213
failRun,
1314
type GithubClient,
@@ -20,6 +21,7 @@ import {
2021
runMerge,
2122
runMulti,
2223
runSingle,
24+
runSnapshot,
2325
SECTIONS,
2426
type SectionModule,
2527
sectionGrant,
@@ -49,6 +51,12 @@ export async function runConfig(cfg: RunConfig, io: Io, host: CliHost): Promise<
4951
);
5052
}
5153
const api = host.createClient(cfg.token, io, cfg.apiVersion);
54+
if (cfg.kind === "snapshot") {
55+
return runSnapshot(api, cfg, io).match(
56+
(finished) => concludeSnapshot(io, finished),
57+
(problem) => failRun(io, problem),
58+
);
59+
}
5260
if (cfg.kind === "multi") {
5361
return runMulti(api, cfg, io).match(
5462
(targets) => concludeRun(io, { kind: "multi", mode: cfg.mode, targets }),

‎src/cli/inputs.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import {
1414
type InputName,
1515
type InputReader,
1616
MERGE_INPUTS,
17+
MERGE_ONLY_INPUTS,
1718
MODES,
1819
type Mode,
20+
SNAPSHOT_INPUTS,
21+
SNAPSHOT_ONLY_INPUTS,
1922
} from "../index.js";
2023

2124
/** Declaration order is the help order, as on the inputs reference page. */
@@ -30,19 +33,22 @@ const PROGRAM_INPUTS = ["mode", "token"] as const satisfies readonly InputName[]
3033
*/
3134
export const CLI_UNSUPPORTED_INPUTS = ["report-public-key"] as const satisfies readonly InputName[];
3235

33-
/**
34-
* The flags a mode's subcommand takes: the inputs its mode reads. `settings-file`
35-
* is the one input both the merge and the engine modes read.
36-
*/
36+
/** The flags a mode's subcommand takes: the inputs its mode reads, in declaration order. */
3737
export function inputsForMode(mode: Mode): InputName[] {
38-
const mergeReads = (name: InputName): boolean =>
39-
(MERGE_INPUTS as readonly InputName[]).includes(name);
4038
const hidden: readonly InputName[] = [...PROGRAM_INPUTS, ...CLI_UNSUPPORTED_INPUTS];
41-
return INPUT_NAMES.filter(
42-
(name) =>
43-
!hidden.includes(name) &&
44-
(mode === "merge" ? mergeReads(name) : !mergeReads(name) || name === "settings-file"),
45-
);
39+
const modeOnly: readonly InputName[] = [...MERGE_ONLY_INPUTS, ...SNAPSHOT_ONLY_INPUTS];
40+
const reads = (name: InputName): boolean => {
41+
switch (mode) {
42+
case "merge":
43+
return (MERGE_INPUTS as readonly InputName[]).includes(name);
44+
case "snapshot":
45+
return (SNAPSHOT_INPUTS as readonly InputName[]).includes(name);
46+
case "apply":
47+
case "check":
48+
return !modeOnly.includes(name);
49+
}
50+
};
51+
return INPUT_NAMES.filter((name) => !hidden.includes(name) && reads(name));
4652
}
4753

4854
/** Every input some subcommand or the program exposes; the mode is the subcommand itself. */

‎src/cli/program.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* The command tree: check, apply, and merge mirror the action's modes with
2+
* The command tree: check, apply, merge, and snapshot mirror the action's modes with
33
* INPUT_DECLS as their flags; validate and permissions read a file alone.
44
* `--token`, `--json`, `--summary`, and `--verbose` are global. main() runs
55
* argv to its exit code without touching the process.
@@ -29,22 +29,34 @@ import { argvReader, inputOption, inputsForMode, once, tokenValues } from "./inp
2929
import { type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js";
3030

3131
/** Every subcommand, in help order; the package smoke asserts the installed help names each. */
32-
export const CLI_COMMANDS = ["check", "apply", "merge", "validate", "permissions"] as const;
32+
export const CLI_COMMANDS = [
33+
"check",
34+
"apply",
35+
"merge",
36+
"snapshot",
37+
"validate",
38+
"permissions",
39+
] as const;
3340

3441
type CliCommand = (typeof CLI_COMMANDS)[number];
3542

3643
const DESCRIPTION: Readonly<Record<CliCommand, string>> = {
3744
check: "Report drift between a settings file and the live repository; exits 1 on any drift",
3845
apply: "Apply a settings file to the repository",
3946
merge: "Fold an ordered list of settings files into one document, with no token and no API call",
47+
snapshot:
48+
"Write a repository's live settings as a settings file, or one file per multi-repo target under a directory",
4049
validate: "Validate a settings file against the schema; no token, no API call",
4150
permissions: "Print the PAT grant each section a settings file declares needs",
4251
};
4352

4453
/** The subcommands that run the engine or the merge, each under its mode. */
45-
const MODE_COMMANDS = { check: "check", apply: "apply", merge: "merge" } as const satisfies Partial<
46-
Record<CliCommand, Mode>
47-
>;
54+
const MODE_COMMANDS = {
55+
check: "check",
56+
apply: "apply",
57+
merge: "merge",
58+
snapshot: "snapshot",
59+
} as const satisfies Partial<Record<CliCommand, Mode>>;
4860

4961
interface Globals {
5062
readonly token?: string;

‎src/flows/inputs.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,11 @@ type _UnlistedLayering = MustBeNever<Exclude<Layering, (typeof LAYERINGS)[number
405405
* Their declared defaults are empty so "explicitly set" is detectable, as with the discovery filters; apply and check
406406
* reject a set one instead of silently ignoring it.
407407
*/
408-
const MERGE_ONLY_INPUTS = ["merged-file", "layering"] as const satisfies readonly InputName[];
409-
const SNAPSHOT_ONLY_INPUTS = [
408+
export const MERGE_ONLY_INPUTS = [
409+
"merged-file",
410+
"layering",
411+
] as const satisfies readonly InputName[];
412+
export const SNAPSHOT_ONLY_INPUTS = [
410413
"snapshot-file",
411414
"snapshot-dir",
412415
] as const satisfies readonly InputName[];

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,14 @@ export {
6969
type InputName,
7070
type InputReader,
7171
MERGE_INPUTS,
72+
MERGE_ONLY_INPUTS,
7273
MERGE_REJECTED_INPUTS,
7374
MODES,
7475
type Mode,
7576
parseConfig,
7677
type RunConfig,
7778
SNAPSHOT_INPUTS,
79+
SNAPSHOT_ONLY_INPUTS,
7880
SNAPSHOT_REJECTED_INPUTS,
7981
} from "./flows/inputs.js";
8082
export { foldLayers, readLayerFiles } from "./flows/layers.js";

‎test/cli/inputs.test.ts‎

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,71 @@ function cases(): Case[] {
7070
inputs: { mode: "check", repository: "o/r", "settings-file": "s.yml" },
7171
env: { GITHUB_TOKEN: "ghp_env" },
7272
},
73+
{
74+
name: "snapshot, one repository to a file with an allowlist",
75+
argv: [
76+
"snapshot",
77+
"--token",
78+
"ghp_flag",
79+
"--repository",
80+
"o/r",
81+
"--snapshot-file",
82+
"snap.yml",
83+
"--sections",
84+
"labels",
85+
"--on-missing-permission",
86+
"warn",
87+
],
88+
inputs: {
89+
mode: "snapshot",
90+
token: "ghp_flag",
91+
repository: "o/r",
92+
"snapshot-file": "snap.yml",
93+
sections: "labels",
94+
"on-missing-permission": "warn",
95+
},
96+
},
97+
{
98+
name: "snapshot, the fleet to a directory with a discovery filter",
99+
argv: [
100+
"snapshot",
101+
"--token",
102+
"ghp_flag",
103+
"--repos",
104+
"*",
105+
"--snapshot-dir",
106+
"snaps",
107+
"--forks",
108+
"exclude",
109+
],
110+
inputs: {
111+
mode: "snapshot",
112+
token: "ghp_flag",
113+
repos: "*",
114+
"snapshot-dir": "snaps",
115+
forks: "exclude",
116+
},
117+
env: { GITHUB_REPOSITORY: "o/admin" },
118+
},
119+
{
120+
name: "snapshot, a merge-only flag is unknown to the subcommand",
121+
argv: [
122+
"snapshot",
123+
"--token",
124+
"ghp_flag",
125+
"--snapshot-file",
126+
"snap.yml",
127+
"--layering",
128+
"replace",
129+
],
130+
inputs: {
131+
mode: "snapshot",
132+
token: "ghp_flag",
133+
"snapshot-file": "snap.yml",
134+
layering: "replace",
135+
},
136+
unknownFlag: "layering",
137+
},
73138
{
74139
name: "check, the workflow's own repository from GITHUB_REPOSITORY",
75140
argv: ["check", "--token", "ghp_flag"],
@@ -294,7 +359,7 @@ describe("argv -> config equals env -> config", () => {
294359
envToken++;
295360
}
296361
}
297-
expect([...kinds].sort()).toEqual(["merge", "multi", "single"]);
362+
expect([...kinds].sort()).toEqual(["merge", "multi", "single", "snapshot"]);
298363
expect(envToken).toBeGreaterThan(0);
299364
expect(rejected).toBeGreaterThanOrEqual(5);
300365
});
@@ -305,6 +370,8 @@ const VALID: Record<Exclude<InputName, "mode" | "token">, string> = {
305370
repository: "o/r",
306371
"settings-file": "s.yml",
307372
"merged-file": "out.yml",
373+
"snapshot-file": "snap.yml",
374+
"snapshot-dir": "snaps",
308375
"on-missing-permission": "warn",
309376
"required-sections": "labels",
310377
sections: "labels",
@@ -338,7 +405,39 @@ const COMPANIONS: Partial<Record<InputName, Inputs>> = {
338405

339406
/** The smallest input set each mode accepts. */
340407
function base(mode: Mode): Inputs {
341-
return mode === "merge" ? { mode, "merged-file": "out.yml" } : { mode, token: "ghp" };
408+
switch (mode) {
409+
case "merge":
410+
return { mode, "merged-file": "out.yml" };
411+
case "snapshot":
412+
return { mode, token: "ghp", "snapshot-file": "snap.yml" };
413+
default:
414+
return { mode, token: "ghp" };
415+
}
416+
}
417+
418+
/**
419+
* The snapshot's fleet inputs need the directory form: they unset the base's
420+
* snapshot-file (an empty value reads as unset) and name a snapshot-dir.
421+
*/
422+
const SNAPSHOT_FLEET: Inputs = { "snapshot-file": "", "snapshot-dir": "snaps", repos: "*" };
423+
const SNAPSHOT_FLEET_INPUTS = new Set<InputName>([
424+
"snapshot-dir",
425+
"repos",
426+
"repos-dir",
427+
"visibility",
428+
"archived",
429+
"forks",
430+
"exclude",
431+
"topics",
432+
"affiliation",
433+
]);
434+
435+
/** The inputs a flag needs beside it under `mode`, on top of that mode's base. */
436+
function companions(mode: Mode, name: InputName): Inputs {
437+
if (mode === "snapshot" && SNAPSHOT_FLEET_INPUTS.has(name)) {
438+
return name === "repos-dir" ? { ...SNAPSHOT_FLEET, repos: "" } : SNAPSHOT_FLEET;
439+
}
440+
return COMPANIONS[name] ?? {};
342441
}
343442

344443
describe("the per-mode flag split", () => {
@@ -350,7 +449,7 @@ describe("the per-mode flag split", () => {
350449
const value = name === "report-public-key" ? recipient : VALID[name];
351450
if (exposed.has(name)) {
352451
const result = parseConfig(
353-
recordReader({ ...base(mode), ...COMPANIONS[name], [name]: value }),
452+
recordReader({ ...base(mode), ...companions(mode, name), [name]: value }),
354453
env,
355454
);
356455
expect(
@@ -366,7 +465,9 @@ describe("the per-mode flag split", () => {
366465
(problem) => problem.code,
367466
),
368467
`${mode} --${name}`,
369-
).toMatch(/^input-(merge-only|rejected-in-merge|report-key-unused)$/);
468+
).toMatch(
469+
/^input-(merge-only|snapshot-only|rejected-in-merge|rejected-in-snapshot|report-key-unused)$/,
470+
);
370471
}
371472
}
372473
}
@@ -397,6 +498,8 @@ describe("the per-mode flag split", () => {
397498
},
398499
mode: "single",
399500
"merged-file": "single",
501+
"snapshot-file": "single",
502+
"snapshot-dir": "single",
400503
"on-missing-permission": "single",
401504
"required-sections": {
402505
argv: check,
@@ -458,8 +561,14 @@ describe("the per-mode flag split", () => {
458561
if (!exposed.has(name)) {
459562
continue; // the mode is the subcommand; an unsupported input has no flag
460563
}
461-
// The base argv minus this flag, so the two repeats below are its only occurrences.
462-
const base = inputsForMode("check").includes(name) || name === "token" ? check : ["merge"];
564+
// The base argv of the first subcommand exposing the flag, minus this flag, so the two
565+
// repeats below are its only occurrences.
566+
const base =
567+
inputsForMode("check").includes(name) || name === "token"
568+
? check
569+
: inputsForMode("merge").includes(name)
570+
? ["merge"]
571+
: ["snapshot", "--token", "ghp"];
463572
const flag = base.filter((argument, index) => {
464573
const value = index > 0 && base[index - 1] === `--${name}`;
465574
return argument !== `--${name}` && !value;

0 commit comments

Comments
 (0)