graftfork 0.1.0 → 0.2.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 +3 -0
  2. package/graft.mjs +248 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,6 +15,9 @@ 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-pr <build> <url> # the pull request you opened; Graft tells you when it's merged
18
21
  ```
19
22
 
20
23
  Also `graft whoami`, `graft status` and `graft usage`; `--json` prints the API's answers as they are. API docs:
package/graft.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/client/cli.ts
4
- import { spawnSync as spawnSync2 } from "node:child_process";
4
+ import { spawnSync as spawnSync3 } from "node:child_process";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { parseArgs } from "node:util";
7
7
 
@@ -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
 
@@ -72,8 +135,25 @@ var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run
72
135
  graft status [copy] a copy and its latest runs
73
136
  graft why [copy] why the copy stayed, and what to do next
74
137
  graft pull [copy] fetch the latest move as graft/<release> in your clone
138
+ graft upgrade <dependency> [version] [--copy <copy>]
139
+ upgrade a dependency (npm or Python), delivered only if the tests pass
140
+ graft upgrades [id] your upgrades, or one with what its tests said
141
+ graft upstream <change> [copy] build one change alone on your release, tested; fetch graft/upstream/<change>
142
+ [--patch <dir>] [--no-wait] (nothing is pushed: you open the pull request yourself)
143
+ graft upstream-status <build> an upstream build's status, tests and links
144
+ graft upstream-pr <build> <url> the pull request you opened upstream; Graft checks it until it's merged
75
145
  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)
146
+ graft verify-record <file> check a run's signed record (from /app or GET /api/v1/runs/{id}/record),
147
+ or an organization's agent change record
148
+ graft agent-record <org> an organization's signed agent change record, as JSON
149
+ [--from <YYYY-MM-DD> --to <YYYY-MM-DD>] (save it: graft agent-record <org> > record.json)
150
+ graft check <base> <head> check a change against the project's own tests (no push, no agent)
151
+ [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]
152
+ graft checks [id] your recent change checks, or one check's result
153
+ graft regen connect <owner/name or URL> --branch <b> --record <path> --impl <dir> --lang ts|py [--builds 1-3] [--on-push]
154
+ connect a spec (a duramen record): Graft rebuilds its implementation blind
155
+ graft regen run <spec> rebuild it now
156
+ graft regen status [id] your specs, a spec's rebuilds, or one rebuild and how to take it
77
157
 
78
158
  [copy] is a copy id or owner/name; left out, it's this clone's origin remote.
79
159
  Token: GRAFT_TOKEN or --token (make one at https://graftfork.com/app#settings). --json prints raw answers.`;
@@ -84,9 +164,9 @@ async function run(argv, io) {
84
164
  args: argv,
85
165
  allowPositionals: true,
86
166
  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" } }
167
+ 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" }, help: { type: "boolean", short: "h" } }
88
168
  });
89
- const [cmd, arg] = positionals;
169
+ const [cmd, arg, arg2] = positionals;
90
170
  if (!cmd || a.help || cmd === "help") {
91
171
  io.out(USAGE);
92
172
  return cmd || a.help ? 0 : 1;
@@ -107,7 +187,7 @@ async function run(argv, io) {
107
187
  const show = (b, text) => io.out(a.json ? JSON.stringify(b, null, 1) : text);
108
188
  const copyId = async (given) => {
109
189
  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 ?? "");
190
+ const repo = given ?? repoOfRemote(spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "");
111
191
  if (!repo) throw new ApiError("Couldn't tell which copy: give its id or owner/name, or run this inside its clone.");
112
192
  const { copies } = await call("GET", "/api/v1/copies");
113
193
  const c = copies.find((x) => String(x.repo).toLowerCase() === repo.toLowerCase());
@@ -147,6 +227,73 @@ Run: ${b.run}`);
147
227
  const r = await pull({ cwd: io.cwd, copy: arg, token, api, fetchImpl: io.fetch });
148
228
  io.out(r.message);
149
229
  return r.ok ? 0 : 1;
230
+ } else if (cmd === "agent-record") {
231
+ if (!arg) {
232
+ io.out("graft agent-record <organization id> [--from <YYYY-MM-DD> --to <YYYY-MM-DD>]");
233
+ return 1;
234
+ }
235
+ const q = new URLSearchParams({ ...a.from && { from: String(a.from) }, ...a.to && { to: String(a.to) } }).toString();
236
+ const b = await call("GET", `/api/v1/orgs/${encodeURIComponent(arg)}/agent-record${q ? `?${q}` : ""}`);
237
+ io.out(JSON.stringify(b, null, 1));
238
+ } else if (cmd === "check") {
239
+ return await check(positionals[1], positionals[2]);
240
+ } else if (cmd === "checks") {
241
+ if (arg) {
242
+ const b = await call("GET", `/api/v1/checks/${encodeURIComponent(arg)}`);
243
+ show(b, checkText(b.check));
244
+ } else {
245
+ const b = await call("GET", "/api/v1/checks");
246
+ const rows = b.checks.map((c) => `${c.id} ${c.repo} ${c.head} vs ${c.base}${c.pr ? ` (#${c.pr})` : ""} ${c.outcome ?? c.status}`);
247
+ 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"));
248
+ }
249
+ } else if (cmd === "upgrade") {
250
+ if (!arg) {
251
+ io.out("graft upgrade <dependency> [version] [--copy <copy id or owner/name>]");
252
+ return 1;
253
+ }
254
+ const b = await call("POST", "/api/v1/upgrades", { copy_id: await copyId(a.copy), dependency: arg, ...positionals[2] && { version: positionals[2] } });
255
+ show(b, `${b.message}
256
+ Upgrade: ${b.upgrade} (graft upgrades ${b.upgrade})`);
257
+ } else if (cmd === "upgrades") {
258
+ if (arg) {
259
+ const { upgrade: u } = await call("GET", `/api/v1/upgrades/${encodeURIComponent(arg)}`);
260
+ const lines = [`${u.dependency} ${u.from ?? ""} to ${u.version ?? u.requested} on ${u.repo ?? "?"}: ${u.status}`, ...u.why ? [` ${u.why}`] : []];
261
+ if (u.tests?.newFailures?.length) lines.push("Broken by the upgrade:", ...u.tests.newFailures.map((f) => ` ${f}`));
262
+ if (u.fix) lines.push(`Graft's agent: ${u.fix.applied ? `changed ${(u.fix.files ?? []).join(", ")}` : `not used (${u.fix.why})`}`);
263
+ if (u.delivered) lines.push(`Delivered as ${u.delivered.branch}. Take it in your clone with: graft pull ${u.run}`);
264
+ if (u.prUrl) lines.push(`Pull request: ${u.prUrl}`);
265
+ show({ upgrade: u }, lines.join("\n"));
266
+ } else {
267
+ const b = await call("GET", "/api/v1/upgrades");
268
+ const rows = b.upgrades.map((u) => `${u.id} ${u.repo ?? "?"} ${u.dependency} to ${u.version ?? u.requested}: ${u.status}`);
269
+ show(b, rows.length ? rows.join("\n") : "No upgrades yet. Start one with: graft upgrade <dependency> [version]");
270
+ }
271
+ } else if (cmd === "upstream") {
272
+ if (!arg) {
273
+ 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");
274
+ return 1;
275
+ }
276
+ 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) } });
277
+ io.out(r.message);
278
+ return r.ok ? 0 : 1;
279
+ } else if (cmd === "upstream-status") {
280
+ if (!arg) {
281
+ io.out("graft upstream-status <build>");
282
+ return 1;
283
+ }
284
+ const b = await call("GET", `/api/v1/upstreams/${encodeURIComponent(arg)}`);
285
+ const u = b.upstream;
286
+ const pr = u.pullRequest ?? {};
287
+ 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"));
288
+ } else if (cmd === "upstream-pr") {
289
+ if (!arg || !arg2) {
290
+ io.out("graft upstream-pr <build> <https://github.com/owner/name/pull/123>");
291
+ return 1;
292
+ }
293
+ const b = await call("POST", `/api/v1/upstreams/${encodeURIComponent(arg)}/pr`, { url: arg2 });
294
+ show(b, b.message);
295
+ } else if (cmd === "regen") {
296
+ return await regen(arg, arg2, a, call, show, io);
150
297
  } else if (cmd === "usage") {
151
298
  const b = await call("GET", "/api/v1/usage");
152
299
  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}.`);
@@ -159,6 +306,97 @@ Run: ${b.run}`);
159
306
  io.out(e instanceof ApiError ? e.message : `graft: ${e.message}`);
160
307
  return 1;
161
308
  }
309
+ async function check(base, head) {
310
+ const pr = a.pr === void 0 ? void 0 : Number(a.pr);
311
+ if ((!base || !head) && pr === void 0) {
312
+ io.out("graft check <base> <head> [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]");
313
+ return 1;
314
+ }
315
+ let where;
316
+ if (a.repo) where = { repo: a.repo };
317
+ else if (a["clone-url"]) where = { clone_url: a["clone-url"] };
318
+ else {
319
+ const remote = (spawnSync3("git", ["remote", "get-url", "origin"], { cwd: io.cwd, encoding: "utf8" }).stdout ?? "").trim();
320
+ const repo = repoOfRemote(remote);
321
+ if (/github\.com[:/]/.test(remote) && repo) where = { repo };
322
+ else if (/^https:\/\//.test(remote) && !/@/.test(remote.split("/")[2] ?? "")) where = { clone_url: remote };
323
+ else {
324
+ io.out("Couldn't tell which repository: pass --repo <owner/name> or --clone-url <url>, or run this inside its clone.");
325
+ return 1;
326
+ }
327
+ }
328
+ const b = await call("POST", "/api/v1/checks", { ...where, ...base && { base }, ...head && { head }, ...pr !== void 0 && { pr } });
329
+ if (!a.wait) {
330
+ show(b, `${b.message}
331
+ Check: ${b.check} (graft checks ${b.check})`);
332
+ return 0;
333
+ }
334
+ const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
335
+ for (let i = 0; i < 200; i++) {
336
+ const c = (await call("GET", `/api/v1/checks/${encodeURIComponent(b.check)}`)).check;
337
+ if (!["queued", "running"].includes(c.status)) {
338
+ show({ check: c }, checkText(c));
339
+ return c.outcome === "pass" ? 0 : c.outcome === "fail" ? 1 : 2;
340
+ }
341
+ await sleep(15e3);
342
+ }
343
+ io.out(`Still running. Read it later with: graft checks ${b.check}`);
344
+ return 2;
345
+ }
346
+ }
347
+ function checkText(c) {
348
+ const word = c.outcome === "pass" ? "PASS" : c.outcome === "fail" ? "FAIL" : c.outcome === "could_not_run" ? "COULD NOT RUN" : String(c.status).toUpperCase();
349
+ const sha = (r) => r?.sha ? ` (${String(r.sha).slice(0, 7)})` : "";
350
+ return [
351
+ `${word}: ${c.repo} ${c.head}${sha(c.headRun)} against ${c.base}${sha(c.baseRun)}${c.pr ? `, pull request #${c.pr}` : ""}`,
352
+ ...c.reason ? [c.reason] : [],
353
+ ...(c.newFailures ?? []).length ? ["New failures:", ...c.newFailures.map((f) => ` ${f}`)] : [],
354
+ ...(c.alreadyFailing ?? []).length ? [`${c.alreadyFailing.length} already failing on the base (not counted against the change).`] : [],
355
+ ...(c.testFilesChanged ?? []).length ? [`The change edits files that shape the tests: ${c.testFilesChanged.join(", ")}. Read them before trusting the result.`] : []
356
+ ].join("\n");
357
+ }
358
+ async function regen(sub, id, a, call, show, io) {
359
+ const line = (r) => `${r.id} ${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}` : ""}`;
360
+ if (sub === "connect") {
361
+ if (!id || !a.record || !a.impl || !a.lang || !a.branch) {
362
+ io.out("graft regen connect <owner/name or https URL> --branch <b> --record <path> --impl <dir> --lang ts|py [--builds 1-3] [--on-push]");
363
+ return 1;
364
+ }
365
+ 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 } });
366
+ show(b, `${b.message ?? "Connected."}
367
+ Spec id: ${b.id}`);
368
+ } else if (sub === "run") {
369
+ if (!id) {
370
+ io.out("graft regen run <spec id>");
371
+ return 1;
372
+ }
373
+ const b = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/run`);
374
+ show(b, `${b.message}
375
+ Rebuild: ${b.run}`);
376
+ } else if (sub === "status") {
377
+ if (!id) {
378
+ const b = await call("GET", "/api/v1/regen");
379
+ const rows = b.specs.map((s) => `${s.id} ${s.repo} ${s.branch}: ${s.record} -> ${s.impl} (${s.lang})${s.last ? `
380
+ last: ${line(s.last)}` : "\n never rebuilt"}`);
381
+ 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.");
382
+ return 0;
383
+ }
384
+ const spec = await call("GET", `/api/v1/regen/${encodeURIComponent(id)}`).catch(() => null);
385
+ if (spec) {
386
+ 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"));
387
+ return 0;
388
+ }
389
+ const { run: run2 } = await call("GET", `/api/v1/regen/runs/${encodeURIComponent(id)}`);
390
+ const differ = run2.choicesDiff?.previous;
391
+ const lines = [line(run2), ...run2.builds.map((b) => ` build ${b.n}: ${b.verdict?.why ?? b.note ?? b.status}`)];
392
+ 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}`);
393
+ if (run2.take) lines.push("Take it, in your clone:", ...run2.take.commands.map((c) => ` ${c}`));
394
+ show({ run: run2 }, lines.join("\n"));
395
+ } else {
396
+ io.out("graft regen connect|run|status (graft help)");
397
+ return 1;
398
+ }
399
+ return 0;
162
400
  }
163
401
  var unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
164
402
  async function verify(file, api, io) {
@@ -194,6 +432,10 @@ async function verify(file, api, io) {
194
432
  return 1;
195
433
  }
196
434
  const r = JSON.parse(e.payload);
435
+ if (r.graft_agent_record === 1) {
436
+ 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).`);
437
+ return 0;
438
+ }
197
439
  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).`);
198
440
  return 0;
199
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graftfork",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",