@mnaimfaizy/agent-kit 0.0.0-stage → 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 +21 -2
- package/bin/agent-kit.mjs +13 -0
- package/package.json +29 -4
- package/src/callers.mjs +204 -0
- package/src/checks/claude-app.mjs +55 -0
- package/src/checks/kit-owned-trees.mjs +78 -0
- package/src/checks/labels.mjs +100 -0
- package/src/checks/pins.mjs +76 -0
- package/src/checks/result.mjs +74 -0
- package/src/checks/rulesets.mjs +346 -0
- package/src/checks/secrets-and-variables.mjs +252 -0
- package/src/cli.mjs +229 -0
- package/src/doctor.mjs +405 -0
- package/src/exec.mjs +64 -0
- package/src/github.mjs +88 -0
- package/src/init.mjs +627 -0
- package/src/kit-owned-paths.mjs +48 -0
- package/src/kit-tag.mjs +257 -0
- package/src/local-tree.mjs +240 -0
- package/src/local-writes.mjs +307 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +55 -0
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
- package/src/workflow-call.mjs +44 -0
- package/src/yaml-subset.mjs +118 -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
|
+
}
|
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
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Release-line tags: vX.Y.Z or vX.Y.Z-(alpha|beta|rc).N, the same shape
|
|
2
|
+
// scripts/verify_release_snapshot.py accepts. The npm package version is the
|
|
3
|
+
// tag without its leading v.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
export const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/;
|
|
8
|
+
|
|
9
|
+
/** The first tag published to npm; older tags have no matching package version. */
|
|
10
|
+
export const FIRST_NPM_TAG = "v1.0.0-alpha.12";
|
|
11
|
+
|
|
12
|
+
/** The first tag that ships kit-owned-paths.txt. */
|
|
13
|
+
export const FIRST_MANIFEST_TAG = "v1.0.0-alpha.11";
|
|
14
|
+
|
|
15
|
+
export const PACKAGE_NAME = "@mnaimfaizy/agent-kit";
|
|
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
|
+
|
|
22
|
+
const STAGE_RANK = { alpha: 0, beta: 1, rc: 2, "": 3 };
|
|
23
|
+
|
|
24
|
+
export function isReleaseTag(tag) {
|
|
25
|
+
return typeof tag === "string" && TAG_PATTERN.test(tag);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function key(tag) {
|
|
29
|
+
const m = TAG_PATTERN.exec(tag);
|
|
30
|
+
if (!m) throw new Error(`not a Release-line tag: ${tag}`);
|
|
31
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), STAGE_RANK[m[4] ?? ""], Number(m[5] ?? 0)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Negative if a < b, 0 if equal, positive if a > b. */
|
|
35
|
+
export function compareTags(a, b) {
|
|
36
|
+
const ka = key(a);
|
|
37
|
+
const kb = key(b);
|
|
38
|
+
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function versionForTag(tag) {
|
|
43
|
+
key(tag);
|
|
44
|
+
return tag.slice(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
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}`;
|
|
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
|
+
}
|