@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.
package/src/kit-tag.mjs CHANGED
@@ -1,6 +1,8 @@
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).
1
+ // Fetches Agent-kit tags into a throwaway bare repository and reads files,
2
+ // file lists and tree hashes from them. Nothing is bundled in the npm
3
+ // package: the git tag Consumers pin in `uses:` is the only source of kit
4
+ // files (ADR 0001). One repository can hold two tags, so `upgrade` can diff
5
+ // them.
4
6
 
5
7
  import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
6
8
  import { tmpdir } from "node:os";
@@ -8,12 +10,28 @@ import { join } from "node:path";
8
10
  import { REPO_PATTERN } from "./checks/result.mjs";
9
11
  import { firstLine } from "./exec.mjs";
10
12
  import { parseKitOwnedPaths } from "./kit-owned-paths.mjs";
13
+ import { caseCollisions, checkSafePath, fileDirConflicts } from "./safe-paths.mjs";
11
14
  import { FIRST_MANIFEST_TAG, isReleaseTag } from "./tags.mjs";
12
15
 
13
16
  const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/?$/;
14
17
  export const DEFAULT_SERVER = "https://github.com";
15
18
  const SERVER = /^https:\/\/[A-Za-z0-9.-]+(?::\d+)?$/;
16
19
 
20
+ /** GITHUB_SERVER_URL when it is a valid server URL (set on GitHub Enterprise Server too), else github.com. */
21
+ export function serverFromEnv(env) {
22
+ const fromEnv = String(env.GITHUB_SERVER_URL ?? "").replace(/\/$/, "");
23
+ return SERVER.test(fromEnv) ? fromEnv : DEFAULT_SERVER;
24
+ }
25
+
26
+ /** The git modes of an executable and of a regular file. */
27
+ export const EXECUTABLE_MODE = "100755";
28
+ export const REGULAR_MODE = "100644";
29
+
30
+ /** File modes the CLI copies: a regular file, or an executable one. */
31
+ const FILE_MODES = new Set([REGULAR_MODE, EXECUTABLE_MODE]);
32
+ const MODE_NAMES = { 120000: "a symbolic link", 160000: "a submodule" };
33
+ const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
34
+
17
35
  export class KitTagError extends Error {}
18
36
 
19
37
  /** A tag with no kit-owned-paths.txt (up to v1.0.0-alpha.10): the CLI refuses it. */
@@ -37,46 +55,64 @@ function checkPath(path) {
37
55
  return path.replace(/\/$/, "");
38
56
  }
39
57
 
58
+ function checkArgs({ repo, serverUrl }) {
59
+ if (!REPO_PATTERN.test(repo)) {
60
+ throw new KitTagError(`not a GitHub repository name: ${JSON.stringify(repo)}`);
61
+ }
62
+ if (!SERVER.test(serverUrl)) {
63
+ throw new KitTagError(`not a GitHub server URL: ${JSON.stringify(serverUrl)}`);
64
+ }
65
+ }
66
+
40
67
  /**
41
- * Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
42
- * PreManifestTagError. Call `cleanup()` when done.
68
+ * Open a throwaway bare repository for `repo`, to fetch one or more tags
69
+ * into. Call `cleanup()` when done.
43
70
  *
44
71
  * @param {{
45
72
  * runner: import("./exec.mjs").Runner,
46
73
  * repo: string,
47
- * tag: string,
48
74
  * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
49
75
  * remoteUrl?: string, // tests: fetch from here instead
50
- * forDeletion?: boolean // strict manifest shapes, for code that deletes paths
51
76
  * }} opts
52
77
  */
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
- }
78
+ export function openKitRepo({ runner, repo, serverUrl = DEFAULT_SERVER, remoteUrl }) {
79
+ checkArgs({ repo, serverUrl });
68
80
  const url = remoteUrl ?? `${serverUrl}/${repo}.git`;
69
81
  const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tag-"));
70
82
  const dir = join(tmp, "repo.git");
71
83
  const noHooks = join(tmp, "no-hooks");
72
84
  mkdirSync(noHooks);
73
- const cleanup = () => rmSync(tmp, { recursive: true, force: true });
85
+ // The retries are a fallback only: with background maintenance off (below)
86
+ // no git process is left writing in `tmp` once its command has returned.
87
+ const cleanup = () =>
88
+ rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
89
+ // No background maintenance: `git fetch` ends with `git maintenance run
90
+ // --auto`, which newer git detaches (maintenance.autoDetach, gc.autoDetach).
91
+ // The detached process outlives the fetch and can still be writing in the
92
+ // repository (objects/maintenance.lock, gc.pid) when cleanup() removes it,
93
+ // which failed cleanup with ENOTEMPTY.
94
+ const noBackground = ["-c", "maintenance.auto=false", "-c", "gc.auto=0"];
74
95
  // No hooks run, whatever the adopter's global git config says.
75
- const git = (...args) => runner("git", ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...args]);
96
+ const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`, ...noBackground];
97
+ const git = (...args) => runner("git", [...base, ...args]);
98
+ const gitBytes = (...args) => runner("git", [...base, ...args], { encoding: "buffer" });
76
99
  try {
77
- let r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
100
+ const r = runner("git", [...noBackground, "init", "--bare", "--quiet", "--template=", dir]);
78
101
  if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
79
- r = git(
102
+ } catch (err) {
103
+ cleanup();
104
+ throw err;
105
+ }
106
+
107
+ /**
108
+ * Fetch `tag` into the repository. It must carry kit-owned-paths.txt, or
109
+ * this throws PreManifestTagError.
110
+ */
111
+ const fetchTag = (tag, { forDeletion = true } = {}) => {
112
+ if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
113
+ let r = git(
114
+ "-c",
115
+ "fetch.fsckObjects=true",
80
116
  "fetch",
81
117
  "--quiet",
82
118
  "--depth=1",
@@ -108,12 +144,123 @@ export function openKitTag({
108
144
  return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
109
145
  };
110
146
 
147
+ /**
148
+ * Every file under folder `path` at the tag, as `{ path, mode, oid }`.
149
+ * Throws KitTagError for anything that is not a regular or executable
150
+ * file (a symbolic link, a submodule), for a file path that is not safe
151
+ * to write on every platform, and for two paths that would be one file
152
+ * on a case-insensitive file system.
153
+ */
154
+ const listFiles = (path) => {
155
+ const folder = checkPath(path);
156
+ const res = git("ls-tree", "-r", "-z", "--full-tree", commit, "--", folder);
157
+ if (res.status !== 0) {
158
+ throw new KitTagError(`could not list ${folder}/ at ${tag}: ${firstLine(res.stderr)}`);
159
+ }
160
+ const files = [];
161
+ for (const entry of res.stdout.split("\0").filter(Boolean)) {
162
+ const m = /^(\d{6}) (\w+) ([0-9a-f]+)\t(.+)$/s.exec(entry);
163
+ if (!m) throw new KitTagError(`unexpected git ls-tree output at ${tag}`);
164
+ const [, mode, type, oid, file] = m;
165
+ if (!file.startsWith(`${folder}/`)) continue;
166
+ if (!FILE_MODES.has(mode) || type !== "blob" || !OID.test(oid)) {
167
+ const kind = MODE_NAMES[mode] ?? `mode ${mode}`;
168
+ throw new KitTagError(
169
+ `${tag} has ${JSON.stringify(file)} as ${kind}; the CLI copies only regular files`,
170
+ );
171
+ }
172
+ try {
173
+ checkSafePath(file, `${tag} file`);
174
+ } catch (err) {
175
+ throw new KitTagError(err.message);
176
+ }
177
+ files.push({ path: file, mode, oid });
178
+ }
179
+ const names = files.map((f) => f.path);
180
+ const collisions = caseCollisions(names);
181
+ if (collisions.length) {
182
+ throw new KitTagError(
183
+ `${tag} has files that differ only in case, which would overwrite each other on ` +
184
+ `Windows and macOS: ${collisions.map((g) => g.join(" and ")).join("; ")}`,
185
+ );
186
+ }
187
+ const conflicts = fileDirConflicts(names);
188
+ if (conflicts.length) {
189
+ throw new KitTagError(
190
+ `${tag} has ${conflicts[0][0]} as both a file and a folder (${conflicts[0][1]})`,
191
+ );
192
+ }
193
+ return files;
194
+ };
195
+
196
+ /** The exact bytes of a blob listed by listFiles, as a Buffer. */
197
+ const readBlob = (oid) => {
198
+ if (!OID.test(oid)) throw new KitTagError(`not a git object id: ${JSON.stringify(oid)}`);
199
+ const res = gitBytes("cat-file", "blob", oid);
200
+ if (res.status !== 0 || !Buffer.isBuffer(res.stdout)) {
201
+ throw new KitTagError(`could not read ${oid} at ${tag}: ${firstLine(res.stderr)}`);
202
+ }
203
+ return res.stdout;
204
+ };
205
+
111
206
  const manifest = readFile("kit-owned-paths.txt");
112
207
  if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
113
208
  const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
114
- return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, cleanup };
209
+ return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, listFiles, readBlob };
210
+ };
211
+
212
+ /**
213
+ * `git diff` of folder `path` between two fetched tags, as text. No
214
+ * external diff tool or textconv filter from the adopter's git config runs.
215
+ */
216
+ const diff = (oldSnapshot, newSnapshot, path) => {
217
+ const folder = checkPath(path);
218
+ const res = git(
219
+ "diff",
220
+ "--no-ext-diff",
221
+ "--no-textconv",
222
+ "--no-color",
223
+ oldSnapshot.commit,
224
+ newSnapshot.commit,
225
+ "--",
226
+ `${folder}/`,
227
+ );
228
+ if (res.status !== 0) throw new KitTagError(`git diff failed: ${firstLine(res.stderr)}`);
229
+ return res.stdout;
230
+ };
231
+
232
+ return { repo, serverUrl, fetchTag, diff, cleanup };
233
+ }
234
+
235
+ /**
236
+ * Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
237
+ * PreManifestTagError. Call `cleanup()` when done.
238
+ *
239
+ * @param {{
240
+ * runner: import("./exec.mjs").Runner,
241
+ * repo: string,
242
+ * tag: string,
243
+ * serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
244
+ * remoteUrl?: string, // tests: fetch from here instead
245
+ * forDeletion?: boolean // strict manifest shapes, for code that deletes paths
246
+ * }} opts
247
+ */
248
+ export function openKitTag({
249
+ runner,
250
+ repo,
251
+ tag,
252
+ serverUrl = DEFAULT_SERVER,
253
+ remoteUrl,
254
+ forDeletion = true,
255
+ }) {
256
+ checkArgs({ repo, serverUrl });
257
+ // Checked before any git runs, so a bad tag never reaches the command line.
258
+ if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
259
+ const kit = openKitRepo({ runner, repo, serverUrl, remoteUrl });
260
+ try {
261
+ return { ...kit.fetchTag(tag, { forDeletion }), cleanup: kit.cleanup };
115
262
  } catch (err) {
116
- cleanup();
263
+ kit.cleanup();
117
264
  throw err;
118
265
  }
119
266
  }
@@ -13,10 +13,21 @@
13
13
  // configured clean/process filter drivers, so hashing a checkout runs no
14
14
  // command that the checkout's or the adopter's git config names.
15
15
 
16
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import {
17
+ existsSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ readdirSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
17
25
  import { tmpdir } from "node:os";
18
26
  import { isAbsolute, join, resolve } from "node:path";
19
27
  import { firstLine } from "./exec.mjs";
28
+ import { EXECUTABLE_MODE, REGULAR_MODE } from "./kit-tag.mjs";
29
+
30
+ const MODES = new Set([EXECUTABLE_MODE, REGULAR_MODE]);
20
31
 
21
32
  const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
22
33
 
@@ -37,11 +48,98 @@ export function filterDrivers(configNames) {
37
48
  return [...drivers];
38
49
  }
39
50
 
51
+ /**
52
+ * True when core.fileMode is off (the default on Windows): git then cannot
53
+ * see an executable bit on disk and takes a file's mode from the index.
54
+ */
55
+ export function fileModeIsOff(runner, root) {
56
+ const r = runner("git", ["-C", root, "config", "--type=bool", "core.fileMode"]);
57
+ return r.status === 0 && r.stdout.trim() === "false";
58
+ }
59
+
60
+ /**
61
+ * `-c` options that keep git from running anything the checkout's or the
62
+ * adopter's git config names: no hooks (`noHooks` is an empty folder), no
63
+ * fsmonitor, no background maintenance, and no clean/process filter drivers.
64
+ */
65
+ export function gitSafetyArgs({ runner, root, noHooks }) {
66
+ const safety = [
67
+ "-c",
68
+ `core.hooksPath=${noHooks}`,
69
+ "-c",
70
+ "core.fsmonitor=false",
71
+ "-c",
72
+ "gc.auto=0",
73
+ "-c",
74
+ "maintenance.auto=false",
75
+ ];
76
+ const listed = runner("git", ["-C", root, "config", "--name-only", "--get-regexp", "^filter\\."]);
77
+ for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
78
+ if (/[=\r\n]/.test(driver)) {
79
+ throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
80
+ }
81
+ safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
82
+ safety.push("-c", `filter.${driver}.required=false`);
83
+ }
84
+ return safety;
85
+ }
86
+
87
+ /** `lstat`, or null when nothing is there. */
88
+ export function lstatOrNull(path) {
89
+ try {
90
+ return lstatSync(path);
91
+ } catch (err) {
92
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
93
+ throw err;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The first symbolic link or junction at, above or inside the folder `path`
99
+ * (relative to `root`, ending in /), as a path without a trailing slash, or
100
+ * null. git looks straight through a Windows junction, stops at a symlink
101
+ * with "beyond a symbolic link", and stores a symlink inside a folder as a
102
+ * link entry, so this is checked before git ever sees the path.
103
+ */
104
+ export function linkAt(root, path) {
105
+ const folder = path.replace(/\/$/, "");
106
+ const parts = folder.split("/");
107
+ for (let i = 1; i <= parts.length; i++) {
108
+ const sub = parts.slice(0, i).join("/");
109
+ const st = lstatOrNull(join(root, sub));
110
+ if (!st) return null;
111
+ if (st.isSymbolicLink()) return sub;
112
+ }
113
+ return linksUnder(root, folder)[0] ?? null;
114
+ }
115
+
116
+ /**
117
+ * Every symbolic link or junction at or inside `rel` (relative to `root`, no
118
+ * trailing slash), in sorted walk order: never followed or guessed at. doctor
119
+ * reports what it finds and upgrade refuses to delete through it, so both
120
+ * agree on what is linked.
121
+ */
122
+ export function linksUnder(root, rel) {
123
+ const found = [];
124
+ const walk = (sub) => {
125
+ const st = lstatOrNull(join(root, sub));
126
+ if (!st) return;
127
+ if (st.isSymbolicLink()) found.push(sub);
128
+ else if (st.isDirectory()) {
129
+ for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
130
+ }
131
+ };
132
+ walk(rel);
133
+ return found;
134
+ }
135
+
40
136
  /**
41
137
  * @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
42
- * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
138
+ * @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string>, links: Map<string, string> }}
43
139
  * A null hash means the path is missing locally; `skipped` holds paths that
44
- * could not be compared, with the reason; `notes` explains how a path was compared.
140
+ * could not be compared, with the reason; `notes` explains how a path was compared;
141
+ * `links` maps a path that is, sits under, or holds a symbolic link or junction to
142
+ * that link. A linked path is never hashed.
45
143
  */
46
144
  export function localTreeHashes({ runner, cwd, paths }) {
47
145
  for (const p of paths) {
@@ -75,29 +173,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
75
173
  // splits on ':' on POSIX, so a path containing one would break it.
76
174
  writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
77
175
 
78
- const safety = [
79
- "-c",
80
- `core.hooksPath=${noHooks}`,
81
- "-c",
82
- "core.fsmonitor=false",
83
- "-c",
84
- "gc.auto=0",
85
- ];
86
- const listed = runner("git", [
87
- "-C",
88
- root,
89
- "config",
90
- "--name-only",
91
- "--get-regexp",
92
- "^filter\\.",
93
- ]);
94
- for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
95
- if (/[=\r\n]/.test(driver)) {
96
- throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
97
- }
98
- safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
99
- safety.push("-c", `filter.${driver}.required=false`);
100
- }
176
+ const safety = gitSafetyArgs({ runner, root, noHooks });
101
177
  const base = ["-C", root, ...safety, "--literal-pathspecs"];
102
178
  const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
103
179
  const git = (...args) => runner("git", [...base, ...args], { env });
@@ -113,10 +189,20 @@ export function localTreeHashes({ runner, cwd, paths }) {
113
189
  }
114
190
  }
115
191
 
192
+ // Without core.fileMode (the default on Windows) git cannot see an
193
+ // executable bit on disk and takes the mode from the index: so does this.
194
+ const trustIndexModes = fileModeIsOff(runner, root);
195
+
116
196
  const hashes = new Map();
117
197
  const skipped = new Map();
118
198
  const notes = new Map();
199
+ const links = new Map();
119
200
  for (const path of paths) {
201
+ const link = linkAt(root, path);
202
+ if (link !== null) {
203
+ links.set(path, link);
204
+ continue;
205
+ }
120
206
  // The real index (read only) knows skip-worktree entries (`S`), which
121
207
  // have no files on disk. Everything else comes from the working copy
122
208
  // (`git add`); only the S files are then reset to HEAD's copy, so local
@@ -160,6 +246,35 @@ export function localTreeHashes({ runner, cwd, paths }) {
160
246
  );
161
247
  }
162
248
  }
249
+ if (trustIndexModes) {
250
+ const modes = (res) =>
251
+ new Map(
252
+ (res.status === 0 ? res.stdout.split("\0") : [])
253
+ .filter(Boolean)
254
+ .map((e) => [e.slice(e.indexOf("\t") + 1), e.slice(0, 6)]),
255
+ );
256
+ const inReal = modes(real("ls-files", "-s", "-z", "--", path));
257
+ const inTemp = modes(git("ls-files", "-s", "-z", "--", path));
258
+ // Both directions: the real index may have gained or lost the bit.
259
+ for (const [want, flag] of [
260
+ [EXECUTABLE_MODE, "--chmod=+x"],
261
+ [REGULAR_MODE, "--chmod=-x"],
262
+ ]) {
263
+ const fix = [...inReal]
264
+ .filter(
265
+ ([f, mode]) => mode === want && MODES.has(inTemp.get(f)) && inTemp.get(f) !== want,
266
+ )
267
+ .map(([f]) => f);
268
+ for (let i = 0; i < fix.length; i += 200) {
269
+ const chmod = git("update-index", flag, "--", ...fix.slice(i, i + 200));
270
+ if (chmod.status !== 0) {
271
+ throw new LocalTreeError(
272
+ `git update-index ${flag} failed: ${firstLine(chmod.stderr)}`,
273
+ );
274
+ }
275
+ }
276
+ }
277
+ }
163
278
  const inIndex = git("ls-files", "-z", "--", path);
164
279
  const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
165
280
  if (staged.length === 0) {
@@ -183,7 +298,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
183
298
  }
184
299
  hashes.set(path, tree.stdout.trim());
185
300
  }
186
- return { root, hashes, skipped, notes };
301
+ return { root, hashes, skipped, notes, links };
187
302
  } finally {
188
303
  rmSync(tmp, { recursive: true, force: true });
189
304
  }