@vivswan/github-settings-as-code 0.0.0 → 2.0.1-main.658.20260922.gdcb742f

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/lib/pkg/cli.js ADDED
@@ -0,0 +1,722 @@
1
+ #!/usr/bin/env node
2
+ import { Ft as countNoun, Kt as redactRanges, N as failRun, Rt as GitHubApi, S as writeReplacing, St as sectionGrant, Wt as maskRegistry, Z as skippedSectionKeys, a as RENDER_INPUTS, c as SNAPSHOT_INPUTS, d as parseConfig, et as describeProblem, f as parseSnapshotFileConfig, k as readSettingsFile, l as SNAPSHOT_ONLY_INPUTS, o as RENDER_ONLY_INPUTS, p as snapshotFileDestination, r as INPUT_DECLS, st as SECTIONS } from "./inputs-BOuUCdpl.js";
3
+ import { a as snapshotRepository, o as validateSettings, s as executeRun } from "./src-C2gb8QkD.js";
4
+ import { appendFileSync, existsSync } from "node:fs";
5
+ import { ResultAsync, err } from "neverthrow";
6
+ import { randomUUID } from "node:crypto";
7
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
8
+ import pc from "picocolors";
9
+ import { EOL } from "node:os";
10
+ import { Writable } from "node:stream";
11
+ import { LogLevels, createConsola } from "consola";
12
+ //#region src/cli/actions.ts
13
+ /**
14
+ * The runner as the CLI's second face: a gsac step under GitHub Actions speaks
15
+ * the runner's workflow commands and files, so the runner sees what the action
16
+ * step gives it. Written without @actions/core, which the CLI does not carry;
17
+ * test/cli/actions.test.ts pins every form against the action's Io.
18
+ */
19
+ /** The runner's files as it sets them; an empty value is unset, as @actions/core reads it. */
20
+ function runnerFile(value) {
21
+ return value === void 0 || value === "" ? void 0 : value;
22
+ }
23
+ /** The runner the process reports to: one when GITHUB_ACTIONS is "true", none for a terminal. */
24
+ function actionsRunner(env) {
25
+ if (env.GITHUB_ACTIONS !== "true") return;
26
+ return {
27
+ outputFile: runnerFile(env.GITHUB_OUTPUT),
28
+ summaryFile: runnerFile(env.GITHUB_STEP_SUMMARY)
29
+ };
30
+ }
31
+ /** One `::name::message` line with the runner's data escaping (%, CR, LF), as @actions/core issues it. */
32
+ function workflowCommand(name, message) {
33
+ return `::${name}::${message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")}${EOL}`;
34
+ }
35
+ /** One GITHUB_OUTPUT record in the heredoc form the runner reads, with the fresh delimiter @actions/core mints per record. */
36
+ function outputRecord(name, value) {
37
+ const delimiter = `ghadelimiter_${randomUUID()}`;
38
+ return `${name}<<${delimiter}${EOL}${value}${EOL}${delimiter}${EOL}`;
39
+ }
40
+ //#endregion
41
+ //#region src/cli/commands.ts
42
+ /**
43
+ * What the file-only subcommands and init render for the program to print;
44
+ * check, apply, render, and snapshot run through the library's executor from
45
+ * the program.
46
+ */
47
+ /** The failed envelope of a file-only command: the problem's text, beside the file when one was named. */
48
+ function failedEnvelope(message, file) {
49
+ return {
50
+ result: "failed",
51
+ ...file === void 0 ? {} : { file },
52
+ problem: message
53
+ };
54
+ }
55
+ /** The section modules a validated document declares, in execution order. */
56
+ function declaredSections(settings) {
57
+ return SECTIONS.filter((section) => settings[section.key] !== void 0);
58
+ }
59
+ /** Read and validate one settings file; the warnings go to `io`, the problem is the error. */
60
+ function readValidated(file, io) {
61
+ return readSettingsFile(file, "settings-file").andThen((doc) => validateSettings(doc, {
62
+ source: file,
63
+ io
64
+ })).map(({ settings }) => settings);
65
+ }
66
+ /** `validate <file>`: the schema verdict alone, no token and no API call. */
67
+ function validateFile(file, io) {
68
+ return readValidated(file, io).match((settings) => {
69
+ const sections = declaredSections(settings).map((section) => section.key);
70
+ return {
71
+ code: 0,
72
+ lines: [`${file} is valid: ${countNoun(sections.length, "section", "sections")} declared (${sections.join(", ")})`],
73
+ json: {
74
+ result: "valid",
75
+ file,
76
+ sections
77
+ }
78
+ };
79
+ }, (problem) => {
80
+ const message = describeProblem(problem);
81
+ io.annotate("error", message);
82
+ return {
83
+ code: 1,
84
+ lines: [],
85
+ json: failedEnvelope(message, file)
86
+ };
87
+ });
88
+ }
89
+ /** The PAT grant each section a document declares needs, from the section declarations, as lines and as an object. */
90
+ function grantTable(settings, bold) {
91
+ const grants = declaredSections(settings).map((section) => [section.key, sectionGrant(section)]);
92
+ return {
93
+ sections: grants.map(([key]) => key),
94
+ lines: grants.map(([key, grant]) => `${bold(key)}: ${grant}`),
95
+ json: Object.fromEntries(grants)
96
+ };
97
+ }
98
+ /** `permissions <file>`: the PAT grant each declared section needs, from the section declarations. */
99
+ function permissionsFor(file, io, bold) {
100
+ return readValidated(file, io).match((settings) => {
101
+ const { lines, json } = grantTable(settings, bold);
102
+ return {
103
+ code: 0,
104
+ lines,
105
+ json: {
106
+ result: "valid",
107
+ file,
108
+ grant: json
109
+ }
110
+ };
111
+ }, (problem) => {
112
+ const message = describeProblem(problem);
113
+ io.annotate("error", message);
114
+ return {
115
+ code: 1,
116
+ lines: [],
117
+ json: failedEnvelope(message, file)
118
+ };
119
+ });
120
+ }
121
+ //#endregion
122
+ //#region src/cli/init.ts
123
+ /**
124
+ * `init`: adoption in one command. Snapshot one repository into the settings
125
+ * file apply and check read (the one destination mode: snapshot refuses, since
126
+ * here that file is the point), refuse to replace a file that already exists
127
+ * unless --force, then print the PAT grant the written sections need. A
128
+ * command-line command alone: no action mode reaches it, so its config and its
129
+ * own problems live here beside the library's rather than in RunConfig and
130
+ * Problem.
131
+ */
132
+ /** The init flags are the snapshot inputs of one repository with settings-file as the destination, so every problem is the library's. */
133
+ function parseInitConfig(read, force, env) {
134
+ return parseSnapshotFileConfig(read, env).map((cfg) => ({
135
+ kind: "init",
136
+ token: cfg.token,
137
+ apiVersion: cfg.apiVersion,
138
+ repo: cfg.repo,
139
+ settingsFile: cfg.snapshotFile,
140
+ sections: cfg.sections,
141
+ onMissingPermission: cfg.onMissingPermission,
142
+ force
143
+ }));
144
+ }
145
+ /** The wording for every problem init can end in: its own here, the library's through the one renderer. */
146
+ function describeInitProblem(problem) {
147
+ switch (problem.code) {
148
+ case "init-settings-file-exists": return `${problem.settingsFile} already exists: init writes the starting settings file and does not replace the one you author. Pass --force to replace it, or --settings-file <path> to write elsewhere`;
149
+ case "init-settings-file-unwritable": return `cannot write the settings file ${problem.settingsFile}: ${problem.reason}. Check that --settings-file names a writable path`;
150
+ case "init-snapshot-failed": return `the snapshot of ${problem.repository} failed, so ${problem.settingsFile} was not written; the errors above name the section and the fix`;
151
+ case "init-empty-document": {
152
+ const why = problem.reasons.filter(([, keys]) => keys.length > 0).map(([label, keys]) => `${label}: ${keys.join(", ")}`).join("; ");
153
+ return `the snapshot of ${problem.repository} declares no section (${why}), so ${problem.settingsFile} was not written. Choose sections init can read back, or drop --sections to read every section`;
154
+ }
155
+ default: return describeProblem(problem);
156
+ }
157
+ }
158
+ /** `file` is the settings file the command was told (or defaulted to); only a test that renders no command omits it. */
159
+ function failInit(io, problem, file) {
160
+ const message = describeInitProblem(problem);
161
+ io.annotate("error", message);
162
+ return {
163
+ code: 1,
164
+ lines: [],
165
+ json: failedEnvelope(message, file)
166
+ };
167
+ }
168
+ function writeSettingsFile(cfg, yaml) {
169
+ return writeReplacing(cfg.settingsFile, yaml).mapErr((reason) => ({
170
+ code: "init-settings-file-unwritable",
171
+ settingsFile: cfg.settingsFile,
172
+ reason
173
+ }));
174
+ }
175
+ /** The outcome keys in one status, for the lines that name them. */
176
+ function keysWith(report, ...statuses) {
177
+ return report.outcomes.filter((o) => statuses.includes(o.status)).map((o) => o.key);
178
+ }
179
+ /** The existence check comes first so a refusal costs no API call. */
180
+ function runInit(cfg, io, host, bold) {
181
+ if (!cfg.force && existsSync(cfg.settingsFile)) return Promise.resolve(failInit(io, {
182
+ code: "init-settings-file-exists",
183
+ settingsFile: cfg.settingsFile
184
+ }, cfg.settingsFile));
185
+ const api = host.createClient(cfg.token, io, cfg.apiVersion);
186
+ return ResultAsync.fromSafePromise(snapshotRepository(api, cfg.repo, {
187
+ sections: cfg.sections,
188
+ onMissingPermission: cfg.onMissingPermission,
189
+ io
190
+ })).andThen((report) => {
191
+ if (report.result === "failed") return err({
192
+ code: "init-snapshot-failed",
193
+ repository: cfg.repo.slug,
194
+ settingsFile: cfg.settingsFile
195
+ });
196
+ const grant = grantTable(report.settings, bold);
197
+ const unsupported = keysWith(report, "unsupported");
198
+ const skipped = skippedSectionKeys(report.outcomes);
199
+ if (grant.sections.length === 0) return err({
200
+ code: "init-empty-document",
201
+ repository: cfg.repo.slug,
202
+ settingsFile: cfg.settingsFile,
203
+ reasons: [
204
+ ["cannot be read back", unsupported],
205
+ ["skipped", skipped],
206
+ ["nothing exists on the repository", keysWith(report, "snapshot")]
207
+ ]
208
+ });
209
+ return writeSettingsFile(cfg, report.yaml).map(() => {
210
+ return {
211
+ code: 0,
212
+ lines: [
213
+ `${cfg.settingsFile} written from ${cfg.repo.slug}: ${countNoun(grant.sections.length, "section", "sections")} declared (${grant.sections.join(", ")})`,
214
+ ...unsupported.length === 0 ? [] : [`not read back: ${unsupported.join(", ")} (the file's header says why; declare them by hand to manage them)`],
215
+ ...skipped.length === 0 ? [] : [`skipped: ${skipped.join(", ")} (the file omits them; the warnings above say why)`],
216
+ "Token permissions the file needs:",
217
+ ...grant.lines.map((line) => ` ${line}`)
218
+ ],
219
+ json: {
220
+ result: report.result,
221
+ file: cfg.settingsFile,
222
+ repository: cfg.repo.slug,
223
+ "skipped-sections": skipped,
224
+ grant: grant.json
225
+ }
226
+ };
227
+ });
228
+ }).match((rendered) => rendered, (problem) => failInit(io, problem, cfg.settingsFile));
229
+ }
230
+ //#endregion
231
+ //#region src/cli/inputs.ts
232
+ /**
233
+ * The CLI's read port over commander: every flag is one INPUT_DECLS entry
234
+ * spelled `--<name> <value>`, so the help text and the action's inputs
235
+ * reference come from one declaration. The subcommand is the `mode` input
236
+ * and `--token` is a program-level flag; every other input is a flag of the
237
+ * subcommands whose mode reads it. parseConfig validates the values; nothing
238
+ * here does.
239
+ */
240
+ /** Declaration order is the help order, as on the inputs reference page. */
241
+ const INPUT_NAMES = Object.keys(INPUT_DECLS);
242
+ /** The two inputs that are not subcommand flags: the mode is the subcommand, the token is global. */
243
+ const PROGRAM_INPUTS = ["mode", "token"];
244
+ /**
245
+ * Inputs no subcommand exposes: the artifact report channel needs the Actions
246
+ * artifact service, which a terminal has no upload for, so its key has no use.
247
+ */
248
+ const CLI_UNSUPPORTED_INPUTS = ["report-public-key"];
249
+ /** The flags a mode's subcommand takes: the inputs its mode reads, in declaration order. */
250
+ function inputsForMode(mode) {
251
+ const hidden = [...PROGRAM_INPUTS, ...CLI_UNSUPPORTED_INPUTS];
252
+ const modeOnly = [...RENDER_ONLY_INPUTS, ...SNAPSHOT_ONLY_INPUTS];
253
+ const reads = (name) => {
254
+ switch (mode) {
255
+ case "render": return RENDER_INPUTS.includes(name);
256
+ case "snapshot": return SNAPSHOT_INPUTS.includes(name);
257
+ case "apply":
258
+ case "check": return !modeOnly.includes(name);
259
+ }
260
+ };
261
+ return INPUT_NAMES.filter((name) => !hidden.includes(name) && reads(name));
262
+ }
263
+ /** A mode's subcommand: its flags are the inputs the mode reads. */
264
+ function modeSubcommand(mode) {
265
+ return {
266
+ flags: new Set(inputsForMode(mode)),
267
+ mode
268
+ };
269
+ }
270
+ /**
271
+ * The init subcommand: the snapshot inputs of one repository, with
272
+ * settings-file as the destination in place of snapshot-file. No mode runs
273
+ * it, so the clauses restricted to modes leave its help.
274
+ */
275
+ const INIT_SUBCOMMAND = {
276
+ flags: /* @__PURE__ */ new Set([
277
+ "repository",
278
+ "settings-file",
279
+ "on-missing-permission",
280
+ "sections",
281
+ "api-version"
282
+ ]),
283
+ mode: null
284
+ };
285
+ /** init's flags in declaration order, the order the help keeps. */
286
+ const INIT_INPUTS = INPUT_NAMES.filter((name) => INIT_SUBCOMMAND.flags.has(name));
287
+ /**
288
+ * init's one reworded flag: the declaration describes the file apply and check
289
+ * READ, and init WRITES it; every other init flag keeps its declared text.
290
+ */
291
+ const INIT_SETTINGS_FILE_DESCRIPTION = "Where the settings document is written: the file apply and check read. One path; an existing file is kept unless --force is passed.";
292
+ /** `text` as the declaration spells it; a reworded declaration fails here rather than leave the help stale. */
293
+ function declared(input, text) {
294
+ if (!INPUT_DECLS[input].description.includes(text)) throw new Error(`BUG: the ${input} input's description no longer says "${text}"; reword the CLI's clause with it`);
295
+ return text;
296
+ }
297
+ const MULTI_REPO_FLAGS = ["repos", "repos-dir"];
298
+ const CLAUSES = [
299
+ {
300
+ input: "repository",
301
+ text: declared("repository", " Single-repo mode only; cannot be combined with repos or repos-dir."),
302
+ flags: MULTI_REPO_FLAGS
303
+ },
304
+ {
305
+ input: "settings-file",
306
+ text: declared("settings-file", " Single-repo and render modes only; multi-repo targets read repos-dir files or each repository's own .github/settings.yml, so overriding it alongside repos or repos-dir fails the run."),
307
+ flags: MULTI_REPO_FLAGS
308
+ },
309
+ {
310
+ input: "snapshot-dir",
311
+ text: declared("snapshot-dir", "; defaults-file does not apply"),
312
+ flags: ["defaults-file"]
313
+ },
314
+ {
315
+ input: "sections",
316
+ text: declared("sections", " apply, check, and snapshot only: mode: render writes every section its layers declare, so the allowlist belongs on the step that runs the rendered document and fails the render when set."),
317
+ modes: [
318
+ "apply",
319
+ "check",
320
+ "snapshot"
321
+ ]
322
+ },
323
+ {
324
+ input: "private-report",
325
+ text: declared("private-report", " Under artifact, those reports are concatenated, age-encrypted to report-public-key, and uploaded as one workflow artifact (settings-as-code-private-report) for readers who hold the key but no GitHub access to the targets; the artifact channel needs the Actions artifact service, so on GitHub Enterprise Server it warns and uploads nothing."),
326
+ flags: ["report-public-key"]
327
+ },
328
+ {
329
+ input: "private-report",
330
+ text: declared("private-report", "issue, issue-on-failure, or artifact."),
331
+ replacement: "issue, or issue-on-failure.",
332
+ flags: ["report-public-key"]
333
+ }
334
+ ];
335
+ function meets(subcommand, clause) {
336
+ const flags = (clause.flags ?? []).every((flag) => subcommand.flags.has(flag));
337
+ const mode = clause.modes === void 0 || subcommand.mode !== null && clause.modes.includes(subcommand.mode);
338
+ return flags && mode;
339
+ }
340
+ /** The sentence the action's `repository` description spends on a default a terminal never has. */
341
+ const ACTIONS_DEFAULT_SENTENCE = declared("repository", "Defaults to the current repository.");
342
+ /**
343
+ * A flag's help text under `subcommand`: the declaration's, minus the clauses
344
+ * about flags and modes the subcommand lacks, and reworded where it assumes
345
+ * the Actions runner.
346
+ */
347
+ function inputDescription(name, subcommand) {
348
+ let description = INPUT_DECLS[name].description;
349
+ for (const clause of CLAUSES) if (clause.input === name && !meets(subcommand, clause)) description = description.replace(clause.text, clause.replacement ?? "");
350
+ if (name === "repository") {
351
+ const unless = MULTI_REPO_FLAGS.every((flag) => subcommand.flags.has(flag)) ? " unless repos or repos-dir is set" : "";
352
+ description = description.replace(ACTIONS_DEFAULT_SENTENCE, `Required${unless} (inside GitHub Actions, GITHUB_REPOSITORY supplies it).`);
353
+ }
354
+ return description;
355
+ }
356
+ /** Whether the declaration is a list; read through InputDecl since only the list members carry the field. */
357
+ function isList(name) {
358
+ return INPUT_DECLS[name].list === true;
359
+ }
360
+ /** A repeated list flag accumulates as a newline-separated list, the form parseConfig splits. */
361
+ function accumulate(value, previous) {
362
+ return previous === void 0 ? value : `${previous}\n${value}`;
363
+ }
364
+ /** A repeated single-value flag is refused: joined, it would form a value the action cannot receive. */
365
+ function once(flag) {
366
+ return (value, previous) => {
367
+ if (previous !== void 0) throw new InvalidArgumentError(`--${flag} takes one value and was given more than once`);
368
+ return value;
369
+ };
370
+ }
371
+ /**
372
+ * The commander option for one input under `subcommand`: `--<name> <value>`,
373
+ * repeatable when the declaration is a list; `description` replaces the
374
+ * declaration's where the subcommand reads the input for another purpose.
375
+ */
376
+ function inputOption(name, subcommand, description = inputDescription(name, subcommand)) {
377
+ const parse = isList(name) ? accumulate : once(name);
378
+ return new Option(`--${name} <value>`, description).argParser(parse);
379
+ }
380
+ /** Commander's attribute for each flag (camelCase of the name), read from commander itself. */
381
+ const ATTRIBUTE = Object.fromEntries(INPUT_NAMES.map((name) => [name, new Option(`--${name} <value>`).attributeName()]));
382
+ /** A flag value as the runner would hand it over: trimmed, as @actions/core trims every input. */
383
+ function inputValue(value) {
384
+ return typeof value === "string" ? value.trim() : "";
385
+ }
386
+ /**
387
+ * The read port for a subcommand: `mode` is the subcommand, every other
388
+ * input is its parsed flag, empty when unset, so parseConfig sees exactly
389
+ * what the action's runner would hand it.
390
+ */
391
+ function argvReader(mode, values) {
392
+ return (name) => name === "mode" ? mode : inputValue(values[ATTRIBUTE[name]]);
393
+ }
394
+ /**
395
+ * Every value `--token` carries in `argv`, in both spellings commander
396
+ * accepts, as the reader would read it. Read before parsing, so the token is
397
+ * masked before the parser can echo it in a message of its own.
398
+ */
399
+ function tokenValues(argv) {
400
+ const values = [];
401
+ argv.forEach((argument, index) => {
402
+ if (argument === "--token") values.push(inputValue(argv[index + 1]));
403
+ else if (argument.startsWith("--token=")) values.push(inputValue(argument.slice(8)));
404
+ });
405
+ return values.filter((value) => value !== "");
406
+ }
407
+ //#endregion
408
+ //#region src/cli/io.ts
409
+ /**
410
+ * The CLI's output boundary and its Io. No runner masks for a terminal, so
411
+ * every writer, the parser included, goes through maskedStreams(). Under a
412
+ * GitHub Actions runner the same Io speaks the runner's commands and files
413
+ * beside that redaction, so a gsac step and the action step read alike.
414
+ */
415
+ /** A chunk that is already final, a workflow command: its name must survive a masked value spelled like it. */
416
+ var Verbatim = class {
417
+ text;
418
+ constructor(text) {
419
+ this.text = text;
420
+ }
421
+ };
422
+ /** A stream that redacts each chunk before handing it to `target`; one queue, so no chunk overtakes another. */
423
+ var RedactingStream = class extends Writable {
424
+ target;
425
+ redact;
426
+ constructor(target, redact) {
427
+ super({ objectMode: true });
428
+ this.target = target;
429
+ this.redact = redact;
430
+ }
431
+ _write(chunk, _encoding, callback) {
432
+ const text = chunk instanceof Verbatim ? chunk.text : this.redact(String(chunk));
433
+ if (this.target.write(text)) callback();
434
+ else this.target.once("drain", callback);
435
+ }
436
+ };
437
+ /**
438
+ * Every write to the returned streams is redacted; register a value before
439
+ * anything can print it. One registry serves the parser, the Io, and the
440
+ * file-only commands alike, so no writer can bypass it. The runner's commands
441
+ * are the two writes redaction never touches whole: the add-mask command must
442
+ * carry the value, and a masked value spelled like a command name ("error")
443
+ * must not turn any command into `::***::`.
444
+ */
445
+ function maskedStreams(streams, runner) {
446
+ const registry = maskRegistry(runner === void 0 ? () => {} : (value) => command(workflowCommand("add-mask", value)));
447
+ const redact = (text) => redactRanges(text, registry.masked());
448
+ const stdout = new RedactingStream(streams.stdout, redact);
449
+ /** A finished command line, queued behind the redacted writes before it. */
450
+ function command(line) {
451
+ stdout.write(new Verbatim(line));
452
+ }
453
+ return {
454
+ stdout,
455
+ stderr: new RedactingStream(streams.stderr, redact),
456
+ redact,
457
+ runner: runner === void 0 ? void 0 : {
458
+ ...runner,
459
+ command: (name, message) => command(workflowCommand(name, redact(message)))
460
+ },
461
+ ...registry
462
+ };
463
+ }
464
+ /** The consola type each annotation level logs as; consola gates them by level. */
465
+ const CONSOLA_TYPE = {
466
+ notice: "info",
467
+ warning: "warn",
468
+ error: "error"
469
+ };
470
+ /** The label a consola type prints under, in the action's annotation words. */
471
+ const LABEL = {
472
+ info: {
473
+ label: "notice",
474
+ paint: (colors) => colors.blue
475
+ },
476
+ warn: {
477
+ label: "warning",
478
+ paint: (colors) => colors.yellow
479
+ },
480
+ error: {
481
+ label: "error",
482
+ paint: (colors) => colors.red
483
+ },
484
+ debug: {
485
+ label: "debug",
486
+ paint: (colors) => colors.dim
487
+ }
488
+ };
489
+ /**
490
+ * How each output reads inside the --json envelope: the action's outputs are strings (a comma list, a JSON document),
491
+ * and a JSON envelope carries the value itself, never a string a reader would parse again.
492
+ */
493
+ const JSON_OUTPUT = {
494
+ result: (value) => value,
495
+ "skipped-sections": (value) => value === "" ? [] : value.split(","),
496
+ "repos-result": (value) => JSON.parse(value)
497
+ };
498
+ function cliIo(options) {
499
+ const { streams } = options;
500
+ const colors = pc.createColors(options.colors);
501
+ const reporter = { log(logObj) {
502
+ const meta = LABEL[logObj.type];
503
+ const text = logObj.args.map(String).join(" ");
504
+ const prefix = meta === void 0 ? "" : `${meta.paint(colors)(meta.label)}: `;
505
+ streams.stderr.write(`${prefix}${text}\n`);
506
+ } };
507
+ const level = options.verbose ? LogLevels.debug : LogLevels.info;
508
+ const consola = createConsola({
509
+ level,
510
+ reporters: [reporter],
511
+ throttle: 0
512
+ });
513
+ const logStream = options.json ? streams.stderr : streams.stdout;
514
+ const { runner } = streams;
515
+ const summaryFile = options.summaryFile ?? runner?.summaryFile;
516
+ const outputs = /* @__PURE__ */ new Map();
517
+ return {
518
+ io: {
519
+ annotate: runner === void 0 ? (level, message) => consola[CONSOLA_TYPE[level]](message) : (level, message) => runner.command(level, message),
520
+ log: (line) => logStream.write(`${line}\n`),
521
+ debug: (line) => consola.debug(line),
522
+ summary: (markdown) => {
523
+ if (summaryFile !== void 0) appendFileSync(summaryFile, `${streams.redact(markdown)}\n`);
524
+ },
525
+ output: (name, value) => {
526
+ outputs.set(name, value);
527
+ if (runner?.outputFile !== void 0) appendFileSync(runner.outputFile, outputRecord(name, value));
528
+ },
529
+ mask: streams.mask,
530
+ masked: streams.masked
531
+ },
532
+ flush: (problem) => {
533
+ if (options.json) {
534
+ const envelope = Object.fromEntries([...outputs].map(([name, value]) => [name, JSON_OUTPUT[name](value)]));
535
+ streams.stdout.write(`${JSON.stringify(problem === void 0 ? envelope : {
536
+ ...envelope,
537
+ problem
538
+ })}\n`);
539
+ return;
540
+ }
541
+ for (const [name, value] of outputs) streams.stdout.write(`${name}=${value}\n`);
542
+ }
543
+ };
544
+ }
545
+ //#endregion
546
+ //#region src/cli/program.ts
547
+ /**
548
+ * The command tree: check, apply, render, and snapshot mirror the action's modes with
549
+ * INPUT_DECLS as their flags; init snapshots one repository into the settings
550
+ * file; validate and permissions read a file alone. `--token`, `--json`,
551
+ * `--summary`, and `--verbose` are global. main() runs argv to its exit code
552
+ * without touching the process.
553
+ */
554
+ const DESCRIPTION = {
555
+ check: "Report drift between a settings file and the live repository; exits 1 on any drift",
556
+ apply: "Apply a settings file to the repository",
557
+ render: "Fold an ordered list of settings files into one rendered document, with no token and no API call",
558
+ snapshot: "Write a repository's live settings as a settings file, or one file per multi-repo target under a directory",
559
+ init: "Start managing a repository: write its live settings to the settings file (.github/settings.yml unless --settings-file says otherwise) and print the PAT grant that file needs",
560
+ validate: "Validate a settings file against the schema; no token, no API call",
561
+ permissions: "Print the PAT grant each section a settings file declares needs"
562
+ };
563
+ /** The subcommands that run the engine or the render, each under its mode. */
564
+ const MODE_COMMANDS = {
565
+ check: "check",
566
+ apply: "apply",
567
+ render: "render",
568
+ snapshot: "snapshot"
569
+ };
570
+ /** The production host: process.env and the real client. */
571
+ function processHost() {
572
+ return {
573
+ env: process.env,
574
+ createClient: (token, io, apiVersion) => new GitHubApi({
575
+ token,
576
+ io,
577
+ apiVersion
578
+ })
579
+ };
580
+ }
581
+ /** A terminal has no Actions artifact service, so the artifact report channel is refused at the parse. */
582
+ const CLI_CAPABILITIES = { artifactUpload: false };
583
+ /**
584
+ * The whole command tree, wired to `options`; the exit code lands in the
585
+ * returned holder. The environment's token is masked here, before any writer
586
+ * exists; the argv token is main()'s to register, before the parse.
587
+ */
588
+ function buildProgram(options) {
589
+ const { host, streams } = options;
590
+ const colors = options.colors ?? pc.isColorSupported;
591
+ const paint = pc.createColors(colors);
592
+ const execute = options.execute ?? ((cfg, io) => executeRun(cfg, {
593
+ io,
594
+ createClient: (token, io, apiVersion) => host.createClient(token, io, apiVersion)
595
+ }));
596
+ const executeInit = options.executeInit ?? ((cfg, io) => runInit(cfg, io, host, paint.bold));
597
+ const envToken = host.env.GITHUB_TOKEN?.trim();
598
+ if (envToken !== void 0 && envToken !== "") streams.mask(envToken);
599
+ let exitCode = 0;
600
+ const program = new Command().name("github-settings-as-code").description("Apply, check, render, and validate declarative GitHub repository settings (also installed as gsac)").addOption(new Option("--token <value>", `${INPUT_DECLS.token.description} Falls back to GITHUB_TOKEN.`).argParser(once("token"))).option("--json", "Print the outputs as one JSON object on stdout; log lines move to stderr").addOption(new Option("--summary <file>", "Append the run's markdown summary to this file (under GitHub Actions, the step summary when absent)").argParser(once("summary"))).option("--verbose", "Show the debug trace on stderr").exitOverride().configureOutput({
601
+ writeOut: (text) => streams.stdout.write(text),
602
+ writeErr: (text) => streams.stderr.write(text)
603
+ });
604
+ const openIo = (globals) => cliIo({
605
+ streams,
606
+ json: globals.json === true,
607
+ verbose: globals.verbose === true,
608
+ summaryFile: globals.summary,
609
+ colors
610
+ });
611
+ /** Print a file-only command's result the way `--json` asks. */
612
+ const present = (rendered, globals) => {
613
+ if (globals.json === true) {
614
+ streams.stdout.write(`${JSON.stringify(rendered.json)}\n`);
615
+ return;
616
+ }
617
+ for (const line of rendered.lines) streams.stdout.write(`${line}\n`);
618
+ };
619
+ for (const [name, mode] of Object.entries(MODE_COMMANDS)) {
620
+ const command = program.command(name).description(DESCRIPTION[name]);
621
+ const subcommand = modeSubcommand(mode);
622
+ for (const input of subcommand.flags) command.addOption(inputOption(input, subcommand));
623
+ command.action(async function() {
624
+ const values = this.optsWithGlobals();
625
+ const { io, flush } = openIo(values);
626
+ const read = argvReader(mode, values);
627
+ let fatal;
628
+ exitCode = await parseConfig(read, host.env, CLI_CAPABILITIES).match(async (cfg) => {
629
+ const end = await execute(cfg, io);
630
+ fatal = end.fatal === void 0 ? void 0 : describeProblem(end.fatal);
631
+ return end.exitCode;
632
+ }, async (problem) => {
633
+ fatal = describeProblem(problem);
634
+ return failRun(io, problem);
635
+ });
636
+ flush(fatal);
637
+ });
638
+ }
639
+ const init = program.command("init").description(DESCRIPTION.init);
640
+ for (const input of INIT_INPUTS) init.addOption(input === "settings-file" ? inputOption(input, INIT_SUBCOMMAND, INIT_SETTINGS_FILE_DESCRIPTION) : inputOption(input, INIT_SUBCOMMAND));
641
+ init.option("--force", "Replace the settings file when it already exists; without it, init refuses").action(async function() {
642
+ const values = this.optsWithGlobals();
643
+ const { io } = openIo(values);
644
+ const read = argvReader("snapshot", values);
645
+ const rendered = await parseInitConfig(read, values.force === true, host.env).match((cfg) => executeInit(cfg, io), async (problem) => failInit(io, problem, snapshotFileDestination(read)));
646
+ present(rendered, values);
647
+ exitCode = rendered.code;
648
+ });
649
+ program.command("validate").description(DESCRIPTION.validate).argument("<file>", "the settings file to validate").action(function(file) {
650
+ const globals = this.optsWithGlobals();
651
+ const { io } = openIo(globals);
652
+ const rendered = validateFile(file, io);
653
+ present(rendered, globals);
654
+ exitCode = rendered.code;
655
+ });
656
+ program.command("permissions").description(DESCRIPTION.permissions).argument("<file>", "the settings file whose sections decide the grant").action(function(file) {
657
+ const globals = this.optsWithGlobals();
658
+ const { io } = openIo(globals);
659
+ const rendered = permissionsFor(file, io, paint.bold);
660
+ present(rendered, globals);
661
+ exitCode = rendered.code;
662
+ });
663
+ return {
664
+ program,
665
+ exitCode: () => exitCode
666
+ };
667
+ }
668
+ /**
669
+ * Run `argv` (the full process.argv shape) to its exit code; every line, a crash's included, is masked. Under --json a
670
+ * failure the parser or a crash ends in prints the same failed envelope a run prints, so stdout is always one object.
671
+ * Whether the run reports to a GitHub Actions runner is decided here, once, from the host's environment.
672
+ */
673
+ async function main(argv, options) {
674
+ const streams = maskedStreams(options.streams, actionsRunner(options.host.env));
675
+ for (const token of tokenValues(argv)) streams.mask(token);
676
+ const { program, exitCode } = buildProgram({
677
+ ...options,
678
+ streams
679
+ });
680
+ const terminator = argv.indexOf("--");
681
+ const optionTokens = argv.slice(0, terminator === -1 ? argv.length : terminator);
682
+ const failedJson = (message) => {
683
+ if (program.opts().json === true || optionTokens.includes("--json")) streams.stdout.write(`${JSON.stringify(failedEnvelope(message))}\n`);
684
+ };
685
+ try {
686
+ await program.parseAsync(argv);
687
+ } catch (error) {
688
+ if (error instanceof CommanderError) {
689
+ if (error.exitCode !== 0) failedJson(error.code === "commander.help" ? "no subcommand was given; the usage above lists them" : error.message.replace(/^error: /, ""));
690
+ return error.exitCode;
691
+ }
692
+ const globals = program.opts();
693
+ const verbose = globals.verbose === true;
694
+ const message = `github-settings-as-code stopped unexpectedly: ${verbose && error instanceof Error && error.stack ? error.stack : String(error)}. ${verbose ? "The stack above is the report: if it recurs, file a bug with it attached" : "Re-run with --verbose for the stack; if it recurs, file a bug with that output attached"}`;
695
+ cliIo({
696
+ streams,
697
+ json: globals.json === true,
698
+ verbose,
699
+ colors: options.colors ?? pc.isColorSupported
700
+ }).io.annotate("error", message);
701
+ failedJson(message);
702
+ return 1;
703
+ }
704
+ return exitCode();
705
+ }
706
+ //#endregion
707
+ //#region src/cli.ts
708
+ /**
709
+ * The bin entry (lib/pkg/cli.js is built from this file, shebang kept):
710
+ * run the command line and map its return code to the process exit code.
711
+ * Everything else lives in src/cli/.
712
+ */
713
+ const streams = {
714
+ stdout: process.stdout,
715
+ stderr: process.stderr
716
+ };
717
+ process.exitCode = await main(process.argv, {
718
+ host: processHost(),
719
+ streams
720
+ });
721
+ //#endregion
722
+ export {};