@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-alpha.12

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/README.md CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # @mnaimfaizy/agent-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The command-line companion to [Agent-kit](https://github.com/mnaimfaizy/agent-kit): checks a Consumer repository against the Agent-kit tag its Callers pin.
4
+
5
+ ```bash
6
+ npx @mnaimfaizy/agent-kit doctor # local run with your own gh login
7
+ npx @mnaimfaizy/agent-kit doctor --ci # Kit-owned paths and pins only
8
+ npx @mnaimfaizy/agent-kit doctor --strict # warnings fail too
9
+ ```
10
+
11
+ - The package holds only code. Kit files are read from the git tag your `uses:` pins name, never bundled.
12
+ - `doctor` is read-only. It runs git in your checkout without hooks or configured filter commands, never changes files or GitHub settings, and for anything to fix prints a POSIX-shell `gh` command for you to run.
13
+ - No runtime dependencies. Needs Node.js 22 or later, `git`, and (outside `--ci`) `gh` logged in with admin on the repository.
14
+ - Tags before `v1.0.0-alpha.11` have no `kit-owned-paths.txt`, and the CLI refuses them: adopt those by hand with that tag's `docs/adopt.md`.
15
+ - `init` and `upgrade` are not in this version yet.
16
+
17
+ The package version matches the Agent-kit Release line: `@mnaimfaizy/agent-kit@1.0.0-alpha.12` is published from tag `v1.0.0-alpha.12`, with npm provenance.
18
+
19
+ Full documentation: [Getting started](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/getting-started.md) and [Adopt](https://github.com/mnaimfaizy/agent-kit/blob/main/docs/adopt.md#check-an-adoption-with-doctor).
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // @mnaimfaizy/agent-kit: adopt, upgrade and check Agent-kit in a Consumer
3
+ // repository. It writes only local files and never changes GitHub settings.
4
+
5
+ import { main } from "../src/cli.mjs";
6
+
7
+ process.exitCode = main(process.argv.slice(2), {
8
+ cwd: process.cwd(),
9
+ stdout: (s) => process.stdout.write(`${s}\n`),
10
+ stderr: (s) => process.stderr.write(`${s}\n`),
11
+ });
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@mnaimfaizy/agent-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-alpha.12",
4
+ "description": "Adopt, upgrade and check Agent-kit in a Consumer repository. Reads kit files from the pinned git tag.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agent-kit": "bin/agent-kit.mjs"
9
+ },
10
+ "files": [
11
+ "bin/agent-kit.mjs",
12
+ "src/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mnaimfaizy/agent-kit.git",
20
+ "directory": "cli"
21
+ },
22
+ "homepage": "https://github.com/mnaimfaizy/agent-kit/blob/main/cli/README.md",
23
+ "bugs": "https://github.com/mnaimfaizy/agent-kit/issues",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "scripts": {
29
+ "test": "node --test ../scripts/cli-*.test.mjs"
30
+ }
31
+ }
@@ -0,0 +1,166 @@
1
+ // Reads a Consumer's Caller workflows: the jobs that `uses:` an Agent-kit
2
+ // reusable workflow, the secrets they pass, the repository variables they
3
+ // read, and their Trigger labels.
4
+
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseScalar, parseYamlSubset, stripBom, stripComment } from "./yaml-subset.mjs";
8
+
9
+ export const KIT_REPO_NAME = "agent-kit";
10
+ export const DEFAULT_KIT_REPO = "mnaimfaizy/agent-kit";
11
+
12
+ // owner/agent-kit/.github/workflows/<file>@<ref>. GitHub owner and repository
13
+ // names are case-insensitive, so the match is too.
14
+ const KIT_USES =
15
+ /^([A-Za-z0-9][A-Za-z0-9-]*)\/(agent-kit)\/\.github\/workflows\/([A-Za-z0-9._-]+\.ya?ml)@(\S+)$/i;
16
+ // `uses:`, `"uses":`, `'uses' :`, optionally as a list item.
17
+ const USES_LINE = /^\s*(?:-\s+)?(?:uses|"uses"|'uses')\s*:\s*(.+)$/;
18
+ const NAME = "[A-Za-z_][A-Za-z0-9_]*";
19
+
20
+ /** Parse a `uses:` value; null unless it points at an Agent-kit reusable workflow. */
21
+ export function parseKitUses(value) {
22
+ const m = KIT_USES.exec(parseScalar(stripComment(String(value))));
23
+ return m ? { owner: m[1], repo: `${m[1]}/${m[2]}`, file: m[3], ref: m[4] } : null;
24
+ }
25
+
26
+ // `key: |` or `- key: >-`: the lines after it, indented deeper than the key,
27
+ // are a block scalar (a `run:` script, say), not YAML keys.
28
+ const BLOCK_START = /^(-\s+)?[^\s:#][^:]*:\s*[|>][-+0-9]*\s*$/;
29
+
30
+ /**
31
+ * Every Agent-kit `uses:` line in a workflow file, with its line number.
32
+ * Text inside block scalars, such as a `run: |` script, is not a pin.
33
+ */
34
+ export function findKitPins(text) {
35
+ const pins = [];
36
+ let blockIndent = null;
37
+ stripBom(String(text))
38
+ .replace(/\r\n?/g, "\n")
39
+ .split("\n")
40
+ .forEach((line, idx) => {
41
+ const indent = line.length - line.trimStart().length;
42
+ if (blockIndent !== null) {
43
+ if (line.trim() === "" || indent > blockIndent) return;
44
+ blockIndent = null;
45
+ }
46
+ if (line.trimStart().startsWith("#")) return;
47
+ const content = stripComment(line.slice(indent)).trimEnd();
48
+ const block = BLOCK_START.exec(content);
49
+ if (block) {
50
+ blockIndent = indent + (block[1]?.length ?? 0);
51
+ return;
52
+ }
53
+ const m = USES_LINE.exec(line);
54
+ if (!m) return;
55
+ const pin = parseKitUses(m[1]);
56
+ if (pin) pins.push({ ...pin, line: idx + 1 });
57
+ });
58
+ return pins;
59
+ }
60
+
61
+ function lines(text) {
62
+ return stripBom(String(text))
63
+ .replace(/\r\n?/g, "\n")
64
+ .split("\n")
65
+ .filter((l) => !l.trimStart().startsWith("#"))
66
+ .map(stripComment);
67
+ }
68
+
69
+ /** Names of `secrets.X` in an expression. */
70
+ export function secretNames(value) {
71
+ return [...String(value ?? "").matchAll(new RegExp(`secrets\\.(${NAME})`, "g"))].map((m) => m[1]);
72
+ }
73
+
74
+ /**
75
+ * Every `vars.X` a file reads. A variable is optional when every read of it
76
+ * falls back with `||` (as the model variables do); otherwise it is required
77
+ * (the Kill switch variables: unset means the flow never runs). The `||` may
78
+ * follow closing parentheses, and may sit on the next line of a multi-line
79
+ * expression.
80
+ */
81
+ export function variableRefs(text) {
82
+ const refs = new Map();
83
+ const re = new RegExp(`vars\\.(${NAME})((?:\\s*\\))*\\s*\\|\\|)?`, "g");
84
+ for (const m of lines(text).join("\n").matchAll(re)) {
85
+ const optional = Boolean(m[2]);
86
+ const prev = refs.get(m[1]);
87
+ refs.set(m[1], { name: m[1], optional: prev ? prev.optional && optional : optional });
88
+ }
89
+ return [...refs.values()];
90
+ }
91
+
92
+ /**
93
+ * Why a job's `secrets:` cannot be read, or null. The parser reads block
94
+ * mappings and `inherit`; a flow mapping, an anchor, an alias or a merge key
95
+ * would need a full YAML parser, so those jobs are reported, not guessed at.
96
+ */
97
+ function unsupportedSecrets(value) {
98
+ if (value === undefined || value === null || value === "inherit") return null;
99
+ if (typeof value === "string") {
100
+ if (value.startsWith("{")) return "`secrets:` is a flow mapping ({ ... })";
101
+ if (value.startsWith("*")) return "`secrets:` is a YAML alias";
102
+ if (value.startsWith("&")) return "`secrets:` uses a YAML anchor";
103
+ return `\`secrets:\` is ${JSON.stringify(value)}`;
104
+ }
105
+ if (value.$seq) return "`secrets:` is a list";
106
+ if ("<<" in value) return "`secrets:` uses a YAML merge key (<<)";
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * The jobs of one workflow file that call an Agent-kit reusable workflow.
112
+ * `parseIncomplete` is set when the file has more Agent-kit pins than jobs
113
+ * the parser could read, so checks built on the jobs report it instead of
114
+ * passing.
115
+ */
116
+ export function parseCaller(text) {
117
+ const doc = parseYamlSubset(text);
118
+ const jobs = [];
119
+ const jobMap = doc.jobs && typeof doc.jobs === "object" && !doc.jobs.$seq ? doc.jobs : {};
120
+ for (const [name, job] of Object.entries(jobMap)) {
121
+ if (!job || typeof job !== "object" || typeof job.uses !== "string") continue;
122
+ const uses = parseKitUses(job.uses);
123
+ if (!uses) continue;
124
+ const withBlock = job.with && typeof job.with === "object" && !job.with.$seq ? job.with : {};
125
+ const secretsUnsupported = unsupportedSecrets(job.secrets);
126
+ let secrets = {};
127
+ if (job.secrets === "inherit") secrets = "inherit";
128
+ else if (!secretsUnsupported && job.secrets && typeof job.secrets === "object") {
129
+ secrets = job.secrets;
130
+ }
131
+ jobs.push({ name, uses, with: withBlock, secrets, secretsUnsupported });
132
+ }
133
+ const pins = findKitPins(text);
134
+ const parseIncomplete =
135
+ pins.length > jobs.length
136
+ ? `${pins.length} Agent-kit pin(s) but only ${jobs.length} job(s) doctor could read; ` +
137
+ "the rest were not checked"
138
+ : null;
139
+ return { jobs, pins, variables: variableRefs(text), parseIncomplete };
140
+ }
141
+
142
+ /** Workflow files in .github/workflows/ (relative paths, sorted). */
143
+ export class WorkflowsDirError extends Error {}
144
+
145
+ export function listWorkflowFiles(repoRoot) {
146
+ const dir = join(repoRoot, ".github", "workflows");
147
+ if (!existsSync(dir)) return [];
148
+ if (!statSync(dir).isDirectory()) {
149
+ throw new WorkflowsDirError(
150
+ ".github/workflows is not a folder, so there are no Callers to read.",
151
+ );
152
+ }
153
+ return readdirSync(dir, { withFileTypes: true })
154
+ .filter((e) => e.isFile() && /\.ya?ml$/.test(e.name))
155
+ .map((e) => `.github/workflows/${e.name}`)
156
+ .sort();
157
+ }
158
+
159
+ /** Read every workflow file; `callers` are the ones with at least one Agent-kit pin. */
160
+ export function readWorkflows(repoRoot) {
161
+ const files = listWorkflowFiles(repoRoot).map((path) => {
162
+ const text = stripBom(readFileSync(join(repoRoot, path), "utf8"));
163
+ return { path, text, ...parseCaller(text) };
164
+ });
165
+ return { files, callers: files.filter((f) => f.pins.length > 0) };
166
+ }
@@ -0,0 +1,55 @@
1
+ // The Claude GitHub App must be installed on the repository. GitHub only shows
2
+ // app installations to an organization admin (`orgs/<org>/installations`) or
3
+ // to a GitHub App user token; a `gh` login on a personal account cannot see
4
+ // them, so there the check is skipped rather than guessed.
5
+
6
+ import { result } from "./result.mjs";
7
+
8
+ const ID = "claude-app";
9
+ const TITLE = "Claude GitHub App installed";
10
+ const APP_SLUG = "claude";
11
+ const APP_URL = "https://github.com/apps/claude";
12
+
13
+ export function checkClaudeApp({ github, repo, owner, ownerType }) {
14
+ if (ownerType !== "Organization") {
15
+ return result(
16
+ ID,
17
+ TITLE,
18
+ "skip",
19
+ "GitHub does not show a personal account's app installations to a gh login.",
20
+ {
21
+ details: [
22
+ `Check https://github.com/settings/installations, or install it from ${APP_URL}.`,
23
+ ],
24
+ },
25
+ );
26
+ }
27
+ let rows;
28
+ try {
29
+ rows = github.getLines(
30
+ `orgs/${owner}/installations`,
31
+ '.installations[] | [.app_slug, .repository_selection] | join(" ")',
32
+ );
33
+ } catch (err) {
34
+ return result(ID, TITLE, "skip", `Could not list ${owner}'s app installations: ${err.message}`);
35
+ }
36
+ const app = rows.map((r) => r.split(" ")).find(([slug]) => slug === APP_SLUG);
37
+ if (!app) {
38
+ return result(ID, TITLE, "fail", `The Claude GitHub App is not installed on ${owner}.`, {
39
+ details: [`Install it on ${repo} from ${APP_URL}.`],
40
+ });
41
+ }
42
+ if (app[1] === "all")
43
+ return result(ID, TITLE, "pass", `Installed on all of ${owner}'s repositories.`);
44
+ return result(
45
+ ID,
46
+ TITLE,
47
+ "skip",
48
+ `Installed on selected ${owner} repositories; gh cannot see which.`,
49
+ {
50
+ details: [
51
+ `Check that ${repo} is selected: https://github.com/organizations/${owner}/settings/installations`,
52
+ ],
53
+ },
54
+ );
55
+ }
@@ -0,0 +1,78 @@
1
+ // Each Kit-owned path in the working copy must have the same git tree hash as
2
+ // the same path at the pinned tag. Kit-owned paths are copied verbatim and
3
+ // never edited, so any difference means a stale, partial or edited copy.
4
+
5
+ import { DEFAULT_KIT_REPO } from "../callers.mjs";
6
+ import { localTreeHashes } from "../local-tree.mjs";
7
+ import { upgradeCommand } from "../tags.mjs";
8
+ import { result } from "./result.mjs";
9
+
10
+ const ID = "kit-owned-trees";
11
+ const TITLE = "Kit-owned paths match the pinned tag";
12
+
13
+ /**
14
+ * @param {{ runner: import("../exec.mjs").Runner, cwd: string, snapshot: { tag: string, kitOwnedPaths: string[], treeHash: (p: string) => string | null } }} opts
15
+ */
16
+ export function checkKitOwnedTrees({ runner, cwd, snapshot }) {
17
+ const { tag, kitOwnedPaths } = snapshot;
18
+ const repo = snapshot.repo ?? DEFAULT_KIT_REPO;
19
+ const { hashes, skipped, notes } = localTreeHashes({ runner, cwd, paths: kitOwnedPaths });
20
+ const serverUrl = snapshot.serverUrl ?? "https://github.com";
21
+ const bad = [];
22
+ const notChecked = [];
23
+ for (const path of kitOwnedPaths) {
24
+ if (skipped.has(path)) {
25
+ notChecked.push(`${path}: not checked, ${skipped.get(path)}`);
26
+ continue;
27
+ }
28
+ const expected = snapshot.treeHash(path);
29
+ const actual = hashes.get(path);
30
+ if (expected === null) {
31
+ bad.push(`${path}: listed in ${tag}'s kit-owned-paths.txt but not in the tag`);
32
+ } else if (actual === null) {
33
+ bad.push(`${path}: missing (expected tree ${expected.slice(0, 12)})`);
34
+ } else if (actual !== expected) {
35
+ bad.push(`${path}: differs (local ${actual.slice(0, 12)}, ${tag} ${expected.slice(0, 12)})`);
36
+ }
37
+ }
38
+ const howCompared = [...notes].map(([path, note]) => `${path}: ${note}`);
39
+ const checked = kitOwnedPaths.length - notChecked.length;
40
+ if (bad.length === 0 && notChecked.length === 0) {
41
+ return result(ID, TITLE, "pass", `All ${kitOwnedPaths.length} Kit-owned paths match ${tag}.`, {
42
+ details: howCompared,
43
+ });
44
+ }
45
+ if (bad.length === 0) {
46
+ // A path that could not be compared is never reported as matching.
47
+ return result(
48
+ ID,
49
+ TITLE,
50
+ "skip",
51
+ `${checked} Kit-owned path(s) match ${tag}; ${notChecked.length} could not be checked.`,
52
+ { details: [...notChecked, ...howCompared, "Run doctor in a full checkout to check them."] },
53
+ );
54
+ }
55
+ return result(
56
+ ID,
57
+ TITLE,
58
+ "fail",
59
+ `${bad.length} of ${kitOwnedPaths.length} Kit-owned paths do not match ${tag}.`,
60
+ {
61
+ details: [
62
+ ...bad,
63
+ ...notChecked,
64
+ ...howCompared,
65
+ `Kit-owned paths are replaced whole from the tag your pins name, never edited.`,
66
+ `If a pin bump (for example from Dependabot) moved the pins to ${tag}, re-copy them on the same branch.`,
67
+ `The command below needs \`upgrade\`, which ships in a later release; until then, ` +
68
+ `delete and re-copy each path by hand as ${manualCopyUrl(repo, tag, serverUrl)} describes.`,
69
+ ],
70
+ fix: [upgradeCommand(tag)],
71
+ },
72
+ );
73
+ }
74
+
75
+ /** adopt.md's copy section at the tag, which links the manual delete-then-copy loop. */
76
+ export function manualCopyUrl(repo, tag, serverUrl = "https://github.com") {
77
+ return `${serverUrl}/${repo}/blob/${tag}/docs/adopt.md#copy-kit-owned-paths-from-the-same-tag`;
78
+ }
@@ -0,0 +1,100 @@
1
+ // Every Trigger label a Caller uses must exist: its `trigger_label`, or the
2
+ // reusable workflow's default for that input at the pinned tag when the
3
+ // Caller leaves it out.
4
+
5
+ import { parseWorkflowCallInputs } from "../workflow-call.mjs";
6
+ import { byHand, command, LABEL_PATTERN, result } from "./result.mjs";
7
+
8
+ const ID = "trigger-labels";
9
+ const TITLE = "Trigger labels exist";
10
+
11
+ /**
12
+ * @param {object[]} callers
13
+ * @param {((workflowFile: string) => string | null) | null} readReusable reads
14
+ * .github/workflows/<file> at the pinned tag; null when the tag is unknown.
15
+ * @returns {{ labels: { name: string, where: string[] }[], unknown: string[] }}
16
+ * `unknown`: jobs whose label could not be worked out (a default that could
17
+ * not be read, or a Caller file doctor could not parse fully).
18
+ */
19
+ export function triggerLabels(callers, readReusable = null) {
20
+ const labels = new Map();
21
+ const unknown = [];
22
+ const defaults = new Map();
23
+ const defaultFor = (file) => {
24
+ if (!readReusable) return undefined;
25
+ if (!defaults.has(file)) {
26
+ const text = readReusable(file);
27
+ const inputs = text === null ? null : parseWorkflowCallInputs(text);
28
+ defaults.set(file, inputs ? (inputs.get("trigger_label")?.default ?? null) : undefined);
29
+ }
30
+ return defaults.get(file);
31
+ };
32
+ for (const caller of callers) {
33
+ if (caller.parseIncomplete) unknown.push(`${caller.path}: ${caller.parseIncomplete}`);
34
+ for (const job of caller.jobs) {
35
+ const explicit = job.with && "trigger_label" in job.with;
36
+ const label = explicit ? job.with.trigger_label : defaultFor(job.uses.file);
37
+ const where = `${caller.path}:${job.name}${explicit ? "" : " (default)"}`;
38
+ if (label === undefined) {
39
+ unknown.push(
40
+ `${where}: uses the default trigger_label of ${job.uses.file}, which could not be read`,
41
+ );
42
+ continue;
43
+ }
44
+ if (typeof label !== "string" || label === "" || label.includes("${{")) continue;
45
+ labels.set(label.toLowerCase(), {
46
+ name: label,
47
+ where: [...(labels.get(label.toLowerCase())?.where ?? []), where],
48
+ });
49
+ }
50
+ }
51
+ return { labels: [...labels.values()], unknown };
52
+ }
53
+
54
+ export function checkLabels({ github, repo, callers, readReusable = null }) {
55
+ const { labels: wanted, unknown } = triggerLabels(callers, readReusable);
56
+ if (wanted.length === 0 && unknown.length === 0) {
57
+ return result(ID, TITLE, "pass", "No Caller uses a Trigger label.");
58
+ }
59
+ let existing = new Set();
60
+ if (wanted.length) {
61
+ try {
62
+ existing = new Set(
63
+ github.getLines(`repos/${repo}/labels`, ".[].name").map((n) => n.toLowerCase()),
64
+ );
65
+ } catch (err) {
66
+ return result(ID, TITLE, "skip", `Could not list labels: ${err.message}`, {
67
+ details: unknown,
68
+ });
69
+ }
70
+ }
71
+ const missing = wanted.filter((l) => !existing.has(l.name.toLowerCase()));
72
+ if (missing.length) {
73
+ const fix = [];
74
+ const notes = [];
75
+ for (const l of missing) {
76
+ if (LABEL_PATTERN.test(l.name))
77
+ fix.push(command("gh", "label", "create", l.name, "--repo", repo));
78
+ else notes.push(byHand("create the label", l.name));
79
+ }
80
+ return result(ID, TITLE, "fail", `${missing.length} Trigger label(s) missing.`, {
81
+ details: [
82
+ ...missing.map((l) => `${JSON.stringify(l.name)} (${l.where.join(", ")})`),
83
+ ...unknown,
84
+ ...notes,
85
+ ],
86
+ fix,
87
+ });
88
+ }
89
+ if (unknown.length) {
90
+ // A label that could not be worked out is never reported as existing.
91
+ return result(
92
+ ID,
93
+ TITLE,
94
+ "skip",
95
+ `${wanted.length} Trigger label(s) exist; ${unknown.length} could not be worked out.`,
96
+ { details: unknown },
97
+ );
98
+ }
99
+ return result(ID, TITLE, "pass", `All ${wanted.length} Trigger label(s) exist.`);
100
+ }
@@ -0,0 +1,76 @@
1
+ // Every Agent-kit `uses:` pin must name the same repository and the same
2
+ // Release-line tag: the copied Kit-owned paths come from one tag.
3
+
4
+ import { DEFAULT_KIT_REPO } from "../callers.mjs";
5
+ import { isReleaseTag } from "../tags.mjs";
6
+ import { result } from "./result.mjs";
7
+
8
+ const ID = "pins";
9
+ const TITLE = "uses: pins agree";
10
+
11
+ /**
12
+ * @param {{ path: string, pins: { repo: string, file: string, ref: string, line: number }[] }[]} files
13
+ * @returns {{ result: object, repo: string | null, tag: string | null }}
14
+ */
15
+ export function checkPins(files) {
16
+ const all = files.flatMap((f) => f.pins.map((p) => ({ ...p, path: f.path })));
17
+ if (all.length === 0) {
18
+ return {
19
+ result: result(
20
+ ID,
21
+ TITLE,
22
+ "fail",
23
+ "No Caller in .github/workflows/ pins an Agent-kit reusable workflow.",
24
+ {
25
+ details: [
26
+ "Copy a Caller from the tag's examples/ (see docs/getting-started.md, step 5).",
27
+ ],
28
+ },
29
+ ),
30
+ repo: null,
31
+ tag: null,
32
+ };
33
+ }
34
+ const where = (p) => `${p.path}:${p.line} → ${p.repo}@${p.ref}`;
35
+ const refs = new Set(all.map((p) => p.ref));
36
+ // GitHub owner names are case-insensitive: MnaimFaizy/ and mnaimfaizy/ are one repository.
37
+ const repos = new Set(all.map((p) => p.repo.toLowerCase()));
38
+ const notTags = all.filter((p) => !isReleaseTag(p.ref));
39
+ const details = [];
40
+ if (repos.size > 1)
41
+ details.push(`Pins name more than one kit repository: ${[...repos].join(", ")}.`);
42
+ if (refs.size > 1) details.push(`Pins name more than one ref: ${[...refs].join(", ")}.`);
43
+ if (notTags.length)
44
+ details.push("Pin a Release-line tag (vX.Y.Z or vX.Y.Z-stage.N), not a branch or SHA.");
45
+ if (details.length) {
46
+ return {
47
+ result: result(
48
+ ID,
49
+ TITLE,
50
+ "fail",
51
+ `${all.length} pins disagree or are not Release-line tags.`,
52
+ {
53
+ details: [...details, ...all.map(where)],
54
+ },
55
+ ),
56
+ repo: null,
57
+ tag: null,
58
+ };
59
+ }
60
+ const { repo } = all[0];
61
+ const [tag] = refs;
62
+ const summary = `${all.length} pin${all.length === 1 ? "" : "s"} use ${repo}@${tag}.`;
63
+ if (repo.toLowerCase() !== DEFAULT_KIT_REPO) {
64
+ // A fork or an Enterprise mirror is a valid choice: say so, but not as a warning.
65
+ return {
66
+ result: result(ID, TITLE, "pass", summary, {
67
+ details: [
68
+ `The pins name ${repo}, not ${DEFAULT_KIT_REPO}; the Kit-owned path checks compare against that repository.`,
69
+ ],
70
+ }),
71
+ repo,
72
+ tag,
73
+ };
74
+ }
75
+ return { result: result(ID, TITLE, "pass", summary), repo, tag };
76
+ }
@@ -0,0 +1,68 @@
1
+ // A check result, and the rules for anything doctor prints. `skip` means the
2
+ // check did not run, and is never counted as a pass.
3
+ //
4
+ // Doctor prints values that come from files and API responses it does not
5
+ // control (secret and variable names, label names, error text). So:
6
+ // - a fix command is printed only when every value in it passes a strict
7
+ // allowlist, and each argument is quoted; otherwise the adopter gets a
8
+ // "fix by hand" note with the value JSON-escaped, never a command;
9
+ // - every printed line has control characters escaped and cannot start a
10
+ // CI log command (see sanitizeLine).
11
+
12
+ export const STATUSES = ["pass", "warn", "fail", "skip"];
13
+
14
+ export function result(id, title, status, summary, { details = [], fix = [] } = {}) {
15
+ if (!STATUSES.includes(status)) throw new Error(`unknown status ${status}`);
16
+ return { id, title, status, summary, details, fix };
17
+ }
18
+
19
+ /** A GitHub Actions secret or variable name. */
20
+ export const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+ /** A label name doctor will put in a command: no quotes, no shell or PowerShell syntax. */
22
+ export const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 :._/-]*$/;
23
+ /** owner/name as GitHub allows it, never starting with `-`. */
24
+ export const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
25
+
26
+ /** Arguments doctor itself writes into a command (flags and fixed words). */
27
+ const WORD = /^[A-Za-z0-9_.:/@=+-]+$/;
28
+
29
+ /**
30
+ * Quote one argument for a POSIX shell. Callers only pass values that matched
31
+ * an allowlist above, none of which contains a quote, so single quotes are
32
+ * enough in bash and in PowerShell alike.
33
+ */
34
+ export function shellQuote(value) {
35
+ const s = String(value);
36
+ if (s.includes("'")) throw new Error("refusing to quote a value that contains a single quote");
37
+ return WORD.test(s) ? s : `'${s}'`;
38
+ }
39
+
40
+ /** Build a printable command from argument words, every one quoted. */
41
+ export function command(...args) {
42
+ return args.map(shellQuote).join(" ");
43
+ }
44
+
45
+ /** A note for a value that cannot go into a command, shown JSON-escaped. */
46
+ export function byHand(what, value) {
47
+ return `fix by hand: ${what} ${JSON.stringify(String(value))} (not printed as a command)`;
48
+ }
49
+
50
+ // C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
51
+ const CONTROL = new RegExp("[\u0000-\u001f\u007f-\u009f\u2028\u2029]", "g");
52
+
53
+ /**
54
+ * Make one output line safe for a terminal and for a CI log: escape control
55
+ * characters (so no ANSI escape, no carriage return, no embedded newline),
56
+ * and keep the line from starting a GitHub Actions workflow command
57
+ * (`::name::` or the older `##[name]`).
58
+ */
59
+ export function sanitizeLine(line) {
60
+ let s = String(line).replace(
61
+ CONTROL,
62
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
63
+ );
64
+ const lead = s.length - s.trimStart().length;
65
+ const rest = s.slice(lead);
66
+ if (rest.startsWith("::") || rest.startsWith("##[")) s = `${s.slice(0, lead)}\\${rest}`;
67
+ return s;
68
+ }