@minnovate/shadow-publisher 0.3.0 → 0.6.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.
@@ -28,7 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  ));
29
29
 
30
30
  // src/main.ts
31
- var import_node_path7 = require("node:path");
31
+ var import_node_path8 = require("node:path");
32
32
 
33
33
  // ../protocol/src/versions.ts
34
34
  var PROTOCOL_VERSION = 1;
@@ -44,6 +44,7 @@ var PUBLICATION_ID_RE = new RegExp(`^pub_${ULID_BODY}$`);
44
44
  var CREATION_ID_RE = new RegExp(`^${ULID_BODY}$`);
45
45
  var TRANSACTION_ID_RE = new RegExp(`^(pub|cmp)_${ULID_BODY}$`);
46
46
  var PRINCIPAL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
47
+ var ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
47
48
  var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
48
49
  function ulid(now = Date.now()) {
49
50
  let time3 = now;
@@ -92,6 +93,10 @@ var ERROR_CODES = [
92
93
  "UNAUTHORIZED",
93
94
  "FORBIDDEN",
94
95
  "REF_FORBIDDEN",
96
+ // The credential names no organisation, so there is nothing to publish a repository FOR. Its own
97
+ // code rather than FORBIDDEN: a caller can act on it (sign in again, choosing an organisation),
98
+ // and a test that could not tell it from "you may not" would prove nothing about the check.
99
+ "ORGANISATION_REQUIRED",
95
100
  // repositories
96
101
  "REPOSITORY_NOT_FOUND",
97
102
  "REPOSITORY_CONFLICT",
@@ -133,6 +138,7 @@ var HTTP_STATUS_FOR_CODE = {
133
138
  UNAUTHORIZED: 401,
134
139
  FORBIDDEN: 403,
135
140
  REF_FORBIDDEN: 403,
141
+ ORGANISATION_REQUIRED: 403,
136
142
  REPOSITORY_NOT_FOUND: 404,
137
143
  REPOSITORY_CONFLICT: 409,
138
144
  REPOSITORY_ARCHIVED: 409,
@@ -2585,6 +2591,7 @@ var PublicationId = string2().check(_regex(PUBLICATION_ID_RE));
2585
2591
  var TransactionId = string2().check(_regex(TRANSACTION_ID_RE));
2586
2592
  var CreationId = string2().check(_regex(CREATION_ID_RE));
2587
2593
  var PrincipalId = string2().check(_regex(PRINCIPAL_ID_RE));
2594
+ var OrgId = string2().check(_regex(ORG_ID_RE));
2588
2595
  var Sha256Hex = string2().check(_regex(SHA256_HEX_RE));
2589
2596
  var Oid = string2().check(_regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/));
2590
2597
  var IsoDate = iso_exports.datetime({ offset: true });
@@ -2666,7 +2673,19 @@ var MetaRecord = object({
2666
2673
  objectFormat: ObjectFormat,
2667
2674
  createdAt: IsoDate,
2668
2675
  createdBy: PrincipalId,
2669
- creationId: CreationId
2676
+ creationId: CreationId,
2677
+ /**
2678
+ * The organisation that published this repository, and what `repoId` is derived from: "that
2679
+ * organisation publishes a repository" is the product's sentence.
2680
+ *
2681
+ * `null` is what a record written BEFORE repositories were org-scoped reads as, and every such
2682
+ * record is still readable, cloneable and publishable — `tests/compat/fixtures/schema-v1` is one,
2683
+ * and the promise that it stays readable is why this field is not required. What `null` must
2684
+ * never do is default: the worker refuses to ANNOUNCE a publication it cannot attribute
2685
+ * (`packages/worker/src/publish.ts`), because a consumer that guessed would create an
2686
+ * application in an organisation nobody chose. Every repository created since carries one.
2687
+ */
2688
+ org: _default(nullable(OrgId), null)
2670
2689
  });
2671
2690
  var GrantPattern = string2().check(
2672
2691
  _minLength(1),
@@ -3566,12 +3585,12 @@ async function resolveRevs(dir, revs, opts = {}) {
3566
3585
  const out = revs.map(
3567
3586
  (r) => r.startsWith("-") ? null : r.includes("\0") ? new PublisherError("INVALID_REQUEST", "NUL in git argument") : null
3568
3587
  );
3569
- const ask2 = revs.flatMap((r, i) => out[i] === null && !r.startsWith("-") ? [i] : []);
3570
- if (ask2.length === 0) return out;
3571
- const { stdout } = await runGit(["cat-file", "--batch-check=%(objectname)", "-Z"], { cwd: dir, input: ask2.map((i) => revs[i]).join("\0") + "\0", ...opts });
3588
+ const ask3 = revs.flatMap((r, i) => out[i] === null && !r.startsWith("-") ? [i] : []);
3589
+ if (ask3.length === 0) return out;
3590
+ const { stdout } = await runGit(["cat-file", "--batch-check=%(objectname)", "-Z"], { cwd: dir, input: ask3.map((i) => revs[i]).join("\0") + "\0", ...opts });
3572
3591
  const records = stdout.toString("utf8").split("\0");
3573
3592
  const misses = [];
3574
- for (const [j, i] of ask2.entries()) {
3593
+ for (const [j, i] of ask3.entries()) {
3575
3594
  const record2 = records[j] ?? "";
3576
3595
  if (record2 && !/ (missing|ambiguous)$/.test(record2)) out[i] = record2;
3577
3596
  else if (!revs[i].includes("^{")) misses.push(i);
@@ -3971,6 +3990,47 @@ async function noteAcceptedPublication(directory, outcome, now = /* @__PURE__ */
3971
3990
  if (!oid) return;
3972
3991
  await advanceWorkspaceBase(directory, { oid, generation: outcome.resultGeneration }, now);
3973
3992
  }
3993
+ async function adoptWorkspace(client, directory, call = {}, now = /* @__PURE__ */ new Date()) {
3994
+ const dir = (0, import_node_path3.resolve)(directory);
3995
+ const existing = await getWorkspaceMapping(dir);
3996
+ if (existing) return existing;
3997
+ const section = await getConfigSection(dir, "publisher").catch(() => null);
3998
+ const a = section ? associationFrom(section) : null;
3999
+ if (!a) throw new PublisherError("LOCAL_STATE", "this folder is not associated with a published repository; run init first");
4000
+ const branch = (await currentBranch(dir))?.replace(/^refs\/heads\//, "") ?? null;
4001
+ if (!branch) throw new PublisherError("LOCAL_STATE", "HEAD is detached; check out the branch this folder publishes, then run sync again");
4002
+ const repo = await client.getRepository(a.repoId, call);
4003
+ const remoteOid = repo.refs[`refs/heads/${branch}`] ?? null;
4004
+ const tip = await revParse(dir, `refs/heads/${branch}`);
4005
+ let baseOid = null;
4006
+ let baseGeneration = 0;
4007
+ if (remoteOid) {
4008
+ if (!await objectExists(dir, remoteOid)) await client.fetch({ repoDir: dir, repoId: a.repoId, remote: a.remote }, call);
4009
+ const haveRemote = await objectExists(dir, remoteOid);
4010
+ if (!tip) {
4011
+ baseOid = haveRemote ? remoteOid : null;
4012
+ baseGeneration = haveRemote ? repo.generation : 0;
4013
+ } else if (haveRemote && (remoteOid === tip || await isAncestor(dir, remoteOid, tip))) {
4014
+ baseOid = remoteOid;
4015
+ baseGeneration = repo.generation;
4016
+ } else if (haveRemote) {
4017
+ const r = await runGit(["merge-base", tip, remoteOid], { cwd: dir, allowFailure: true });
4018
+ const shared = r.code === 0 ? r.stdout.toString("utf8").trim() : "";
4019
+ baseOid = shared === "" ? null : shared;
4020
+ }
4021
+ }
4022
+ await writeMapping(dir, {
4023
+ endpoint: a.endpoint,
4024
+ repoId: a.repoId,
4025
+ remote: a.remote,
4026
+ branch,
4027
+ baseOid,
4028
+ baseGeneration,
4029
+ checkedOutAt: now.toISOString(),
4030
+ baseAdvancedAt: now.toISOString()
4031
+ });
4032
+ return await getWorkspaceMapping(dir);
4033
+ }
3974
4034
  async function isEmptyDir(p) {
3975
4035
  try {
3976
4036
  return (await (0, import_promises4.readdir)(p)).length === 0;
@@ -4065,7 +4125,16 @@ async function inspectWorkspace(client, directory, call = {}) {
4065
4125
  const dir = (0, import_node_path3.resolve)(directory);
4066
4126
  const mapping = await getWorkspaceMapping(dir);
4067
4127
  const pending = await listPending(dir).catch(() => []);
4068
- if (!mapping) return { state: "unassociated", mapping: null, local: null, remote: null, pending, conflicts: [], hint: "not a workspace; run checkout" };
4128
+ if (!mapping)
4129
+ return {
4130
+ state: "unassociated",
4131
+ mapping: null,
4132
+ local: null,
4133
+ remote: null,
4134
+ pending,
4135
+ conflicts: [],
4136
+ hint: "not a workspace yet; run sync here (an associated folder adopts itself), or checkout to create one"
4137
+ };
4069
4138
  const branchRef = `refs/heads/${mapping.branch}`;
4070
4139
  const tip = await revParse(dir, branchRef);
4071
4140
  const head = await revParse(dir, "HEAD");
@@ -4088,27 +4157,31 @@ async function inspectWorkspace(client, directory, call = {}) {
4088
4157
  );
4089
4158
  }
4090
4159
  if (!remoteOid) {
4091
- if (!tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; commit, then publish` : "empty on both sides");
4092
- return done("local_changes", "the server has no such branch yet; publish to create it");
4160
+ if (!tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; publisher sync records and publishes them` : "empty on both sides");
4161
+ return done("local_changes", "the server has no such branch yet; publisher sync creates it");
4093
4162
  }
4094
4163
  if (!tip) return done("remote_ahead", hasLocalWork ? `local branch is unborn and there are ${work}; move them aside, then sync` : "local branch is unborn; sync to fast-forward");
4095
4164
  if (!await objectExists(dir, remoteOid)) {
4096
4165
  await client.fetch({ repoDir: dir, repoId: mapping.repoId, remote: mapping.remote }, call);
4097
4166
  if (!await objectExists(dir, remoteOid)) return done("remote_ahead", "server tip could not be fetched; retry");
4098
4167
  }
4099
- if (remoteOid === tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; commit, then publish` : "in sync with the server");
4168
+ if (remoteOid === tip)
4169
+ return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; publisher sync records and publishes them` : "in sync with the server");
4100
4170
  if (await isAncestor(dir, remoteOid, tip))
4101
- return done("local_changes", hasLocalWork ? `local commits and ${work}; commit, then publish` : "local commits not yet published; publish");
4171
+ return done(
4172
+ "local_changes",
4173
+ hasLocalWork ? `local commits and ${work}; publisher sync records and publishes them` : "local commits not yet published; publisher sync publishes them"
4174
+ );
4102
4175
  if (await isAncestor(dir, tip, remoteOid))
4103
4176
  return done(
4104
4177
  "remote_ahead",
4105
- hasLocalWork ? `server moved ahead and there are ${work}; commit or stash them, then sync to fast-forward` : "server moved ahead; sync to fast-forward"
4178
+ hasLocalWork ? `server moved ahead and there are ${work}; publisher sync records them, brings the server's changes in and publishes` : "server moved ahead; publisher sync brings it in"
4106
4179
  );
4107
4180
  const merge2 = await mergeRemote({ repoDir: dir, ours: branchRef, theirs: remoteOid, apply: false });
4108
4181
  if (merge2.ok)
4109
4182
  return done(
4110
4183
  "diverged_clean_merge",
4111
- hasLocalWork ? `both sides changed, merge is clean; commit or stash the ${work}, then sync --apply` : "both sides changed, merge is clean; sync --apply, then publish"
4184
+ hasLocalWork ? `both sides changed, merge is clean; publisher sync records the ${work}, merges and publishes` : "both sides changed, merge is clean; publisher sync merges and publishes"
4112
4185
  );
4113
4186
  return done(
4114
4187
  "conflict",
@@ -4118,6 +4191,7 @@ async function inspectWorkspace(client, directory, call = {}) {
4118
4191
  }
4119
4192
  async function syncWorkspace(client, opts, call = {}, now = /* @__PURE__ */ new Date()) {
4120
4193
  const dir = (0, import_node_path3.resolve)(opts.directory);
4194
+ await adoptWorkspace(client, dir, call, now);
4121
4195
  const before = await inspectWorkspace(client, dir, call);
4122
4196
  if (!before.mapping) throw new PublisherError("LOCAL_STATE", "not a workspace; run checkout");
4123
4197
  const mapping = before.mapping;
@@ -4125,7 +4199,7 @@ async function syncWorkspace(client, opts, call = {}, now = /* @__PURE__ */ new
4125
4199
  let applied = null;
4126
4200
  if (before.state === "detached") throw new PublisherError("LOCAL_STATE", before.hint);
4127
4201
  if (opts.apply && before.remote?.oid && (before.state === "remote_ahead" || before.state === "diverged_clean_merge")) {
4128
- if (before.local?.dirty) throw new PublisherError("LOCAL_STATE", "the working tree has uncommitted changes; commit or stash them before sync --apply");
4202
+ if (before.local?.dirty) throw new PublisherError("LOCAL_STATE", "the working tree has uncommitted changes; record them first (publisher sync does)");
4129
4203
  if (!before.local?.tip) {
4130
4204
  await runGit(["checkout", "-q", "-B", mapping.branch, before.remote.oid], { cwd: dir });
4131
4205
  applied = "fast-forward";
@@ -4419,18 +4493,6 @@ async function updateEntry(url2, change) {
4419
4493
  await writeCredentials(file);
4420
4494
  }
4421
4495
 
4422
- // ../client/src/fetch.ts
4423
- var import_node_fs5 = require("node:fs");
4424
- var import_promises7 = require("node:fs/promises");
4425
- var import_node_os3 = require("node:os");
4426
- var import_node_path5 = require("node:path");
4427
- var import_promises8 = require("node:stream/promises");
4428
- var import_node_stream2 = require("node:stream");
4429
- var import_node_crypto4 = require("node:crypto");
4430
-
4431
- // ../client/src/publish.ts
4432
- var import_promises6 = require("node:fs/promises");
4433
-
4434
4496
  // ../client/src/transport.ts
4435
4497
  function assertCredentialEndpoint(endpoint) {
4436
4498
  let url2;
@@ -4536,6 +4598,328 @@ function sleep(ms, signal) {
4536
4598
  });
4537
4599
  }
4538
4600
 
4601
+ // ../client/src/run-progress.ts
4602
+ var RUN_STAGES = ["received", "running", "deploying", "live"];
4603
+ var STAGE_WORDS = { received: "received", running: "checked", deploying: "going live", live: "live" };
4604
+ var STATES = ["pending", "in-progress", "done", "failed", "skipped"];
4605
+ function readStages(run) {
4606
+ if (!isRecord(run)) return { malformed: "a run that is not an object" };
4607
+ if (!Object.hasOwn(run, "stages")) return null;
4608
+ const stages = run["stages"];
4609
+ if (!Array.isArray(stages) || stages.length !== RUN_STAGES.length) return { malformed: "stages that are not exactly four" };
4610
+ const out = [];
4611
+ for (const [index, entry] of stages.entries()) {
4612
+ const stage = RUN_STAGES[index];
4613
+ if (!isRecord(entry) || entry["stage"] !== stage) return { malformed: `stage ${index + 1} is not ${stage}` };
4614
+ const state = entry["state"];
4615
+ if (!STATES.includes(state)) return { malformed: `stage ${stage} has an unknown state` };
4616
+ const at = entry["at"];
4617
+ if (typeof at !== "string" && at !== null) return { malformed: `stage ${stage} has an unreadable time` };
4618
+ out.push({ stage, word: STAGE_WORDS[stage], state, at });
4619
+ }
4620
+ return { stages: out };
4621
+ }
4622
+ function ending(stages) {
4623
+ if (stages.some((s) => s.state === "pending" || s.state === "in-progress")) return null;
4624
+ if (stages.some((s) => s.state === "failed")) return "failed";
4625
+ if (stages[3].state === "done") return "live";
4626
+ if (stages[1].state === "done" && stages[2].state === "skipped") return "not-deployed";
4627
+ return "stopped";
4628
+ }
4629
+ var POLL_INTERVAL_MS = 5e3;
4630
+ var FOLLOW_TIMEOUT_MS = 15 * 6e4;
4631
+ async function followRun(input, opts) {
4632
+ const fetchImpl = opts.fetch ?? fetch;
4633
+ const sleep2 = opts.sleep ?? sleep;
4634
+ const now = opts.now ?? Date.now;
4635
+ const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
4636
+ const deadline = now() + (opts.timeoutMs ?? FOLLOW_TIMEOUT_MS);
4637
+ const query = new URLSearchParams({ publication: input.publicationId, ref: input.ref, commit: input.commit });
4638
+ const url2 = `${input.portal.replace(/\/+$/, "")}/api/v1/terminal/repositories/${encodeURIComponent(input.repoId)}/runs?${query}`;
4639
+ const seen = /* @__PURE__ */ new Map();
4640
+ let last = null;
4641
+ let lastError = null;
4642
+ let refreshed = false;
4643
+ for (; ; ) {
4644
+ const answer = await ask(fetchImpl, url2, await opts.token());
4645
+ if (answer.status === 401 && !refreshed && opts.refresh !== void 0) {
4646
+ refreshed = true;
4647
+ if (await opts.refresh()) continue;
4648
+ }
4649
+ if (answer.status === 401 && isRecord(answer.body) && typeof answer.body["error"] === "string") return { kind: "unauthorized" };
4650
+ if (answer.status === 404 || answer.status === 401) return { kind: "unavailable", reason: "no-route" };
4651
+ if (answer.status === 403) return { kind: "forbidden" };
4652
+ if (answer.status === 200) {
4653
+ const run = soleRun(answer.body);
4654
+ if ("detail" in run) return { kind: "malformed", detail: run.detail };
4655
+ if (run.run !== null) {
4656
+ const read = readStages(run.run);
4657
+ if (read === null) return { kind: "unavailable", reason: "no-stages" };
4658
+ if ("malformed" in read) return { kind: "malformed", detail: read.malformed };
4659
+ last = read.stages;
4660
+ for (const entry of read.stages) {
4661
+ if (entry.state === "pending" || seen.get(entry.stage) === entry.state) continue;
4662
+ seen.set(entry.stage, entry.state);
4663
+ opts.onStage?.(entry);
4664
+ }
4665
+ const end = ending(read.stages);
4666
+ if (end !== null) return { kind: "settled", ending: end, failedAt: read.stages.find((s) => s.state === "failed")?.word ?? null, stages: read.stages };
4667
+ }
4668
+ lastError = null;
4669
+ } else {
4670
+ lastError = answer.status === 0 ? "the portal could not be reached" : `the portal answered ${answer.status}`;
4671
+ }
4672
+ if (now() + interval > deadline) return { kind: "timed-out", stages: last, lastError };
4673
+ await sleep2(interval);
4674
+ }
4675
+ }
4676
+ function soleRun(body) {
4677
+ if (!isRecord(body) || !Array.isArray(body["runs"])) return { detail: "an answer with no list of runs" };
4678
+ const runs = body["runs"];
4679
+ if (runs.length > 1) return { detail: "more than the one run this publication started" };
4680
+ return { run: runs[0] ?? null };
4681
+ }
4682
+ async function ask(fetchImpl, url2, token) {
4683
+ try {
4684
+ const res = await fetchImpl(url2, { headers: { authorization: `Bearer ${token}`, accept: "application/json" }, signal: AbortSignal.timeout(3e4) });
4685
+ const text = await res.text();
4686
+ let body = null;
4687
+ try {
4688
+ body = text ? JSON.parse(text) : null;
4689
+ } catch {
4690
+ if (res.status === 200) return { status: 200, body: void 0 };
4691
+ }
4692
+ return { status: res.status, body };
4693
+ } catch {
4694
+ return { status: 0, body: null };
4695
+ }
4696
+ }
4697
+ function portalForEndpoint(endpoint) {
4698
+ let url2;
4699
+ try {
4700
+ url2 = new URL(endpoint);
4701
+ } catch {
4702
+ return null;
4703
+ }
4704
+ if (url2.protocol !== "https:" || url2.pathname.replace(/\/+$/, "") !== "/publish") return null;
4705
+ return url2.origin;
4706
+ }
4707
+ async function confirmPortal(portal, endpoint, fetchImpl = fetch) {
4708
+ try {
4709
+ const res = await fetchImpl(`${portal}/api/v1/config`, { signal: AbortSignal.timeout(1e4) });
4710
+ if (!res.ok) return false;
4711
+ const body = await res.json();
4712
+ const named = body?.publishingEndpoint;
4713
+ const bare = (u) => u.replace(/\/+$/, "");
4714
+ return typeof named === "string" && bare(named) === bare(endpoint);
4715
+ } catch {
4716
+ return false;
4717
+ }
4718
+ }
4719
+ function isRecord(value) {
4720
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4721
+ }
4722
+
4723
+ // ../client/src/onboard.ts
4724
+ var import_promises6 = require("node:fs/promises");
4725
+ var import_node_path5 = require("node:path");
4726
+ var DEFAULT_EXCLUDED_DIRS = [
4727
+ ".git",
4728
+ "node_modules",
4729
+ ".venv",
4730
+ "venv",
4731
+ "__pycache__",
4732
+ ".cache",
4733
+ ".next",
4734
+ ".nuxt",
4735
+ ".turbo",
4736
+ ".parcel-cache",
4737
+ ".aws",
4738
+ ".ssh",
4739
+ ".gnupg",
4740
+ ".idea",
4741
+ ".terraform",
4742
+ ".pytest_cache",
4743
+ ".mypy_cache",
4744
+ "coverage",
4745
+ ".DS_Store"
4746
+ ];
4747
+ var DEFAULT_EXCLUDED_FILES = [
4748
+ /^\.env(\..*)?$/,
4749
+ /\.(pem|key|p12|pfx|jks|keystore)$/i,
4750
+ /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
4751
+ /^\.npmrc$/,
4752
+ /^\.pypirc$/,
4753
+ /^\.netrc$/,
4754
+ /\.(log|tmp|swp)$/i,
4755
+ /^\.DS_Store$/,
4756
+ /^Thumbs\.db$/i,
4757
+ /^credentials(\.json)?$/i,
4758
+ /^service-account.*\.json$/i,
4759
+ /^\.git(modules|attributes)?$/
4760
+ ];
4761
+ async function onboardFolder(opts) {
4762
+ const dir = opts.dir;
4763
+ const policy = opts.policy ?? {};
4764
+ const maxBytes = policy.maxFileBytes ?? 50 * 1024 * 1024;
4765
+ let initialized = false;
4766
+ if (await isGitRepo(dir)) {
4767
+ const head = await headCommit(dir);
4768
+ if (head && !policy.allowExistingHistory) {
4769
+ throw new PublisherError("LOCAL_STATE", "folder is already a Git repository with history; commit your changes and use publish instead of init");
4770
+ }
4771
+ } else {
4772
+ await initWorkRepo(dir, opts.branch ?? "main");
4773
+ initialized = true;
4774
+ }
4775
+ const excluded = [];
4776
+ const candidates = [];
4777
+ const extra = policy.exclude ?? [];
4778
+ const walk = async (d) => {
4779
+ for (const e of await (0, import_promises6.readdir)(d, { withFileTypes: true })) {
4780
+ if (opts.signal?.aborted) throw new PublisherError("CANCELLED", "cancelled");
4781
+ const full = (0, import_node_path5.join)(d, e.name);
4782
+ const rel = (0, import_node_path5.relative)(dir, full).split(import_node_path5.sep).join("/");
4783
+ if (e.isSymbolicLink()) {
4784
+ excluded.push({ path: rel, reason: "symbolic link" });
4785
+ continue;
4786
+ }
4787
+ if (e.isDirectory()) {
4788
+ if (excludedDir(rel, e.name, extra)) {
4789
+ excluded.push({ path: rel + "/", reason: "excluded by policy" });
4790
+ continue;
4791
+ }
4792
+ await walk(full);
4793
+ continue;
4794
+ }
4795
+ if (!e.isFile()) continue;
4796
+ if (excludedFile(rel, e.name, extra)) {
4797
+ excluded.push({ path: rel, reason: "excluded by policy" });
4798
+ continue;
4799
+ }
4800
+ const st = await (0, import_promises6.stat)(full);
4801
+ if (st.size > maxBytes) {
4802
+ excluded.push({ path: rel, reason: `larger than ${maxBytes} bytes` });
4803
+ continue;
4804
+ }
4805
+ candidates.push(rel);
4806
+ }
4807
+ };
4808
+ await walk(dir);
4809
+ const ignored = await checkIgnore(dir, candidates);
4810
+ const selected = candidates.filter((p) => !ignored.has(p)).sort();
4811
+ for (const p of ignored) excluded.push({ path: p, reason: "ignored by .gitignore" });
4812
+ if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded, initialized };
4813
+ const commit = await commitPaths(dir, selected, {
4814
+ message: opts.message ?? "Initial publication",
4815
+ ...opts.authorName ? { authorName: opts.authorName } : {},
4816
+ ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
4817
+ ...opts.signal ? { signal: opts.signal } : {}
4818
+ });
4819
+ return { repoDir: dir, commit, committed: selected, excluded, initialized };
4820
+ }
4821
+ function excludedDir(rel, name, extra) {
4822
+ return DEFAULT_EXCLUDED_DIRS.includes(name) || extra.some((p) => matches(rel, name, p));
4823
+ }
4824
+ function excludedFile(rel, name, extra) {
4825
+ return DEFAULT_EXCLUDED_FILES.some((re) => re.test(name)) || extra.some((p) => matches(rel, name, p));
4826
+ }
4827
+ async function recordChanges(opts) {
4828
+ const dir = opts.dir;
4829
+ if (!await isGitRepo(dir)) throw new PublisherError("LOCAL_STATE", "this folder is not published yet; run publisher init first");
4830
+ const maxBytes = opts.policy?.maxFileBytes ?? 50 * 1024 * 1024;
4831
+ const extra = opts.policy?.exclude ?? [];
4832
+ const excluded = [];
4833
+ const selected = [];
4834
+ const entries = await statusPorcelain(dir);
4835
+ if (await mergeInProgress(dir)) {
4836
+ const marked = [];
4837
+ for (const { code, path } of entries) {
4838
+ if (!UNMERGED.has(code)) continue;
4839
+ const text = await (0, import_promises6.readFile)((0, import_node_path5.join)(dir, path), "utf8").catch(() => "");
4840
+ if (/^<{7} /m.test(text) && /^>{7} /m.test(text)) marked.push(path);
4841
+ }
4842
+ if (marked.length)
4843
+ throw new PublisherError(
4844
+ "LOCAL_STATE",
4845
+ `still marked as conflicting: ${marked.join(", ")}; keep one version (or both) in each, remove the <<<<<<< ======= >>>>>>> lines, then sync again`,
4846
+ { details: { marked } }
4847
+ );
4848
+ }
4849
+ for (const { code, path } of entries) {
4850
+ if (code !== "??") {
4851
+ selected.push(path);
4852
+ continue;
4853
+ }
4854
+ const parts = path.split("/");
4855
+ const name = parts[parts.length - 1];
4856
+ const dirs = parts.slice(0, -1);
4857
+ const inExcludedDir = dirs.findIndex((d, i) => excludedDir(dirs.slice(0, i + 1).join("/"), d, extra));
4858
+ if (inExcludedDir >= 0) {
4859
+ excluded.push({ path: dirs.slice(0, inExcludedDir + 1).join("/") + "/", reason: "excluded by policy" });
4860
+ continue;
4861
+ }
4862
+ if (excludedFile(path, name, extra)) {
4863
+ excluded.push({ path, reason: "excluded by policy" });
4864
+ continue;
4865
+ }
4866
+ const st = await (0, import_promises6.lstat)((0, import_node_path5.join)(dir, path));
4867
+ if (st.isSymbolicLink()) {
4868
+ excluded.push({ path, reason: "symbolic link" });
4869
+ continue;
4870
+ }
4871
+ if (st.size > maxBytes) {
4872
+ excluded.push({ path, reason: `larger than ${maxBytes} bytes` });
4873
+ continue;
4874
+ }
4875
+ selected.push(path);
4876
+ }
4877
+ const unique = dedupe(excluded);
4878
+ if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded: unique, initialized: false };
4879
+ const commit = await commitPaths(dir, selected.sort(), {
4880
+ message: opts.message ?? "Update",
4881
+ ...opts.authorName ? { authorName: opts.authorName } : {},
4882
+ ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
4883
+ ...opts.signal ? { signal: opts.signal } : {}
4884
+ });
4885
+ return { repoDir: dir, commit, committed: selected, excluded: unique, initialized: false };
4886
+ }
4887
+ var UNMERGED = /* @__PURE__ */ new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
4888
+ async function mergeInProgress(dir) {
4889
+ return (await runGit(["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: dir, allowFailure: true })).code === 0;
4890
+ }
4891
+ function dedupe(excluded) {
4892
+ const seen = /* @__PURE__ */ new Set();
4893
+ return excluded.filter((e) => seen.has(e.path) ? false : (seen.add(e.path), true));
4894
+ }
4895
+ function matches(rel, base, pattern) {
4896
+ if (pattern.includes("*")) {
4897
+ const re = new RegExp(
4898
+ "^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
4899
+ );
4900
+ return re.test(base) || re.test(rel);
4901
+ }
4902
+ return base === pattern || rel === pattern || rel.startsWith(pattern.replace(/\/$/, "") + "/");
4903
+ }
4904
+ async function checkIgnore(dir, paths) {
4905
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
4906
+ const r = await runGit(["check-ignore", "--stdin", "-z"], { cwd: dir, input: paths.join("\0") + "\0", allowFailure: true });
4907
+ if (r.code !== 0) return /* @__PURE__ */ new Set();
4908
+ return new Set(r.stdout.toString("utf8").split("\0").filter(Boolean));
4909
+ }
4910
+
4911
+ // ../client/src/fetch.ts
4912
+ var import_node_fs5 = require("node:fs");
4913
+ var import_promises8 = require("node:fs/promises");
4914
+ var import_node_os3 = require("node:os");
4915
+ var import_node_path6 = require("node:path");
4916
+ var import_promises9 = require("node:stream/promises");
4917
+ var import_node_stream2 = require("node:stream");
4918
+ var import_node_crypto4 = require("node:crypto");
4919
+
4920
+ // ../client/src/publish.ts
4921
+ var import_promises7 = require("node:fs/promises");
4922
+
4539
4923
  // ../client/src/upload.ts
4540
4924
  var import_node_fs4 = require("node:fs");
4541
4925
  var import_node_stream = require("node:stream");
@@ -4614,7 +4998,7 @@ async function publish(ctx, repoId, remote, opts, call) {
4614
4998
  } catch (e) {
4615
4999
  if (!(e instanceof PublisherError && e.code === "LOCAL_STATE")) throw e;
4616
5000
  }
4617
- if (remaining.length === 0) return toOutcome(outcome, true);
5001
+ if (remaining.length === 0) return toOutcome(outcome, true, pending2.refUpdates);
4618
5002
  return publish(ctx, repoId, remote, opts, call);
4619
5003
  }
4620
5004
  }
@@ -4690,7 +5074,7 @@ async function runPublication(ctx, repoId, remote, repoDir, store2, pending, opt
4690
5074
  }
4691
5075
  if (opts.wait === false) {
4692
5076
  summary = summary ?? await ctx.getStatus(repoId, pending.publicationId, call);
4693
- return toOutcome(summary, resumed);
5077
+ return toOutcome(summary, resumed, pending.refUpdates);
4694
5078
  }
4695
5079
  progress({ phase: "validate", message: "waiting for validation" });
4696
5080
  summary = await waitForOutcome(ctx, repoId, pending.publicationId, opts.waitTimeoutMs ?? 10 * 6e4, call);
@@ -4700,9 +5084,9 @@ async function runPublication(ctx, repoId, remote, repoDir, store2, pending, opt
4700
5084
  progress({ phase: "done", message: `published generation ${summary.resultGeneration}` });
4701
5085
  } else if (summary.state === "rejected") {
4702
5086
  await store2.remove(pending.publicationId);
4703
- if (pending.pack) await (0, import_promises6.rm)(pending.pack.path, { force: true });
5087
+ if (pending.pack) await (0, import_promises7.rm)(pending.pack.path, { force: true });
4704
5088
  }
4705
- return toOutcome(summary, resumed);
5089
+ return toOutcome(summary, resumed, pending.refUpdates);
4706
5090
  }
4707
5091
  async function waitForOutcome(ctx, repoId, publicationId, timeoutMs, call) {
4708
5092
  const deadline = Date.now() + timeoutMs;
@@ -4749,7 +5133,7 @@ async function resolvePending(ctx, repoDir, remote, pending, call, store2) {
4749
5133
  }
4750
5134
  return summary;
4751
5135
  }
4752
- function toOutcome(s, resumed) {
5136
+ function toOutcome(s, resumed, updates) {
4753
5137
  return {
4754
5138
  publicationId: s.publicationId,
4755
5139
  repoId: s.repoId,
@@ -4758,10 +5142,23 @@ function toOutcome(s, resumed) {
4758
5142
  resultGeneration: s.resultGeneration,
4759
5143
  refs: s.refs,
4760
5144
  rejection: s.rejection,
5145
+ next: publishNext(s.rejection, updates[0]?.ref ?? null),
4761
5146
  resumed,
4762
5147
  summary: s
4763
5148
  };
4764
5149
  }
5150
+ var REF_MOVED = /* @__PURE__ */ new Set(["NON_FAST_FORWARD", "EXPECTED_OLD_MISMATCH", "STALE_GENERATION"]);
5151
+ function publishNext(rejection, published) {
5152
+ if (rejection === null || !REF_MOVED.has(rejection.code)) return null;
5153
+ const named = rejection.details?.["ref"];
5154
+ const ref = typeof named === "string" ? named : published;
5155
+ const name = ref?.replace(/^refs\/heads\//, "") ?? "main";
5156
+ return {
5157
+ action: "sync-and-publish",
5158
+ ask: `${name} moved since your last fetch: someone else published to it. Sync brings those changes in without rewriting yours, and publishes the result.`,
5159
+ command: `publisher sync`
5160
+ };
5161
+ }
4765
5162
  async function defaultUpdate(repoDir) {
4766
5163
  const branch = await currentBranch(repoDir);
4767
5164
  if (!branch) throw new PublisherError("LOCAL_STATE", "detached HEAD: specify which ref to publish");
@@ -4807,15 +5204,15 @@ function trackingRef(remote, ref) {
4807
5204
  // ../client/src/fetch.ts
4808
5205
  async function installedManifest(gd) {
4809
5206
  try {
4810
- const parsed = JSON.parse(await (0, import_promises7.readFile)((0, import_node_path5.join)(gd, "publisher", "installed.json"), "utf8"));
5207
+ const parsed = JSON.parse(await (0, import_promises8.readFile)((0, import_node_path6.join)(gd, "publisher", "installed.json"), "utf8"));
4811
5208
  return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []);
4812
5209
  } catch {
4813
5210
  return /* @__PURE__ */ new Set();
4814
5211
  }
4815
5212
  }
4816
5213
  async function writeManifest(gd, installed) {
4817
- await (0, import_promises7.mkdir)((0, import_node_path5.join)(gd, "publisher"), { recursive: true });
4818
- await (0, import_promises7.writeFile)((0, import_node_path5.join)(gd, "publisher", "installed.json"), JSON.stringify(installed));
5214
+ await (0, import_promises8.mkdir)((0, import_node_path6.join)(gd, "publisher"), { recursive: true });
5215
+ await (0, import_promises8.writeFile)((0, import_node_path6.join)(gd, "publisher", "installed.json"), JSON.stringify(installed));
4819
5216
  }
4820
5217
  async function fetchRepository(ctx, repoId, opts, call) {
4821
5218
  const remote = opts.remote ?? "publisher";
@@ -4862,13 +5259,14 @@ async function haveAll(repoDir, refs) {
4862
5259
  async function install(ctx, gd, artifacts, call) {
4863
5260
  if (artifacts.length === 0) return 0;
4864
5261
  const progress = call.onProgress ?? (() => void 0);
4865
- const tmp = await (0, import_promises7.mkdtemp)((0, import_node_path5.join)((0, import_node_os3.tmpdir)(), "gitpub-fetch-"));
5262
+ const tmp = await (0, import_promises8.mkdtemp)((0, import_node_path6.join)((0, import_node_os3.tmpdir)(), "gitpub-fetch-"));
4866
5263
  let bytes = 0;
5264
+ const total = artifacts.reduce((sum, a) => sum + a.size, 0);
4867
5265
  try {
4868
5266
  for (const a of artifacts) {
4869
- const file = (0, import_node_path5.join)(tmp, `${a.sha256}.${a.kind}`);
5267
+ const file = (0, import_node_path6.join)(tmp, `${a.sha256}.${a.kind}`);
4870
5268
  bytes += await download(ctx.fetchImpl, a.url, file, a.sha256, a.size, call);
4871
- progress({ phase: "fetch", message: `installing ${a.kind}`, bytes, total: a.size });
5269
+ progress({ phase: "fetch", message: `installing ${a.kind}`, bytes, total });
4872
5270
  if (a.kind === "bundle") {
4873
5271
  await bundleUnbundleObjects(gd, file);
4874
5272
  } else {
@@ -4876,7 +5274,7 @@ async function install(ctx, gd, artifacts, call) {
4876
5274
  }
4877
5275
  }
4878
5276
  } finally {
4879
- await (0, import_promises7.rm)(tmp, { recursive: true, force: true });
5277
+ await (0, import_promises8.rm)(tmp, { recursive: true, force: true });
4880
5278
  }
4881
5279
  return bytes;
4882
5280
  }
@@ -4916,7 +5314,7 @@ async function download(fetchImpl, url2, dest, sha256, size, call) {
4916
5314
  controller.enqueue(chunk);
4917
5315
  }
4918
5316
  });
4919
- await (0, import_promises8.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs5.createWriteStream)(dest));
5317
+ await (0, import_promises9.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs5.createWriteStream)(dest));
4920
5318
  if (n !== size || hash.digest("hex") !== sha256) throw new PublisherError("NETWORK", "downloaded artifact failed checksum verification", { retryable: true });
4921
5319
  return n;
4922
5320
  }
@@ -4924,6 +5322,11 @@ async function download(fetchImpl, url2, dest, sha256, size, call) {
4924
5322
  // ../client/src/publisher.ts
4925
5323
  var CLIENT_NAME = "git-publisher-client";
4926
5324
  var CLIENT_VERSION = "0.1.0";
5325
+ async function markConflicts(dir, remote) {
5326
+ const who = { GIT_AUTHOR_NAME: "Publisher", GIT_AUTHOR_EMAIL: "publisher@localhost", GIT_COMMITTER_NAME: "Publisher", GIT_COMMITTER_EMAIL: "publisher@localhost" };
5327
+ const r = await runGit(["merge", "--no-ff", "--no-commit", "--no-verify", remote], { cwd: dir, env: { extra: who }, allowFailure: true });
5328
+ if (r.code !== 0 && !await mergeInProgress(dir)) throw new PublisherError("GIT", `could not mark the conflicts: ${r.stderr.trim()}`);
5329
+ }
4927
5330
  var Publisher = class {
4928
5331
  constructor(opts) {
4929
5332
  this.opts = opts;
@@ -4950,6 +5353,19 @@ var Publisher = class {
4950
5353
  fetchImpl;
4951
5354
  tokenFn;
4952
5355
  clientInfo;
5356
+ /**
5357
+ * Follow the run an accepted publication started, at the portal that serves this endpoint, with
5358
+ * this publisher's own credential and its one refresh — the portal verifies the same token the
5359
+ * control API does.
5360
+ */
5361
+ async followRun(input, opts = {}) {
5362
+ return followRun(input, {
5363
+ ...opts,
5364
+ token: this.tokenFn,
5365
+ refresh: async () => !this.opts.token && refreshStoredCredential(this.endpoint, { fetch: this.fetchImpl }),
5366
+ fetch: this.fetchImpl
5367
+ });
5368
+ }
4953
5369
  async getCapabilities(call = {}) {
4954
5370
  const r = await this.transport.request("GET", "/v1/capabilities", void 0, call);
4955
5371
  return r.body;
@@ -5022,6 +5438,10 @@ var Publisher = class {
5022
5438
  async checkout(options, call = {}) {
5023
5439
  return checkoutWorkspace(this, options, call);
5024
5440
  }
5441
+ /** Make an associated folder a workspace (idempotent); `init` and `sync` both call it. */
5442
+ async adoptWorkspace(directory, call = {}) {
5443
+ return adoptWorkspace(this, directory, call);
5444
+ }
5025
5445
  /** Offline: the persisted workspace mapping, or null. */
5026
5446
  async workspace(directory) {
5027
5447
  return getWorkspaceMapping(directory);
@@ -5034,6 +5454,37 @@ var Publisher = class {
5034
5454
  async sync(options, call = {}) {
5035
5455
  return syncWorkspace(this, options, call);
5036
5456
  }
5457
+ /**
5458
+ * A creator's whole round trip in one call, so nobody runs git: record their changes, bring in
5459
+ * what the service has (fast-forward or a clean merge, never a rewrite), and publish. It stops,
5460
+ * having published nothing, on a conflict; the recorded commit stays, so nothing is lost.
5461
+ *
5462
+ * Everything that can refuse without touching the folder refuses first: a folder with no
5463
+ * association (or one pinned elsewhere) and a HEAD off the workspace branch would otherwise get
5464
+ * a commit and then an error. `syncWorkspace` itself still never commits or publishes; this is
5465
+ * the composition, and the only one.
5466
+ */
5467
+ async syncChanges(options, call = {}) {
5468
+ const dir = options.directory;
5469
+ await this.resolveRepo(dir);
5470
+ const branch = (await currentBranch(dir))?.replace(/^refs\/heads\//, "") ?? null;
5471
+ if (!branch) throw new PublisherError("LOCAL_STATE", "HEAD is detached; check out the branch this folder publishes, then run sync again");
5472
+ const mapped = await getWorkspaceMapping(dir);
5473
+ if (mapped && mapped.branch !== branch)
5474
+ throw new PublisherError("LOCAL_STATE", `branch ${branch} is checked out, but this folder publishes ${mapped.branch}; check it out, then run sync again`);
5475
+ const recorded = await recordChanges({ dir, ...options.message ? { message: options.message } : {} });
5476
+ const reconciled = await syncWorkspace(this, { directory: dir, apply: true }, call);
5477
+ const base = { recorded: { commit: recorded.commit, committed: recorded.committed, excluded: recorded.excluded }, reconciled };
5478
+ if (reconciled.after === "conflict") {
5479
+ const remote = reconciled.before.remote?.oid ?? null;
5480
+ if (options.markConflicts && remote) await markConflicts(dir, remote);
5481
+ return { ...base, state: "conflict", published: null, marked: options.markConflicts === true && remote !== null };
5482
+ }
5483
+ const tip = await revParse(dir, `refs/heads/${branch}`);
5484
+ if (tip === null || tip === (reconciled.before.remote?.oid ?? null)) return { ...base, state: "up_to_date", published: null, marked: false };
5485
+ const published = await this.publish({ repoDir: dir }, call);
5486
+ return { ...base, state: published.state === "accepted" ? "published" : published.state === "rejected" ? "rejected" : "unknown", published, marked: false };
5487
+ }
5037
5488
  /** Resolve interrupted publications recorded locally without creating duplicates. */
5038
5489
  async resumePublications(repoDir, call = {}) {
5039
5490
  const { remote } = await this.resolveRepo(repoDir);
@@ -5051,7 +5502,7 @@ var Publisher = class {
5051
5502
  call.onProgress?.({ phase: "validate", message: `waiting for ${p.publicationId}` });
5052
5503
  s = await resolvePending(this.publishContext(), repoDir, remote, p, { ...call, waitSeconds: MAX_WAIT_SECONDS }, store2);
5053
5504
  }
5054
- out.push(toOutcome(s, true));
5505
+ out.push(toOutcome(s, true, p.refUpdates));
5055
5506
  }
5056
5507
  for (const o of out) await noteAcceptedPublication(repoDir, o);
5057
5508
  return out;
@@ -5087,120 +5538,6 @@ function createPublisher(opts) {
5087
5538
  return new Publisher(opts);
5088
5539
  }
5089
5540
 
5090
- // ../client/src/onboard.ts
5091
- var import_promises9 = require("node:fs/promises");
5092
- var import_node_path6 = require("node:path");
5093
- var DEFAULT_EXCLUDED_DIRS = [
5094
- ".git",
5095
- "node_modules",
5096
- ".venv",
5097
- "venv",
5098
- "__pycache__",
5099
- ".cache",
5100
- ".next",
5101
- ".nuxt",
5102
- ".turbo",
5103
- ".parcel-cache",
5104
- ".aws",
5105
- ".ssh",
5106
- ".gnupg",
5107
- ".idea",
5108
- ".terraform",
5109
- ".pytest_cache",
5110
- ".mypy_cache",
5111
- "coverage",
5112
- ".DS_Store"
5113
- ];
5114
- var DEFAULT_EXCLUDED_FILES = [
5115
- /^\.env(\..*)?$/,
5116
- /\.(pem|key|p12|pfx|jks|keystore)$/i,
5117
- /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
5118
- /^\.npmrc$/,
5119
- /^\.pypirc$/,
5120
- /^\.netrc$/,
5121
- /\.(log|tmp|swp)$/i,
5122
- /^\.DS_Store$/,
5123
- /^Thumbs\.db$/i,
5124
- /^credentials(\.json)?$/i,
5125
- /^service-account.*\.json$/i,
5126
- /^\.git(modules|attributes)?$/
5127
- ];
5128
- async function onboardFolder(opts) {
5129
- const dir = opts.dir;
5130
- const policy = opts.policy ?? {};
5131
- const maxBytes = policy.maxFileBytes ?? 50 * 1024 * 1024;
5132
- let initialized = false;
5133
- if (await isGitRepo(dir)) {
5134
- const head = await headCommit(dir);
5135
- if (head && !policy.allowExistingHistory) {
5136
- throw new PublisherError("LOCAL_STATE", "folder is already a Git repository with history; commit your changes and use publish instead of init");
5137
- }
5138
- } else {
5139
- await initWorkRepo(dir, opts.branch ?? "main");
5140
- initialized = true;
5141
- }
5142
- const excluded = [];
5143
- const candidates = [];
5144
- const extra = policy.exclude ?? [];
5145
- const walk = async (d) => {
5146
- for (const e of await (0, import_promises9.readdir)(d, { withFileTypes: true })) {
5147
- if (opts.signal?.aborted) throw new PublisherError("CANCELLED", "cancelled");
5148
- const full = (0, import_node_path6.join)(d, e.name);
5149
- const rel = (0, import_node_path6.relative)(dir, full).split(import_node_path6.sep).join("/");
5150
- if (e.isSymbolicLink()) {
5151
- excluded.push({ path: rel, reason: "symbolic link" });
5152
- continue;
5153
- }
5154
- if (e.isDirectory()) {
5155
- if (DEFAULT_EXCLUDED_DIRS.includes(e.name) || extra.some((p) => matches(rel, e.name, p))) {
5156
- excluded.push({ path: rel + "/", reason: "excluded by policy" });
5157
- continue;
5158
- }
5159
- await walk(full);
5160
- continue;
5161
- }
5162
- if (!e.isFile()) continue;
5163
- if (DEFAULT_EXCLUDED_FILES.some((re) => re.test(e.name)) || extra.some((p) => matches(rel, e.name, p))) {
5164
- excluded.push({ path: rel, reason: "excluded by policy" });
5165
- continue;
5166
- }
5167
- const st = await (0, import_promises9.stat)(full);
5168
- if (st.size > maxBytes) {
5169
- excluded.push({ path: rel, reason: `larger than ${maxBytes} bytes` });
5170
- continue;
5171
- }
5172
- candidates.push(rel);
5173
- }
5174
- };
5175
- await walk(dir);
5176
- const ignored = await checkIgnore(dir, candidates);
5177
- const selected = candidates.filter((p) => !ignored.has(p)).sort();
5178
- for (const p of ignored) excluded.push({ path: p, reason: "ignored by .gitignore" });
5179
- if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded, initialized };
5180
- const commit = await commitPaths(dir, selected, {
5181
- message: opts.message ?? "Initial publication",
5182
- ...opts.authorName ? { authorName: opts.authorName } : {},
5183
- ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
5184
- ...opts.signal ? { signal: opts.signal } : {}
5185
- });
5186
- return { repoDir: dir, commit, committed: selected, excluded, initialized };
5187
- }
5188
- function matches(rel, base, pattern) {
5189
- if (pattern.includes("*")) {
5190
- const re = new RegExp(
5191
- "^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
5192
- );
5193
- return re.test(base) || re.test(rel);
5194
- }
5195
- return base === pattern || rel === pattern || rel.startsWith(pattern.replace(/\/$/, "") + "/");
5196
- }
5197
- async function checkIgnore(dir, paths) {
5198
- if (paths.length === 0) return /* @__PURE__ */ new Set();
5199
- const r = await runGit(["check-ignore", "--stdin", "-z"], { cwd: dir, input: paths.join("\0") + "\0", allowFailure: true });
5200
- if (r.code !== 0) return /* @__PURE__ */ new Set();
5201
- return new Set(r.stdout.toString("utf8").split("\0").filter(Boolean));
5202
- }
5203
-
5204
5541
  // ../client/src/migrate.ts
5205
5542
  async function fetchCapabilities(endpoint, fetchImpl = fetch, signal) {
5206
5543
  const res = await fetchImpl(`${normalize(endpoint)}/v1/capabilities`, { method: "GET", ...signal ? { signal } : {} });
@@ -5354,7 +5691,7 @@ function resolveChoice(answer, names) {
5354
5691
  }
5355
5692
  return names.find((n) => n.toLowerCase() === a.toLowerCase()) ?? null;
5356
5693
  }
5357
- async function ask(question) {
5694
+ async function ask2(question) {
5358
5695
  const rl = (0, import_promises10.createInterface)({ input: process.stdin, output: process.stderr });
5359
5696
  try {
5360
5697
  return await rl.question(question);
@@ -5372,7 +5709,7 @@ ${purpose}
5372
5709
  names.forEach((n, i) => process.stderr.write(` ${i + 1}) ${n}
5373
5710
  `));
5374
5711
  for (let attempt = 0; attempt < 3; attempt++) {
5375
- const answer = await ask(`
5712
+ const answer = await ask2(`
5376
5713
  Which one? [1-${names.length}, or a name] `);
5377
5714
  if (answer === null) return null;
5378
5715
  const chosen = resolveChoice(answer, names);
@@ -5388,7 +5725,7 @@ Name this connection, so you never have to type ${endpoint} again.
5388
5725
  (for example: prod, dev, staging)
5389
5726
  `);
5390
5727
  for (let attempt = 0; attempt < 3; attempt++) {
5391
- const answer = await ask(suggested ? `
5728
+ const answer = await ask2(suggested ? `
5392
5729
  Profile name [${suggested}]: ` : `
5393
5730
  Profile name: `);
5394
5731
  if (answer === null) return null;
@@ -5411,6 +5748,7 @@ var OPTIONS = {
5411
5748
  repo: { type: "string" },
5412
5749
  message: { type: "string" },
5413
5750
  publication: { type: "string" },
5751
+ timeout: { type: "string" },
5414
5752
  branch: { type: "string" },
5415
5753
  ours: { type: "string" },
5416
5754
  theirs: { type: "string" },
@@ -5420,7 +5758,9 @@ var OPTIONS = {
5420
5758
  quiet: { type: "boolean" },
5421
5759
  force: { type: "boolean" },
5422
5760
  apply: { type: "boolean" },
5761
+ "mark-conflicts": { type: "boolean" },
5423
5762
  help: { type: "boolean" },
5763
+ version: { type: "boolean" },
5424
5764
  "set-default": { type: "boolean" },
5425
5765
  // A flag of its own, not a negation of --wait: there is no --wait.
5426
5766
  "no-wait": { type: "boolean" }
@@ -5434,10 +5774,122 @@ function parseArgs(argv) {
5434
5774
  }
5435
5775
  }
5436
5776
 
5777
+ // src/skills.ts
5778
+ var import_node_child_process3 = require("node:child_process");
5779
+ var import_node_fs6 = require("node:fs");
5780
+ var import_node_path7 = require("node:path");
5781
+ var MARKETPLACE = "shadow-publisher";
5782
+ var PLUGIN = "git-publisher";
5783
+ function packageRoot() {
5784
+ return (0, import_node_path7.join)((0, import_node_path7.dirname)((0, import_node_fs6.realpathSync)(process.argv[1])), "..");
5785
+ }
5786
+ function packageVersion() {
5787
+ try {
5788
+ return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(packageRoot(), "package.json"), "utf8")).version;
5789
+ } catch {
5790
+ return "unknown";
5791
+ }
5792
+ }
5793
+ function claude(args) {
5794
+ return new Promise((resolve3) => {
5795
+ let stdout = "";
5796
+ let stderr = "";
5797
+ const child = (0, import_node_child_process3.spawn)("claude", args, { stdio: ["ignore", "pipe", "pipe"] });
5798
+ child.stdout.on("data", (d) => stdout += d.toString("utf8"));
5799
+ child.stderr.on("data", (d) => stderr += d.toString("utf8"));
5800
+ child.on("error", (e) => resolve3({ code: null, stdout, stderr: e.message, missing: e.code === "ENOENT" }));
5801
+ child.on("close", (code) => resolve3({ code, stdout, stderr, missing: false }));
5802
+ });
5803
+ }
5804
+ async function installSkills({ log }) {
5805
+ const source = (0, import_node_path7.join)(packageRoot(), "dist", "claude");
5806
+ if (!(0, import_node_fs6.existsSync)((0, import_node_path7.join)(source, ".claude-plugin", "marketplace.json"))) {
5807
+ throw new PublisherError("LOCAL_STATE", `this publisher carries no skills at ${source}; reinstall it with: npm install -g @minnovate/shadow-publisher`);
5808
+ }
5809
+ const step = async (what, args) => {
5810
+ log(what);
5811
+ const r = await claude(args);
5812
+ if (r.missing) {
5813
+ throw new PublisherError(
5814
+ "LOCAL_STATE",
5815
+ 'Claude Code is not installed, or "claude" is not on PATH. Install it from https://claude.com/claude-code, then run: publisher skills install'
5816
+ );
5817
+ }
5818
+ if (r.code !== 0) throw new PublisherError("LOCAL_STATE", `"claude ${args.slice(0, 3).join(" ")}" failed: ${(r.stderr || r.stdout).trim()}`);
5819
+ };
5820
+ await step("Adding the publisher to Claude Code\u2026", ["plugin", "marketplace", "add", source, "--scope", "user"]);
5821
+ await step("Installing its skills\u2026", ["plugin", "install", `${PLUGIN}@${MARKETPLACE}`, "--scope", "user"]);
5822
+ return {
5823
+ ok: true,
5824
+ plugin: `${PLUGIN}@${MARKETPLACE}`,
5825
+ marketplace: MARKETPLACE,
5826
+ source,
5827
+ version: packageVersion(),
5828
+ message: "The publisher skills are installed in Claude Code for your user. Start a new Claude Code session (or run /reload-plugins in an open one) and ask it to publish your app."
5829
+ };
5830
+ }
5831
+
5832
+ // src/follow.ts
5833
+ async function followPublication(p, endpoint, o, opts) {
5834
+ const derived = portalForEndpoint(endpoint);
5835
+ const portal = derived !== null && await confirmPortal(derived, endpoint, opts.fetch) ? derived : null;
5836
+ const elsewhere = (reason) => ({ kind: "unavailable", reason, portal, hint: inPortal(portal, "Published.", "Follow it") });
5837
+ if (portal === null) return elsewhere("no-portal");
5838
+ const refs = Object.entries(o.refs ?? {});
5839
+ if (refs.length !== 1) return elsewhere("several-refs");
5840
+ const [ref, commit] = refs[0];
5841
+ opts.log("Following it: received -> checked -> going live -> live. Ctrl-C stops watching; the publication is already saved.");
5842
+ const outcome = await p.followRun(
5843
+ { portal, repoId: o.repoId, publicationId: o.publicationId, ref, commit },
5844
+ { onStage: opts.onStage, ...opts.timeoutMs === void 0 ? {} : { timeoutMs: opts.timeoutMs } }
5845
+ );
5846
+ const next = outcome.kind === "timed-out" ? { action: "follow", command: `publisher follow --publication ${o.publicationId} --json` } : void 0;
5847
+ return { ...outcome, portal, hint: progressHint(outcome, portal), ...next === void 0 ? {} : { next } };
5848
+ }
5849
+ function followTimeoutMs(flag) {
5850
+ if (flag === void 0) return FOLLOW_TIMEOUT_MS;
5851
+ if (!/^[1-9][0-9]*$/.test(flag)) throw new PublisherError("INVALID_REQUEST", `--timeout takes a whole number of seconds, not ${JSON.stringify(flag)}`);
5852
+ return Math.min(Number(flag) * 1e3, FOLLOW_TIMEOUT_MS);
5853
+ }
5854
+ function stageLine(e, json) {
5855
+ return json ? JSON.stringify({ event: "stage", stage: e.stage, word: e.word, state: e.state, at: e.at }) : ` ${e.word}: ${e.state.replace("-", " ")}`;
5856
+ }
5857
+ function inPortal(portal, lead, verb) {
5858
+ return `${lead} ${verb} in the portal${portal === null ? "" : `: ${portal}`}.`;
5859
+ }
5860
+ function progressHint(r, portal) {
5861
+ switch (r.kind) {
5862
+ case "settled":
5863
+ switch (r.ending) {
5864
+ case "live":
5865
+ return "Your app is live.";
5866
+ case "not-deployed":
5867
+ return "Published and checked; this organisation does not deploy automatically.";
5868
+ case "failed":
5869
+ return inPortal(portal, `It stopped at "${r.failedAt ?? "a step"}".`, "See why");
5870
+ case "stopped":
5871
+ return "A newer publication replaced this one before it went live.";
5872
+ }
5873
+ break;
5874
+ case "timed-out":
5875
+ return inPortal(portal, `Still going${r.lastError === null ? "" : ` (${r.lastError})`}; run next.command to keep following.`, "Or follow it");
5876
+ case "unavailable":
5877
+ return inPortal(portal, "Published. This portal cannot show progress in the terminal yet.", "Follow it");
5878
+ case "unauthorized":
5879
+ return inPortal(portal, "Published, but the portal did not accept this sign-in (run publisher login).", "Follow it");
5880
+ case "forbidden":
5881
+ return inPortal(portal, "Published, but the portal would not show this sign-in the run.", "Follow it");
5882
+ case "malformed":
5883
+ return inPortal(portal, `Published, but the portal's answer about the run could not be read (${r.detail}).`, "Follow it");
5884
+ }
5885
+ }
5886
+
5437
5887
  // src/main.ts
5438
5888
  var USAGE = `publisher \u2014 publish an app's Git history to the serverless Git backend
5439
5889
 
5440
5890
  Usage:
5891
+ publisher skills install add the publisher's skills to Claude Code (user scope);
5892
+ run it again after updating the publisher
5441
5893
  publisher login sign in. With NO arguments it asks the
5442
5894
  portal where publishing lives, opens a
5443
5895
  browser to confirm who you are, and asks
@@ -5447,19 +5899,35 @@ Usage:
5447
5899
  [--profile <name>] [--set-default] [--token <token>]
5448
5900
  publisher logout (--endpoint <url> | --profile <name>)
5449
5901
  publisher profile list | profile use <name> | profile remove <name>
5450
- publisher init [dir] [--name <name>] [--repo <id>] create (or attach to) a repository and associate this folder
5451
- publisher publish [dir] [--ref <ref>]... [--force] [--no-wait]
5902
+ publisher init [dir] [--name <name>] [--repo <id>] create (or attach to) a repository, associate this folder
5903
+ and record it as a workspace (so sync works here)
5904
+ publisher publish [dir] [--ref <ref>]... [--force] [--no-wait] publish commits as they are; to save and publish
5905
+ a change, use sync
5452
5906
  publisher status [dir] [--publication <id>] resolve pending/interrupted publications
5453
5907
  publisher fetch [dir]
5454
5908
  publisher checkout <dir> --repo <id> [--branch <name>] create a NEW editable workspace (never overlays a folder)
5455
5909
  publisher workspace-status [dir] offline mapping + where the workspace stands (real three-way check)
5456
- publisher sync [dir] [--apply] fetch; with --apply fast-forward or merge cleanly, advance the base
5910
+ publisher sync [dir] [--message <text>] THE way to save and publish a change: records the folder's
5911
+ changes (never .env, keys or node_modules), brings in what
5912
+ was published meanwhile (fast-forward or a clean merge,
5913
+ never a rewrite) and publishes. Stops on a conflict with
5914
+ nothing published and nothing lost. No git needed
5915
+ [--mark-conflicts] on a conflict, put both versions in each file, marked,
5916
+ to edit; the next sync records and publishes the edit
5917
+ [--no-wait] don't follow the app going live afterwards (received ->
5918
+ checked -> going live -> live, up to 15 minutes)
5919
+ [--timeout <seconds>] follow for at most this long; if it is still going, the
5920
+ result's next.command picks it up
5921
+ publisher follow [dir] --publication <id> follow an accepted publication's run where a sync left it
5922
+ [--timeout <seconds>]
5457
5923
  publisher merge-check [dir] --ours <ref> --theirs <ref> [--apply]
5458
5924
  publisher info [dir] repository state as seen by the server
5459
5925
  publisher doctor environment check (node, git, credentials, association)
5460
5926
  publisher migrate [dir] [--to <url>] re-point a folder whose service moved (asks the service where)
5461
5927
 
5462
- Global flags: --endpoint <url>, --profile <name>, --token, --json, --quiet
5928
+ Global flags: --endpoint <url>, --profile <name>, --token, --json, --quiet, --version
5929
+
5930
+ A creator never needs git: init once, then sync after every change.
5463
5931
 
5464
5932
  Which endpoint a command uses, first hit wins:
5465
5933
  --endpoint <url> an explicit URL; needs no stored profile
@@ -5495,7 +5963,11 @@ async function main(argv) {
5495
5963
  process.stderr.write(`\r[${e.phase}] ${e.message}${pct} `);
5496
5964
  if (e.phase === "done") process.stderr.write("\n");
5497
5965
  };
5498
- const dirArg = (i) => (0, import_node_path7.resolve)(cmd[i] ?? ".");
5966
+ const dirArg = (i) => (0, import_node_path8.resolve)(cmd[i] ?? ".");
5967
+ const emitStage = (e) => {
5968
+ if (json) process.stdout.write(stageLine(e, true) + "\n");
5969
+ else if (!quiet) process.stderr.write(stageLine(e, false) + "\n");
5970
+ };
5499
5971
  const resolutionInput = async (dir) => {
5500
5972
  let association;
5501
5973
  if (dir && await isGitRepo(dir)) association = (await readAssociation(dir))?.endpoint;
@@ -5524,6 +5996,11 @@ async function main(argv) {
5524
5996
  const token = str2(flags["token"]);
5525
5997
  return { endpoint, p: createPublisher({ endpoint, endpointSource: source, ...token ? { token } : {}, clientName: "git-publisher-cli", clientVersion: CLIENT_VERSION }) };
5526
5998
  };
5999
+ if (flags["version"] === true) {
6000
+ process.stdout.write(`${packageVersion()}
6001
+ `);
6002
+ return 0;
6003
+ }
5527
6004
  switch (flags["help"] === true ? "help" : cmd[0]) {
5528
6005
  case void 0:
5529
6006
  case "help":
@@ -5669,19 +6146,30 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5669
6146
  await p.getRepository(repoId);
5670
6147
  }
5671
6148
  await p.associate(dir, repoId);
6149
+ const workspace = await p.adoptWorkspace(dir).catch(() => null);
5672
6150
  out({
5673
6151
  ok: true,
5674
6152
  repoId,
5675
6153
  created,
5676
6154
  endpoint,
5677
6155
  dir,
5678
- onboarding: onboarding ? { commit: onboarding.commit, committed: onboarding.committed.length, excluded: onboarding.excluded } : null
6156
+ onboarding: onboarding ? { commit: onboarding.commit, committed: onboarding.committed.length, excluded: onboarding.excluded } : null,
6157
+ workspace: workspace === null ? null : { branch: workspace.branch, baseOid: workspace.baseOid, baseGeneration: workspace.baseGeneration }
5679
6158
  });
5680
6159
  return 0;
5681
6160
  }
5682
6161
  case "publish": {
5683
6162
  let finishPublish2 = function(o) {
5684
- out({ state: o.state, publicationId: o.publicationId, repoId: o.repoId, generation: o.resultGeneration, refs: o.refs, rejection: o.rejection, resumed: o.resumed });
6163
+ out({
6164
+ state: o.state,
6165
+ publicationId: o.publicationId,
6166
+ repoId: o.repoId,
6167
+ generation: o.resultGeneration,
6168
+ refs: o.refs,
6169
+ rejection: o.rejection,
6170
+ resumed: o.resumed,
6171
+ ...o.next === null ? {} : { next: o.next }
6172
+ });
5685
6173
  return o.state === "accepted" ? 0 : o.state === "rejected" ? 2 : 3;
5686
6174
  };
5687
6175
  var finishPublish = finishPublish2;
@@ -5717,7 +6205,13 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5717
6205
  const resolved = await p.resumePublications(dir, { onProgress: progress });
5718
6206
  out({
5719
6207
  pending: pending.map((x) => x.publicationId),
5720
- resolved: resolved.map((o) => ({ publicationId: o.publicationId, state: o.state, generation: o.resultGeneration, rejection: o.rejection }))
6208
+ resolved: resolved.map((o) => ({
6209
+ publicationId: o.publicationId,
6210
+ state: o.state,
6211
+ generation: o.resultGeneration,
6212
+ rejection: o.rejection,
6213
+ ...o.next === null ? {} : { next: o.next }
6214
+ }))
5721
6215
  });
5722
6216
  return resolved.every((o) => o.state === "accepted") ? 0 : resolved.some((o) => o.state === "rejected") ? 2 : 3;
5723
6217
  }
@@ -5745,12 +6239,63 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5745
6239
  out(s);
5746
6240
  return s.state === "conflict" ? 2 : 0;
5747
6241
  }
6242
+ /**
6243
+ * The creator's one verb: record the folder's changes, bring in what the service has, publish.
6244
+ * `--apply` is what this was before it recorded and published, and is still accepted, so a
6245
+ * `sync --apply` written down anywhere does the whole round trip rather than failing.
6246
+ */
5748
6247
  case "sync": {
5749
6248
  const dir = dirArg(1);
5750
- const { p } = await client(dir);
5751
- const r = await p.sync({ directory: dir, apply: flags["apply"] === true, ...optStr(flags, "message") }, { onProgress: progress });
5752
- out({ ok: true, before: r.before.state, applied: r.applied, after: r.after, base: { oid: r.mapping.baseOid, generation: r.mapping.baseGeneration }, hint: r.before.hint });
5753
- return r.after === "conflict" ? 2 : 0;
6249
+ const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
6250
+ const { p, endpoint } = await client(dir);
6251
+ const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: progress });
6252
+ const o = r.published;
6253
+ const followed = o !== null && o.state === "accepted" && flags["no-wait"] !== true ? await followPublication(p, endpoint, o, { onStage: emitStage, log, timeoutMs }) : null;
6254
+ out({
6255
+ ok: r.state === "published" || r.state === "up_to_date",
6256
+ state: r.state,
6257
+ recorded: r.recorded,
6258
+ merged: r.reconciled.applied,
6259
+ conflicts: r.reconciled.before.conflicts,
6260
+ marked: r.marked,
6261
+ publication: o === null ? null : {
6262
+ state: o.state,
6263
+ publicationId: o.publicationId,
6264
+ generation: o.resultGeneration,
6265
+ refs: o.refs,
6266
+ rejection: o.rejection,
6267
+ ...o.next === null ? {} : { next: o.next }
6268
+ },
6269
+ hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked),
6270
+ progress: followed
6271
+ });
6272
+ return r.state === "published" || r.state === "up_to_date" ? 0 : r.state === "unknown" ? 3 : 2;
6273
+ }
6274
+ /**
6275
+ * Pick up following a run where a bounded `sync --timeout` (or an interrupted one) left it. The
6276
+ * ref and commit come from git-publisher's own record of the publication, so the command needs
6277
+ * only its id. Each call reports the stages as they now stand, from the first.
6278
+ */
6279
+ case "follow": {
6280
+ const dir = dirArg(1);
6281
+ const publicationId = str2(flags["publication"]);
6282
+ if (!publicationId) throw new PublisherError("INVALID_REQUEST", "follow requires --publication <id>");
6283
+ const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
6284
+ const { p, endpoint } = await client(dir);
6285
+ const repoId = str2(flags["repo"]) ?? (await readAssociation(dir))?.repoId;
6286
+ if (!repoId) throw new PublisherError("LOCAL_STATE", "no repository association; pass --repo");
6287
+ const publication = await p.getPublicationStatus(repoId, publicationId);
6288
+ if (publication.state !== "accepted") {
6289
+ throw new PublisherError("LOCAL_STATE", `publication ${publicationId} is ${publication.state}, not accepted; run publisher status`);
6290
+ }
6291
+ out({ publicationId, progress: await followPublication(p, endpoint, publication, { onStage: emitStage, log, timeoutMs }) });
6292
+ return 0;
6293
+ }
6294
+ case "skills": {
6295
+ if (cmd[1] !== "install") throw new PublisherError("INVALID_REQUEST", `unknown skills command: ${cmd[1] ?? "(none)"}; expected install`);
6296
+ const r = await installSkills({ log });
6297
+ out(r);
6298
+ return 0;
5754
6299
  }
5755
6300
  case "merge-check": {
5756
6301
  const dir = dirArg(1);
@@ -5796,6 +6341,20 @@ function demand(r, input) {
5796
6341
  throw new PublisherError("LOCAL_STATE", `not connected to a publishing service yet.${run}`, { details });
5797
6342
  }
5798
6343
  }
6344
+ function syncHint(state, conflicts, marked) {
6345
+ switch (state) {
6346
+ case "published":
6347
+ return "Your changes are saved and published.";
6348
+ case "up_to_date":
6349
+ return "Nothing new to publish: the service already has this version.";
6350
+ case "conflict":
6351
+ return marked ? `Both versions are now in ${conflicts.join(", ")}, between <<<<<<< and >>>>>>> lines. Edit each to what should be kept, remove those lines, then run publisher sync.` : `Your changes and the published version both changed ${conflicts.join(", ")}. Nothing was published and nothing was lost; decide which version to keep.`;
6352
+ case "rejected":
6353
+ return "The service refused the publication; see publication.rejection.";
6354
+ default:
6355
+ return "The publication was sent but its outcome is not known yet; run publisher status.";
6356
+ }
6357
+ }
5799
6358
  function formatHuman(v) {
5800
6359
  return JSON.stringify(v, null, 2);
5801
6360
  }