@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-alpha.12

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,190 @@
1
+ // Git tree hashes of paths in the local working copy, as they would be if the
2
+ // working copy were committed now (tracked and untracked files, minus ignored
3
+ // ones, after git's own line-ending rules).
4
+ //
5
+ // Read-only: git runs against a temporary index built from HEAD, and writes
6
+ // any new objects to a temporary object directory that borrows the
7
+ // repository's objects through objects/info/alternates. The Consumer's index,
8
+ // object store and refs are never written. Because the temporary index holds
9
+ // only HEAD plus the Kit-owned paths, a merge conflict elsewhere in the
10
+ // repository does not get in the way.
11
+ //
12
+ // git runs no hooks (core.hooksPath is an empty folder), no fsmonitor, and no
13
+ // configured clean/process filter drivers, so hashing a checkout runs no
14
+ // command that the checkout's or the adopter's git config names.
15
+
16
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { isAbsolute, join, resolve } from "node:path";
19
+ import { firstLine } from "./exec.mjs";
20
+
21
+ const SAFE_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/$/;
22
+
23
+ export const SKIP_WORKTREE_REASON =
24
+ "some of its files are marked skip-worktree (for example by a sparse checkout) and HEAD has no copy of them to compare";
25
+ export const HEAD_COPY_NOTE =
26
+ "its files are marked skip-worktree (for example by a sparse checkout), so the copy committed at HEAD was compared";
27
+
28
+ export class LocalTreeError extends Error {}
29
+
30
+ /** `filter.<driver>.<key>` config names → the driver names. */
31
+ export function filterDrivers(configNames) {
32
+ const drivers = new Set();
33
+ for (const raw of configNames) {
34
+ const m = /^filter\.(.+)\.[^.]+$/i.exec(raw.trim());
35
+ if (m) drivers.add(m[1]);
36
+ }
37
+ return [...drivers];
38
+ }
39
+
40
+ /**
41
+ * @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> }}
43
+ * 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.
45
+ */
46
+ export function localTreeHashes({ runner, cwd, paths }) {
47
+ for (const p of paths) {
48
+ if (!SAFE_PATH.test(p) || p.split("/").includes("..")) {
49
+ throw new LocalTreeError(`refusing to hash ${JSON.stringify(p)}`);
50
+ }
51
+ }
52
+ const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
53
+ if (top.status !== 0) throw new LocalTreeError(`not a git repository: ${firstLine(top.stderr)}`);
54
+ const root = top.stdout.trim();
55
+ // --git-common-dir (not --path-format=absolute, which needs git 2.31) may
56
+ // be relative to `root`; the objects of a linked worktree live there too.
57
+ const common = runner("git", ["-C", root, "rev-parse", "--git-common-dir"]);
58
+ if (common.status !== 0) {
59
+ throw new LocalTreeError(`git rev-parse failed: ${firstLine(common.stderr)}`);
60
+ }
61
+ const commonDir = common.stdout.trim();
62
+ const realObjects = join(isAbsolute(commonDir) ? commonDir : resolve(root, commonDir), "objects");
63
+ if (/[\r\n]/.test(realObjects) || realObjects.startsWith('"')) {
64
+ throw new LocalTreeError("the repository path cannot be written to an alternates file");
65
+ }
66
+
67
+ const tmp = mkdtempSync(join(tmpdir(), "agent-kit-tree-"));
68
+ try {
69
+ const index = join(tmp, "index");
70
+ const objects = join(tmp, "objects");
71
+ const noHooks = join(tmp, "no-hooks");
72
+ mkdirSync(join(objects, "info"), { recursive: true });
73
+ mkdirSync(noHooks);
74
+ // An alternates file, not GIT_ALTERNATE_OBJECT_DIRECTORIES: that variable
75
+ // splits on ':' on POSIX, so a path containing one would break it.
76
+ writeFileSync(join(objects, "info", "alternates"), `${realObjects}\n`);
77
+
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
+ }
101
+ const base = ["-C", root, ...safety, "--literal-pathspecs"];
102
+ const env = { GIT_INDEX_FILE: index, GIT_OBJECT_DIRECTORY: objects };
103
+ const git = (...args) => runner("git", [...base, ...args], { env });
104
+ const real = (...args) => runner("git", [...base, ...args]);
105
+
106
+ const head = real("rev-parse", "--verify", "--quiet", "HEAD^{commit}");
107
+ // HEAD gives tracked files their recorded modes (core.fileMode=false on
108
+ // Windows would otherwise drop executable bits).
109
+ if (head.status === 0) {
110
+ const read = git("read-tree", "HEAD");
111
+ if (read.status !== 0) {
112
+ throw new LocalTreeError(`git read-tree HEAD failed: ${firstLine(read.stderr)}`);
113
+ }
114
+ }
115
+
116
+ const hashes = new Map();
117
+ const skipped = new Map();
118
+ const notes = new Map();
119
+ for (const path of paths) {
120
+ // The real index (read only) knows skip-worktree entries (`S`), which
121
+ // have no files on disk. Everything else comes from the working copy
122
+ // (`git add`); only the S files are then reset to HEAD's copy, so local
123
+ // edits and new files elsewhere in the path still count.
124
+ const tags = real("ls-files", "-t", "-z", "--", path);
125
+ const entries = tags.status === 0 ? tags.stdout.split("\0").filter(Boolean) : [];
126
+ const skipFiles = entries.filter((e) => e.startsWith("S ")).map((e) => e.slice(2));
127
+ if (skipFiles.length) {
128
+ const inHead =
129
+ head.status === 0
130
+ ? real("ls-tree", "-r", "-z", "--name-only", "HEAD", "--", path)
131
+ : { status: 1, stdout: "" };
132
+ const committed = new Set(inHead.stdout.split("\0").filter(Boolean));
133
+ if (inHead.status !== 0 || skipFiles.some((f) => !committed.has(f))) {
134
+ skipped.set(path, SKIP_WORKTREE_REASON);
135
+ continue;
136
+ }
137
+ }
138
+ // --sparse: in a sparse checkout, a path outside the cone may still have
139
+ // stray files on disk, and plain `git add` refuses to touch it. git
140
+ // before 2.34 has no --sparse; it then gets a plain add.
141
+ let add = git("add", "--all", "--sparse", "--", path);
142
+ if (add.status !== 0 && /unknown option|usage: git add/i.test(add.stderr)) {
143
+ add = git("add", "--all", "--", path);
144
+ }
145
+ // Two refusals that still stage what can be staged, then exit 1: files
146
+ // force-added under an ignored folder, and (old git) a path outside
147
+ // the sparse-checkout definition.
148
+ const benign =
149
+ /ignored by one of your \.gitignore files|outside of your sparse-checkout definition/i.test(
150
+ add.stderr,
151
+ );
152
+ if (add.status !== 0 && !benign && existsSync(join(root, path))) {
153
+ throw new LocalTreeError(`git add ${path} failed: ${firstLine(add.stderr)}`);
154
+ }
155
+ for (let i = 0; i < skipFiles.length; i += 200) {
156
+ const reset = git("reset", "--quiet", "HEAD", "--", ...skipFiles.slice(i, i + 200));
157
+ if (reset.status !== 0) {
158
+ throw new LocalTreeError(
159
+ `git reset of skip-worktree files failed: ${firstLine(reset.stderr)}`,
160
+ );
161
+ }
162
+ }
163
+ const inIndex = git("ls-files", "-z", "--", path);
164
+ const staged = inIndex.status === 0 ? inIndex.stdout.split("\0").filter(Boolean) : [];
165
+ if (staged.length === 0) {
166
+ hashes.set(path, null);
167
+ continue;
168
+ }
169
+ if (skipFiles.length) {
170
+ // The HEAD-only wording only when nothing came from the working copy.
171
+ notes.set(
172
+ path,
173
+ staged.length === skipFiles.length
174
+ ? HEAD_COPY_NOTE
175
+ : `${skipFiles.length} of its ${staged.length} files are marked skip-worktree and were compared as committed at HEAD; the rest came from the working copy`,
176
+ );
177
+ }
178
+ const tree = git("write-tree", `--prefix=${path}`);
179
+ if (tree.status !== 0) {
180
+ throw new LocalTreeError(
181
+ `git write-tree --prefix=${path} failed: ${firstLine(tree.stderr)}`,
182
+ );
183
+ }
184
+ hashes.set(path, tree.stdout.trim());
185
+ }
186
+ return { root, hashes, skipped, notes };
187
+ } finally {
188
+ rmSync(tmp, { recursive: true, force: true });
189
+ }
190
+ }
package/src/tags.mjs ADDED
@@ -0,0 +1,45 @@
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
+ export const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/;
6
+
7
+ /** The first tag published to npm; older tags have no matching package version. */
8
+ export const FIRST_NPM_TAG = "v1.0.0-alpha.12";
9
+
10
+ /** The first tag that ships kit-owned-paths.txt. */
11
+ export const FIRST_MANIFEST_TAG = "v1.0.0-alpha.11";
12
+
13
+ export const PACKAGE_NAME = "@mnaimfaizy/agent-kit";
14
+
15
+ const STAGE_RANK = { alpha: 0, beta: 1, rc: 2, "": 3 };
16
+
17
+ export function isReleaseTag(tag) {
18
+ return typeof tag === "string" && TAG_PATTERN.test(tag);
19
+ }
20
+
21
+ function key(tag) {
22
+ const m = TAG_PATTERN.exec(tag);
23
+ if (!m) throw new Error(`not a Release-line tag: ${tag}`);
24
+ return [Number(m[1]), Number(m[2]), Number(m[3]), STAGE_RANK[m[4] ?? ""], Number(m[5] ?? 0)];
25
+ }
26
+
27
+ /** Negative if a < b, 0 if equal, positive if a > b. */
28
+ export function compareTags(a, b) {
29
+ const ka = key(a);
30
+ const kb = key(b);
31
+ for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
32
+ return 0;
33
+ }
34
+
35
+ export function versionForTag(tag) {
36
+ key(tag);
37
+ return tag.slice(1);
38
+ }
39
+
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}`;
45
+ }
@@ -0,0 +1,44 @@
1
+ // Reads a reusable workflow's `on.workflow_call.secrets` block: which secrets
2
+ // it declares and which of them are required.
3
+
4
+ import { parseYamlSubset } from "./yaml-subset.mjs";
5
+
6
+ /** @returns {Map<string, { required: boolean }> | null} null when the file is not a reusable workflow. */
7
+ export function parseWorkflowCallSecrets(text) {
8
+ const doc = parseYamlSubset(text);
9
+ const on = doc.on ?? doc["'on'"] ?? doc['"on"'];
10
+ if (!on || typeof on !== "object" || !("workflow_call" in on)) return null;
11
+ const call = on.workflow_call && typeof on.workflow_call === "object" ? on.workflow_call : {};
12
+ const secrets =
13
+ call.secrets && typeof call.secrets === "object" && !call.secrets.$seq ? call.secrets : {};
14
+ const out = new Map();
15
+ for (const [name, spec] of Object.entries(secrets)) {
16
+ const required =
17
+ spec && typeof spec === "object" ? String(spec.required).toLowerCase() === "true" : false;
18
+ out.set(name.toLowerCase(), { name, required });
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /**
24
+ * A reusable workflow's `on.workflow_call.inputs`: each input's default (or
25
+ * null) and whether it is required.
26
+ * @returns {Map<string, { default: string | null, required: boolean }> | null}
27
+ */
28
+ export function parseWorkflowCallInputs(text) {
29
+ const doc = parseYamlSubset(text);
30
+ const on = doc.on ?? doc["'on'"] ?? doc['"on"'];
31
+ if (!on || typeof on !== "object" || !("workflow_call" in on)) return null;
32
+ const call = on.workflow_call && typeof on.workflow_call === "object" ? on.workflow_call : {};
33
+ const inputs =
34
+ call.inputs && typeof call.inputs === "object" && !call.inputs.$seq ? call.inputs : {};
35
+ const out = new Map();
36
+ for (const [name, spec] of Object.entries(inputs)) {
37
+ const obj = spec && typeof spec === "object" ? spec : {};
38
+ out.set(name, {
39
+ default: typeof obj.default === "string" ? obj.default : null,
40
+ required: String(obj.required).toLowerCase() === "true",
41
+ });
42
+ }
43
+ return out;
44
+ }
@@ -0,0 +1,118 @@
1
+ // A small, dependency-free reader for the YAML that workflow files use: block
2
+ // mappings, plain and quoted scalars, block scalars (| and >), and sequences.
3
+ // It returns nested plain objects. Sequences come back as { $seq: rawLines }
4
+ // and block scalars as strings; flow collections ([a, b], {a: b}) stay as
5
+ // their raw text. That is enough to read a Caller's jobs and a reusable
6
+ // workflow's `on.workflow_call.secrets`, which is all the CLI needs.
7
+
8
+ const BOM = String.fromCharCode(0xfeff);
9
+
10
+ /** Drop a leading UTF-8 byte-order mark, which would otherwise hide the first key. */
11
+ export function stripBom(text) {
12
+ const s = String(text);
13
+ return s.startsWith(BOM) ? s.slice(1) : s;
14
+ }
15
+
16
+ /** Remove a trailing `# comment` that sits outside quotes. */
17
+ export function stripComment(line) {
18
+ let quote = null;
19
+ for (let i = 0; i < line.length; i++) {
20
+ const c = line[i];
21
+ if (quote) {
22
+ if (c === quote) quote = null;
23
+ continue;
24
+ }
25
+ if (c === '"' || c === "'") quote = c;
26
+ else if (c === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
27
+ }
28
+ return line;
29
+ }
30
+
31
+ export function parseScalar(raw) {
32
+ const v = raw.trim();
33
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
34
+ return v.slice(1, -1).replace(/\\(["\\])/g, "$1");
35
+ }
36
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
37
+ return v.slice(1, -1).replace(/''/g, "'");
38
+ }
39
+ return v;
40
+ }
41
+
42
+ const KEY = /^("[^"]*"|'[^']*'|[^\s"'#:-][^:]*?|-[^\s:][^:]*?):(?:\s+(.*))?$/;
43
+
44
+ export function parseYamlSubset(text) {
45
+ const lines = stripBom(text).replace(/\r\n?/g, "\n").split("\n");
46
+ const indentOf = (l) => l.length - l.trimStart().length;
47
+ const isBlank = (l) => stripComment(l).trim() === "";
48
+
49
+ function nextContent(i) {
50
+ while (i < lines.length && isBlank(lines[i])) i++;
51
+ return i;
52
+ }
53
+
54
+ function parseMapping(start, parentIndent) {
55
+ const obj = {};
56
+ let i = start;
57
+ let childIndent = null;
58
+ while (i < lines.length) {
59
+ if (isBlank(lines[i])) {
60
+ i++;
61
+ continue;
62
+ }
63
+ const ind = indentOf(lines[i]);
64
+ if (ind <= parentIndent) break;
65
+ if (childIndent === null) childIndent = ind;
66
+ if (ind < childIndent) break;
67
+ const content = stripComment(lines[i].slice(ind)).trimEnd();
68
+ if (ind > childIndent || content.startsWith("- ") || content === "-") {
69
+ i++;
70
+ continue;
71
+ }
72
+ const m = KEY.exec(content);
73
+ i++;
74
+ if (!m) continue;
75
+ const key = parseScalar(m[1]);
76
+ const value = (m[2] ?? "").trim();
77
+ if (value === "") {
78
+ const j = nextContent(i);
79
+ if (
80
+ j < lines.length &&
81
+ stripComment(lines[j]).trimStart().startsWith("-") &&
82
+ indentOf(lines[j]) >= childIndent
83
+ ) {
84
+ const raw = [];
85
+ while (i < lines.length) {
86
+ const l = lines[i];
87
+ if (!isBlank(l)) {
88
+ const li = indentOf(l);
89
+ const starts = l.trimStart().startsWith("-");
90
+ if (li < childIndent || (li === childIndent && !starts)) break;
91
+ }
92
+ raw.push(l);
93
+ i++;
94
+ }
95
+ obj[key] = { $seq: raw };
96
+ } else if (j < lines.length && indentOf(lines[j]) > childIndent) {
97
+ const [sub, next] = parseMapping(j, childIndent);
98
+ obj[key] = sub;
99
+ i = next;
100
+ } else {
101
+ obj[key] = null;
102
+ }
103
+ } else if (/^[|>][-+0-9]*$/.test(value)) {
104
+ const raw = [];
105
+ while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > childIndent)) {
106
+ raw.push(lines[i]);
107
+ i++;
108
+ }
109
+ obj[key] = raw.join("\n");
110
+ } else {
111
+ obj[key] = parseScalar(value);
112
+ }
113
+ }
114
+ return [obj, i];
115
+ }
116
+
117
+ return parseMapping(0, -1)[0];
118
+ }