Skip to content

Commit 10a41b7

Browse files
author
settings-as-code-release
committed
fix(cli): name the program, refuse repeated single-value flags, tighten the README cap
The help usage line names github-settings-as-code (gsac in the description) instead of commander's default "cli". A repeated flag now accumulates only for the inputs parseConfig splits (LIST_INPUTS, pinned against the library's own reading of each pair); every other value flag, --token and --summary included, rejects a second value with commander's message naming the flag, so no argv can form a joined value the action never receives. The README line budget is 101, one over the file.
1 parent 42e89fd commit 10a41b7

6 files changed

Lines changed: 146 additions & 12 deletions

File tree

‎docs/start/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ One line per declared section: the grant its declaration names, the same words a
8888

8989
The subcommand is the action's `mode` input. Every other input of that mode is a flag named `--<input>`, taking the value the action's `with:` key takes;
9090
the [inputs reference](../reference/inputs.md) lists each one with its default and meaning, and `gsac <command> --help` prints the same descriptions.
91-
A list input (`--settings-file` under merge, `--repos`, `--exclude`, `--topics`, `--affiliation`, `--sections`, `--required-sections`) takes a comma-separated value or the flag repeated.
91+
A list input (`--settings-file` under merge, `--repos`, `--exclude`, `--topics`, `--affiliation`, `--sections`, `--required-sections`) takes a comma-separated value or the flag repeated; repeating any other value flag, `--token` and `--summary` included, is an error naming it.
9292

9393
Four flags are the command line's own:
9494

‎src/cli/inputs.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* here does.
88
*/
99

10-
import { Option } from "commander";
10+
import { InvalidArgumentError, Option } from "commander";
1111
import {
1212
INPUT_DECLS,
1313
type InputName,
@@ -43,14 +43,40 @@ export function exposedInputs(): InputName[] {
4343
return INPUT_NAMES.filter((name) => flags.has(name));
4444
}
4545

46-
/** A repeated flag accumulates as a newline-separated list, the form parseConfig splits. */
46+
/**
47+
* The inputs parseConfig splits on newlines and commas (its splitList calls),
48+
* so a repeated flag may accumulate into one list. Pinned to parseConfig in
49+
* test/cli/inputs.test.ts: every name here accepts a newline-joined pair.
50+
*/
51+
export const LIST_INPUTS = [
52+
"settings-file",
53+
"required-sections",
54+
"sections",
55+
"repos",
56+
"exclude",
57+
"topics",
58+
"affiliation",
59+
] as const satisfies readonly InputName[];
60+
61+
/** A repeated list flag accumulates as a newline-separated list, the form parseConfig splits. */
4762
function accumulate(value: string, previous?: string): string {
4863
return previous === undefined ? value : `${previous}\n${value}`;
4964
}
5065

66+
/** A repeated single-value flag is refused: joined, it would form a value the action cannot receive. */
67+
export function once(flag: string): (value: string, previous?: string) => string {
68+
return (value, previous) => {
69+
if (previous !== undefined) {
70+
throw new InvalidArgumentError(`--${flag} takes one value and was given more than once`);
71+
}
72+
return value;
73+
};
74+
}
75+
5176
/** The commander option for one input: `--<name> <value>` with the action's description. */
5277
export function inputOption(name: InputName): Option {
53-
return new Option(`--${name} <value>`, INPUT_DECLS[name].description).argParser(accumulate);
78+
const parse = (LIST_INPUTS as readonly InputName[]).includes(name) ? accumulate : once(name);
79+
return new Option(`--${name} <value>`, INPUT_DECLS[name].description).argParser(parse);
5480
}
5581

5682
/** Commander's attribute for each flag (camelCase of the name), read from commander itself. */

‎src/cli/program.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* argv to its exit code without touching the process.
77
*/
88

9-
import { Command, CommanderError } from "commander";
9+
import { Command, CommanderError, Option } from "commander";
1010
import pc from "picocolors";
1111
import {
1212
failRun,
@@ -25,7 +25,7 @@ import {
2525
unavailable,
2626
validateFile,
2727
} from "./commands.js";
28-
import { argvReader, inputOption, inputsForMode, tokenValues } from "./inputs.js";
28+
import { argvReader, inputOption, inputsForMode, once, tokenValues } from "./inputs.js";
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. */
@@ -101,10 +101,22 @@ export function buildProgram(options: ProgramOptions): {
101101
}
102102
let exitCode = 0;
103103
const program = new Command()
104-
.description("Apply, check, merge, and validate declarative GitHub repository settings")
105-
.option("--token <value>", `${INPUT_DECLS.token.description} Falls back to GITHUB_TOKEN.`)
104+
.name("github-settings-as-code")
105+
.description(
106+
"Apply, check, merge, and validate declarative GitHub repository settings (also installed as gsac)",
107+
)
108+
.addOption(
109+
new Option(
110+
"--token <value>",
111+
`${INPUT_DECLS.token.description} Falls back to GITHUB_TOKEN.`,
112+
).argParser(once("token")),
113+
)
106114
.option("--json", "Print the outputs as one JSON object on stdout; log lines move to stderr")
107-
.option("--summary <file>", "Append the run's markdown summary to this file")
115+
.addOption(
116+
new Option("--summary <file>", "Append the run's markdown summary to this file").argParser(
117+
once("summary"),
118+
),
119+
)
108120
.option("--verbose", "Show the debug trace on stderr")
109121
.exitOverride()
110122
.configureOutput({

‎test/cli/inputs.test.ts‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { beforeAll, describe, expect, test } from "bun:test";
1212
import { generateX25519Identity, identityToRecipient } from "age-encryption";
13-
import { exposedInputs, inputsForMode } from "../../src/cli/inputs.js";
13+
import { exposedInputs, inputsForMode, LIST_INPUTS } from "../../src/cli/inputs.js";
1414
import { maskedStreams } from "../../src/cli/io.js";
1515
import { buildProgram, CLI_COMMANDS, main } from "../../src/cli/program.js";
1616
import {
@@ -21,6 +21,7 @@ import {
2121
MODES,
2222
type Mode,
2323
parseConfig,
24+
parseReposInput,
2425
type RunConfig,
2526
} from "../../src/index.js";
2627
import { MockApi } from "../mock-api.js";
@@ -348,6 +349,66 @@ describe("the per-mode flag split", () => {
348349
}
349350
});
350351

352+
test("every LIST_INPUTS entry is one parseConfig splits: a newline-joined pair is accepted whole", () => {
353+
// Two entries per list input, each valid alone; the pair must come out of the
354+
// parse as those two values, which only the library's own splitting can prove
355+
// (repos is split downstream by parseReposInput, so that is what reads it).
356+
const engine: Inputs = { mode: "check", token: "ghp" };
357+
const discovery: Inputs = { ...engine, repos: "*" };
358+
const cases: Record<
359+
(typeof LIST_INPUTS)[number],
360+
[Inputs, [string, string], (cfg: RunConfig) => readonly string[]]
361+
> = {
362+
"settings-file": [
363+
{ mode: "merge", "merged-file": "out.yml" },
364+
["a.yml", "b.yml"],
365+
(cfg) => (cfg.kind === "merge" ? cfg.settingsFiles : []),
366+
],
367+
"required-sections": [
368+
engine,
369+
["labels", "milestones"],
370+
(cfg) => (cfg.kind === "merge" ? [] : [...cfg.sections.required]),
371+
],
372+
sections: [
373+
engine,
374+
["labels", "milestones"],
375+
(cfg) => (cfg.kind === "merge" ? [] : [...cfg.sections.only]),
376+
],
377+
repos: [
378+
engine,
379+
["o/a", "o/b"],
380+
(cfg) =>
381+
cfg.kind === "multi"
382+
? parseReposInput(cfg.reposInput)
383+
.map((r) => r.slugs)
384+
.unwrapOr([])
385+
: [],
386+
],
387+
exclude: [
388+
discovery,
389+
["x/*", "tmp-*"],
390+
(cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.exclude : []),
391+
],
392+
topics: [
393+
discovery,
394+
["a", "b"],
395+
(cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.topics : []),
396+
],
397+
affiliation: [
398+
discovery,
399+
["owner", "collaborator"],
400+
(cfg) => (cfg.kind === "multi" ? cfg.discoveryFilters.affiliation : []),
401+
],
402+
};
403+
const env: ConfigEnv = { GITHUB_REPOSITORY: "o/self" };
404+
for (const name of LIST_INPUTS) {
405+
const [companions, pair, kept] = cases[name];
406+
const result = parseConfig(recordReader({ ...companions, [name]: pair.join("\n") }), env);
407+
expect(result.isOk(), `${name}: ${result.match(() => "", describeProblem)}`).toBe(true);
408+
expect(result.map(kept).unwrapOr([]), name).toEqual(pair);
409+
}
410+
});
411+
351412
test("every declared input is the mode, the global token, or a flag of some subcommand", () => {
352413
expect(new Set([...exposedInputs(), "mode"])).toEqual(new Set(Object.keys(INPUT_DECLS)));
353414
});

‎test/cli/program.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,49 @@ describe("the commands this build cannot run", () => {
276276
});
277277

278278
describe("commander's own exits", () => {
279-
test("--help exits 0 and names every subcommand", async () => {
279+
test("--help exits 0, prints the bin name, and names every subcommand", async () => {
280280
const result = await cli(["--help"]);
281281
expect(result.code).toBe(0);
282+
expect(result.stdout).toStartWith("Usage: github-settings-as-code [options] [command]");
283+
expect(result.stdout).toContain("gsac");
282284
for (const command of CLI_COMMANDS) {
283285
expect(result.stdout).toContain(` ${command}`);
284286
}
285287
});
286288

289+
test("a repeated single-value flag exits 1 naming the flag; a repeated list flag accumulates", async () => {
290+
const repeated = await cli(
291+
["check", "--token", TOKEN, "--repository", "a/b", "--repository", "c/d"],
292+
new MockApi({}),
293+
);
294+
expect(repeated.code).toBe(1);
295+
expect(repeated.stderr).toBe(
296+
"error: option '--repository <value>' argument 'c/d' is invalid. --repository takes one value and was given more than once\n",
297+
);
298+
// Both unknown names must be reported: a last-wins parser would name only the second.
299+
const list = await cli([
300+
"check",
301+
"--token",
302+
TOKEN,
303+
"--repository",
304+
"o/r",
305+
"--sections",
306+
"nope",
307+
"--sections",
308+
"also",
309+
]);
310+
expect(list.code).toBe(1);
311+
expect(list.stderr).toStartWith(
312+
'error: unknown sections "nope", "also" in the "sections" input',
313+
);
314+
// Both token values were registered before the parse, so the echo is masked.
315+
const token = await cli(["check", "--token", "first", "--token", "second"]);
316+
expect(token.code).toBe(1);
317+
expect(token.stderr).toBe(
318+
"error: option '--token <value>' argument '***' is invalid. --token takes one value and was given more than once\n",
319+
);
320+
});
321+
287322
test("an unknown flag exits 1 with commander's line on stderr", async () => {
288323
const result = await cli(["check", "--tokn", "x"]);
289324
expect(result.code).toBe(1);

‎test/docs/readme.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe("README front door", () => {
117117
test("stays a front door in size", () => {
118118
// The budget is the tripwire against the reference tables growing back; the full
119119
// reference is docs/. The Library section's package and command pointers sit inside it.
120-
expect(readme.split("\n").length).toBeLessThanOrEqual(105);
120+
expect(readme.split("\n").length).toBeLessThanOrEqual(101);
121121
});
122122
});
123123

0 commit comments

Comments
 (0)