@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/src/doctor.mjs ADDED
@@ -0,0 +1,368 @@
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 { DEFAULT_SERVER, openKitTag, PreManifestTagError } from "./kit-tag.mjs";
28
+
29
+ const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
30
+
31
+ /**
32
+ * The GitHub server the pins resolve on: the repository's host in a local
33
+ * run, GITHUB_SERVER_URL in GitHub Actions (set on GitHub Enterprise Server
34
+ * too), else github.com.
35
+ */
36
+ function serverFor(info, env) {
37
+ if (info?.host) return `https://${info.host}`;
38
+ const fromEnv = String(env.GITHUB_SERVER_URL ?? "").replace(/\/$/, "");
39
+ return SERVER.test(fromEnv) ? fromEnv : DEFAULT_SERVER;
40
+ }
41
+
42
+ /** The checks that need a `gh` login with admin on the repository. */
43
+ export const LOCAL_ONLY_CHECKS = [
44
+ ["secrets", "Secrets the Callers pass are set"],
45
+ ["variables", "Repository variables the Callers read are set"],
46
+ ["trigger-labels", "Trigger labels exist"],
47
+ ["claude-app", "Claude GitHub App installed"],
48
+ ["ruleset-update", "Default branch has an update rule"],
49
+ ["ruleset-creation", "Release branches have a creation rule"],
50
+ ["ruleset-tags", "Release tags have a v* tag ruleset"],
51
+ ["release-tag-push", "Release tags can be pushed under the tag ruleset"],
52
+ ];
53
+
54
+ const skipAll = (ids, reason) => ids.map(([id, title]) => result(id, title, "skip", reason));
55
+
56
+ /**
57
+ * @param {{
58
+ * cwd: string,
59
+ * runner: import("./exec.mjs").Runner,
60
+ * ci?: boolean,
61
+ * strict?: boolean,
62
+ * repo?: string,
63
+ * openTag?: typeof openKitTag,
64
+ * env?: NodeJS.ProcessEnv,
65
+ * }} opts
66
+ * @returns {{ mode: "local" | "ci", modeReason: string | null, repo: string | null, tag: string | null, results: object[], refusal: string | null, exitCode: number }}
67
+ */
68
+ export function runDoctor({
69
+ cwd,
70
+ runner,
71
+ ci = false,
72
+ strict = false,
73
+ repo: repoArg,
74
+ openTag = openKitTag,
75
+ env = process.env,
76
+ }) {
77
+ const results = [];
78
+ let mode = ci ? "ci" : "local";
79
+ let modeReason = ci ? "--ci" : null;
80
+
81
+ // Which repository, and may this login read its settings?
82
+ let info = null;
83
+ if (!ci) {
84
+ const github = createGitHub(runner, { cwd });
85
+ try {
86
+ info = repoInfo(github, repoArg);
87
+ if (!info.admin) {
88
+ mode = "ci";
89
+ modeReason = `your gh login is not an admin on ${info.repo}`;
90
+ }
91
+ } catch (err) {
92
+ mode = "ci";
93
+ modeReason = `gh could not read the repository (${firstLine(err.message)})`;
94
+ }
95
+ }
96
+
97
+ let root = cwd;
98
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
99
+ if (top.status === 0) root = top.stdout.trim();
100
+ else {
101
+ results.push(result("pins", "uses: pins agree", "fail", `${cwd} is not a git repository.`));
102
+ return finish({
103
+ mode,
104
+ modeReason,
105
+ repo: info?.repo ?? null,
106
+ tag: null,
107
+ results,
108
+ refusal: null,
109
+ strict,
110
+ });
111
+ }
112
+
113
+ let workflows;
114
+ try {
115
+ workflows = readWorkflows(root);
116
+ } catch (err) {
117
+ // Nothing else can run without the Callers: report it, never crash.
118
+ results.push(result("pins", "uses: pins agree", "fail", firstLine(err?.message ?? err)));
119
+ return finish({
120
+ mode,
121
+ modeReason,
122
+ repo: info?.repo ?? null,
123
+ tag: null,
124
+ results,
125
+ refusal: null,
126
+ strict,
127
+ });
128
+ }
129
+ const { files, callers } = workflows;
130
+ const pins = checkPins(files);
131
+ results.push(pins.result);
132
+
133
+ let snapshot = null;
134
+ let refusal = null;
135
+ if (pins.tag) {
136
+ try {
137
+ // Read-only: accept any safe manifest path, so an older CLI can check a
138
+ // newer tag. Code that deletes paths keeps the strict shapes.
139
+ snapshot = openTag({
140
+ runner,
141
+ repo: pins.repo,
142
+ tag: pins.tag,
143
+ serverUrl: serverFor(info, env),
144
+ forDeletion: false,
145
+ });
146
+ } catch (err) {
147
+ if (err instanceof PreManifestTagError) refusal = err.message;
148
+ else
149
+ results.push(
150
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
151
+ );
152
+ }
153
+ } else {
154
+ results.push(
155
+ result(
156
+ "kit-owned-trees",
157
+ "Kit-owned paths match the pinned tag",
158
+ "skip",
159
+ "No single pinned tag to compare against.",
160
+ ),
161
+ );
162
+ }
163
+ if (refusal) {
164
+ return finish({
165
+ mode,
166
+ modeReason,
167
+ repo: info?.repo ?? null,
168
+ tag: pins.tag,
169
+ results,
170
+ refusal,
171
+ strict,
172
+ });
173
+ }
174
+
175
+ try {
176
+ if (snapshot) {
177
+ try {
178
+ results.push(checkKitOwnedTrees({ runner, cwd: root, snapshot }));
179
+ } catch (err) {
180
+ results.push(
181
+ result("kit-owned-trees", "Kit-owned paths match the pinned tag", "fail", err.message),
182
+ );
183
+ }
184
+ }
185
+
186
+ if (mode === "ci") {
187
+ results.push(
188
+ ...skipAll(
189
+ LOCAL_ONLY_CHECKS,
190
+ `CI mode (${modeReason}): needs a local run with an admin gh login.`,
191
+ ),
192
+ );
193
+ } else {
194
+ results.push(...localChecks({ runner, cwd, info, files, callers, snapshot }));
195
+ }
196
+ } finally {
197
+ snapshot?.cleanup();
198
+ }
199
+ return finish({
200
+ mode,
201
+ modeReason,
202
+ repo: info?.repo ?? null,
203
+ tag: pins.tag,
204
+ results,
205
+ refusal: null,
206
+ strict,
207
+ });
208
+ }
209
+
210
+ function repoInfo(github, repoArg) {
211
+ return github.repoInfo(repoArg);
212
+ }
213
+
214
+ const TITLES = Object.fromEntries(LOCAL_ONLY_CHECKS);
215
+
216
+ /** Run one check; an exception becomes a FAIL for that check, never a silent pass. */
217
+ export function guarded(id, run) {
218
+ try {
219
+ return run();
220
+ } catch (err) {
221
+ return result(
222
+ id,
223
+ TITLES[id] ?? id,
224
+ "fail",
225
+ `Check crashed: ${firstLine(err?.message ?? err)}`,
226
+ {
227
+ details: ["This is a bug in doctor; the other checks still ran."],
228
+ },
229
+ );
230
+ }
231
+ }
232
+
233
+ function localChecks({ runner, cwd, info, files, callers, snapshot }) {
234
+ const github = createGitHub(runner, { cwd, hostname: info.host ?? null });
235
+ const { repo, owner, ownerType, defaultBranch } = info;
236
+ const raw = [];
237
+ const out = { push: (...items) => raw.push(...items) };
238
+
239
+ if (snapshot) {
240
+ let requirements = null;
241
+ out.push(
242
+ guarded("secrets", () => {
243
+ requirements = deriveRequirements(callers, (file) =>
244
+ snapshot.readFile(`.github/workflows/${file}`),
245
+ );
246
+ return checkSecrets({ github, repo, ownerType, requirements });
247
+ }),
248
+ );
249
+ out.push(
250
+ guarded("variables", () =>
251
+ checkVariables({
252
+ github,
253
+ repo,
254
+ ownerType,
255
+ requirements: requirements ?? { variables: deriveVariables(callers) },
256
+ }),
257
+ ),
258
+ );
259
+ } else {
260
+ out.push(
261
+ ...skipAll(
262
+ LOCAL_ONLY_CHECKS.slice(0, 1),
263
+ "Needs the pinned tag's reusable workflows; fix the pins first.",
264
+ ),
265
+ );
266
+ // Variables come from the Caller files alone.
267
+ out.push(
268
+ guarded("variables", () =>
269
+ checkVariables({
270
+ github,
271
+ repo,
272
+ ownerType,
273
+ requirements: { variables: deriveVariables(callers) },
274
+ }),
275
+ ),
276
+ );
277
+ }
278
+
279
+ const readReusable = snapshot ? (file) => snapshot.readFile(`.github/workflows/${file}`) : null;
280
+ if (callers.length) {
281
+ out.push(guarded("trigger-labels", () => checkLabels({ github, repo, callers, readReusable })));
282
+ } else
283
+ out.push(
284
+ result(
285
+ "trigger-labels",
286
+ "Trigger labels exist",
287
+ "skip",
288
+ "No Caller to read Trigger labels from.",
289
+ ),
290
+ );
291
+
292
+ out.push(guarded("claude-app", () => checkClaudeApp({ github, repo, owner, ownerType })));
293
+
294
+ let rulesets = null;
295
+ let rulesetError = null;
296
+ try {
297
+ rulesets = loadRulesets(github, repo);
298
+ } catch (err) {
299
+ rulesetError = `Could not read rulesets: ${err.message}`;
300
+ }
301
+ if (rulesets) {
302
+ out.push(guarded("ruleset-update", () => checkUpdateRule(rulesets, { defaultBranch })));
303
+ out.push(guarded("ruleset-creation", () => checkCreationRule(rulesets)));
304
+ out.push(guarded("ruleset-tags", () => checkTagRuleset(rulesets)));
305
+ } else {
306
+ out.push(...skipAll(LOCAL_ONLY_CHECKS.slice(4, 7), rulesetError));
307
+ }
308
+ out.push(guarded("release-tag-push", () => checkPersonalTagPush({ ownerType, files })));
309
+ return raw;
310
+ }
311
+
312
+ function finish({ mode, modeReason, repo, tag, results, refusal, strict }) {
313
+ const final = strict
314
+ ? results.map((r) => (r.status === "warn" ? { ...r, status: "fail", strict: true } : r))
315
+ : results;
316
+ const failed = refusal !== null || final.some((r) => r.status === "fail");
317
+ return { mode, modeReason, repo, tag, results: final, refusal, exitCode: failed ? 1 : 0 };
318
+ }
319
+
320
+ const LABEL = { pass: "PASS", warn: "WARN", fail: "FAIL", skip: "SKIP" };
321
+
322
+ /** Plain-text report. Every line is sanitized for terminals and CI logs. */
323
+ export function formatReport(report, opts = {}) {
324
+ return formatLines(report, opts).map(sanitizeLine).join("\n");
325
+ }
326
+
327
+ function formatLines(report, { version } = {}) {
328
+ const lines = [];
329
+ lines.push(`agent-kit doctor${version ? ` ${version}` : ""}`);
330
+ lines.push(` repository: ${report.repo ?? "(unknown)"}`);
331
+ lines.push(` pinned tag: ${report.tag ?? "(none)"}`);
332
+ lines.push(` mode: ${report.mode}${report.modeReason ? ` (${report.modeReason})` : ""}`);
333
+ lines.push("");
334
+ if (report.refusal) {
335
+ for (const r of report.results) lines.push(...formatResult(r));
336
+ lines.push(`REFUSED ${report.refusal}`);
337
+ lines.push("");
338
+ lines.push("Result: refused (exit 1).");
339
+ return lines;
340
+ }
341
+ for (const r of report.results) lines.push(...formatResult(r));
342
+ const count = (s) => report.results.filter((r) => r.status === s).length;
343
+ const skipped = report.results.filter((r) => r.status === "skip");
344
+ lines.push("");
345
+ lines.push(
346
+ `Result: ${count("fail")} failed, ${count("warn")} warning(s), ${skipped.length} skipped, ${count("pass")} passed (exit ${report.exitCode}).`,
347
+ );
348
+ if (skipped.length) {
349
+ lines.push(
350
+ `Skipped checks did not run and are not passes: ${skipped.map((r) => r.id).join(", ")}.`,
351
+ );
352
+ }
353
+ if (report.results.some((r) => r.fix.length)) {
354
+ lines.push(
355
+ "Lines starting with $ are POSIX shell (bash, zsh) commands for you to run; doctor ran none of them.",
356
+ );
357
+ }
358
+ return lines;
359
+ }
360
+
361
+ function formatResult(r) {
362
+ const out = [
363
+ `${LABEL[r.status]} ${r.title}: ${r.summary}${r.strict ? " (warning; failed by --strict)" : ""}`,
364
+ ];
365
+ for (const d of r.details) out.push(` ${d}`);
366
+ for (const f of r.fix) out.push(` $ ${f}`);
367
+ return out;
368
+ }
package/src/exec.mjs ADDED
@@ -0,0 +1,51 @@
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 {(cmd: string, args: string[], opts?: { cwd?: string, env?: NodeJS.ProcessEnv }) => RunResult} Runner
12
+ */
13
+
14
+ /** @type {Runner} */
15
+ export function systemRunner(cmd, args, opts = {}) {
16
+ if (!ALLOWED.has(cmd)) throw new Error(`refusing to run ${cmd}: only git and gh are allowed`);
17
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
18
+ throw new TypeError("runner arguments must be an array of strings");
19
+ }
20
+ const result = spawnSync(cmd, args, {
21
+ cwd: opts.cwd,
22
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", ...opts.env },
23
+ encoding: "utf8",
24
+ shell: false,
25
+ windowsHide: true,
26
+ maxBuffer: 64 * 1024 * 1024,
27
+ });
28
+ if (result.error) {
29
+ return { status: null, stdout: "", stderr: `${cmd}: ${result.error.message}` };
30
+ }
31
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
32
+ }
33
+
34
+ /**
35
+ * The line of a command's stderr that says what went wrong, for short
36
+ * messages: the first `fatal:` or `error:` line, else the first line that is
37
+ * not a `warning:` or `hint:` (git prints those before the real error, for
38
+ * example about line endings), else the first non-empty line.
39
+ */
40
+ export function firstLine(text) {
41
+ const lines = String(text ?? "")
42
+ .split(/\r\n|\r|\n/)
43
+ .map((l) => l.trim())
44
+ .filter(Boolean);
45
+ return (
46
+ lines.find((l) => /^(fatal|error):/i.test(l)) ??
47
+ lines.find((l) => !/^(warning|hint):/i.test(l)) ??
48
+ lines[0] ??
49
+ ""
50
+ );
51
+ }
package/src/github.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // Read-only GitHub API calls through the adopter's own `gh` login. The CLI
2
+ // never changes GitHub settings: it only reads, and prints `gh` commands for
3
+ // the adopter to run.
4
+
5
+ import { REPO_PATTERN as REPO } from "./checks/result.mjs";
6
+ import { firstLine } from "./exec.mjs";
7
+
8
+ const HOST = /^[A-Za-z0-9.-]+(?::\d+)?$/;
9
+
10
+ export class GhError extends Error {
11
+ constructor(message, { status } = {}) {
12
+ super(message);
13
+ this.httpStatus = status ?? null;
14
+ }
15
+ }
16
+
17
+ function fail(r, what) {
18
+ const text = `${r.stderr}\n${r.stdout}`;
19
+ const m = /HTTP (\d{3})/.exec(text);
20
+ return new GhError(`${what}: ${firstLine(r.stderr) || firstLine(r.stdout) || "gh failed"}`, {
21
+ status: m ? Number(m[1]) : null,
22
+ });
23
+ }
24
+
25
+ export function checkRepoName(repo) {
26
+ if (!REPO.test(repo)) throw new GhError(`not a GitHub repository name: ${JSON.stringify(repo)}`);
27
+ return repo;
28
+ }
29
+
30
+ /**
31
+ * @param {import("./exec.mjs").Runner} runner
32
+ * @param {{ cwd?: string, hostname?: string | null }} [opts] hostname: a GitHub
33
+ * Enterprise Server host; gh api then targets it with --hostname.
34
+ */
35
+ export function createGitHub(runner, { cwd, hostname = null } = {}) {
36
+ if (hostname !== null && !HOST.test(hostname)) {
37
+ throw new GhError(`not a host name: ${JSON.stringify(hostname)}`);
38
+ }
39
+ const gh = (args) => runner("gh", args, { cwd });
40
+ const host = hostname && hostname !== "github.com" ? ["--hostname", hostname] : [];
41
+
42
+ /** GET an endpoint and parse its JSON. */
43
+ function getJson(endpoint) {
44
+ const r = gh(["api", ...host, "--method", "GET", endpoint]);
45
+ if (r.status !== 0) throw fail(r, `gh api ${endpoint}`);
46
+ return JSON.parse(r.stdout);
47
+ }
48
+
49
+ /** GET every page of an endpoint and return one line per `jq` result. */
50
+ function getLines(endpoint, jq) {
51
+ const r = gh(["api", ...host, "--method", "GET", "--paginate", endpoint, "--jq", jq]);
52
+ if (r.status !== 0) throw fail(r, `gh api ${endpoint}`);
53
+ return r.stdout.split(/\r?\n/).filter(Boolean);
54
+ }
55
+
56
+ /** The repository doctor checks, and whether this login is an admin on it. */
57
+ function repoInfo(repo) {
58
+ const args = [
59
+ "repo",
60
+ "view",
61
+ "--json",
62
+ "nameWithOwner,isInOrganization,viewerPermission,defaultBranchRef,url",
63
+ ];
64
+ if (repo) args.splice(2, 0, checkRepoName(repo));
65
+ const r = gh(args);
66
+ if (r.status !== 0) throw fail(r, "gh repo view");
67
+ const data = JSON.parse(r.stdout);
68
+ // The host the repository lives on: github.com, or a GitHub Enterprise Server.
69
+ let hostName = "github.com";
70
+ try {
71
+ if (data.url) hostName = new URL(data.url).host.toLowerCase();
72
+ } catch {
73
+ hostName = "github.com";
74
+ }
75
+ if (!HOST.test(hostName))
76
+ throw new GhError(`unexpected repository URL ${JSON.stringify(data.url)}`);
77
+ return {
78
+ host: hostName,
79
+ repo: checkRepoName(data.nameWithOwner),
80
+ owner: data.nameWithOwner.split("/")[0],
81
+ ownerType: data.isInOrganization ? "Organization" : "User",
82
+ admin: data.viewerPermission === "ADMIN",
83
+ defaultBranch: data.defaultBranchRef?.name ?? "main",
84
+ };
85
+ }
86
+
87
+ return { getJson, getLines, repoInfo };
88
+ }
@@ -0,0 +1,48 @@
1
+ // Parses kit-owned-paths.txt.
2
+ //
3
+ // Two levels of trust:
4
+ // - forDeletion (the default): `upgrade` deletes every entry before copying
5
+ // it fresh, so only the permitted shapes pass, the same rule
6
+ // scripts/kit-owned-paths-contract.test.py enforces on the kit side. A tag
7
+ // that broke that rule must not delete Consumer files.
8
+ // - read-only (`forDeletion: false`, used by doctor): any plain relative
9
+ // folder path is accepted, so an older CLI can still check a newer tag that
10
+ // adds a new kind of Kit-owned path. Nothing is deleted on this path.
11
+
12
+ const ALLOWED_SHAPE =
13
+ /^(?:\.claude\/skills\/[A-Za-z0-9._-]+\/|\.github\/(?:agent-runtime|agent-pipeline|security-audit)\/)$/;
14
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
15
+
16
+ export class ManifestError extends Error {}
17
+
18
+ function safe(line) {
19
+ const parts = line.slice(0, -1).split("/");
20
+ return (
21
+ SAFE_PATH.test(line) &&
22
+ !parts.includes("..") &&
23
+ !parts.includes(".") &&
24
+ !parts.some((p) => p.toLowerCase() === ".git")
25
+ );
26
+ }
27
+
28
+ /**
29
+ * @param {string} text
30
+ * @param {{ forDeletion?: boolean }} [opts]
31
+ */
32
+ export function parseKitOwnedPaths(text, { forDeletion = true } = {}) {
33
+ const entries = [];
34
+ for (const raw of String(text).split("\n")) {
35
+ const line = raw.replace(/\r$/, "");
36
+ if (!line || line.startsWith("#")) continue;
37
+ const ok = safe(line) && (!forDeletion || ALLOWED_SHAPE.test(line));
38
+ if (!ok) {
39
+ throw new ManifestError(
40
+ `kit-owned-paths.txt: ${JSON.stringify(line)} is not a permitted Kit-owned path`,
41
+ );
42
+ }
43
+ if (entries.includes(line)) throw new ManifestError(`kit-owned-paths.txt lists ${line} twice`);
44
+ entries.push(line);
45
+ }
46
+ if (entries.length === 0) throw new ManifestError("kit-owned-paths.txt lists no paths");
47
+ return entries;
48
+ }
@@ -0,0 +1,119 @@
1
+ // Fetches one Agent-kit tag into a throwaway bare repository and reads files
2
+ // and tree hashes from it. Nothing is bundled in the npm package: the git tag
3
+ // Consumers pin in `uses:` is the only source of kit files (ADR 0001).
4
+
5
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { REPO_PATTERN } from "./checks/result.mjs";
9
+ import { firstLine } from "./exec.mjs";
10
+ import { parseKitOwnedPaths } from "./kit-owned-paths.mjs";
11
+ import { FIRST_MANIFEST_TAG, isReleaseTag } from "./tags.mjs";
12
+
13
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/?$/;
14
+ export const DEFAULT_SERVER = "https://github.com";
15
+ const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
16
+
17
+ export class KitTagError extends Error {}
18
+
19
+ /** A tag with no kit-owned-paths.txt (up to v1.0.0-alpha.10): the CLI refuses it. */
20
+ export class PreManifestTagError extends KitTagError {
21
+ constructor(repo, tag, serverUrl = DEFAULT_SERVER) {
22
+ super(
23
+ `${tag} has no kit-owned-paths.txt, so the CLI cannot handle it ` +
24
+ `(the manifest ships from ${FIRST_MANIFEST_TAG}). Adopt or upgrade ${tag} by hand ` +
25
+ `with that tag's ${serverUrl}/${repo}/blob/${tag}/docs/adopt.md, ` +
26
+ `or move your pins to ${FIRST_MANIFEST_TAG} or later.`,
27
+ );
28
+ this.repo = repo;
29
+ this.tag = tag;
30
+ }
31
+ }
32
+
33
+ function checkPath(path) {
34
+ if (!SAFE_PATH.test(path) || path.split("/").includes("..")) {
35
+ throw new KitTagError(`refusing to read ${JSON.stringify(path)} from the tag`);
36
+ }
37
+ return path.replace(/\/$/, "");
38
+ }
39
+
40
+ /**
41
+ * Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
42
+ * PreManifestTagError. Call `cleanup()` when done.
43
+ *
44
+ * @param {{
45
+ * runner: import("./exec.mjs").Runner,
46
+ * repo: string,
47
+ * tag: string,
48
+ * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
49
+ * remoteUrl?: string, // tests: fetch from here instead
50
+ * forDeletion?: boolean // strict manifest shapes, for code that deletes paths
51
+ * }} opts
52
+ */
53
+ export function openKitTag({
54
+ runner,
55
+ repo,
56
+ tag,
57
+ serverUrl = DEFAULT_SERVER,
58
+ remoteUrl,
59
+ forDeletion = true,
60
+ }) {
61
+ if (!REPO_PATTERN.test(repo)) {
62
+ throw new KitTagError(`not a GitHub repository name: ${JSON.stringify(repo)}`);
63
+ }
64
+ if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
65
+ if (!SERVER.test(serverUrl)) {
66
+ throw new KitTagError(`not a GitHub server URL: ${JSON.stringify(serverUrl)}`);
67
+ }
68
+ const url = remoteUrl ?? `${serverUrl}/${repo}.git`;
69
+ const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tag-"));
70
+ const dir = join(tmp, "repo.git");
71
+ const noHooks = join(tmp, "no-hooks");
72
+ mkdirSync(noHooks);
73
+ const cleanup = () => rmSync(tmp, { recursive: true, force: true });
74
+ // No hooks run, whatever the adopter's global git config says.
75
+ const git = (...args) => runner("git", ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...args]);
76
+ try {
77
+ let r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
78
+ if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
79
+ r = git(
80
+ "fetch",
81
+ "--quiet",
82
+ "--depth=1",
83
+ "--no-tags",
84
+ "--",
85
+ url,
86
+ `+refs/tags/${tag}:refs/tags/${tag}`,
87
+ );
88
+ if (r.status !== 0) {
89
+ throw new KitTagError(
90
+ `could not fetch ${tag} from ${repo}: ${firstLine(r.stderr) || "git fetch failed"}`,
91
+ );
92
+ }
93
+ r = git("rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`);
94
+ if (r.status !== 0) throw new KitTagError(`${tag} does not point at a commit`);
95
+ const commit = r.stdout.trim();
96
+
97
+ const readFile = (path) => {
98
+ const res = git("cat-file", "blob", `${commit}:${checkPath(path)}`);
99
+ return res.status === 0 ? res.stdout : null;
100
+ };
101
+ const treeHash = (path) => {
102
+ // `<rev>:<path>` takes the rest as a path, so `^{tree}` cannot follow it:
103
+ // resolve the object, then check its type.
104
+ const res = git("rev-parse", "--verify", "--quiet", `${commit}:${checkPath(path)}`);
105
+ if (res.status !== 0) return null;
106
+ const hash = res.stdout.trim();
107
+ const type = git("cat-file", "-t", hash);
108
+ return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
109
+ };
110
+
111
+ const manifest = readFile("kit-owned-paths.txt");
112
+ if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
113
+ const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
114
+ return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, cleanup };
115
+ } catch (err) {
116
+ cleanup();
117
+ throw err;
118
+ }
119
+ }