@mnaimfaizy/agent-kit 1.0.0-alpha.12 → 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/prompt.mjs ADDED
@@ -0,0 +1,42 @@
1
+ // Questions on the terminal, with Node's built-in readline/promises (no
2
+ // dependency). Only used when stdin and stdout are both a terminal; without
3
+ // one, commands stop and name the flags to pass instead.
4
+
5
+ import { createInterface } from "node:readline/promises";
6
+ import { sanitizeLine } from "./checks/result.mjs";
7
+
8
+ /**
9
+ * @param {{ input: NodeJS.ReadableStream & { isTTY?: boolean }, output: NodeJS.WritableStream & { isTTY?: boolean } }} streams
10
+ * @returns {((question: string) => Promise<string>) | null} null when there is no terminal
11
+ */
12
+ export function terminalPrompt({ input, output }) {
13
+ if (!input.isTTY || !output.isTTY) return null;
14
+ // Ctrl-D (end of input) and Ctrl-C answer "" rather than throwing or
15
+ // ending the process, so the command stops cleanly ("Stopped. Nothing was
16
+ // changed.") and still removes its temporary repository.
17
+ return (question) =>
18
+ new Promise((resolve) => {
19
+ const rl = createInterface({ input, output, terminal: true });
20
+ let done = false;
21
+ const finish = (answer) => {
22
+ if (done) return;
23
+ done = true;
24
+ rl.close();
25
+ resolve(answer);
26
+ };
27
+ rl.on("close", () => finish(""));
28
+ rl.on("SIGINT", () => {
29
+ output.write("\n");
30
+ finish("");
31
+ });
32
+ rl.question(sanitizeLine(question)).then(
33
+ (answer) => finish(answer.trim()),
34
+ () => finish(""),
35
+ );
36
+ });
37
+ }
38
+
39
+ /** Ask a yes/no question; anything but y or yes is no. */
40
+ export async function askYesNo(prompt, question) {
41
+ return /^(?:y|yes)$/i.test(await prompt(question));
42
+ }
@@ -0,0 +1,68 @@
1
+ // Rules for a repository-relative path the CLI writes or deletes in a
2
+ // Consumer's checkout. A path read from a tag passes only if writing it is
3
+ // safe on Linux, macOS and Windows alike: plain name characters, no `.` or
4
+ // `..` segment, nothing git or Windows treats as special (`.git` in any case,
5
+ // a trailing dot, a device name such as `nul`). `~` is not a name character,
6
+ // so no 8.3 short name (`git~1`, `PROGRA~1`) can alias another folder.
7
+
8
+ const SEGMENT = /^[A-Za-z0-9._-]+$/;
9
+ const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i;
10
+
11
+ export class UnsafePathError extends Error {}
12
+
13
+ /** Why `segment` is unsafe, or null. */
14
+ function segmentProblem(segment) {
15
+ if (!SEGMENT.test(segment)) return "has a character outside A-Z a-z 0-9 . _ -";
16
+ if (segment === "." || segment === "..") return "is a . or .. segment";
17
+ if (segment.toLowerCase() === ".git") return "names .git";
18
+ if (segment.endsWith(".")) return "ends with a dot, which Windows drops";
19
+ if (WINDOWS_DEVICE.test(segment)) return "is a Windows device name";
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * Throw UnsafePathError unless `path` (no leading or trailing slash) is safe
25
+ * to write or delete. Returns the path.
26
+ */
27
+ export function checkSafePath(path, what = "path") {
28
+ const s = String(path);
29
+ if (s === "" || s.startsWith("/") || s.endsWith("/")) {
30
+ throw new UnsafePathError(`refusing ${what} ${JSON.stringify(s)}: not a relative file path`);
31
+ }
32
+ for (const segment of s.split("/")) {
33
+ const problem = segmentProblem(segment);
34
+ if (problem) {
35
+ throw new UnsafePathError(
36
+ `refusing ${what} ${JSON.stringify(s)}: ${JSON.stringify(segment)} ${problem}`,
37
+ );
38
+ }
39
+ }
40
+ return s;
41
+ }
42
+
43
+ /**
44
+ * Groups of paths that are the same file on a case-insensitive file system
45
+ * (Windows and macOS by default). Writing them would leave only one.
46
+ */
47
+ export function caseCollisions(paths) {
48
+ const byKey = new Map();
49
+ for (const p of paths) {
50
+ const key = p.toLowerCase();
51
+ byKey.set(key, [...(byKey.get(key) ?? []), p]);
52
+ }
53
+ return [...byKey.values()].filter((group) => new Set(group).size > 1);
54
+ }
55
+
56
+ /** Every prefix that is also a file: `a` and `a/b` cannot both be files. */
57
+ export function fileDirConflicts(paths) {
58
+ const lower = new Set(paths.map((p) => p.toLowerCase()));
59
+ const out = [];
60
+ for (const p of paths) {
61
+ const parts = p.split("/");
62
+ for (let i = 1; i < parts.length; i++) {
63
+ const prefix = parts.slice(0, i).join("/");
64
+ if (lower.has(prefix.toLowerCase())) out.push([prefix, p]);
65
+ }
66
+ }
67
+ return out;
68
+ }
package/src/tags.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // scripts/verify_release_snapshot.py accepts. The npm package version is the
3
3
  // tag without its leading v.
4
4
 
5
+ import { readFileSync } from "node:fs";
6
+
5
7
  export const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/;
6
8
 
7
9
  /** The first tag published to npm; older tags have no matching package version. */
@@ -12,6 +14,11 @@ export const FIRST_MANIFEST_TAG = "v1.0.0-alpha.11";
12
14
 
13
15
  export const PACKAGE_NAME = "@mnaimfaizy/agent-kit";
14
16
 
17
+ /** The version of this CLI, from its package.json. */
18
+ export const CLI_VERSION = JSON.parse(
19
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
20
+ ).version;
21
+
15
22
  const STAGE_RANK = { alpha: 0, beta: 1, rc: 2, "": 3 };
16
23
 
17
24
  export function isReleaseTag(tag) {
@@ -37,9 +44,12 @@ export function versionForTag(tag) {
37
44
  return tag.slice(1);
38
45
  }
39
46
 
40
- /** The `npx` command that upgrades to `tag`. */
41
- export function upgradeCommand(tag) {
42
- const spec =
43
- compareTags(tag, FIRST_NPM_TAG) >= 0 ? `${PACKAGE_NAME}@${versionForTag(tag)}` : PACKAGE_NAME;
44
- return `npx ${spec} upgrade --tag ${tag}`;
47
+ /**
48
+ * The `npx` command that upgrades to `tag`: the npm version that matches the
49
+ * tag, or this CLI's version when the tag is older (an older package has no
50
+ * `upgrade`, and tags before FIRST_NPM_TAG have no package at all).
51
+ */
52
+ export function upgradeCommand(tag, cliVersion = CLI_VERSION) {
53
+ const version = compareTags(tag, `v${cliVersion}`) >= 0 ? versionForTag(tag) : cliVersion;
54
+ return `npx ${PACKAGE_NAME}@${version} upgrade --tag ${tag}`;
45
55
  }
@@ -0,0 +1,133 @@
1
+ // The Caller templates `init` fills in: which example each flow copies, where
2
+ // it lands, and the placeholders in it, each with the exact number of times
3
+ // it must appear. A count that does not match means the tag's example
4
+ // changed shape, and init fails loudly instead of writing a half-filled
5
+ // Caller. scripts/example-placeholders-contract.test.mjs holds this
6
+ // repository's examples to the same counts.
7
+
8
+ export class TemplateError extends Error {}
9
+
10
+ /** The two flows init sets up, keyed by the name `--flows` takes. */
11
+ export const FLOWS = {
12
+ pipeline: {
13
+ example: "examples/plan-implement-review-caller.yml",
14
+ caller: ".github/workflows/agent-pipeline.yml",
15
+ },
16
+ audit: {
17
+ example: "examples/security-audit-caller.yml",
18
+ caller: ".github/workflows/security-audit.yml",
19
+ },
20
+ };
21
+
22
+ export const DEFAULT_THREAT_PACK_DIR = ".security";
23
+ export const THREAT_PACK_FILES = ["threat-model.md", "findings-ledger.md"];
24
+
25
+ const VERIFY_LINE =
26
+ /^( *)echo "Provide Consumer verify commands here \(lint\/tests\/typecheck\)\."$/gm;
27
+ const SETUP_BLOCK = /^( *)# setup_commands: \|\n\1# {3}set -euo pipefail\n\1# {3}\.\.\.$/gm;
28
+ const KIT_PIN =
29
+ /(\/agent-kit\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml)@v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?(?=$|[\s"'#])/gm;
30
+
31
+ const indentLines = (text, indent) =>
32
+ text
33
+ .split("\n")
34
+ .map((line) => (line ? indent + line : ""))
35
+ .join("\n");
36
+
37
+ /**
38
+ * Each placeholder: what it matches, how many times per flow, and what it
39
+ * becomes. `values` is { actors, tag, defaultBranch, verify, preset,
40
+ * threatPackDir }; `preset` is the preset's text, or null to leave the
41
+ * commented block as it is.
42
+ */
43
+ export const PLACEHOLDERS = [
44
+ {
45
+ name: "your-github-login",
46
+ pattern: /your-github-login/g,
47
+ counts: { pipeline: 4, audit: 1 },
48
+ replace: (v) => () => v.actors.join('","'),
49
+ },
50
+ {
51
+ name: "the @vX.Y.Z pin of each Agent-kit uses:",
52
+ pattern: KIT_PIN,
53
+ counts: { pipeline: 3, audit: 1 },
54
+ replace: (v) => (_, path) => `${path}@${v.tag}`,
55
+ },
56
+ {
57
+ name: '"main" (the default branch)',
58
+ pattern: /"main"/g,
59
+ counts: { pipeline: 2, audit: 0 },
60
+ replace: (v) => () => `"${v.defaultBranch}"`,
61
+ },
62
+ {
63
+ name: "the placeholder verify_commands echo line",
64
+ pattern: VERIFY_LINE,
65
+ counts: { pipeline: 1, audit: 0 },
66
+ replace: (v) => (_, indent) => indentLines(v.verify, indent),
67
+ },
68
+ {
69
+ name: "the commented `# setup_commands: |` block",
70
+ pattern: SETUP_BLOCK,
71
+ counts: { pipeline: 1, audit: 0 },
72
+ replace: (v) => (match, indent) =>
73
+ v.preset === null
74
+ ? match
75
+ : `${indent}setup_commands: |\n${indentLines(v.preset.replace(/\n+$/, ""), `${indent} `)}`,
76
+ },
77
+ {
78
+ name: '".security/threat-model.md"',
79
+ pattern: /"\.security\/threat-model\.md"/g,
80
+ counts: { pipeline: 0, audit: 1 },
81
+ replace: (v) => () => `"${v.threatPackDir}/threat-model.md"`,
82
+ },
83
+ {
84
+ name: '".security/findings-ledger.md"',
85
+ pattern: /"\.security\/findings-ledger\.md"/g,
86
+ counts: { pipeline: 0, audit: 1 },
87
+ replace: (v) => () => `"${v.threatPackDir}/findings-ledger.md"`,
88
+ },
89
+ ];
90
+
91
+ /** How many times each placeholder appears in `text`, by name. */
92
+ export function placeholderCounts(text) {
93
+ return Object.fromEntries(
94
+ PLACEHOLDERS.map((p) => [p.name, [...String(text).matchAll(p.pattern)].length]),
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Fill in the example for `flow`. Throws TemplateError, naming every
100
+ * placeholder whose count is wrong, before replacing anything.
101
+ */
102
+ export function renderCaller(flow, text, values) {
103
+ const { example } = FLOWS[flow];
104
+ if (/\r/.test(text)) throw new TemplateError(`${example} has CR line endings`);
105
+ const found = placeholderCounts(text);
106
+ const wrong = PLACEHOLDERS.filter((p) => found[p.name] !== p.counts[flow]).map(
107
+ (p) => `${p.name}: expected ${p.counts[flow]}, found ${found[p.name]}`,
108
+ );
109
+ if (wrong.length) {
110
+ throw new TemplateError(
111
+ `${example} does not have the placeholders this CLI fills in (${wrong.join("; ")}). ` +
112
+ "Use the CLI version that matches the tag, or copy the example by hand.",
113
+ );
114
+ }
115
+ // One pass over the original text: a value written for one placeholder
116
+ // (an actor called main, say) is never matched by a later one.
117
+ const edits = PLACEHOLDERS.filter((p) => p.counts[flow] > 0).flatMap((p) =>
118
+ [...text.matchAll(p.pattern)].map((m) => ({
119
+ start: m.index,
120
+ end: m.index + m[0].length,
121
+ text: p.replace(values)(...m),
122
+ })),
123
+ );
124
+ edits.sort((a, b) => a.start - b.start);
125
+ let out = "";
126
+ let at = 0;
127
+ for (const e of edits) {
128
+ if (e.start < at) throw new TemplateError(`${example}: two placeholders overlap`);
129
+ out += text.slice(at, e.start) + e.text;
130
+ at = e.end;
131
+ }
132
+ return out + text.slice(at);
133
+ }
@@ -0,0 +1,288 @@
1
+ // `agent-kit upgrade --tag vY`: move a Consumer from the tag its pins name to
2
+ // vY. It replaces each Kit-owned path whole (delete, then copy from vY),
3
+ // deletes paths only the old tag listed, rewrites the `@ref` of every
4
+ // Agent-kit `uses:` pin, prints the examples/ diff for the Consumer to apply
5
+ // by hand, and then runs doctor's file and pin checks.
6
+ //
7
+ // Plan first, then write: every file is read from the tag and every refusal
8
+ // is checked before anything on disk changes, so a refusal never leaves a
9
+ // half-upgraded checkout. It writes only local files.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { PinRewriteError, readWorkflows, rewriteKitPins } from "./callers.mjs";
14
+ import { checkPins } from "./checks/pins.mjs";
15
+ import { fileAndPinChecks, printFileAndPinChecks } from "./doctor.mjs";
16
+ import { firstLine } from "./exec.mjs";
17
+ import { openKitRepo, PreManifestTagError, serverFromEnv } from "./kit-tag.mjs";
18
+ import {
19
+ applyPlan,
20
+ checkDeletable,
21
+ checkLocalPath,
22
+ checkNoLinks,
23
+ kitFileWrites,
24
+ listed,
25
+ Refusal,
26
+ reportRefusal,
27
+ stageFileModes,
28
+ uncommittedChanges,
29
+ } from "./local-writes.mjs";
30
+ import { caseCollisions } from "./safe-paths.mjs";
31
+ import { compareTags } from "./tags.mjs";
32
+
33
+ const BOM = "\ufeff";
34
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
35
+
36
+ /** A workflow file's text, or a Refusal when it is not UTF-8 (rewriting it would corrupt it). */
37
+ function readWorkflowText(root, path) {
38
+ // The Consumer's own file, rewritten in place: any name it has is fine.
39
+ checkNoLinks(root, path);
40
+ const raw = readFileSync(join(root, path));
41
+ try {
42
+ // TextDecoder drops a leading byte-order mark: keep it, so it is written back.
43
+ const hasBom = raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
44
+ return (hasBom ? BOM : "") + utf8.decode(raw);
45
+ } catch {
46
+ throw new Refusal(`${path} is not valid UTF-8, so its pins cannot be rewritten safely.`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Work out everything upgrade would do, reading every file it would write,
52
+ * and throw Refusal for anything that stops it. Touches nothing on disk.
53
+ */
54
+ export function planUpgrade({ runner, root, oldSnap, newSnap, files, force, pinnedTag }) {
55
+ const newPaths = newSnap.kitOwnedPaths;
56
+ const removed = oldSnap.kitOwnedPaths.filter((p) => !newPaths.includes(p));
57
+ for (const snap of [newSnap, oldSnap]) {
58
+ const clash = caseCollisions(snap.kitOwnedPaths);
59
+ if (clash.length) {
60
+ throw new Refusal(
61
+ `${snap.tag}'s kit-owned-paths.txt lists paths that differ only in case: ${clash[0].join(" and ")}.`,
62
+ );
63
+ }
64
+ }
65
+
66
+ // Every path is deleted first; the new tag's paths are then copied fresh.
67
+ const deletes = [...newPaths, ...removed].map((p) => p.replace(/\/$/, ""));
68
+ for (const rel of deletes) checkDeletable(root, rel);
69
+ const writes = kitFileWrites(newSnap);
70
+ for (const w of writes) checkLocalPath(root, w.path, { deleted: deletes });
71
+
72
+ const pinEdits = [];
73
+ if (pinnedTag !== newSnap.tag) {
74
+ for (const file of files) {
75
+ if (file.pins.length === 0) continue;
76
+ const text = readWorkflowText(root, file.path);
77
+ let rewritten;
78
+ try {
79
+ rewritten = rewriteKitPins(text, newSnap.tag);
80
+ } catch (err) {
81
+ if (err instanceof PinRewriteError) throw new Refusal(`${file.path}: ${err.message}`);
82
+ throw err;
83
+ }
84
+ pinEdits.push({
85
+ path: file.path,
86
+ count: rewritten.count,
87
+ bytes: Buffer.from(rewritten.text, "utf8"),
88
+ });
89
+ }
90
+ }
91
+
92
+ const { changed: dirty, ignored } = uncommittedChanges({ runner, root, paths: deletes });
93
+ if (dirty.length && !force) {
94
+ throw new Refusal(
95
+ `${dirty.length} file(s) in the paths upgrade would replace or delete have uncommitted changes. ` +
96
+ `Commit or stash them, or run again with --force to discard them.`,
97
+ listed(dirty),
98
+ );
99
+ }
100
+
101
+ return { deletes, removed, writes, pinEdits, dirty, ignored };
102
+ }
103
+
104
+ /**
105
+ * @param {{
106
+ * cwd: string,
107
+ * runner: import("./exec.mjs").Runner,
108
+ * tag: string,
109
+ * from?: string, // the tag being left, when the pins already name `tag`
110
+ * force?: boolean,
111
+ * yes?: boolean,
112
+ * confirm?: ((question: string) => Promise<boolean>) | null,
113
+ * out: (text: string) => void,
114
+ * openRepo?: typeof openKitRepo,
115
+ * env?: NodeJS.ProcessEnv,
116
+ * }} opts
117
+ * @returns {Promise<number>} exit code
118
+ */
119
+ export async function runUpgrade({
120
+ cwd,
121
+ runner,
122
+ tag: newTag,
123
+ from,
124
+ force = false,
125
+ yes = false,
126
+ confirm = null,
127
+ out,
128
+ openRepo = openKitRepo,
129
+ env = process.env,
130
+ }) {
131
+ const refuse = (err) => reportRefusal(out, err);
132
+
133
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
134
+ if (top.status !== 0) return refuse(new Refusal(`${cwd} is not a git repository.`));
135
+ const root = top.stdout.trim();
136
+
137
+ let workflows;
138
+ try {
139
+ workflows = readWorkflows(root);
140
+ } catch (err) {
141
+ return refuse(new Refusal(firstLine(err?.message ?? err)));
142
+ }
143
+ const pins = checkPins(workflows.files);
144
+ if (!pins.tag) {
145
+ return refuse(
146
+ new Refusal(
147
+ `upgrade needs every Agent-kit uses: pin to name the same Release-line tag: ${pins.result.summary}`,
148
+ pins.result.details,
149
+ ),
150
+ );
151
+ }
152
+ const pinnedTag = pins.tag;
153
+ if (from !== undefined && pinnedTag !== from && pinnedTag !== newTag) {
154
+ return refuse(
155
+ new Refusal(
156
+ `The pins name ${pinnedTag}, which is neither --from ${from} nor --tag ${newTag}.`,
157
+ ),
158
+ );
159
+ }
160
+ const oldTag = from ?? pinnedTag;
161
+ out(`Upgrading ${pins.repo} from ${oldTag} to ${newTag} in ${root}.`);
162
+ if (compareTags(newTag, oldTag) < 0) out(`Note: ${newTag} is older than ${oldTag}.`);
163
+
164
+ let kit;
165
+ try {
166
+ kit = openRepo({ runner, repo: pins.repo, serverUrl: serverFromEnv(env) });
167
+ } catch (err) {
168
+ return refuse(err);
169
+ }
170
+ try {
171
+ let oldSnap;
172
+ let newSnap;
173
+ let plan;
174
+ try {
175
+ oldSnap = kit.fetchTag(oldTag, { forDeletion: true });
176
+ newSnap = newTag === oldTag ? oldSnap : kit.fetchTag(newTag, { forDeletion: true });
177
+ plan = planUpgrade({
178
+ runner,
179
+ root,
180
+ oldSnap,
181
+ newSnap,
182
+ files: workflows.files,
183
+ force,
184
+ pinnedTag,
185
+ });
186
+ } catch (err) {
187
+ // PreManifestTagError gives the same message doctor prints.
188
+ if (err instanceof Refusal || err instanceof PreManifestTagError) return refuse(err);
189
+ return refuse(new Refusal(firstLine(err?.message ?? err)));
190
+ }
191
+
192
+ out("");
193
+ out(`Replace (delete, then copy from ${newTag}):`);
194
+ for (const p of newSnap.kitOwnedPaths) {
195
+ const n = plan.writes.filter((w) => w.path.startsWith(p)).length;
196
+ out(` ${p} (${n} file${n === 1 ? "" : "s"})`);
197
+ }
198
+ if (plan.removed.length) {
199
+ out(`Delete (no longer in ${newTag}'s kit-owned-paths.txt):`);
200
+ for (const p of plan.removed) out(` ${p}`);
201
+ }
202
+ if (plan.pinEdits.length) {
203
+ out(`Rewrite uses: pins to @${newTag}:`);
204
+ for (const e of plan.pinEdits) out(` ${e.path} (${e.count} pin${e.count === 1 ? "" : "s"})`);
205
+ } else {
206
+ out(`The uses: pins already name ${newTag}.`);
207
+ }
208
+ if (plan.dirty.length) {
209
+ out(`--force: discarding uncommitted changes in ${plan.dirty.length} file(s):`);
210
+ for (const d of listed(plan.dirty)) out(` ${d}`);
211
+ }
212
+ if (plan.ignored.length) {
213
+ out(`Also deleted: ${plan.ignored.length} ignored file(s) or folder(s) in those paths:`);
214
+ for (const d of listed(plan.ignored)) out(` ${d}`);
215
+ }
216
+ out("");
217
+
218
+ if (!yes) {
219
+ if (!confirm) {
220
+ out("Not run: there is no terminal to confirm in. Run again with --yes to go ahead.");
221
+ return 2;
222
+ }
223
+ if (!(await confirm("Go ahead? [y/N] "))) {
224
+ out("Stopped. Nothing was changed.");
225
+ return 1;
226
+ }
227
+ }
228
+
229
+ try {
230
+ applyPlan(root, { deletes: plan.deletes, writes: plan.writes, rewrites: plan.pinEdits });
231
+ } catch (err) {
232
+ if (err instanceof Refusal) return refuse(err);
233
+ out(`FAILED upgrade stopped part-way: ${firstLine(err?.message ?? err)}`);
234
+ out(" See `git status`; restore with `git checkout -- .` and `git clean` as needed.");
235
+ return 1;
236
+ }
237
+ out(
238
+ `Copied ${plan.writes.length} file(s) from ${newTag}` +
239
+ (plan.pinEdits.length ? ` and rewrote ${plan.pinEdits.length} workflow file(s).` : "."),
240
+ );
241
+ const note = stageFileModes({ runner, root, writes: plan.writes });
242
+ if (note) out(note);
243
+
244
+ out("");
245
+ if (oldTag === newTag) {
246
+ out(
247
+ `The pins already named ${newTag}, so there is no examples/ diff to show, and no path ` +
248
+ `only an older tag listed was deleted. If they were bumped from an older tag (by ` +
249
+ `Dependabot, say), run again with --from <that tag> to get both.`,
250
+ );
251
+ } else {
252
+ let diff = "";
253
+ try {
254
+ diff = kit.diff(oldSnap, newSnap, "examples/");
255
+ } catch (err) {
256
+ out(`Could not diff examples/: ${firstLine(err?.message ?? err)}`);
257
+ }
258
+ if (diff.trim()) {
259
+ out(
260
+ `examples/ changed between ${oldTag} and ${newTag}. Your Callers are yours: ` +
261
+ `apply what you need by hand.`,
262
+ );
263
+ // eslint-disable-next-line no-control-regex
264
+ if (/[\u0000-\u0009\u000b-\u001f]/.test(diff)) {
265
+ out(
266
+ "(Control characters, tabs and carriage returns included, are shown escaped as \\uXXXX.)",
267
+ );
268
+ }
269
+ out(diff.replace(/\n$/, ""));
270
+ } else {
271
+ out(`examples/ did not change between ${oldTag} and ${newTag}.`);
272
+ }
273
+ }
274
+
275
+ // The same file and pin checks as `doctor --ci`, against the tag already fetched.
276
+ out("");
277
+ const failed = printFileAndPinChecks(out, fileAndPinChecks({ runner, root, snap: newSnap }));
278
+ out("");
279
+ out(
280
+ failed
281
+ ? "Result: a check failed (exit 1)."
282
+ : "Result: done. Review `git status` and `git diff`, then commit the pins and the Kit-owned paths together.",
283
+ );
284
+ return failed ? 1 : 0;
285
+ } finally {
286
+ kit.cleanup();
287
+ }
288
+ }