@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/LICENSE.md +561 -1
- package/README.md +101 -2
- package/lib/pkg/cli.js +722 -0
- package/lib/pkg/index.d.ts +157 -0
- package/lib/pkg/index.js +3 -0
- package/lib/pkg/inputs-BOuUCdpl.js +15883 -0
- package/lib/pkg/internal-la_KkjCS.js +1 -0
- package/lib/pkg/internal.d.ts +49 -0
- package/lib/pkg/internal.js +3 -0
- package/lib/pkg/layers-MA87H-hC.d.ts +9786 -0
- package/lib/pkg/src-C2gb8QkD.js +160 -0
- package/lib/settings.schema.json +4128 -0
- package/package.json +91 -3
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 {};
|