@mnaimfaizy/agent-kit 1.0.0-alpha.12 → 1.0.0-beta.2

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.
@@ -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
+ }