@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/README.md +9 -6
- package/bin/agent-kit.mjs +3 -1
- package/package.json +1 -1
- package/src/callers.mjs +38 -0
- package/src/checks/kit-owned-trees.mjs +2 -2
- package/src/checks/result.mjs +8 -2
- package/src/cli.mjs +150 -18
- package/src/doctor.mjs +43 -6
- package/src/exec.mjs +17 -4
- package/src/init.mjs +627 -0
- package/src/kit-tag.mjs +165 -27
- package/src/local-tree.mjs +73 -23
- package/src/local-writes.mjs +307 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +15 -5
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
package/src/kit-tag.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Fetches
|
|
2
|
-
// and tree hashes from
|
|
3
|
-
// Consumers pin in `uses:` is the only source of kit
|
|
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,34 +55,28 @@ 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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
54
|
-
|
|
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");
|
|
@@ -72,11 +84,26 @@ export function openKitTag({
|
|
|
72
84
|
mkdirSync(noHooks);
|
|
73
85
|
const cleanup = () => rmSync(tmp, { recursive: true, force: true });
|
|
74
86
|
// No hooks run, whatever the adopter's global git config says.
|
|
75
|
-
const
|
|
87
|
+
const base = ["-C", dir, "-c", `core.hooksPath=${noHooks}`];
|
|
88
|
+
const git = (...args) => runner("git", [...base, ...args]);
|
|
89
|
+
const gitBytes = (...args) => runner("git", [...base, ...args], { encoding: "buffer" });
|
|
76
90
|
try {
|
|
77
|
-
|
|
91
|
+
const r = runner("git", ["init", "--bare", "--quiet", "--template=", dir]);
|
|
78
92
|
if (r.status !== 0) throw new KitTagError(`git init failed: ${firstLine(r.stderr)}`);
|
|
79
|
-
|
|
93
|
+
} catch (err) {
|
|
94
|
+
cleanup();
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch `tag` into the repository. It must carry kit-owned-paths.txt, or
|
|
100
|
+
* this throws PreManifestTagError.
|
|
101
|
+
*/
|
|
102
|
+
const fetchTag = (tag, { forDeletion = true } = {}) => {
|
|
103
|
+
if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
|
|
104
|
+
let r = git(
|
|
105
|
+
"-c",
|
|
106
|
+
"fetch.fsckObjects=true",
|
|
80
107
|
"fetch",
|
|
81
108
|
"--quiet",
|
|
82
109
|
"--depth=1",
|
|
@@ -108,12 +135,123 @@ export function openKitTag({
|
|
|
108
135
|
return type.status === 0 && type.stdout.trim() === "tree" ? hash : null;
|
|
109
136
|
};
|
|
110
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Every file under folder `path` at the tag, as `{ path, mode, oid }`.
|
|
140
|
+
* Throws KitTagError for anything that is not a regular or executable
|
|
141
|
+
* file (a symbolic link, a submodule), for a file path that is not safe
|
|
142
|
+
* to write on every platform, and for two paths that would be one file
|
|
143
|
+
* on a case-insensitive file system.
|
|
144
|
+
*/
|
|
145
|
+
const listFiles = (path) => {
|
|
146
|
+
const folder = checkPath(path);
|
|
147
|
+
const res = git("ls-tree", "-r", "-z", "--full-tree", commit, "--", folder);
|
|
148
|
+
if (res.status !== 0) {
|
|
149
|
+
throw new KitTagError(`could not list ${folder}/ at ${tag}: ${firstLine(res.stderr)}`);
|
|
150
|
+
}
|
|
151
|
+
const files = [];
|
|
152
|
+
for (const entry of res.stdout.split("\0").filter(Boolean)) {
|
|
153
|
+
const m = /^(\d{6}) (\w+) ([0-9a-f]+)\t(.+)$/s.exec(entry);
|
|
154
|
+
if (!m) throw new KitTagError(`unexpected git ls-tree output at ${tag}`);
|
|
155
|
+
const [, mode, type, oid, file] = m;
|
|
156
|
+
if (!file.startsWith(`${folder}/`)) continue;
|
|
157
|
+
if (!FILE_MODES.has(mode) || type !== "blob" || !OID.test(oid)) {
|
|
158
|
+
const kind = MODE_NAMES[mode] ?? `mode ${mode}`;
|
|
159
|
+
throw new KitTagError(
|
|
160
|
+
`${tag} has ${JSON.stringify(file)} as ${kind}; the CLI copies only regular files`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
checkSafePath(file, `${tag} file`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
throw new KitTagError(err.message);
|
|
167
|
+
}
|
|
168
|
+
files.push({ path: file, mode, oid });
|
|
169
|
+
}
|
|
170
|
+
const names = files.map((f) => f.path);
|
|
171
|
+
const collisions = caseCollisions(names);
|
|
172
|
+
if (collisions.length) {
|
|
173
|
+
throw new KitTagError(
|
|
174
|
+
`${tag} has files that differ only in case, which would overwrite each other on ` +
|
|
175
|
+
`Windows and macOS: ${collisions.map((g) => g.join(" and ")).join("; ")}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const conflicts = fileDirConflicts(names);
|
|
179
|
+
if (conflicts.length) {
|
|
180
|
+
throw new KitTagError(
|
|
181
|
+
`${tag} has ${conflicts[0][0]} as both a file and a folder (${conflicts[0][1]})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return files;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The exact bytes of a blob listed by listFiles, as a Buffer. */
|
|
188
|
+
const readBlob = (oid) => {
|
|
189
|
+
if (!OID.test(oid)) throw new KitTagError(`not a git object id: ${JSON.stringify(oid)}`);
|
|
190
|
+
const res = gitBytes("cat-file", "blob", oid);
|
|
191
|
+
if (res.status !== 0 || !Buffer.isBuffer(res.stdout)) {
|
|
192
|
+
throw new KitTagError(`could not read ${oid} at ${tag}: ${firstLine(res.stderr)}`);
|
|
193
|
+
}
|
|
194
|
+
return res.stdout;
|
|
195
|
+
};
|
|
196
|
+
|
|
111
197
|
const manifest = readFile("kit-owned-paths.txt");
|
|
112
198
|
if (manifest === null) throw new PreManifestTagError(repo, tag, serverUrl);
|
|
113
199
|
const kitOwnedPaths = parseKitOwnedPaths(manifest, { forDeletion });
|
|
114
|
-
return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash,
|
|
200
|
+
return { repo, tag, commit, serverUrl, kitOwnedPaths, readFile, treeHash, listFiles, readBlob };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `git diff` of folder `path` between two fetched tags, as text. No
|
|
205
|
+
* external diff tool or textconv filter from the adopter's git config runs.
|
|
206
|
+
*/
|
|
207
|
+
const diff = (oldSnapshot, newSnapshot, path) => {
|
|
208
|
+
const folder = checkPath(path);
|
|
209
|
+
const res = git(
|
|
210
|
+
"diff",
|
|
211
|
+
"--no-ext-diff",
|
|
212
|
+
"--no-textconv",
|
|
213
|
+
"--no-color",
|
|
214
|
+
oldSnapshot.commit,
|
|
215
|
+
newSnapshot.commit,
|
|
216
|
+
"--",
|
|
217
|
+
`${folder}/`,
|
|
218
|
+
);
|
|
219
|
+
if (res.status !== 0) throw new KitTagError(`git diff failed: ${firstLine(res.stderr)}`);
|
|
220
|
+
return res.stdout;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return { repo, serverUrl, fetchTag, diff, cleanup };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fetch `tag` of `repo`. The tag must carry kit-owned-paths.txt, or this throws
|
|
228
|
+
* PreManifestTagError. Call `cleanup()` when done.
|
|
229
|
+
*
|
|
230
|
+
* @param {{
|
|
231
|
+
* runner: import("./exec.mjs").Runner,
|
|
232
|
+
* repo: string,
|
|
233
|
+
* tag: string,
|
|
234
|
+
* serverUrl?: string, // https://github.com, or a GitHub Enterprise Server host
|
|
235
|
+
* remoteUrl?: string, // tests: fetch from here instead
|
|
236
|
+
* forDeletion?: boolean // strict manifest shapes, for code that deletes paths
|
|
237
|
+
* }} opts
|
|
238
|
+
*/
|
|
239
|
+
export function openKitTag({
|
|
240
|
+
runner,
|
|
241
|
+
repo,
|
|
242
|
+
tag,
|
|
243
|
+
serverUrl = DEFAULT_SERVER,
|
|
244
|
+
remoteUrl,
|
|
245
|
+
forDeletion = true,
|
|
246
|
+
}) {
|
|
247
|
+
checkArgs({ repo, serverUrl });
|
|
248
|
+
// Checked before any git runs, so a bad tag never reaches the command line.
|
|
249
|
+
if (!isReleaseTag(tag)) throw new KitTagError(`not a Release-line tag: ${JSON.stringify(tag)}`);
|
|
250
|
+
const kit = openKitRepo({ runner, repo, serverUrl, remoteUrl });
|
|
251
|
+
try {
|
|
252
|
+
return { ...kit.fetchTag(tag, { forDeletion }), cleanup: kit.cleanup };
|
|
115
253
|
} catch (err) {
|
|
116
|
-
cleanup();
|
|
254
|
+
kit.cleanup();
|
|
117
255
|
throw err;
|
|
118
256
|
}
|
|
119
257
|
}
|
package/src/local-tree.mjs
CHANGED
|
@@ -17,6 +17,9 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { isAbsolute, join, resolve } from "node:path";
|
|
19
19
|
import { firstLine } from "./exec.mjs";
|
|
20
|
+
import { EXECUTABLE_MODE, REGULAR_MODE } from "./kit-tag.mjs";
|
|
21
|
+
|
|
22
|
+
const MODES = new Set([EXECUTABLE_MODE, REGULAR_MODE]);
|
|
20
23
|
|
|
21
24
|
const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
|
|
22
25
|
|
|
@@ -37,6 +40,42 @@ export function filterDrivers(configNames) {
|
|
|
37
40
|
return [...drivers];
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* True when core.fileMode is off (the default on Windows): git then cannot
|
|
45
|
+
* see an executable bit on disk and takes a file's mode from the index.
|
|
46
|
+
*/
|
|
47
|
+
export function fileModeIsOff(runner, root) {
|
|
48
|
+
const r = runner("git", ["-C", root, "config", "--type=bool", "core.fileMode"]);
|
|
49
|
+
return r.status === 0 && r.stdout.trim() === "false";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `-c` options that keep git from running anything the checkout's or the
|
|
54
|
+
* adopter's git config names: no hooks (`noHooks` is an empty folder), no
|
|
55
|
+
* fsmonitor, no background maintenance, and no clean/process filter drivers.
|
|
56
|
+
*/
|
|
57
|
+
export function gitSafetyArgs({ runner, root, noHooks }) {
|
|
58
|
+
const safety = [
|
|
59
|
+
"-c",
|
|
60
|
+
`core.hooksPath=${noHooks}`,
|
|
61
|
+
"-c",
|
|
62
|
+
"core.fsmonitor=false",
|
|
63
|
+
"-c",
|
|
64
|
+
"gc.auto=0",
|
|
65
|
+
"-c",
|
|
66
|
+
"maintenance.auto=false",
|
|
67
|
+
];
|
|
68
|
+
const listed = runner("git", ["-C", root, "config", "--name-only", "--get-regexp", "^filter\\."]);
|
|
69
|
+
for (const driver of filterDrivers(listed.status === 0 ? listed.stdout.split("\n") : [])) {
|
|
70
|
+
if (/[=\r\n]/.test(driver)) {
|
|
71
|
+
throw new LocalTreeError(`cannot turn off git filter driver ${JSON.stringify(driver)}`);
|
|
72
|
+
}
|
|
73
|
+
safety.push("-c", `filter.${driver}.clean=`, "-c", `filter.${driver}.process=`);
|
|
74
|
+
safety.push("-c", `filter.${driver}.required=false`);
|
|
75
|
+
}
|
|
76
|
+
return safety;
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
/**
|
|
41
80
|
* @param {{ runner: import("./exec.mjs").Runner, cwd: string, paths: string[] }} opts
|
|
42
81
|
* @returns {{ root: string, hashes: Map<string, string | null>, skipped: Map<string, string>, notes: Map<string, string> }}
|
|
@@ -75,29 +114,7 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
75
114
|
// splits on ':' on POSIX, so a path containing one would break it.
|
|
76
115
|
writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
|
|
77
116
|
|
|
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
|
-
}
|
|
117
|
+
const safety = gitSafetyArgs({ runner, root, noHooks });
|
|
101
118
|
const base = ["-C", root, ...safety, "--literal-pathspecs"];
|
|
102
119
|
const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
|
|
103
120
|
const git = (...args) => runner("git", [...base, ...args], { env });
|
|
@@ -113,6 +130,10 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
113
130
|
}
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
// Without core.fileMode (the default on Windows) git cannot see an
|
|
134
|
+
// executable bit on disk and takes the mode from the index: so does this.
|
|
135
|
+
const trustIndexModes = fileModeIsOff(runner, root);
|
|
136
|
+
|
|
116
137
|
const hashes = new Map();
|
|
117
138
|
const skipped = new Map();
|
|
118
139
|
const notes = new Map();
|
|
@@ -160,6 +181,35 @@ export function localTreeHashes({ runner, cwd, paths }) {
|
|
|
160
181
|
);
|
|
161
182
|
}
|
|
162
183
|
}
|
|
184
|
+
if (trustIndexModes) {
|
|
185
|
+
const modes = (res) =>
|
|
186
|
+
new Map(
|
|
187
|
+
(res.status === 0 ? res.stdout.split("\0") : [])
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.map((e) => [e.slice(e.indexOf("\t") + 1), e.slice(0, 6)]),
|
|
190
|
+
);
|
|
191
|
+
const inReal = modes(real("ls-files", "-s", "-z", "--", path));
|
|
192
|
+
const inTemp = modes(git("ls-files", "-s", "-z", "--", path));
|
|
193
|
+
// Both directions: the real index may have gained or lost the bit.
|
|
194
|
+
for (const [want, flag] of [
|
|
195
|
+
[EXECUTABLE_MODE, "--chmod=+x"],
|
|
196
|
+
[REGULAR_MODE, "--chmod=-x"],
|
|
197
|
+
]) {
|
|
198
|
+
const fix = [...inReal]
|
|
199
|
+
.filter(
|
|
200
|
+
([f, mode]) => mode === want && MODES.has(inTemp.get(f)) && inTemp.get(f) !== want,
|
|
201
|
+
)
|
|
202
|
+
.map(([f]) => f);
|
|
203
|
+
for (let i = 0; i < fix.length; i += 200) {
|
|
204
|
+
const chmod = git("update-index", flag, "--", ...fix.slice(i, i + 200));
|
|
205
|
+
if (chmod.status !== 0) {
|
|
206
|
+
throw new LocalTreeError(
|
|
207
|
+
`git update-index ${flag} failed: ${firstLine(chmod.stderr)}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
163
213
|
const inIndex = git("ls-files", "-z", "--", path);
|
|
164
214
|
const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
|
|
165
215
|
if (staged.length === 0) {
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// What `init` and `upgrade` share for changing a Consumer's checkout: path
|
|
2
|
+
// checks, the uncommitted-changes check, reading Kit-owned files from a tag,
|
|
3
|
+
// and carrying out a plan. Both commands plan first and write second: every
|
|
4
|
+
// refusal (a Refusal) is raised while planning, before anything is written.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { firstLine } from "./exec.mjs";
|
|
18
|
+
import { EXECUTABLE_MODE } from "./kit-tag.mjs";
|
|
19
|
+
import { fileModeIsOff, gitSafetyArgs } from "./local-tree.mjs";
|
|
20
|
+
import { checkSafePath } from "./safe-paths.mjs";
|
|
21
|
+
|
|
22
|
+
/** A reason not to start. Nothing has been written when this is thrown. */
|
|
23
|
+
export class Refusal extends Error {
|
|
24
|
+
constructor(message, details = []) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.details = details;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The most items a plan or a refusal lists before "…". */
|
|
31
|
+
export const MAX_LISTED = 20;
|
|
32
|
+
|
|
33
|
+
/** `items`, cut to MAX_LISTED with a closing "…" when there are more. */
|
|
34
|
+
export function listed(items) {
|
|
35
|
+
return items.length > MAX_LISTED ? [...items.slice(0, MAX_LISTED), "…"] : items;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Print a refusal and its details; the exit code is 1. */
|
|
39
|
+
export function reportRefusal(out, err) {
|
|
40
|
+
out(`REFUSED ${err.message}`);
|
|
41
|
+
for (const d of err.details ?? []) out(` ${d}`);
|
|
42
|
+
out("Nothing was changed.");
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function lstatOrNull(path) {
|
|
47
|
+
try {
|
|
48
|
+
return lstatSync(path);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR") return null;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refuse unless `rel` (a repository-relative path, no trailing slash) can be
|
|
57
|
+
* deleted and written without leaving `root`: it must be a safe path (the
|
|
58
|
+
* strict rules for names that come from a tag), no part of it may be a
|
|
59
|
+
* symbolic link or junction, and every existing parent must be a folder.
|
|
60
|
+
*/
|
|
61
|
+
export function checkLocalPath(root, rel, { deleted = [] } = {}) {
|
|
62
|
+
try {
|
|
63
|
+
checkSafePath(rel);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Refusal(err.message);
|
|
66
|
+
}
|
|
67
|
+
checkNoLinks(root, rel, { deleted });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Refuse unless no part of `rel` is a symbolic link or junction and every
|
|
72
|
+
* existing parent is a folder. For the Consumer's own files, rewritten in
|
|
73
|
+
* place, whose names need not follow the strict tag rules.
|
|
74
|
+
*
|
|
75
|
+
* `deleted` lists folders the plan deletes before it writes: what is on disk
|
|
76
|
+
* inside them now (a file the new tag turns into a folder, say) is gone by
|
|
77
|
+
* the time `rel` is written, so the walk stops there.
|
|
78
|
+
*/
|
|
79
|
+
export function checkNoLinks(root, rel, { deleted = [] } = {}) {
|
|
80
|
+
const parts = rel.split("/");
|
|
81
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
82
|
+
const sub = parts.slice(0, i).join("/");
|
|
83
|
+
if (deleted.includes(sub)) return;
|
|
84
|
+
const st = lstatOrNull(join(root, sub));
|
|
85
|
+
if (!st) return;
|
|
86
|
+
if (st.isSymbolicLink()) {
|
|
87
|
+
throw new Refusal(
|
|
88
|
+
`${sub} is a symbolic link or junction; the CLI does not delete or write through links. ` +
|
|
89
|
+
`Replace it with a plain folder or file, then run the command again.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (i < parts.length && !st.isDirectory()) {
|
|
93
|
+
throw new Refusal(`${sub} is a file, but ${rel} needs it to be a folder.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Links inside `rel`: never followed or guessed at. */
|
|
99
|
+
function linksUnder(root, rel) {
|
|
100
|
+
const found = [];
|
|
101
|
+
const walk = (sub) => {
|
|
102
|
+
const st = lstatOrNull(join(root, sub));
|
|
103
|
+
if (!st) return;
|
|
104
|
+
if (st.isSymbolicLink()) found.push(sub);
|
|
105
|
+
else if (st.isDirectory()) {
|
|
106
|
+
for (const name of readdirSync(join(root, sub)).sort()) walk(`${sub}/${name}`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(rel);
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Refuse unless folder `rel` can be deleted whole: checkLocalPath, and no link inside it. */
|
|
114
|
+
export function checkDeletable(root, rel) {
|
|
115
|
+
checkLocalPath(root, rel);
|
|
116
|
+
const links = linksUnder(root, rel);
|
|
117
|
+
if (links.length) {
|
|
118
|
+
throw new Refusal(
|
|
119
|
+
`${rel}/ holds a symbolic link or junction, which the kit never ships: ${links[0]}. ` +
|
|
120
|
+
`Remove it by hand, then run the command again.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A temporary empty hooks folder and the safe git options for `root`; call done(). */
|
|
126
|
+
function safeGit(runner, root) {
|
|
127
|
+
const tmp = mkdtempSync(join(tmpdir(), "agent-kit-git-"));
|
|
128
|
+
const noHooks = join(tmp, "no-hooks");
|
|
129
|
+
mkdirSync(noHooks);
|
|
130
|
+
try {
|
|
131
|
+
return {
|
|
132
|
+
args: ["-C", root, ...gitSafetyArgs({ runner, root, noHooks }), "--literal-pathspecs"],
|
|
133
|
+
done: () => rmSync(tmp, { recursive: true, force: true }),
|
|
134
|
+
};
|
|
135
|
+
} catch (err) {
|
|
136
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Files under `paths` with uncommitted changes (modified, staged, deleted or
|
|
143
|
+
* untracked), as "XY path", and, apart, the ignored files and folders there
|
|
144
|
+
* (deleting the paths deletes those too). git runs no hooks, fsmonitor or
|
|
145
|
+
* filter drivers, and takes no optional locks.
|
|
146
|
+
*
|
|
147
|
+
* @returns {{ changed: string[], ignored: string[] }}
|
|
148
|
+
*/
|
|
149
|
+
export function uncommittedChanges({ runner, root, paths }) {
|
|
150
|
+
const git = safeGit(runner, root);
|
|
151
|
+
try {
|
|
152
|
+
const r = runner("git", [
|
|
153
|
+
...git.args,
|
|
154
|
+
"--no-optional-locks",
|
|
155
|
+
"status",
|
|
156
|
+
"--porcelain=v1",
|
|
157
|
+
"-z",
|
|
158
|
+
"--untracked-files=all",
|
|
159
|
+
"--ignored=matching",
|
|
160
|
+
"--ignore-submodules=all",
|
|
161
|
+
"--no-renames",
|
|
162
|
+
"--",
|
|
163
|
+
...paths,
|
|
164
|
+
]);
|
|
165
|
+
if (r.status !== 0) throw new Refusal(`git status failed: ${firstLine(r.stderr)}`);
|
|
166
|
+
const entries = r.stdout.split("\0").filter(Boolean);
|
|
167
|
+
// git status cannot see local edits to files marked assume-unchanged
|
|
168
|
+
// (a lower-case tag) or skip-worktree (S): count those as changed.
|
|
169
|
+
const flagged = runner("git", [...git.args, "ls-files", "-v", "-z", "--", ...paths]);
|
|
170
|
+
if (flagged.status !== 0)
|
|
171
|
+
throw new Refusal(`git ls-files failed: ${firstLine(flagged.stderr)}`);
|
|
172
|
+
const hidden = flagged.stdout
|
|
173
|
+
.split("\0")
|
|
174
|
+
.filter((e) => /^(?:[a-z]|S) /.test(e))
|
|
175
|
+
.map(
|
|
176
|
+
(e) =>
|
|
177
|
+
`${e[0]} ${e.slice(2)} (marked ${e[0] === "S" ? "skip-worktree" : "assume-unchanged"})`,
|
|
178
|
+
);
|
|
179
|
+
return {
|
|
180
|
+
changed: [
|
|
181
|
+
...entries
|
|
182
|
+
.filter((e) => !e.startsWith("!!"))
|
|
183
|
+
.map((e) => `${e.slice(0, 2).trim() || "?"} ${e.slice(3)}`),
|
|
184
|
+
...hidden,
|
|
185
|
+
],
|
|
186
|
+
ignored: entries.filter((e) => e.startsWith("!!")).map((e) => e.slice(3)),
|
|
187
|
+
};
|
|
188
|
+
} finally {
|
|
189
|
+
git.done();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every file of every Kit-owned path at the tag, with its bytes, ready to
|
|
195
|
+
* write. Refuses a listed path with no files.
|
|
196
|
+
*
|
|
197
|
+
* @returns {{ path: string, mode: string, bytes: Buffer }[]}
|
|
198
|
+
*/
|
|
199
|
+
export function kitFileWrites(snap) {
|
|
200
|
+
const writes = [];
|
|
201
|
+
for (const path of snap.kitOwnedPaths) {
|
|
202
|
+
const listed = snap.listFiles(path);
|
|
203
|
+
if (listed.length === 0) {
|
|
204
|
+
throw new Refusal(
|
|
205
|
+
`${snap.tag}'s kit-owned-paths.txt lists ${path}, but the tag has no files there.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
for (const f of listed) {
|
|
209
|
+
writes.push({ path: f.path, mode: f.mode, bytes: snap.readBlob(f.oid) });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return writes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Carry out a plan: delete each folder in `deletes`, write each file in
|
|
217
|
+
* `writes` (paths from a tag), then each file in `rewrites` (the Consumer's
|
|
218
|
+
* own files, rewritten in place, whose names need not follow the tag rules).
|
|
219
|
+
* Only file-system errors can stop it part-way.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} root
|
|
222
|
+
* @param {{ deletes?: string[], writes: { path: string, mode?: string, bytes: Buffer | string }[], rewrites?: { path: string, bytes: Buffer | string }[] }} plan
|
|
223
|
+
*/
|
|
224
|
+
export function applyPlan(root, { deletes = [], writes, rewrites = [] }) {
|
|
225
|
+
// Checked again here: time may have passed at the confirmation prompt.
|
|
226
|
+
for (const rel of deletes) checkDeletable(root, rel);
|
|
227
|
+
for (const w of writes) checkLocalPath(root, w.path, { deleted: deletes });
|
|
228
|
+
for (const w of rewrites) checkNoLinks(root, w.path);
|
|
229
|
+
for (const rel of deletes) rmSync(join(root, rel), { recursive: true, force: true });
|
|
230
|
+
for (const w of [...writes, ...rewrites]) {
|
|
231
|
+
const target = join(root, w.path);
|
|
232
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
233
|
+
const executable = w.mode === EXECUTABLE_MODE;
|
|
234
|
+
writeFileSync(target, w.bytes, { mode: executable ? 0o755 : 0o644 });
|
|
235
|
+
if (executable) chmodSync(target, 0o755);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Without core.fileMode (the default on Windows) git cannot see an
|
|
241
|
+
* executable bit on disk, and `git add` keeps the mode the index already
|
|
242
|
+
* has (100644 for a new file). So stage the tag's mode: +x for each
|
|
243
|
+
* executable file written, and -x for each regular file the index still
|
|
244
|
+
* records as executable. Returns a line to print, or null when nothing was
|
|
245
|
+
* needed.
|
|
246
|
+
*
|
|
247
|
+
* @param {{ runner: import("./exec.mjs").Runner, root: string, writes: { path: string, mode?: string }[] }} opts
|
|
248
|
+
*/
|
|
249
|
+
export function stageFileModes({ runner, root, writes }) {
|
|
250
|
+
if (writes.length === 0 || !fileModeIsOff(runner, root)) return null;
|
|
251
|
+
const git = safeGit(runner, root);
|
|
252
|
+
try {
|
|
253
|
+
const failed = [];
|
|
254
|
+
const add = writes.filter((w) => w.mode === EXECUTABLE_MODE).map((w) => w.path);
|
|
255
|
+
const regular = writes.filter((w) => w.mode !== EXECUTABLE_MODE).map((w) => w.path);
|
|
256
|
+
const regularSet = new Set(regular);
|
|
257
|
+
const drop = [];
|
|
258
|
+
for (const batch of batches(regular)) {
|
|
259
|
+
const indexed = runner("git", [...git.args, "ls-files", "-s", "-z", "--", ...batch]);
|
|
260
|
+
if (indexed.status !== 0) {
|
|
261
|
+
failed.push(`--chmod=-x (git ls-files: ${firstLine(indexed.stderr)}): ${batch.join(", ")}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
drop.push(
|
|
265
|
+
...indexed.stdout
|
|
266
|
+
.split("\0")
|
|
267
|
+
.filter((e) => e.startsWith(`${EXECUTABLE_MODE} `))
|
|
268
|
+
.map((e) => e.slice(e.indexOf("\t") + 1))
|
|
269
|
+
.filter((f) => regularSet.has(f)),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
for (const [flag, files] of [
|
|
273
|
+
["--chmod=+x", add],
|
|
274
|
+
["--chmod=-x", drop],
|
|
275
|
+
]) {
|
|
276
|
+
for (const batch of batches(files)) {
|
|
277
|
+
// --sparse: a path outside a sparse-checkout cone is refused without
|
|
278
|
+
// it. git before 2.34 has no --sparse; it then gets a plain add.
|
|
279
|
+
let r = runner("git", [...git.args, "add", "--sparse", flag, "--", ...batch]);
|
|
280
|
+
if (r.status !== 0 && /unknown option|usage: git add/i.test(r.stderr)) {
|
|
281
|
+
r = runner("git", [...git.args, "add", flag, "--", ...batch]);
|
|
282
|
+
}
|
|
283
|
+
if (r.status !== 0) failed.push(`${flag} (${firstLine(r.stderr)}): ${batch.join(", ")}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (failed.length) {
|
|
287
|
+
return (
|
|
288
|
+
"Could not stage the tag's file modes; after `git add`, run " +
|
|
289
|
+
`\`git update-index <flag> -- <file>\` for: ${failed.join("; ")}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (add.length + drop.length === 0) return null;
|
|
293
|
+
return (
|
|
294
|
+
`Staged the tag's file mode for ${add.length + drop.length} file(s) ` +
|
|
295
|
+
"(core.fileMode is off here, so git cannot see it on disk)."
|
|
296
|
+
);
|
|
297
|
+
} finally {
|
|
298
|
+
git.done();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Command-line-sized slices of a file list. */
|
|
303
|
+
function batches(files, size = 200) {
|
|
304
|
+
const out = [];
|
|
305
|
+
for (let i = 0; i < files.length; i += size) out.push(files.slice(i, i + size));
|
|
306
|
+
return out;
|
|
307
|
+
}
|