graftfork 0.2.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 +17 -1
  2. package/graft.mjs +117 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,8 +17,24 @@ 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
18
  graft upstream "fix the parser" # Graft builds that one change alone on your release and runs your tests;
19
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
20
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
21
32
  ```
22
33
 
23
- 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:
24
40
  https://graftfork.com/docs/api. Support by email: support@graftfork.com. Apache-2.0.
package/graft.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/client/cli.ts
4
4
  import { spawnSync as spawnSync3 } from "node:child_process";
5
- import { readFileSync } from "node:fs";
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.` };
@@ -132,6 +132,7 @@ var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run
132
132
  graft connect <owner/name> connect a repository (Graft's GitHub app must be installed on it)
133
133
  [--branch <b> --upstream <owner/name or URL> --base <release tag>] [--tags <pattern>]
134
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
135
136
  graft status [copy] a copy and its latest runs
136
137
  graft why [copy] why the copy stayed, and what to do next
137
138
  graft pull [copy] fetch the latest move as graft/<release> in your clone
@@ -144,16 +145,18 @@ var USAGE = `graft: the command line for graftfork.com (a thin client; syncs run
144
145
  graft upstream-pr <build> <url> the pull request you opened upstream; Graft checks it until it's merged
145
146
  graft usage this month's syncs and agent work
146
147
  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
+ 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
148
150
  graft agent-record <org> an organization's signed agent change record, as JSON
149
151
  [--from <YYYY-MM-DD> --to <YYYY-MM-DD>] (save it: graft agent-record <org> > record.json)
150
152
  graft check <base> <head> check a change against the project's own tests (no push, no agent)
151
153
  [--pr <n>] [--repo <owner/name> | --clone-url <url>] [--wait]
152
154
  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]
155
+ graft regen connect <owner/name or URL> --branch <b> --record <path> --impl <dir> --lang ts|py|rs|go [--builds 1-3] [--on-push]
154
156
  connect a spec (a duramen record): Graft rebuilds its implementation blind
155
- graft regen run <spec> rebuild it now
157
+ graft regen run <spec> rebuild it now [--force: even if nothing changed] [--max-usd <n>: its budget]
156
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]]
157
160
 
158
161
  [copy] is a copy id or owner/name; left out, it's this clone's origin remote.
159
162
  Token: GRAFT_TOKEN or --token (make one at https://graftfork.com/app#settings). --json prints raw answers.`;
@@ -164,7 +167,7 @@ async function run(argv, io) {
164
167
  args: argv,
165
168
  allowPositionals: true,
166
169
  strict: false,
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" } }
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" } }
168
171
  });
169
172
  const [cmd, arg, arg2] = positionals;
170
173
  if (!cmd || a.help || cmd === "help") {
@@ -172,8 +175,8 @@ async function run(argv, io) {
172
175
  return cmd || a.help ? 0 : 1;
173
176
  }
174
177
  const api = String(a.api ?? io.env.GRAFT_API ?? "https://graftfork.com").replace(/\/+$/, "");
175
- if (cmd === "verify-record") return verify(arg, api, io);
176
178
  const token = a.token ?? io.env.GRAFT_TOKEN;
179
+ if (cmd === "verify-record") return verify(arg, api, io, token);
177
180
  if (!token) {
178
181
  io.out("graft needs a personal API token: make one at https://graftfork.com/app#settings, then set GRAFT_TOKEN or pass --token.");
179
182
  return 1;
@@ -211,7 +214,7 @@ async function run(argv, io) {
211
214
  show(b, `${b.message ?? "Connected."}
212
215
  Copy id: ${b.copyId}`);
213
216
  } else if (cmd === "sync") {
214
- 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);
215
218
  show(b, `${b.message}
216
219
  Run: ${b.run}`);
217
220
  } else if (cmd === "status") {
@@ -224,7 +227,7 @@ Run: ${b.run}`);
224
227
  const stuck = (b.stuck ?? []).map((s) => ` ${s.change}: ${s.why}`);
225
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"));
226
229
  } else if (cmd === "pull") {
227
- 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 });
228
231
  io.out(r.message);
229
232
  return r.ok ? 0 : 1;
230
233
  } else if (cmd === "agent-record") {
@@ -296,7 +299,9 @@ Upgrade: ${b.upgrade} (graft upgrades ${b.upgrade})`);
296
299
  return await regen(arg, arg2, a, call, show, io);
297
300
  } else if (cmd === "usage") {
298
301
  const b = await call("GET", "/api/v1/usage");
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}.`);
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}.`);
300
305
  } else {
301
306
  io.out(USAGE);
302
307
  return 1;
@@ -356,10 +361,10 @@ function checkText(c) {
356
361
  ].join("\n");
357
362
  }
358
363
  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}` : ""}`;
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}` : ""}`;
360
365
  if (sub === "connect") {
361
366
  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]");
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]");
363
368
  return 1;
364
369
  }
365
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 } });
@@ -367,10 +372,18 @@ async function regen(sub, id, a, call, show, io) {
367
372
  Spec id: ${b.id}`);
368
373
  } else if (sub === "run") {
369
374
  if (!id) {
370
- io.out("graft regen run <spec id>");
375
+ io.out("graft regen run <spec id> [--force] [--max-usd <n>]");
371
376
  return 1;
372
377
  }
373
- const b = await call("POST", `/api/v1/regen/${encodeURIComponent(id)}/run`);
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
+ });
374
387
  show(b, `${b.message}
375
388
  Rebuild: ${b.run}`);
376
389
  } else if (sub === "status") {
@@ -390,26 +403,91 @@ Rebuild: ${b.run}`);
390
403
  const differ = run2.choicesDiff?.previous;
391
404
  const lines = [line(run2), ...run2.builds.map((b) => ` build ${b.n}: ${b.verdict?.why ?? b.note ?? b.status}`)];
392
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
+ }
393
410
  if (run2.take) lines.push("Take it, in your clone:", ...run2.take.commands.map((c) => ` ${c}`));
394
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"));
395
449
  } else {
396
- io.out("graft regen connect|run|status (graft help)");
450
+ io.out("graft regen connect|run|status|report (graft help)");
397
451
  return 1;
398
452
  }
399
453
  return 0;
400
454
  }
401
455
  var unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
402
- async function verify(file, api, io) {
456
+ async function verify(file, api, io, token) {
403
457
  if (!file) {
404
- io.out("graft verify-record <file>");
458
+ io.out("graft verify-record <file or rebuild id>");
405
459
  return 1;
406
460
  }
407
461
  let e;
408
- try {
409
- e = JSON.parse(readFileSync(file, "utf8"));
410
- } catch {
411
- io.out(`${file} isn't a Graft record (not JSON).`);
412
- 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
+ }
413
491
  }
414
492
  if (e?.graft_record !== 1 || e.alg !== "Ed25519" || typeof e.payload !== "string" || typeof e.signature !== "string") {
415
493
  io.out(`${file} isn't a Graft record.`);
@@ -436,7 +514,23 @@ async function verify(file, api, io) {
436
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).`);
437
515
  return 0;
438
516
  }
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).`);
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}`);
440
534
  return 0;
441
535
  }
442
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.2.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",