graftfork 0.1.0 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +20 -1
  2. package/graft.mjs +356 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,26 @@ graft connect you/project # connect a repository Graft's GitHub app is i
15
15
  graft sync # start a sync now (inside the clone, or pass the copy)
16
16
  graft why # why the copy stayed, and what to do next
17
17
  graft pull # fetch the latest move as graft/<release>; your branch is untouched
18
+ graft upstream "fix the parser" # Graft builds that one change alone on your release and runs your tests;
19
+ # this fetches graft/upstream/<change>. Nothing is pushed: you open the PR
20
+ graft upstream-status <build> # an upstream build's status, tests and links
21
+ graft upstream-pr <build> <url> # the pull request you opened; Graft tells you when it's merged
22
+ graft upgrade <dependency> # upgrade one dependency (npm or Python), delivered only if the tests pass
23
+ graft upgrades # your upgrades and what their tests said
24
+ graft check <base> <head> --wait # check a change against the project's own tests: exit 0 pass, 1 fail, 2 couldn't run
25
+ graft checks # your recent change checks
26
+ graft verify-record <file> # check a signed run record or agent change record; no token needed
27
+ graft agent-record <org> # an organization's signed agent change record, as JSON
28
+ graft regen connect <repo> ... # connect a spec (a duramen record); Graft rebuilds its implementation blind
29
+ graft regen run <spec> # rebuild it now
30
+ graft regen status [id] # your specs, a spec's rebuilds, or one rebuild and how to take it
31
+ graft regen report <spec> # the spec's grade and why, before anything is spent
18
32
  ```
19
33
 
20
- Also `graft whoami`, `graft status` and `graft usage`; `--json` prints the API's answers as they are. API docs:
34
+ Also `graft whoami`, `graft status` and `graft usage`; `--json` prints the API's answers as they are, and
35
+ `graft --help` lists every command with its options.
36
+
37
+ Which version has what: 0.1.0 had copies, connect, sync, status, why, pull, usage, whoami and verify-record; 0.1.1
38
+ added agent-record; 0.2.0 added check(s), upgrade(s) and upstream, upstream-status and upstream-pr; 0.3.0 adds
39
+ regen connect, run, status and report. API docs:
21
40
  https://graftfork.com/docs/api. Support by email: support@graftfork.com. Apache-2.0.
package/graft.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/client/cli.ts
4
- import { spawnSync as spawnSync2 } from "node:child_process";
5
- import { readFileSync } from "node:fs";
4
+ import { spawnSync as spawnSync3 } from "node:child_process";
5
+ import { existsSync, readFileSync } from "node:fs";
6
6
  import { parseArgs } from "node:util";
7
7
 
8
8
  // src/pull.ts
@@ -30,7 +30,7 @@ async function pull(o) {
30
30
  let id = o.copy && /^[A-Za-z0-9_-]{8,64}$/.test(o.copy) && !o.copy.includes("/") ? o.copy : null;
31
31
  if (!id) {
32
32
  const repo = o.copy ?? repoOfRemote(git(o.cwd, ["remote", "get-url", "origin"]).stdout ?? "");
33
- if (!repo) return { ok: false, message: "Couldn't tell which copy this is: pass --copy owner/name." };
33
+ if (!repo) return { ok: false, message: "Couldn't tell which copy this is: give its id or owner/name (graft pull <copy>, or --copy <copy>), or run this inside its clone." };
34
34
  const { copies } = await get("/api/v1/copies");
35
35
  const c = copies.find((x) => String(x.repo).toLowerCase() === repo.toLowerCase());
36
36
  if (!c) return { ok: false, message: `${repo} isn't one of your copies on Graft.` };
@@ -53,7 +53,7 @@ async function pull(o) {
53
53
  rmSync(dir, { recursive: true, force: true });
54
54
  }
55
55
  const sha = git(o.cwd, ["rev-parse", "--short", t.branch]).stdout.trim();
56
- return { ok: true, branch: t.branch, message: `Fetched ${t.branch} (${sha}): your changes on ${t.release}. Your branch is untouched.
56
+ return { ok: true, branch: t.branch, message: `Fetched ${t.branch} (${sha}): ${t.release ? `your changes on ${t.release}` : "the branch Graft delivered"}. Your branch is untouched.
57
57
  Switch with: git switch ${t.branch}
58
58
  Or take it into ${t.copy.branch}: git checkout ${t.copy.branch} && git merge --ff-only ${t.branch}` };
59
59
  } catch (e) {
@@ -61,6 +61,69 @@ Or take it into ${t.copy.branch}: git checkout ${t.copy.branch} && git merge --f
61
61
  }
62
62
  }
63
63
 
64
+ // src/client/upstream.ts
65
+ import { spawnSync as spawnSync2 } from "node:child_process";
66
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
67
+ import { tmpdir as tmpdir2 } from "node:os";
68
+ import { join as join2 } from "node:path";
69
+ var git2 = (cwd, args) => spawnSync2("git", args, { cwd, encoding: "utf8" });
70
+ async function upstream(o) {
71
+ const started = await o.call("POST", `/api/v1/copies/${o.copyId}/upstream`, { change: o.change });
72
+ let u = started.upstream;
73
+ if (o.noWait) return { ok: true, message: `${started.message ?? "Building."}
74
+ Build: ${u.id}. Check it with graft upstream-status ${u.id}.` };
75
+ const until = Date.now() + (o.waitMs ?? 30 * 6e4);
76
+ while (u.status === "queued" || u.status === "running") {
77
+ if (Date.now() > until) return { ok: false, message: `Still building after ${Math.round((o.waitMs ?? 30 * 6e4) / 6e4)} minutes. Check it later with graft upstream-status ${u.id}.` };
78
+ await new Promise((r) => setTimeout(r, o.pollMs ?? 1e4));
79
+ u = (await o.call("GET", `/api/v1/upstreams/${u.id}`)).upstream;
80
+ }
81
+ if (u.status !== "built") return { ok: false, message: `Not built: ${u.note ?? "the build failed"}` };
82
+ return take(o, u);
83
+ }
84
+ async function take(o, u) {
85
+ if (!u.downloads?.bundle) return { ok: false, message: `Built ${u.branch}, but Graft doesn't keep it any more. Build it again with graft upstream.` };
86
+ if (git2(o.cwd, ["rev-parse", "--git-dir"]).status !== 0) return { ok: false, message: `Built ${u.branch}. Run this inside your clone of the copy to fetch it, or download it: ${u.downloads.bundle}` };
87
+ const r = await o.fetchImpl(u.downloads.bundle);
88
+ if (!r.ok) return { ok: false, message: `Downloading the branch failed (${r.status}). Try again; graft upstream-status makes a fresh link.` };
89
+ const dir = mkdtempSync2(join2(tmpdir2(), "graft-upstream-"));
90
+ const file = join2(dir, "upstream.bundle");
91
+ try {
92
+ writeFileSync2(file, new Uint8Array(await r.arrayBuffer()));
93
+ const v = git2(o.cwd, ["bundle", "verify", "-q", file]);
94
+ if (v.status !== 0) return { ok: false, message: `This clone is missing what the branch builds on (${v.stderr.trim().split("\n").pop()}). Bring your copy's branch up to date, then run it again.` };
95
+ const f = git2(o.cwd, ["fetch", "-q", file, `+refs/heads/${u.branch}:refs/heads/${u.branch}`]);
96
+ if (f.status !== 0) return { ok: false, message: `git fetch failed: ${f.stderr.trim().split("\n").pop()}` };
97
+ } finally {
98
+ rmSync2(dir, { recursive: true, force: true });
99
+ }
100
+ let saved = "";
101
+ if (o.patchDir && u.downloads.patch) {
102
+ const p = await o.fetchImpl(u.downloads.patch);
103
+ if (p.ok) {
104
+ const path = join2(o.patchDir, `${String(u.branch).split("/").pop()}.patch`);
105
+ writeFileSync2(path, await p.text());
106
+ saved = `
107
+ The patch, for projects that take them by email: ${path}`;
108
+ }
109
+ }
110
+ const t = u.tests ?? {};
111
+ const pr = u.pullRequest ?? {};
112
+ return {
113
+ ok: true,
114
+ branch: u.branch,
115
+ message: [
116
+ `Fetched ${u.branch}: only "${u.change?.subject}" on ${u.release}${t.ran ? `; your tests: ${t.verdict ?? "ran"}` : "; no tests ran"}.${saved}`,
117
+ "Nothing was pushed. To send it, from your account:",
118
+ ...(u.commands ?? []).slice(2).map((c) => ` ${c}`),
119
+ `Pull request title: ${pr.title}`,
120
+ "Pull request text:",
121
+ ...String(pr.body ?? "").split("\n").map((l) => ` ${l}`),
122
+ `Then: graft upstream-pr ${u.id} <the pull request's URL>, and Graft tells you when it's merged.`
123
+ ].join("\n")
124
+ };
125
+ }
126
+
64
127
  // src/client/cli.ts
65
128
  var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run on graftfork.com)
66
129
 
@@ -69,11 +132,31 @@ var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run
69
132
  graft connect <owner/name> connect a repository (Graft's GitHub app must be installed on it)
70
133
  [--branch <b> --upstream <owner/name or URL> --base <release tag>] [--tags <pattern>]
71
134
  graft sync [copy] start a sync now
135
+ [--force] again, though the last sync of this release stopped at the agent's per-sync budget
72
136
  graft status [copy] a copy and its latest runs
73
137
  graft why [copy] why the copy stayed, and what to do next
74
138
  graft pull [copy] fetch the latest move as graft/<release> in your clone
139
+ graft upgrade <dependency> [version] [--copy <copy>]
140
+ upgrade a dependency (npm or Python), delivered only if the tests pass
141
+ graft upgrades [id] your upgrades, or one with what its tests said
142
+ graft upstream <change> [copy] build one change alone on your release, tested; fetch graft/upstream/<change>
143
+ [--patch <dir>] [--no-wait] (nothing is pushed: you open the pull request yourself)
144
+ graft upstream-status <build> an upstream build's status, tests and links
145
+ graft upstream-pr <build> <url> the pull request you opened upstream; Graft checks it until it's merged
75
146
  graft usage this month's syncs and agent work
76
- graft verify-record <file> check a run's signed record (from /app or GET /api/v1/runs/{id}/record)
147
+ graft verify-record <file> check a run's signed record (from /app or GET /api/v1/runs/{id}/record),
148
+ a rebuild's, or an organization's agent change record
149
+ graft verify-record <id> fetch a rebuild's (or a sync's) signed record by its id, with the token, and check it
150
+ graft agent-record <org> an organization's signed agent change record, as JSON
151
+ [--from <YYYY-MM-DD> --to <YYYY-MM-DD>] (save it: graft agent-record <org> > record.json)
152
+ graft check <base> <head> check a change against the project's own tests (no push, no agent)
153
+ [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]
154
+ graft checks [id] your recent change checks, or one check's result
155
+ graft regen connect <owner/name or URL> --branch <b> --record <path> --impl <dir> --lang ts|py|rs|go [--builds 1-3] [--on-push]
156
+ connect a spec (a duramen record): Graft rebuilds its implementation blind
157
+ graft regen run <spec> rebuild it now [--force: even if nothing changed] [--max-usd <n>: its budget]
158
+ graft regen status [id] your specs, a spec's rebuilds, or one rebuild and how to take it
159
+ graft regen report <spec> the spec's grade (strong, usable, thin) and why [--refresh [--yes]]
77
160
 
78
161
  [copy] is a copy id or owner/name; left out, it's this clone's origin remote.
79
162
  Token: GRAFT_TOKEN or --token (make one at https://graftfork.com/app#settings). --json prints raw answers.`;
@@ -84,16 +167,16 @@ async function run(argv, io) {
84
167
  args: argv,
85
168
  allowPositionals: true,
86
169
  strict: false,
87
- options: { token: { type: "string" }, api: { type: "string" }, json: { type: "boolean" }, branch: { type: "string" }, upstream: { type: "string" }, base: { type: "string" }, tags: { type: "string" }, help: { type: "boolean", short: "h" } }
170
+ options: { token: { type: "string" }, api: { type: "string" }, json: { type: "boolean" }, patch: { type: "string" }, "no-wait": { type: "boolean" }, branch: { type: "string" }, upstream: { type: "string" }, base: { type: "string" }, tags: { type: "string" }, from: { type: "string" }, to: { type: "string" }, repo: { type: "string" }, "clone-url": { type: "string" }, pr: { type: "string" }, wait: { type: "boolean" }, copy: { type: "string" }, record: { type: "string" }, impl: { type: "string" }, lang: { type: "string" }, builds: { type: "string" }, "on-push": { type: "boolean" }, refresh: { type: "boolean" }, force: { type: "boolean" }, "max-usd": { type: "string" }, yes: { type: "boolean" }, help: { type: "boolean", short: "h" } }
88
171
  });
89
- const [cmd, arg] = positionals;
172
+ const [cmd, arg, arg2] = positionals;
90
173
  if (!cmd || a.help || cmd === "help") {
91
174
  io.out(USAGE);
92
175
  return cmd || a.help ? 0 : 1;
93
176
  }
94
177
  const api = String(a.api ?? io.env.GRAFT_API ?? "https://graftfork.com").replace(/\/+$/, "");
95
- if (cmd === "verify-record") return verify(arg, api, io);
96
178
  const token = a.token ?? io.env.GRAFT_TOKEN;
179
+ if (cmd === "verify-record") return verify(arg, api, io, token);
97
180
  if (!token) {
98
181
  io.out("graft needs a personal API token: make one at https://graftfork.com/app#settings, then set GRAFT_TOKEN or pass --token.");
99
182
  return 1;
@@ -107,7 +190,7 @@ async function run(argv, io) {
107
190
  const show = (b, text) => io.out(a.json ? JSON.stringify(b, null, 1) : text);
108
191
  const copyId = async (given) => {
109
192
  if (given && !given.includes("/") && /^[A-Za-z0-9_-]{8,64}$/.test(given)) return given;
110
- const repo = given ?? repoOfRemote(spawnSync2("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "");
193
+ const repo = given ?? repoOfRemote(spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "");
111
194
  if (!repo) throw new ApiError("Couldn't tell which copy: give its id or owner/name, or run this inside its clone.");
112
195
  const { copies } = await call("GET", "/api/v1/copies");
113
196
  const c = copies.find((x) => String(x.repo).toLowerCase() === repo.toLowerCase());
@@ -131,7 +214,7 @@ async function run(argv, io) {
131
214
  show(b, `${b.message ?? "Connected."}
132
215
  Copy id: ${b.copyId}`);
133
216
  } else if (cmd === "sync") {
134
- const b = await call("POST", `/api/v1/copies/${await copyId(arg)}/sync`);
217
+ const b = await call("POST", `/api/v1/copies/${await copyId(arg)}/sync`, a.force ? { force: true } : void 0);
135
218
  show(b, `${b.message}
136
219
  Run: ${b.run}`);
137
220
  } else if (cmd === "status") {
@@ -144,12 +227,81 @@ Run: ${b.run}`);
144
227
  const stuck = (b.stuck ?? []).map((s) => ` ${s.change}: ${s.why}`);
145
228
  show(b, [b.headline, ...stuck.length ? ["Stuck:", ...stuck] : [], ...b.action ? [`What to do: ${b.action}`] : [], ...b.command ? [` ${b.command}`] : [], `Next: ${b.next?.why ?? ""}`].filter(Boolean).join("\n"));
146
229
  } else if (cmd === "pull") {
147
- const r = await pull({ cwd: io.cwd, copy: arg, token, api, fetchImpl: io.fetch });
230
+ const r = await pull({ cwd: io.cwd, copy: arg ?? a.copy, token, api, fetchImpl: io.fetch });
231
+ io.out(r.message);
232
+ return r.ok ? 0 : 1;
233
+ } else if (cmd === "agent-record") {
234
+ if (!arg) {
235
+ io.out("graft agent-record <organization id> [--from <YYYY-MM-DD> --to <YYYY-MM-DD>]");
236
+ return 1;
237
+ }
238
+ const q = new URLSearchParams({ ...a.from && { from: String(a.from) }, ...a.to && { to: String(a.to) } }).toString();
239
+ const b = await call("GET", `/api/v1/orgs/${encodeURIComponent(arg)}/agent-record${q ? `?${q}` : ""}`);
240
+ io.out(JSON.stringify(b, null, 1));
241
+ } else if (cmd === "check") {
242
+ return await check(positionals[1], positionals[2]);
243
+ } else if (cmd === "checks") {
244
+ if (arg) {
245
+ const b = await call("GET", `/api/v1/checks/${encodeURIComponent(arg)}`);
246
+ show(b, checkText(b.check));
247
+ } else {
248
+ const b = await call("GET", "/api/v1/checks");
249
+ const rows = b.checks.map((c) => `${c.id} ${c.repo} ${c.head} vs ${c.base}${c.pr ? ` (#${c.pr})` : ""} ${c.outcome ?? c.status}`);
250
+ show(b, [...rows.length ? rows : ["No change checks yet. Start one with: graft check <base> <head>"], ...b.usage ? [`${b.usage.used} of ${b.usage.limit} checks this month.`] : []].join("\n"));
251
+ }
252
+ } else if (cmd === "upgrade") {
253
+ if (!arg) {
254
+ io.out("graft upgrade <dependency> [version] [--copy <copy id or owner/name>]");
255
+ return 1;
256
+ }
257
+ const b = await call("POST", "/api/v1/upgrades", { copy_id: await copyId(a.copy), dependency: arg, ...positionals[2] && { version: positionals[2] } });
258
+ show(b, `${b.message}
259
+ Upgrade: ${b.upgrade} (graft upgrades ${b.upgrade})`);
260
+ } else if (cmd === "upgrades") {
261
+ if (arg) {
262
+ const { upgrade: u } = await call("GET", `/api/v1/upgrades/${encodeURIComponent(arg)}`);
263
+ const lines = [`${u.dependency} ${u.from ?? ""} to ${u.version ?? u.requested} on ${u.repo ?? "?"}: ${u.status}`, ...u.why ? [` ${u.why}`] : []];
264
+ if (u.tests?.newFailures?.length) lines.push("Broken by the upgrade:", ...u.tests.newFailures.map((f) => ` ${f}`));
265
+ if (u.fix) lines.push(`Graft's agent: ${u.fix.applied ? `changed ${(u.fix.files ?? []).join(", ")}` : `not used (${u.fix.why})`}`);
266
+ if (u.delivered) lines.push(`Delivered as ${u.delivered.branch}. Take it in your clone with: graft pull ${u.run}`);
267
+ if (u.prUrl) lines.push(`Pull request: ${u.prUrl}`);
268
+ show({ upgrade: u }, lines.join("\n"));
269
+ } else {
270
+ const b = await call("GET", "/api/v1/upgrades");
271
+ const rows = b.upgrades.map((u) => `${u.id} ${u.repo ?? "?"} ${u.dependency} to ${u.version ?? u.requested}: ${u.status}`);
272
+ show(b, rows.length ? rows.join("\n") : "No upgrades yet. Start one with: graft upgrade <dependency> [version]");
273
+ }
274
+ } else if (cmd === "upstream") {
275
+ if (!arg) {
276
+ io.out("graft upstream <change> [copy] [--patch <dir>] [--no-wait]: the change's subject (or part of it), or its number in the latest move");
277
+ return 1;
278
+ }
279
+ const r = await upstream({ cwd: io.cwd, copyId: await copyId(arg2), change: arg, call, fetchImpl: io.fetch, noWait: !!a["no-wait"], ...a.patch && { patchDir: String(a.patch) }, ...io.env.GRAFT_POLL_MS && { pollMs: Number(io.env.GRAFT_POLL_MS) } });
148
280
  io.out(r.message);
149
281
  return r.ok ? 0 : 1;
282
+ } else if (cmd === "upstream-status") {
283
+ if (!arg) {
284
+ io.out("graft upstream-status <build>");
285
+ return 1;
286
+ }
287
+ const b = await call("GET", `/api/v1/upstreams/${encodeURIComponent(arg)}`);
288
+ const u = b.upstream;
289
+ const pr = u.pullRequest ?? {};
290
+ show(b, [`${u.change?.subject} on ${u.release}: ${u.status}${u.phase ? ` (${u.phase})` : ""}${u.note ? `. ${u.note}` : ""}`, ...(u.commands ?? []).map((c) => ` ${c}`), ...pr.url ? [`Pull request: ${pr.url}${pr.state ? ` (${pr.state})` : ""}`] : []].join("\n"));
291
+ } else if (cmd === "upstream-pr") {
292
+ if (!arg || !arg2) {
293
+ io.out("graft upstream-pr <build> <https://github.com/owner/name/pull/123>");
294
+ return 1;
295
+ }
296
+ const b = await call("POST", `/api/v1/upstreams/${encodeURIComponent(arg)}/pr`, { url: arg2 });
297
+ show(b, b.message);
298
+ } else if (cmd === "regen") {
299
+ return await regen(arg, arg2, a, call, show, io);
150
300
  } else if (cmd === "usage") {
151
301
  const b = await call("GET", "/api/v1/usage");
152
- show(b, `${b.plan}: ${b.syncs?.used ?? 0} of ${b.syncs?.limit ?? "?"} syncs this month; Graft's agent $${Number(b.graftAgentUsd ?? 0).toFixed(2)}. Resets ${b.resetsOn}.`);
302
+ const orgs = Array.isArray(b.coveredBy) ? b.coveredBy : [];
303
+ const covered = orgs.length ? ` Copies in ${orgs.map((o) => `${o.name} (${o.plan})`).join(", ")} work as Premium: up to ${b.syncs?.coveredSyncs ?? "?"} syncs this month.` : "";
304
+ show(b, `${b.plan}${orgs.length ? " (your own)" : ""}: ${b.syncs?.used ?? 0} of ${b.syncs?.limit ?? "?"} syncs this month; Graft's agent $${Number(b.graftAgentUsd ?? 0).toFixed(2)}.${covered} Resets ${b.resetsOn}.`);
153
305
  } else {
154
306
  io.out(USAGE);
155
307
  return 1;
@@ -159,19 +311,183 @@ Run: ${b.run}`);
159
311
  io.out(e instanceof ApiError ? e.message : `graft: ${e.message}`);
160
312
  return 1;
161
313
  }
314
+ async function check(base, head) {
315
+ const pr = a.pr === void 0 ? void 0 : Number(a.pr);
316
+ if ((!base || !head) && pr === void 0) {
317
+ io.out("graft check <base> <head> [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]");
318
+ return 1;
319
+ }
320
+ let where;
321
+ if (a.repo) where = { repo: a.repo };
322
+ else if (a["clone-url"]) where = { clone_url: a["clone-url"] };
323
+ else {
324
+ const remote = (spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "").trim();
325
+ const repo = repoOfRemote(remote);
326
+ if (/github\.com[:/]/.test(remote) && repo) where = { repo };
327
+ else if (/^https:\/\//.test(remote) && !/@/.test(remote.split("/")[2] ?? "")) where = { clone_url: remote };
328
+ else {
329
+ io.out("Couldn't tell which repository: pass --repo <owner/name> or --clone-url <url>, or run this inside its clone.");
330
+ return 1;
331
+ }
332
+ }
333
+ const b = await call("POST", "/api/v1/checks", { ...where, ...base && { base }, ...head && { head }, ...pr !== void 0 && { pr } });
334
+ if (!a.wait) {
335
+ show(b, `${b.message}
336
+ Check: ${b.check} (graft checks ${b.check})`);
337
+ return 0;
338
+ }
339
+ const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
340
+ for (let i = 0; i < 200; i++) {
341
+ const c = (await call("GET", `/api/v1/checks/${encodeURIComponent(b.check)}`)).check;
342
+ if (!["queued", "running"].includes(c.status)) {
343
+ show({ check: c }, checkText(c));
344
+ return c.outcome === "pass" ? 0 : c.outcome === "fail" ? 1 : 2;
345
+ }
346
+ await sleep(15e3);
347
+ }
348
+ io.out(`Still running. Read it later with: graft checks ${b.check}`);
349
+ return 2;
350
+ }
351
+ }
352
+ function checkText(c) {
353
+ const word = c.outcome === "pass" ? "PASS" : c.outcome === "fail" ? "FAIL" : c.outcome === "could_not_run" ? "COULD NOT RUN" : String(c.status).toUpperCase();
354
+ const sha = (r) => r?.sha ? ` (${String(r.sha).slice(0, 7)})` : "";
355
+ return [
356
+ `${word}: ${c.repo} ${c.head}${sha(c.headRun)} against ${c.base}${sha(c.baseRun)}${c.pr ? `, pull request #${c.pr}` : ""}`,
357
+ ...c.reason ? [c.reason] : [],
358
+ ...(c.newFailures ?? []).length ? ["New failures:", ...c.newFailures.map((f) => ` ${f}`)] : [],
359
+ ...(c.alreadyFailing ?? []).length ? [`${c.alreadyFailing.length} already failing on the base (not counted against the change).`] : [],
360
+ ...(c.testFilesChanged ?? []).length ? [`The change edits files that shape the tests: ${c.testFilesChanged.join(", ")}. Read them before trusting the result.`] : []
361
+ ].join("\n");
362
+ }
363
+ async function regen(sub, id, a, call, show, io) {
364
+ const line = (r) => `${r.id} ${r.status === "stayed" ? "not kept" : r.status}${r.phase ? ` (${r.phase})` : ""}${r.suite?.total ? ` suite ${r.suite.passed}/${r.suite.total}` : ""}${r.choicesDiffer ? ` ${r.choicesDiffer} choice(s) differ` : ""}${r.why ? `: ${r.why}` : ""}`;
365
+ if (sub === "connect") {
366
+ if (!id || !a.record || !a.impl || !a.lang || !a.branch) {
367
+ io.out("graft regen connect <owner/name or https URL> --branch <b> --record <path> --impl <dir> --lang ts|py|rs|go [--builds 1-3] [--on-push]");
368
+ return 1;
369
+ }
370
+ const b = await call("POST", "/api/v1/regen", { .../^https:\/\//.test(id) ? { clone_url: id } : { repo: id }, branch: a.branch, record: a.record, impl: a.impl, lang: a.lang, ...a.builds && { builds: Number(a.builds) }, ...a["on-push"] && { on_push: true } });
371
+ show(b, `${b.message ?? "Connected."}
372
+ Spec id: ${b.id}`);
373
+ } else if (sub === "run") {
374
+ if (!id) {
375
+ io.out("graft regen run <spec id> [--force] [--max-usd <n>]");
376
+ return 1;
377
+ }
378
+ const max = a["max-usd"] === void 0 ? void 0 : Number(a["max-usd"]);
379
+ if (max !== void 0 && !(Number.isFinite(max) && max >= 0)) {
380
+ io.out("--max-usd is a number of dollars, 0 or more.");
381
+ return 1;
382
+ }
383
+ const body = { ...a.force && { force: true }, ...max !== void 0 && { max_usd: max } };
384
+ const b = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/run`, Object.keys(body).length ? body : void 0).catch((e) => {
385
+ throw e instanceof ApiError ? new ApiError(e.message.replace(/\bmax_usd\b/g, "--max-usd")) : e;
386
+ });
387
+ show(b, `${b.message}
388
+ Rebuild: ${b.run}`);
389
+ } else if (sub === "status") {
390
+ if (!id) {
391
+ const b = await call("GET", "/api/v1/regen");
392
+ const rows = b.specs.map((s) => `${s.id} ${s.repo} ${s.branch}: ${s.record} -> ${s.impl} (${s.lang})${s.last ? `
393
+ last: ${line(s.last)}` : "\n never rebuilt"}`);
394
+ show(b, rows.length ? rows.join("\n") : b.available ? "No specs yet. Connect one with: graft regen connect <owner/name> --branch main --record spec/ --impl impl --lang ts" : "Rebuilding from a spec is on Premium, Team and Enterprise.");
395
+ return 0;
396
+ }
397
+ const spec = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}`).catch(() => null);
398
+ if (spec) {
399
+ show(spec, [`${spec.spec.repo} ${spec.spec.branch}: ${spec.spec.record} -> ${spec.spec.impl} (${spec.spec.lang})`, ...spec.runs.length ? spec.runs.map((r) => ` ${line(r)}`) : [" No rebuilds yet."]].join("\n"));
400
+ return 0;
401
+ }
402
+ const { run: run2 } = await call("GET", `/api/v1/regen/runs/${encodeURIComponent(id)}`);
403
+ const differ = run2.choicesDiff?.previous;
404
+ const lines = [line(run2), ...run2.builds.map((b) => ` build ${b.n}: ${b.verdict?.why ?? b.note ?? b.status}`)];
405
+ if (differ) for (const [k, label] of [["changed", "chosen differently"], ["added", "new"], ["removed", "gone"]]) for (const c of differ[k]) lines.push(` ${label}: ${c.title}`);
406
+ if (run2.doctor?.gaps?.length) {
407
+ const say = (g) => g.kind === "failing" ? `${g.ref}: cases ${g.cases.join(", ")} fail${g.why ? ` (${g.why})` : ""}` : g.kind === "builds differ" ? `${g.ref ?? g.title}: the builds chose differently` : g.kind === "changed since" ? `${g.ref ?? g.title}: chosen differently from the last passing rebuild` : g.kind === "build crashed" ? `${g.build ? `build ${g.build}` : "a build"} crashed: ${g.why}` : g.kind === "hidden request" ? `build ${g.build} answered ${g.op} ${g.input} differently from the oracle` : `the oracle can't answer ${g.op} ${g.input}: ${g.error}`;
408
+ lines.push(run2.doctor.summary, ...run2.doctor.gaps.slice(0, 8).map((g) => ` - ${say(g)}`), ...run2.doctor.gaps.length > 8 ? [` and ${run2.doctor.gaps.length - 8} more (--json)`] : []);
409
+ }
410
+ if (run2.take) lines.push("Take it, in your clone:", ...run2.take.commands.map((c) => ` ${c}`));
411
+ show({ run: run2 }, lines.join("\n"));
412
+ } else if (sub === "report") {
413
+ if (!id) {
414
+ io.out("graft regen report <spec id> [--refresh]");
415
+ return 1;
416
+ }
417
+ if (a.refresh) {
418
+ const before = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}/report`);
419
+ const price = Number(before.nextReport?.priceUsd ?? 0);
420
+ if (before.nextReport?.available === false) {
421
+ io.out(before.nextReport.why);
422
+ return 1;
423
+ }
424
+ if (price > 0 && !a.yes) {
425
+ io.out(`A new report costs $${price.toFixed(2)} from your agent work (today's free ones are used). Run again with --yes to pay for it.`);
426
+ return 1;
427
+ }
428
+ const q = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/report`, { max_usd: price });
429
+ show(q, `${q.message}
430
+ Report run: ${q.run}`);
431
+ return 0;
432
+ }
433
+ const b = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}/report`);
434
+ const r = b.report;
435
+ if (!r) {
436
+ show(b, b.pending ? `A report is on its way (${b.pending}).` : b.message ?? "No report yet: graft regen report <spec id> --refresh");
437
+ return 0;
438
+ }
439
+ const n = r.numbers ?? {};
440
+ const m = r.mutation ?? {};
441
+ show(b, [
442
+ `${String(r.grade).toUpperCase()}${r.commit ? ` (at ${String(r.commit).slice(0, 12)})` : ""}`,
443
+ ...r.reasons.map((x) => ` - ${x}`),
444
+ `${n.requirements ?? "?"} requirements, ${n.examples ?? "?"} examples, ${n.decisions ?? "?"} decisions, ${n.open ?? "?"} open; ${n.oracle ? "an oracle" : "no oracle"}`,
445
+ m.ran ? `mistakes planted in the oracle: ${m.killed} caught, ${m.unpinned} unnoticed, ${m.silent} change nothing (of ${m.total})` : `no mutation score: ${m.why}`,
446
+ `fit: ${r.fit?.why ?? ""}`,
447
+ ...b.pending ? [`A newer report is on its way (${b.pending}).`] : []
448
+ ].join("\n"));
449
+ } else {
450
+ io.out("graft regen connect|run|status|report (graft help)");
451
+ return 1;
452
+ }
453
+ return 0;
162
454
  }
163
455
  var unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
164
- async function verify(file, api, io) {
456
+ async function verify(file, api, io, token) {
165
457
  if (!file) {
166
- io.out("graft verify-record <file>");
458
+ io.out("graft verify-record <file or rebuild id>");
167
459
  return 1;
168
460
  }
169
461
  let e;
170
- try {
171
- e = JSON.parse(readFileSync(file, "utf8"));
172
- } catch {
173
- io.out(`${file} isn't a Graft record (not JSON).`);
174
- return 1;
462
+ if (!existsSync(file) && /^[A-Za-z0-9_-]{8,64}$/.test(file)) {
463
+ if (!token) {
464
+ io.out(`${file} isn't a file. To fetch a record by its id, graft needs a personal API token: make one at https://graftfork.com/app#settings, then set GRAFT_TOKEN or pass --token.`);
465
+ return 1;
466
+ }
467
+ let got = null;
468
+ for (const path of [`/api/v1/regen/runs/${file}/record`, `/api/v1/runs/${file}/record`]) {
469
+ const r2 = await io.fetch(`${api}${path}`, { headers: { authorization: `Bearer ${token}` } });
470
+ if (r2.ok) {
471
+ got = await r2.json().catch(() => null);
472
+ break;
473
+ }
474
+ if (r2.status !== 404) {
475
+ io.out(`graftfork.com answered ${r2.status} for ${file}'s record.`);
476
+ return 1;
477
+ }
478
+ }
479
+ if (!got) {
480
+ io.out(`No signed record for ${file}: not one of your rebuilds or syncs (or a public spec's rebuild), or it hasn't finished.`);
481
+ return 1;
482
+ }
483
+ e = got;
484
+ } else {
485
+ try {
486
+ e = JSON.parse(readFileSync(file, "utf8"));
487
+ } catch {
488
+ io.out(`${file} isn't a Graft record (not JSON).`);
489
+ return 1;
490
+ }
175
491
  }
176
492
  if (e?.graft_record !== 1 || e.alg !== "Ed25519" || typeof e.payload !== "string" || typeof e.signature !== "string") {
177
493
  io.out(`${file} isn't a Graft record.`);
@@ -194,7 +510,27 @@ async function verify(file, api, io) {
194
510
  return 1;
195
511
  }
196
512
  const r = JSON.parse(e.payload);
197
- io.out(`Valid: signed by graftfork.com (key ${k.id}). Run ${r.run}, ${r.outcome}${r.upstream?.to ? ` onto ${r.upstream.to}` : ""}, ${(r.changes ?? []).length} change(s).`);
513
+ if (r.graft_agent_record === 1) {
514
+ io.out(`Valid: signed by graftfork.com (key ${k.id}). Agent change record for ${r.org?.name ?? r.org?.id}, ${r.range?.from} to ${r.range?.to}: ${(r.changes ?? []).length} change(s) in ${r.counts?.runs ?? 0} run(s), ${(r.agent_actions ?? []).length} agent action(s).`);
515
+ return 0;
516
+ }
517
+ const i = r.initiator;
518
+ const by = !i?.kind ? "" : i.kind === "agent" ? ` Started by ${i.agent ? `the organization's agent ${i.agent} (org ${i.org ?? "unknown"}, ` : "an agent ("}token ${i.token ?? "unknown"}${i.client?.name ? `, ${i.client.name}${i.client.version ? ` ${i.client.version}` : ""}` : ""}).` : i.kind === "ops" ? " Started by Graft's operator." : i.kind === "schedule" ? " Started by Graft on its own (a release, a push or a new model)." : " Started by a person.";
519
+ if (r.kind === "regen") {
520
+ const outcome = r.outcome === "passed" ? "kept" : r.outcome === "stayed" ? "not kept" : r.outcome;
521
+ if (r.rejudge?.of) {
522
+ const verdict = r.outcome === "passed" ? "passed" : r.outcome === "stayed" ? "didn't pass" : r.outcome;
523
+ const agree = r.rejudge.agrees === true ? "agrees with" : r.rejudge.agrees === false ? "differs from" : "can't be compared with";
524
+ io.out(`Valid: signed by graftfork.com (key ${k.id}). Re-judge ${r.run} of rebuild ${r.rejudge.of}: ${verdict}${r.suite ? `, suite ${r.suite.passed}/${r.suite.total}` : ""}, holdout seed ${r.rejudge.seed}${r.rejudge.same_seed ? " (the rebuild's own)" : ""}; it ${agree} the first judge.${r.judge ? ` Judged as ${r.judge.driver_user === "separate" ? "a separate user" : "the oracle's user"}, network ${r.judge.network}.` : ""}${by}`);
525
+ return 0;
526
+ }
527
+ const what = r.draft?.branch ? `Draft rebuild ${r.run} of branch ${r.draft.branch}` : `Rebuild ${r.run}`;
528
+ const b = r.behaviour;
529
+ const beh = !b ? "" : b.sampled ? ` Against rebuild ${b.against?.run ?? "?"}: ${b.requests} requests, ${(b.paths ?? []).length} part(s) of the answer changed, ${b.unexplained} change(s) the record didn't ask for.` : ` Not compared with the kept build${b.why ? `: ${b.why}` : ""}.`;
530
+ io.out(`Valid: signed by graftfork.com (key ${k.id}). ${what}, ${outcome}${r.copy?.repo ? `: ${r.copy.repo} ${r.copy.record ?? ""} to ${r.copy.impl ?? ""}` : ""}${r.spec?.name ? ` (${r.spec.name}${r.spec.version ? ` ${r.spec.version}` : ""})` : ""}${r.suite ? `, suite ${r.suite.passed}/${r.suite.total}` : ""}, ${(r.builds ?? []).length} build(s).${beh}${by}`);
531
+ return 0;
532
+ }
533
+ io.out(`Valid: signed by graftfork.com (key ${k.id}). Run ${r.run}, ${r.outcome}${r.upstream?.to ? ` onto ${r.upstream.to}` : ""}, ${(r.changes ?? []).length} change(s).${by}`);
198
534
  return 0;
199
535
  }
200
536
  if (process.argv[1] && /graft(\.mjs)?$|client[/\\]cli\.ts$/.test(process.argv[1])) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graftfork",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The command line for graftfork.com: connect a copy, sync it, see why it stayed, and take each move. A thin client; syncs run on graftfork.com.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://graftfork.com/docs/api",