@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-beta.1

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/src/cli.mjs ADDED
@@ -0,0 +1,229 @@
1
+ // Argument parsing and dispatch. Exit codes: 0 ok, 1 a check failed (or a
2
+ // warning under --strict, or the tag or the change was refused), 2 usage
3
+ // error (including a missing answer when there is no terminal to ask on).
4
+
5
+ import { parseArgs } from "node:util";
6
+ import { formatReport, runDoctor } from "./doctor.mjs";
7
+ import { REPO_PATTERN, sanitizeLine } from "./checks/result.mjs";
8
+ import { systemRunner } from "./exec.mjs";
9
+ import { askYesNo } from "./prompt.mjs";
10
+ import { CLI_VERSION, isReleaseTag } from "./tags.mjs";
11
+ import { runInit } from "./init.mjs";
12
+ import { runUpgrade } from "./upgrade.mjs";
13
+
14
+ export const VERSION = CLI_VERSION;
15
+
16
+ const USAGE = `Usage: agent-kit <command> [options]
17
+
18
+ Commands:
19
+ init Adopt Agent-kit from a tag: copy the Kit-owned paths, write a
20
+ Caller per flow with your answers filled in, the Threat pack
21
+ skeleton and .github/dependabot.yml, then print the GitHub-side
22
+ steps as gh commands. Local files only.
23
+ doctor Read-only checks of this repository against the Agent-kit tag
24
+ its Callers pin. Never changes anything.
25
+ upgrade Move this repository to another Agent-kit tag: replace the
26
+ Kit-owned paths, rewrite the uses: pins, print the examples/
27
+ diff, then run doctor's file and pin checks. Local files only.
28
+
29
+ Options for doctor:
30
+ --ci Check only Kit-owned paths and uses: pins (no gh needed).
31
+ Used automatically when gh has no admin login on the repo.
32
+ --strict Treat warnings as failures.
33
+ --repo <o/r> The GitHub repository to check (default: gh's choice for
34
+ the current directory).
35
+
36
+ Options for init (each one is asked for when missing, on a terminal):
37
+ --tag <vX> The Agent-kit tag to adopt.
38
+ --flows <list> pipeline, audit, or both (comma-separated).
39
+ --actors <logins> GitHub logins allowed to trigger runs.
40
+ --default-branch <b> Default branch (default: origin's HEAD, else main).
41
+ --verify <commands> verify_commands for implement (pipeline). In a
42
+ prompt, \\n separates commands.
43
+ --preset <name|none> Inline examples/setup-presets/<name>.sh as
44
+ setup_commands (pipeline; default none).
45
+ --threat-pack-dir <d> Where the Threat pack goes (audit; default .security).
46
+ --yes Ask nothing; every answer comes from flags.
47
+ --force Overwrite existing Callers, Threat pack files and
48
+ Kit-owned paths (use upgrade to change tags).
49
+
50
+ Options for upgrade:
51
+ --tag <vY> The Agent-kit tag to move to (asked for when missing).
52
+ --from <vX> The tag you are leaving, when the pins already name <vY>
53
+ (after a Dependabot bump): deletes paths only <vX> listed
54
+ and prints the examples/ diff.
55
+ --yes Do not ask before changing files.
56
+ --force Replace Kit-owned paths even when they have uncommitted
57
+ changes (those changes are lost).
58
+
59
+ -h, --help Show this help.
60
+ -v, --version Show the version.`;
61
+
62
+ const COMMAND_OPTIONS = {
63
+ doctor: ["ci", "strict", "repo"],
64
+ init: [
65
+ "tag",
66
+ "flows",
67
+ "actors",
68
+ "default-branch",
69
+ "verify",
70
+ "preset",
71
+ "threat-pack-dir",
72
+ "yes",
73
+ "force",
74
+ ],
75
+ upgrade: ["tag", "from", "yes", "force"],
76
+ };
77
+
78
+ /**
79
+ * @param {string[]} argv
80
+ * @param {{
81
+ * cwd: string,
82
+ * stdout: (s: string) => void,
83
+ * stderr: (s: string) => void,
84
+ * runner?: import("./exec.mjs").Runner,
85
+ * openTag?: Function,
86
+ * openRepo?: Function,
87
+ * prompt?: ((question: string) => Promise<string>) | null,
88
+ * }} io `prompt` is null (or absent) when there is no terminal to ask on.
89
+ * @returns {Promise<number>} exit code
90
+ */
91
+ export async function main(argv, io) {
92
+ const { cwd, runner = systemRunner, openTag, openRepo, prompt = null } = io;
93
+ // Anything printed can echo arguments or file contents: sanitize every line.
94
+ const clean = (text) => String(text).split("\n").map(sanitizeLine).join("\n");
95
+ const stdout = (text) => io.stdout(clean(text));
96
+ const stderr = (text) => io.stderr(clean(text));
97
+ let parsed;
98
+ try {
99
+ parsed = parseArgs({
100
+ args: argv,
101
+ allowPositionals: true,
102
+ strict: true,
103
+ options: {
104
+ ci: { type: "boolean" },
105
+ strict: { type: "boolean" },
106
+ repo: { type: "string" },
107
+ tag: { type: "string" },
108
+ from: { type: "string" },
109
+ flows: { type: "string" },
110
+ actors: { type: "string" },
111
+ "default-branch": { type: "string" },
112
+ verify: { type: "string" },
113
+ preset: { type: "string" },
114
+ "threat-pack-dir": { type: "string" },
115
+ yes: { type: "boolean", short: "y" },
116
+ force: { type: "boolean" },
117
+ help: { type: "boolean", short: "h" },
118
+ version: { type: "boolean", short: "v" },
119
+ },
120
+ });
121
+ } catch (err) {
122
+ stderr(`${err.message}\n\n${USAGE}`);
123
+ return 2;
124
+ }
125
+ const { values, positionals } = parsed;
126
+ if (values.version) {
127
+ stdout(VERSION);
128
+ return 0;
129
+ }
130
+ const [command, ...rest] = positionals;
131
+ if (values.help || !command) {
132
+ (command || values.help ? stdout : stderr)(USAGE);
133
+ return values.help ? 0 : 2;
134
+ }
135
+ if (!(command in COMMAND_OPTIONS)) {
136
+ stderr(`Unknown command: ${command}\n\n${USAGE}`);
137
+ return 2;
138
+ }
139
+ if (rest.length) {
140
+ stderr(`${command} takes no arguments, got: ${rest.join(" ")}`);
141
+ return 2;
142
+ }
143
+ const foreign = Object.keys(values).filter(
144
+ (k) => k !== "help" && k !== "version" && !COMMAND_OPTIONS[command].includes(k),
145
+ );
146
+ if (foreign.length) {
147
+ stderr(`${command} does not take ${foreign.map((k) => `--${k}`).join(", ")}\n\n${USAGE}`);
148
+ return 2;
149
+ }
150
+
151
+ if (command === "init") {
152
+ stdout(`agent-kit init ${VERSION}`);
153
+ return runInit({
154
+ cwd,
155
+ runner,
156
+ flags: {
157
+ tag: values.tag,
158
+ flows: values.flows,
159
+ actors: values.actors,
160
+ defaultBranch: values["default-branch"],
161
+ verify: values.verify,
162
+ preset: values.preset,
163
+ threatPackDir: values["threat-pack-dir"],
164
+ },
165
+ force: Boolean(values.force),
166
+ yes: Boolean(values.yes),
167
+ prompt,
168
+ confirm: prompt ? (q) => askYesNo(prompt, q) : null,
169
+ out: stdout,
170
+ err: stderr,
171
+ ...(openTag ? { openTag } : {}),
172
+ });
173
+ }
174
+ if (command === "upgrade") {
175
+ return upgrade(values, { cwd, runner, openRepo, prompt, stdout, stderr });
176
+ }
177
+
178
+ if (values.repo !== undefined && !REPO_PATTERN.test(values.repo)) {
179
+ stderr(`--repo must be owner/name, got: ${JSON.stringify(values.repo)}`);
180
+ return 2;
181
+ }
182
+ const report = runDoctor({
183
+ cwd,
184
+ runner,
185
+ ci: Boolean(values.ci),
186
+ strict: Boolean(values.strict),
187
+ repo: values.repo,
188
+ ...(openTag ? { openTag } : {}),
189
+ });
190
+ stdout(formatReport(report, { version: VERSION }));
191
+ return report.exitCode;
192
+ }
193
+
194
+ async function upgrade(values, { cwd, runner, openRepo, prompt, stdout, stderr }) {
195
+ if (!prompt) {
196
+ // No terminal: every answer must come from a flag.
197
+ const needed = [];
198
+ if (values.tag === undefined) needed.push("--tag <vY>");
199
+ if (!values.yes) needed.push("--yes");
200
+ if (needed.length) {
201
+ stderr(`agent-kit upgrade: there is no terminal to ask on, so pass ${needed.join(" and ")}.`);
202
+ return 2;
203
+ }
204
+ }
205
+ let tag = values.tag;
206
+ if (tag === undefined) tag = await prompt("Agent-kit tag to upgrade to (for example v1.0.0): ");
207
+ if (values.from !== undefined && !isReleaseTag(values.from)) {
208
+ stderr(`--from must be a Release-line tag, got: ${JSON.stringify(values.from)}`);
209
+ return 2;
210
+ }
211
+ if (!isReleaseTag(tag)) {
212
+ stderr(
213
+ `--tag must be a Release-line tag (vX.Y.Z or vX.Y.Z-stage.N), got: ${JSON.stringify(tag)}`,
214
+ );
215
+ return 2;
216
+ }
217
+ stdout(`agent-kit upgrade ${VERSION}`);
218
+ return runUpgrade({
219
+ cwd,
220
+ runner,
221
+ tag,
222
+ from: values.from,
223
+ force: Boolean(values.force),
224
+ yes: Boolean(values.yes),
225
+ confirm: prompt ? (q) => askYesNo(prompt, q) : null,
226
+ out: stdout,
227
+ ...(openRepo ? { openRepo } : {}),
228
+ });
229
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,405 @@
1
+ // `agent-kit doctor`: read-only checks of a Consumer repository against the
2
+ // Agent-kit tag its Callers pin. Local mode (an admin `gh` login) runs every
3
+ // check; CI mode runs only the file and pin checks and lists the rest as
4
+ // skipped. A skipped check is never reported as passed.
5
+
6
+ import { readWorkflows } from "./callers.mjs";
7
+ import { checkClaudeApp } from "./checks/claude-app.mjs";
8
+ import { checkKitOwnedTrees } from "./checks/kit-owned-trees.mjs";
9
+ import { checkLabels } from "./checks/labels.mjs";
10
+ import { checkPins } from "./checks/pins.mjs";
11
+ import { result, sanitizeLine } from "./checks/result.mjs";
12
+ import {
13
+ checkCreationRule,
14
+ checkPersonalTagPush,
15
+ checkTagRuleset,
16
+ checkUpdateRule,
17
+ loadRulesets,
18
+ } from "./checks/rulesets.mjs";
19
+ import {
20
+ checkSecrets,
21
+ checkVariables,
22
+ deriveRequirements,
23
+ deriveVariables,
24
+ } from "./checks/secrets-and-variables.mjs";
25
+ import { firstLine } from "./exec.mjs";
26
+ import { createGitHub } from "./github.mjs";
27
+ import { openKitTag, PreManifestTagError, serverFromEnv } from "./kit-tag.mjs";
28
+
29
+ /**
30
+ * The GitHub server the pins resolve on: the repository's host in a local
31
+ * run, GITHUB_SERVER_URL in GitHub Actions (set on GitHub Enterprise Server
32
+ * too), else github.com.
33
+ */
34
+ function serverFor(info, env) {
35
+ if (info?.host) return `https://${info.host}`;
36
+ return serverFromEnv(env);
37
+ }
38
+
39
+ /** The checks that need a `gh` login with admin on the repository. */
40
+ export const LOCAL_ONLY_CHECKS = [
41
+ ["secrets", "Secrets the Callers pass are set"],
42
+ ["variables", "Repository variables the Callers read are set"],
43
+ ["trigger-labels", "Trigger labels exist"],
44
+ ["claude-app", "Claude GitHub App installed"],
45
+ ["ruleset-update", "Default branch has an update rule"],
46
+ ["ruleset-creation", "Release branches have a creation rule"],
47
+ ["ruleset-tags", "Release tags have a v* tag ruleset"],
48
+ ["release-tag-push", "Release tags can be pushed under the tag ruleset"],
49
+ ];
50
+
51
+ const skipAll = (ids, reason) => ids.map(([id, title]) => result(id, title, "skip", reason));
52
+
53
+ /**
54
+ * @param {{
55
+ * cwd: string,
56
+ * runner: import("./exec.mjs").Runner,
57
+ * ci?: boolean,
58
+ * strict?: boolean,
59
+ * repo?: string,
60
+ * openTag?: typeof openKitTag,
61
+ * env?: NodeJS.ProcessEnv,
62
+ * }} opts
63
+ * @returns {{ mode: "local" | "ci", modeReason: string | null, repo: string | null, tag: string | null, results: object[], refusal: string | null, exitCode: number }}
64
+ */
65
+ export function runDoctor({
66
+ cwd,
67
+ runner,
68
+ ci = false,
69
+ strict = false,
70
+ repo: repoArg,
71
+ openTag = openKitTag,
72
+ env = process.env,
73
+ }) {
74
+ const results = [];
75
+ let mode = ci ? "ci" : "local";
76
+ let modeReason = ci ? "--ci" : null;
77
+
78
+ // Which repository, and may this login read its settings?
79
+ let info = null;
80
+ if (!ci) {
81
+ const github = createGitHub(runner, { cwd });
82
+ try {
83
+ info = repoInfo(github, repoArg);
84
+ if (!info.admin) {
85
+ mode = "ci";
86
+ modeReason = `your gh login is not an admin on ${info.repo}`;
87
+ }
88
+ } catch (err) {
89
+ mode = "ci";
90
+ modeReason = `gh could not read the repository (${firstLine(err.message)})`;
91
+ }
92
+ }
93
+
94
+ let root = cwd;
95
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
96
+ if (top.status === 0) root = top.stdout.trim();
97
+ else {
98
+ results.push(result("pins", "uses: pins agree", "fail", `${cwd} is not a git repository.`));
99
+ return finish({
100
+ mode,
101
+ modeReason,
102
+ repo: info?.repo ?? null,
103
+ tag: null,
104
+ results,
105
+ refusal: null,
106
+ strict,
107
+ });
108
+ }
109
+
110
+ let workflows;
111
+ try {
112
+ workflows = readWorkflows(root);
113
+ } catch (err) {
114
+ // Nothing else can run without the Callers: report it, never crash.
115
+ results.push(result("pins", "uses: pins agree", "fail", firstLine(err?.message ?? err)));
116
+ return finish({
117
+ mode,
118
+ modeReason,
119
+ repo: info?.repo ?? null,
120
+ tag: null,
121
+ results,
122
+ refusal: null,
123
+ strict,
124
+ });
125
+ }
126
+ const { files, callers } = workflows;
127
+ const pins = checkPins(files);
128
+ results.push(pins.result);
129
+
130
+ let snapshot = null;
131
+ let refusal = null;
132
+ if (pins.tag) {
133
+ try {
134
+ // Read-only: accept any safe manifest path, so an older CLI can check a
135
+ // newer tag. Code that deletes paths keeps the strict shapes.
136
+ snapshot = openTag({
137
+ runner,
138
+ repo: pins.repo,
139
+ tag: pins.tag,
140
+ serverUrl: serverFor(info, env),
141
+ forDeletion: false,
142
+ });
143
+ } catch (err) {
144
+ if (err instanceof PreManifestTagError) refusal = err.message;
145
+ else
146
+ results.push(
147
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
148
+ );
149
+ }
150
+ } else {
151
+ results.push(
152
+ result(
153
+ "kit-owned-trees",
154
+ "Kit-owned paths match the pinned tag",
155
+ "skip",
156
+ "No single pinned tag to compare against.",
157
+ ),
158
+ );
159
+ }
160
+ if (refusal) {
161
+ return finish({
162
+ mode,
163
+ modeReason,
164
+ repo: info?.repo ?? null,
165
+ tag: pins.tag,
166
+ results,
167
+ refusal,
168
+ strict,
169
+ });
170
+ }
171
+
172
+ try {
173
+ if (snapshot) {
174
+ try {
175
+ results.push(checkKitOwnedTrees({ runner, cwd: root, snapshot }));
176
+ } catch (err) {
177
+ results.push(
178
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
179
+ );
180
+ }
181
+ }
182
+
183
+ if (mode === "ci") {
184
+ results.push(
185
+ ...skipAll(
186
+ LOCAL_ONLY_CHECKS,
187
+ `CI mode (${modeReason}): needs a local run with an admin gh login.`,
188
+ ),
189
+ );
190
+ } else {
191
+ results.push(...localChecks({ runner, cwd, info, files, callers, snapshot }));
192
+ }
193
+ } finally {
194
+ snapshot?.cleanup();
195
+ }
196
+ return finish({
197
+ mode,
198
+ modeReason,
199
+ repo: info?.repo ?? null,
200
+ tag: pins.tag,
201
+ results,
202
+ refusal: null,
203
+ strict,
204
+ });
205
+ }
206
+
207
+ function repoInfo(github, repoArg) {
208
+ return github.repoInfo(repoArg);
209
+ }
210
+
211
+ const TITLES = Object.fromEntries(LOCAL_ONLY_CHECKS);
212
+
213
+ /** Run one check; an exception becomes a FAIL for that check, never a silent pass. */
214
+ export function guarded(id, run) {
215
+ try {
216
+ return run();
217
+ } catch (err) {
218
+ return result(
219
+ id,
220
+ TITLES[id] ?? id,
221
+ "fail",
222
+ `Check crashed: ${firstLine(err?.message ?? err)}`,
223
+ {
224
+ details: ["This is a bug in doctor; the other checks still ran."],
225
+ },
226
+ );
227
+ }
228
+ }
229
+
230
+ function localChecks({ runner, cwd, info, files, callers, snapshot }) {
231
+ const github = createGitHub(runner, { cwd, hostname: info.host ?? null });
232
+ const { repo, owner, ownerType, defaultBranch } = info;
233
+ const raw = [];
234
+ const out = { push: (...items) => raw.push(...items) };
235
+
236
+ if (snapshot) {
237
+ let requirements = null;
238
+ out.push(
239
+ guarded("secrets", () => {
240
+ requirements = deriveRequirements(callers, (file) =>
241
+ snapshot.readFile(`.github/workflows/${file}`),
242
+ );
243
+ return checkSecrets({ github, repo, ownerType, requirements });
244
+ }),
245
+ );
246
+ out.push(
247
+ guarded("variables", () =>
248
+ checkVariables({
249
+ github,
250
+ repo,
251
+ ownerType,
252
+ requirements: requirements ?? { variables: deriveVariables(callers) },
253
+ }),
254
+ ),
255
+ );
256
+ } else {
257
+ out.push(
258
+ ...skipAll(
259
+ LOCAL_ONLY_CHECKS.slice(0, 1),
260
+ "Needs the pinned tag's reusable workflows; fix the pins first.",
261
+ ),
262
+ );
263
+ // Variables come from the Caller files alone.
264
+ out.push(
265
+ guarded("variables", () =>
266
+ checkVariables({
267
+ github,
268
+ repo,
269
+ ownerType,
270
+ requirements: { variables: deriveVariables(callers) },
271
+ }),
272
+ ),
273
+ );
274
+ }
275
+
276
+ const readReusable = snapshot ? (file) => snapshot.readFile(`.github/workflows/${file}`) : null;
277
+ if (callers.length) {
278
+ out.push(guarded("trigger-labels", () => checkLabels({ github, repo, callers, readReusable })));
279
+ } else
280
+ out.push(
281
+ result(
282
+ "trigger-labels",
283
+ "Trigger labels exist",
284
+ "skip",
285
+ "No Caller to read Trigger labels from.",
286
+ ),
287
+ );
288
+
289
+ out.push(guarded("claude-app", () => checkClaudeApp({ github, repo, owner, ownerType })));
290
+
291
+ let rulesets = null;
292
+ let rulesetError = null;
293
+ try {
294
+ rulesets = loadRulesets(github, repo);
295
+ } catch (err) {
296
+ rulesetError = `Could not read rulesets: ${err.message}`;
297
+ }
298
+ if (rulesets) {
299
+ out.push(guarded("ruleset-update", () => checkUpdateRule(rulesets, { defaultBranch })));
300
+ out.push(guarded("ruleset-creation", () => checkCreationRule(rulesets)));
301
+ out.push(guarded("ruleset-tags", () => checkTagRuleset(rulesets)));
302
+ } else {
303
+ out.push(...skipAll(LOCAL_ONLY_CHECKS.slice(4, 7), rulesetError));
304
+ }
305
+ out.push(guarded("release-tag-push", () => checkPersonalTagPush({ ownerType, files })));
306
+ return raw;
307
+ }
308
+
309
+ function finish({ mode, modeReason, repo, tag, results, refusal, strict }) {
310
+ const final = strict
311
+ ? results.map((r) => (r.status === "warn" ? { ...r, status: "fail", strict: true } : r))
312
+ : results;
313
+ const failed = refusal !== null || final.some((r) => r.status === "fail");
314
+ return { mode, modeReason, repo, tag, results: final, refusal, exitCode: failed ? 1 : 0 };
315
+ }
316
+
317
+ const LABEL = { pass: "PASS", warn: "WARN", fail: "FAIL", skip: "SKIP" };
318
+
319
+ /** Plain-text report. Every line is sanitized for terminals and CI logs. */
320
+ export function formatReport(report, opts = {}) {
321
+ return formatLines(report, opts).map(sanitizeLine).join("\n");
322
+ }
323
+
324
+ function formatLines(report, { version } = {}) {
325
+ const lines = [];
326
+ lines.push(`agent-kit doctor${version ? ` ${version}` : ""}`);
327
+ lines.push(` repository: ${report.repo ?? "(unknown)"}`);
328
+ lines.push(` pinned tag: ${report.tag ?? "(none)"}`);
329
+ lines.push(` mode: ${report.mode}${report.modeReason ? ` (${report.modeReason})` : ""}`);
330
+ lines.push("");
331
+ if (report.refusal) {
332
+ for (const r of report.results) lines.push(...formatResult(r));
333
+ lines.push(`REFUSED ${report.refusal}`);
334
+ lines.push("");
335
+ lines.push("Result: refused (exit 1).");
336
+ return lines;
337
+ }
338
+ for (const r of report.results) lines.push(...formatResult(r));
339
+ const count = (s) => report.results.filter((r) => r.status === s).length;
340
+ const skipped = report.results.filter((r) => r.status === "skip");
341
+ lines.push("");
342
+ lines.push(
343
+ `Result: ${count("fail")} failed, ${count("warn")} warning(s), ${skipped.length} skipped, ${count("pass")} passed (exit ${report.exitCode}).`,
344
+ );
345
+ if (skipped.length) {
346
+ lines.push(
347
+ `Skipped checks did not run and are not passes: ${skipped.map((r) => r.id).join(", ")}.`,
348
+ );
349
+ }
350
+ if (report.results.some((r) => r.fix.length)) {
351
+ lines.push(
352
+ "Lines starting with $ are POSIX shell (bash, zsh) commands for you to run; doctor ran none of them.",
353
+ );
354
+ }
355
+ return lines;
356
+ }
357
+
358
+ export function formatResult(r) {
359
+ const out = [
360
+ `${LABEL[r.status]} ${r.title}: ${r.summary}${r.strict ? " (warning; failed by --strict)" : ""}`,
361
+ ];
362
+ for (const d of r.details) out.push(` ${d}`);
363
+ for (const f of r.fix) out.push(` $ ${f}`);
364
+ return out;
365
+ }
366
+
367
+ /** doctor --ci's pin and Kit-owned path checks, against an already-fetched tag. */
368
+ export function fileAndPinChecks({ runner, root, snap }) {
369
+ let pins;
370
+ try {
371
+ pins = checkPins(readWorkflows(root).files);
372
+ } catch (err) {
373
+ return [result("pins", "uses: pins agree", "fail", firstLine(err?.message ?? err))];
374
+ }
375
+ const results = [pins.result];
376
+ if (pins.tag !== snap.tag) {
377
+ results.push(
378
+ result(
379
+ "kit-owned-trees",
380
+ "Kit-owned paths match the pinned tag",
381
+ "fail",
382
+ `The pins do not all name ${snap.tag}.`,
383
+ ),
384
+ );
385
+ return results;
386
+ }
387
+ try {
388
+ results.push(checkKitOwnedTrees({ runner, cwd: root, snapshot: snap }));
389
+ } catch (err) {
390
+ results.push(
391
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
392
+ );
393
+ }
394
+ return results;
395
+ }
396
+
397
+ /**
398
+ * Print fileAndPinChecks results under a heading, for init and upgrade.
399
+ * Returns true when one failed.
400
+ */
401
+ export function printFileAndPinChecks(out, results) {
402
+ out("Checks (as doctor --ci):");
403
+ for (const r of results) for (const line of formatResult(r)) out(line);
404
+ return results.some((r) => r.status === "fail");
405
+ }
package/src/exec.mjs ADDED
@@ -0,0 +1,64 @@
1
+ // Runs `git` and `gh` as argument arrays, never through a shell, so no string
2
+ // from a Consumer file or a GitHub response is ever parsed as shell syntax.
3
+ // Every module takes a runner as a parameter; tests inject a fake one.
4
+
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const ALLOWED = new Set(["git", "gh"]);
8
+
9
+ /**
10
+ * @typedef {{ status: number | null, stdout: string, stderr: string }} RunResult
11
+ * @typedef {{ status: number | null, stdout: Buffer, stderr: string }} BufferResult
12
+ * @typedef {(cmd: string, args: string[], opts?: { cwd?: string, env?: NodeJS.ProcessEnv, encoding?: "utf8" | "buffer" }) => RunResult} Runner
13
+ * With `encoding: "buffer"`, stdout is the raw bytes (a Buffer): use it for
14
+ * file contents, which need not be UTF-8. stderr is always text.
15
+ */
16
+
17
+ /** @type {Runner} */
18
+ export function systemRunner(cmd, args, opts = {}) {
19
+ if (!ALLOWED.has(cmd)) throw new Error(`refusing to run ${cmd}: only git and gh are allowed`);
20
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
21
+ throw new TypeError("runner arguments must be an array of strings");
22
+ }
23
+ const result = spawnSync(cmd, args, {
24
+ cwd: opts.cwd,
25
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", ...opts.env },
26
+ encoding: "buffer",
27
+ shell: false,
28
+ windowsHide: true,
29
+ maxBuffer: 64 * 1024 * 1024,
30
+ });
31
+ const raw = opts.encoding === "buffer";
32
+ if (result.error) {
33
+ return {
34
+ status: null,
35
+ stdout: raw ? Buffer.alloc(0) : "",
36
+ stderr: `${cmd}: ${result.error.message}`,
37
+ };
38
+ }
39
+ const stdout = result.stdout ?? Buffer.alloc(0);
40
+ return {
41
+ status: result.status,
42
+ stdout: raw ? stdout : stdout.toString("utf8"),
43
+ stderr: (result.stderr ?? Buffer.alloc(0)).toString("utf8"),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The line of a command's stderr that says what went wrong, for short
49
+ * messages: the first `fatal:` or `error:` line, else the first line that is
50
+ * not a `warning:` or `hint:` (git prints those before the real error, for
51
+ * example about line endings), else the first non-empty line.
52
+ */
53
+ export function firstLine(text) {
54
+ const lines = String(text ?? "")
55
+ .split(/\r\n|\r|\n/)
56
+ .map((l) => l.trim())
57
+ .filter(Boolean);
58
+ return (
59
+ lines.find((l) => /^(fatal|error):/i.test(l)) ??
60
+ lines.find((l) => !/^(warning|hint):/i.test(l)) ??
61
+ lines[0] ??
62
+ ""
63
+ );
64
+ }