mainflow 0.42.0 → 0.43.0-alpha.2

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.
@@ -1,4 +1,5 @@
1
- import { At as WriteError, B as renumberRequest, G as applyOperation, H as renumberPath, Mt as splitFrontMatter, Ot as ConflictError, U as renumberText, V as renumberFiles, W as scopeOfPath, _t as readsAsText, at as nowMinute, ct as nodeStorage, dt as MAX_BLOB_BYTES, ft as MAX_FILE_BYTES, gt as exceedsFileLimit, ht as comparePaths, jt as contentHash, kt as DATA_DIR, lt as ACCESS_HEADERS, mt as SOCKET_MAX_AGE_CLOSE, pt as ROUTES, ut as BLOB_HASH, vt as requestTime, yt as textBytes, z as applyOffline } from "./host-BAMZmRy8.js";
1
+ import { $t as WriteError, B as applyOffline, Bt as registeredName, Ct as readsAsText, G as scopeOfPath, Gt as nodeStorage, H as renumberFiles, J as syncedDir, K as hubDigest, Ot as hubRequirements, St as exceedsFileLimit, Tt as textBytes, U as renumberPath, V as renumberRequest, W as renumberText, X as applyOperation, Y as syncedPath, Zt as ConflictError, _t as MAX_FILE_BYTES, bt as VERSION_HEADER, en as contentHash, ht as MAX_BLOB_BYTES, kt as versionMismatch, mt as BLOB_HASH, pt as ACCESS_HEADERS, q as mayHoldSynced, tn as splitFrontMatter, ut as nowMinute, vt as ROUTES, wt as requestTime, xt as comparePaths, yt as SOCKET_MAX_AGE_CLOSE } from "./host-BJvqZy-i.js";
2
+ import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, watch, writeFileSync } from "node:fs";
4
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -15,14 +16,13 @@ import { EventEmitter } from "node:events";
15
16
  import { COPYFILE_EXCL } from "node:constants";
16
17
  import { appendFile, mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
17
18
  import { diff3Merge } from "node-diff3";
18
- import ignore from "ignore";
19
19
  //#region ../sync/dist/paths.js
20
20
  /** The sync client's own directory, inside local/ so it is neither synced nor committed. */
21
21
  const SYNC_DIR = "local/sync";
22
22
  /** The settings a machine keeps instead of the environment variables: { url, clientId, clientSecret }. Never synced or committed. */
23
23
  const SETTINGS_FILE = "local/sync.json";
24
- const syncPaths = (root) => {
25
- const data = join(root, DATA_DIR);
24
+ const syncPaths = (dirs) => {
25
+ const data = dirs.data;
26
26
  const own = join(data, SYNC_DIR);
27
27
  return {
28
28
  data,
@@ -50,27 +50,8 @@ const syncPaths = (root) => {
50
50
  log: join(own, "sync.log")
51
51
  };
52
52
  };
53
- /** Names programs leave behind while writing: editor swap and backup files, temporary files, Finder's. */
54
- const TRANSIENT = [
55
- /\.tmp$/,
56
- /\.sw[a-p]$/,
57
- /\.swx$/,
58
- /~$/,
59
- /^\.#/,
60
- /^4913$/,
61
- /\.crswap$/,
62
- /^\.DS_Store$/
63
- ];
64
- /** Whether a file at `path` (relative to .mainflow/) is synced. */
65
- function isSynced(path) {
66
- const parts = path.split("/");
67
- if (parts.some((part) => part === "" || part === "." || part === "..")) return false;
68
- if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
69
- const name = parts.at(-1);
70
- return !TRANSIENT.some((pattern) => pattern.test(name));
71
- }
72
- /** Whether the directory at `path` may hold synced files, so a scan walks into it. */
73
- const mayHoldSynced = (path) => path !== "local" ? !path.startsWith("local/") || path === "local/sessions" : true;
53
+ /** Whether a file at `path` (relative to .mainflow/) is synced (syncedPath in the core package). */
54
+ const isSynced = syncedPath;
74
55
  /**
75
56
  * Whether the client's file watcher watches inside the directory at `path`: one that may hold synced files, and
76
57
  * local/sync/ itself for the outbox; the directories under local/sync/ hold only the client's own files.
@@ -89,22 +70,30 @@ const ENV = {
89
70
  clientSecret: "MAINFLOW_SYNC_CLIENT_SECRET",
90
71
  onDemand: "MAINFLOW_SYNC_ON_DEMAND"
91
72
  };
92
- /** The values of MAINFLOW_SYNC_ON_DEMAND that turn it on; any other value that is not empty turns it off. */
73
+ /** The values of MAINFLOW_SYNC_ON_DEMAND that keep it on; any other value that is not empty (such as 0) turns it off. */
93
74
  const ON = /* @__PURE__ */ new Set([
94
75
  "1",
95
76
  "true",
96
77
  "on"
97
78
  ]);
98
- /** The hub's name as config.yaml gives it. */
99
- function hubName(root) {
100
- const config = parse$1(readFileSync(join(root, DATA_DIR, "config.yaml"), "utf8"));
101
- if (typeof config?.hub !== "string") throw new Error(`${join(root, DATA_DIR, "config.yaml")} names no hub`);
79
+ /**
80
+ * The hub's name as config.yaml gives it. A hub whose config.yaml is not downloaded yet, as in a new environment before
81
+ * its first `mainflow sync`, has the name it was registered under.
82
+ */
83
+ function hubName(dirs, env = process.env) {
84
+ const file = join(dirs.data, "config.yaml");
85
+ if (!existsSync(file)) {
86
+ const registered = registeredName(dirs, env);
87
+ if (registered) return registered;
88
+ }
89
+ const config = parse$1(readFileSync(file, "utf8"));
90
+ if (typeof config?.hub !== "string") throw new Error(`${file} names no hub`);
102
91
  return config.hub;
103
92
  }
104
93
  const text = (value) => typeof value === "string" ? value : "";
105
94
  /** What .mainflow/local/sync.json holds, nothing when there is no such file. */
106
- function stored(root) {
107
- const file = join(root, DATA_DIR, SETTINGS_FILE);
95
+ function stored(dirs) {
96
+ const file = join(dirs.data, SETTINGS_FILE);
108
97
  if (!existsSync(file)) return {};
109
98
  try {
110
99
  return JSON.parse(readFileSync(file, "utf8")) ?? {};
@@ -115,20 +104,20 @@ function stored(root) {
115
104
  /**
116
105
  * The sync settings of the hub at `root`, or null when either half of the token is missing. Each value comes from its
117
106
  * environment variable, or from sync.json when the variable is unset or empty; the server is DEFAULT_SYNC_URL when
118
- * neither names one.
107
+ * neither names one, and on-demand download is on unless one of them sets it off.
119
108
  */
120
- function syncSettings(root, env = process.env) {
121
- const saved = stored(root);
109
+ function syncSettings(dirs, env = process.env) {
110
+ const saved = stored(dirs);
122
111
  const url = env[ENV.url] || text(saved.url) || "https://mainflow-sync.carlos209310.space";
123
112
  const clientId = env[ENV.clientId] || text(saved.clientId);
124
113
  const clientSecret = env[ENV.clientSecret] || text(saved.clientSecret);
125
114
  if (!clientId || !clientSecret) return null;
126
- const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand === true;
115
+ const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand !== false;
127
116
  return {
128
117
  url: url.replace(/\/+$/, ""),
129
118
  clientId,
130
119
  clientSecret,
131
- hub: hubName(root),
120
+ hub: hubName(dirs, env),
132
121
  onDemand
133
122
  };
134
123
  }
@@ -142,9 +131,9 @@ const EARLIER_TOKEN = {
142
131
  * nothing reads now: where that token is and which two values to set instead, never the token itself. Null when the
143
132
  * settings are whole or no earlier token is kept, so a hub that never synced writes to its local files without a word.
144
133
  */
145
- function earlierToken(root, env = process.env) {
146
- if (syncSettings(root, env)) return null;
147
- const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(root)[EARLIER_TOKEN.key]) ? [`the ${EARLIER_TOKEN.key} key of .mainflow/${SETTINGS_FILE}`] : []];
134
+ function earlierToken(dirs, env = process.env) {
135
+ if (syncSettings(dirs, env)) return null;
136
+ const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(dirs)[EARLIER_TOKEN.key]) ? [`the ${EARLIER_TOKEN.key} key of .mainflow/${SETTINGS_FILE}`] : []];
148
137
  if (where.length === 0) return null;
149
138
  return `${where.join(" and ")} ${where.length > 1 ? "are" : "is"} no longer read, so mainflow does not write through the sync server; set ${ENV.clientId} and ${ENV.clientSecret}, or clientId and clientSecret in .mainflow/${SETTINGS_FILE}, to a Cloudflare Access service token`;
150
139
  }
@@ -172,6 +161,32 @@ var Unreachable = class extends Error {
172
161
  this.broken = broken;
173
162
  }
174
163
  };
164
+ /**
165
+ * The hub takes writes only from other mainflow versions than this CLI's (`client-outdated`): nothing of the request was
166
+ * applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again after `retryAfter` seconds; the
167
+ * message says which versions the hub takes and the command that installs one.
168
+ */
169
+ var ClientOutdatedError = class extends Unreachable {
170
+ retryAfter;
171
+ constructor(message, retryAfter) {
172
+ super(message, false);
173
+ this.retryAfter = retryAfter;
174
+ }
175
+ };
176
+ /**
177
+ * The hub is being migrated or restored in batches, or such a rewrite stopped partway (`hub-rewriting`): nothing of the
178
+ * request was applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again; `rewrite` is the
179
+ * rewrite's state, for a command that waits for it or says what holds the hub.
180
+ */
181
+ var HubRewritingError = class extends Unreachable {
182
+ rewrite;
183
+ retryAfter;
184
+ constructor(message, rewrite, retryAfter) {
185
+ super(message, false);
186
+ this.rewrite = rewrite;
187
+ this.retryAfter = retryAfter;
188
+ }
189
+ };
175
190
  /** The server refused before the hub (400, 404) or failed (500), or Cloudflare Access in front of it refused (AccessRefused). */
176
191
  var ServerError = class extends Error {
177
192
  status;
@@ -261,7 +276,8 @@ var ServerApi = class {
261
276
  wire: 0,
262
277
  body: 0,
263
278
  sent: 0,
264
- listed: 0
279
+ listed: 0,
280
+ unchanged: 0
265
281
  };
266
282
  constructor(settings, timeoutMs = 3e4) {
267
283
  this.settings = settings;
@@ -283,11 +299,15 @@ var ServerApi = class {
283
299
  maxFreeSockets: 1
284
300
  });
285
301
  }
286
- /** The service token's headers, which every request and the WebSocket's upgrade carry. Never logged. */
302
+ /**
303
+ * The service token's headers, and the CLI's version when the settings name it (VERSION_HEADER), which every request
304
+ * and the WebSocket's upgrade carry. Never logged.
305
+ */
287
306
  get access() {
288
307
  return {
289
308
  [ACCESS_HEADERS.clientId]: this.settings.clientId,
290
- [ACCESS_HEADERS.clientSecret]: this.settings.clientSecret
309
+ [ACCESS_HEADERS.clientSecret]: this.settings.clientSecret,
310
+ ...this.settings.version === void 0 ? {} : { [VERSION_HEADER]: this.settings.version }
291
311
  };
292
312
  }
293
313
  /** A failure to reach the server or of the connection, as the Unreachable it is. */
@@ -319,8 +339,13 @@ var ServerApi = class {
319
339
  } catch {
320
340
  throw new Unreachable(`the sync server answered ${answer.status} without a readable body`, true);
321
341
  }
322
- if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
323
342
  const refused = parsed;
343
+ if (answer.status === 503 && refused.reason === "client-outdated") throw new ClientOutdatedError(`the sync server refused the write: ${refused.message ?? "this mainflow version may not write to the hub"}`, Math.max(1, Number(answer.retryAfter) || 60));
344
+ if (answer.status === 503 && refused.reason === "hub-rewriting") {
345
+ const rewriting = parsed;
346
+ throw new HubRewritingError(rewriting.message, rewriting.rewrite, Math.max(1, Number(answer.retryAfter) || 10));
347
+ }
348
+ if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
324
349
  if (answer.status === 404 || refused.reason === "malformed") throw new ServerError(`the sync server refused: ${refused.message ?? answer.status}`, answer.status);
325
350
  return {
326
351
  status: answer.status,
@@ -543,6 +568,7 @@ var ServerApi = class {
543
568
  this.transfer.batches++;
544
569
  this.transfer.files += body.files.filter((entry) => entry.kind === "file").length;
545
570
  this.transfer.listed += body.files.filter((entry) => entry.kind === "listed").length;
571
+ if (body.unchanged === true) this.transfer.unchanged++;
546
572
  this.transfer.wire += wire;
547
573
  this.transfer.body += size;
548
574
  this.transfer.sent += sent;
@@ -558,6 +584,9 @@ var ServerApi = class {
558
584
  * `listOnly` asks for the files under those prefixes as `listed`, without their text. A part of the hub alone is taken
559
585
  * from `after` on, and ends with the batch that covers `through`; only the paths of that range are told of. `blobs` takes
560
586
  * the binary files too, each that differs from `have` as `blob`; `have` names a binary file by its sha256 then.
587
+ * `digest` names the digest of `have` and the directories the client holds (hubDigest), asked on the first batch of a
588
+ * whole hub's snapshot with `blobs`: a server whose hub it is the digest of answers that nothing differs, and the
589
+ * snapshot is that one batch, with the client's directories as the entries, as the server would have listed them.
561
590
  */
562
591
  async snapshot(have = /* @__PURE__ */ new Map(), options = {}) {
563
592
  const paths = [...have.keys()].sort(comparePaths);
@@ -569,14 +598,23 @@ var ServerApi = class {
569
598
  for (;;) {
570
599
  while (after !== void 0 && start < paths.length && comparePaths(paths[start], after) <= 0) start++;
571
600
  const range = Object.fromEntries(paths.slice(start).map((path) => [path, have.get(path)]));
572
- const { seq, files, next } = await this.batch({
601
+ const asking = options.digest !== void 0 && options.blobs === true && after === void 0;
602
+ const { seq, files, next, unchanged } = await this.batch({
573
603
  protocol: 3,
574
604
  have: range,
575
605
  unknown: true,
576
606
  ...after === void 0 ? {} : { after },
607
+ ...asking ? { digest: options.digest.value } : {},
577
608
  ...options.listOnly?.length ? { listOnly: options.listOnly } : {},
578
609
  ...options.blobs ? { blobs: true } : {}
579
610
  });
611
+ if (unchanged === true && asking) return [{
612
+ seq,
613
+ files: options.digest.dirs.map((path) => ({
614
+ kind: "dir",
615
+ path
616
+ }))
617
+ }];
580
618
  if (parts.length > 0 && seq < parts.at(-1).seq) break;
581
619
  parts.push({
582
620
  seq,
@@ -613,6 +651,53 @@ var ServerApi = class {
613
651
  return body;
614
652
  }
615
653
  /**
654
+ * A page of the hub's change log (ROUTES.changes), newest first. A server from before the log has no such route: a
655
+ * ServerError of status 404, as is any refusal.
656
+ */
657
+ async changes(request = {}) {
658
+ const { body } = await this.call("POST", ROUTES.changes(this.settings.hub), {
659
+ protocol: 3,
660
+ ...request
661
+ }, "read");
662
+ if (!body.ok) throw new ServerError(`the sync server refused the change log: ${body.message}`, body.status);
663
+ return body;
664
+ }
665
+ /**
666
+ * Put the hub, or a path of it, back to a seq or a time (ROUTES.restore): the hub's answer, applied, set off in batches
667
+ * (`started`), or refused. It goes on a connection of its own, as an upload does, and is never sent again, since it has
668
+ * no requestId: one sent again after its answer was lost finds the hub as it left it only when nothing was written in
669
+ * between, and else puts that writing back too. A server from before the log has no such route: a ServerError of status
670
+ * 404. A hub being rewritten throws HubRewritingError.
671
+ */
672
+ async restore(request) {
673
+ return (await this.call("POST", ROUTES.restore(this.settings.hub), {
674
+ protocol: 3,
675
+ ...request
676
+ }, "fresh")).body;
677
+ }
678
+ /**
679
+ * Convert the hub to the server's data format on the server (ROUTES.migrate): the hub's answer, applied, set off in
680
+ * batches (`started`), or refused. It goes on a connection of its own and is never sent again, as a restore. A server
681
+ * from before the route answers 404 from its router: a ServerError of status 404. A hub being rewritten throws
682
+ * HubRewritingError.
683
+ */
684
+ async migrate(request = {}) {
685
+ return (await this.call("POST", ROUTES.migrate(this.settings.hub), {
686
+ protocol: 3,
687
+ ...request
688
+ }, "fresh")).body;
689
+ }
690
+ /**
691
+ * The migration or restore the server runs on the hub in batches, or ran last (ROUTES.rewrite). It goes on a connection
692
+ * of the reads, so one the server closed meanwhile is asked again. A server from before batches has no such route: a
693
+ * ServerError of status 404.
694
+ */
695
+ async rewrite() {
696
+ const { body } = await this.call("POST", ROUTES.rewrite(this.settings.hub), { protocol: 3 }, "read");
697
+ if (!body.ok) throw new ServerError(`the sync server refused to tell the hub's rewrite: ${body.message}`, body.status);
698
+ return body;
699
+ }
700
+ /**
616
701
  * Open the hub's broadcast connection, taking the binary files too (ROUTES.socket). It opens before the client knows
617
702
  * whether the server keeps binary files (blobRoute), and a server from before them reads nothing of `blobs`.
618
703
  */
@@ -764,135 +849,6 @@ function mergeThreeWay(current, base, incoming) {
764
849
  };
765
850
  }
766
851
  //#endregion
767
- //#region ../sync/dist/scope.js
768
- /** The name of a file of ignore rules. */
769
- const GITIGNORE = ".gitignore";
770
- const decoder$1 = new TextDecoder("utf-8", { fatal: true });
771
- /** The text of the file at `file`, or null when there is none or it cannot be read. */
772
- function readText(file) {
773
- try {
774
- return readFileSync(file, "utf8");
775
- } catch {
776
- return null;
777
- }
778
- }
779
- /** The hub's ignore rules, by the directory of each .gitignore, as last read. */
780
- var SyncScope = class {
781
- /** Each .gitignore's text and rules by its directory, relative to .mainflow/ with `/`, '' for .mainflow/ itself */
782
- rules = /* @__PURE__ */ new Map();
783
- /** Whether each path, a directory's ending in `/`, is ignored under the rules as they are */
784
- decided = /* @__PURE__ */ new Map();
785
- changes = 0;
786
- /** How many times the rules changed: a caller that noted it tells whether they changed since. */
787
- get generation() {
788
- return this.changes;
789
- }
790
- /** The text of `dir`'s .gitignore as the rules have it, null for none. */
791
- text(dir) {
792
- return this.rules.get(dir)?.text ?? null;
793
- }
794
- /** Take `text` as the .gitignore of `dir`, null for none; true when that changes the rules. */
795
- set(dir, text) {
796
- if ((this.rules.get(dir)?.text ?? null) === text) return false;
797
- if (text === null) this.rules.delete(dir);
798
- else this.rules.set(dir, {
799
- text,
800
- matcher: ignore().add(text)
801
- });
802
- this.decided.clear();
803
- this.changes++;
804
- return true;
805
- }
806
- /** Forget the rules of every directory but `dirs`, as when a scan of the whole hub no longer finds the others. */
807
- keepOnly(dirs) {
808
- for (const dir of this.rules.keys()) if (!dirs.has(dir)) this.set(dir, null);
809
- }
810
- /** Whether the file at `path`, relative to .mainflow/, is synced: by the sync client's own rules, and not ignored. */
811
- synced(path) {
812
- return isSynced(path) && !this.ignored(path, false);
813
- }
814
- /** Whether a scan walks into the directory at `path`: one that may hold synced files, and not ignored. */
815
- walks(path) {
816
- return mayHoldSynced(path) && !this.ignored(path, true);
817
- }
818
- /** Whether git leaves the file or directory (`dir`) at `path` out: it, or a directory above it, is ignored. */
819
- ignored(path, dir) {
820
- if (path === "local" || path.startsWith("local/")) return false;
821
- const parts = path.split("/");
822
- if (parts.some((part) => part === "" || part === "." || part === "..")) return true;
823
- const key = dir ? `${path}/` : path;
824
- const known = this.decided.get(key);
825
- if (known !== void 0) return known;
826
- let out = false;
827
- for (let n = 1; n <= parts.length && !out; n++) out = this.matches(parts, n, n < parts.length || dir);
828
- this.decided.set(key, out);
829
- return out;
830
- }
831
- /**
832
- * Whether the rules ignore the first `n` parts of a path, a directory's when `dir`: each .gitignore above it in turn,
833
- * from .mainflow/ down, the last one with a pattern that matches deciding.
834
- */
835
- matches(parts, n, dir) {
836
- if (parts[n - 1] === ".git") return true;
837
- let out = false;
838
- for (let depth = 0; depth < n; depth++) {
839
- const rules = this.rules.get(parts.slice(0, depth).join("/"));
840
- if (!rules) continue;
841
- const test = rules.matcher.test(`${parts.slice(depth, n).join("/")}${dir ? "/" : ""}`);
842
- if (test.ignored) out = true;
843
- else if (test.unignored) out = false;
844
- }
845
- return out;
846
- }
847
- /**
848
- * Read every .gitignore under `data`, the hub's .mainflow/, again, and return the synced files there; it walks only into
849
- * the directories the rules leave in, as git does. Synchronous, for a start and for `mainflow check`.
850
- */
851
- load(data) {
852
- const files = [];
853
- const dirs = /* @__PURE__ */ new Set();
854
- const visit = (dir) => {
855
- let entries;
856
- try {
857
- entries = readdirSync(join(data, dir), { withFileTypes: true });
858
- } catch {
859
- return;
860
- }
861
- dirs.add(dir);
862
- this.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? readText(join(data, dir, GITIGNORE)) : null);
863
- for (const entry of entries) {
864
- const path = dir ? `${dir}/${entry.name}` : entry.name;
865
- if (entry.isDirectory()) {
866
- if (this.walks(path)) visit(path);
867
- } else if (entry.isFile() && this.synced(path)) files.push(path);
868
- }
869
- };
870
- visit("");
871
- this.keepOnly(dirs);
872
- return files;
873
- }
874
- };
875
- /**
876
- * The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text the hub's
877
- * .gitignore files leave in. The sync client leaves them out of sync; `mainflow check` warns of them.
878
- */
879
- function oversizedFiles(root) {
880
- const data = join(root, DATA_DIR);
881
- const out = [];
882
- for (const path of new SyncScope().load(data)) {
883
- const file = join(data, path);
884
- try {
885
- if (statSync(file).size <= 19e5) continue;
886
- const bytes = textBytes(decoder$1.decode(readFileSync(file)));
887
- if (bytes > 19e5) out.push({
888
- path,
889
- bytes
890
- });
891
- } catch {}
892
- }
893
- return out;
894
- }
895
- //#endregion
896
852
  //#region ../sync/dist/state.js
897
853
  /** The requestId an outbox entry goes under: its id, with the number of the try for a binary file's write sent again. */
898
854
  const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.id}-${entry.retry}` : entry.id;
@@ -954,18 +910,18 @@ function writeJson(file, value) {
954
910
  writeFileSync(temp, JSON.stringify(value, null, 1));
955
911
  renameSync(temp, file);
956
912
  }
957
- const readKnown = (root) => readJson(syncPaths(root).known, {}).files ?? {};
913
+ const readKnown = (dirs) => readJson(syncPaths(dirs).known, {}).files ?? {};
958
914
  /** The directories known.json names; none from a client before it kept them. */
959
- const readKnownDirs = (root) => {
960
- const dirs = readJson(syncPaths(root).known, {}).dirs;
915
+ const readKnownDirs = (hub) => {
916
+ const dirs = readJson(syncPaths(hub).known, {}).dirs;
961
917
  return Array.isArray(dirs) ? dirs.filter((dir) => typeof dir === "string") : [];
962
918
  };
963
919
  /** What known.json writes before a binary file's sha256, apart from a text's content hash. */
964
920
  const BLOB_PREFIX = "blob:";
965
921
  /** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
966
- function readKnownText(root, hash) {
922
+ function readKnownText(dirs, hash) {
967
923
  try {
968
- const text = readFileSync(join(syncPaths(root).knownTexts, hash), "utf8");
924
+ const text = readFileSync(join(syncPaths(dirs).knownTexts, hash), "utf8");
969
925
  return contentHash(text) === hash ? text : void 0;
970
926
  } catch {
971
927
  return;
@@ -978,8 +934,8 @@ function readKnownText(root, hash) {
978
934
  * step leaves a known.json whose every text is in known/. A binary file is given as its sha256 with BLOB_PREFIX, and no text.
979
935
  * `dirs` are the directories it names.
980
936
  */
981
- function writeKnown(root, files, dirs = []) {
982
- const dir = syncPaths(root).knownTexts;
937
+ function writeKnown(hub, files, dirs = []) {
938
+ const dir = syncPaths(hub).knownTexts;
983
939
  mkdirSync(dir, { recursive: true });
984
940
  const named = new Set(Object.values(files).map((file) => file.hash));
985
941
  const held = new Set(readdirSync(dir));
@@ -993,25 +949,25 @@ function writeKnown(root, files, dirs = []) {
993
949
  files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])),
994
950
  ...dirs.length > 0 ? { dirs } : {}
995
951
  };
996
- writeJson(syncPaths(root).known, known);
952
+ writeJson(syncPaths(hub).known, known);
997
953
  for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
998
954
  }
999
- const writeQueue = (root, entries) => writeJson(syncPaths(root).queue, { entries });
1000
- const readRemote = (root) => readJson(syncPaths(root).remote, {}).files ?? {};
1001
- const writeRemote = (root, files) => writeJson(syncPaths(root).remote, { files });
955
+ const writeQueue = (dirs, entries) => writeJson(syncPaths(dirs).queue, { entries });
956
+ const readRemote = (dirs) => readJson(syncPaths(dirs).remote, {}).files ?? {};
957
+ const writeRemote = (dirs, files) => writeJson(syncPaths(dirs).remote, { files });
1002
958
  /** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
1003
- function readQueue(root) {
1004
- const entries = readJson(syncPaths(root).queue, {}).entries ?? [];
959
+ function readQueue(dirs) {
960
+ const entries = readJson(syncPaths(dirs).queue, {}).entries ?? [];
1005
961
  if (entries.every((entry) => typeof entry.id === "string")) return entries;
1006
962
  const named = entries.map((entry) => ({
1007
963
  ...entry,
1008
964
  id: entry.id ?? entryId()
1009
965
  }));
1010
- writeQueue(root, named);
966
+ writeQueue(dirs, named);
1011
967
  return named;
1012
968
  }
1013
969
  /** Run `fn` under the hub's write lock, the one every mainflow writer of this machine takes. */
1014
- const underLock = (root, fn) => nodeStorage(root).lock(fn);
970
+ const underLock = (dirs, fn) => nodeStorage(dirs).lock(fn);
1015
971
  //#endregion
1016
972
  //#region ../sync/dist/swap-helper.js
1017
973
  const HELPER_SOURCE = "mainflow-swap.c";
@@ -1386,14 +1342,14 @@ const SWAP_PAUSE_MS = 6e4;
1386
1342
  /** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
1387
1343
  const OUTBOX = `${SYNC_DIR}/queue.json`;
1388
1344
  const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1389
- const decoder = new TextDecoder("utf-8", { fatal: true });
1345
+ const decoder$1 = new TextDecoder("utf-8", { fatal: true });
1390
1346
  const sameLocal = (a, b) => a.kind === b.kind && (a.kind !== "text" || a.text === b.text) && (a.kind !== "binary" || a.hash === b.hash);
1391
1347
  /** What `bytes` read from a file are: a text when they are UTF-8, a binary file otherwise. */
1392
1348
  function contentOf(bytes) {
1393
1349
  try {
1394
1350
  return {
1395
1351
  kind: "text",
1396
- text: decoder.decode(bytes)
1352
+ text: decoder$1.decode(bytes)
1397
1353
  };
1398
1354
  } catch {
1399
1355
  return {
@@ -1566,7 +1522,7 @@ function holdsFiles(parts, held) {
1566
1522
  }
1567
1523
  var SyncClient = class extends EventEmitter {
1568
1524
  opts;
1569
- root;
1525
+ dirs;
1570
1526
  paths;
1571
1527
  api;
1572
1528
  problems = [];
@@ -1664,14 +1620,6 @@ var SyncClient = class extends EventEmitter {
1664
1620
  uploadWanted = false;
1665
1621
  /** Why the server refused the upload of this environment's copy, as last reported (snapshotNow); null once it holds the hub */
1666
1622
  uploadRefusal = null;
1667
- /** The hub's .gitignore files, which leave a file out of sync as they leave it out of git (src/scope.ts) */
1668
- scope = new SyncScope();
1669
- /**
1670
- * The generation of the rules (SyncScope.generation) the last snapshot was asked for under. A file the rules left out
1671
- * then was not taken in, whatever the server sent, nor a broadcast of it since; rules changed after it may let such a
1672
- * file in, so the snapshot is taken again (walk), and a file not taken in from the server waits for it (scopeStale).
1673
- */
1674
- scopeTaken = -1;
1675
1623
  /**
1676
1624
  * The paths the server last named `unknown`: it holds no file there and never removed one. A file here at such a path
1677
1625
  * is new to the server, whatever known.json says, as when another environment uploaded the hub to a new server
@@ -1742,6 +1690,13 @@ var SyncClient = class extends EventEmitter {
1742
1690
  flushRun = null;
1743
1691
  resyncing = null;
1744
1692
  resyncAgain = false;
1693
+ /**
1694
+ * The snapshot the hub held off while the server migrates or restores it in batches (HubRewritingError): taken again
1695
+ * when its reload broadcast comes, or after the Retry-After it came with, whichever is first. The connection stays
1696
+ * open meanwhile, so the client neither reconnects nor asks for the snapshot more often than that.
1697
+ */
1698
+ snapshotHeld = false;
1699
+ snapshotTimer;
1745
1700
  /** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
1746
1701
  synced = false;
1747
1702
  reconnectTimer;
@@ -1767,15 +1722,25 @@ var SyncClient = class extends EventEmitter {
1767
1722
  backoff = 500;
1768
1723
  lastPong = 0;
1769
1724
  knownTimer;
1725
+ /**
1726
+ * Until when the outbox waits, in ms since the epoch, after the hub refused a write for a while: for this CLI's version
1727
+ * (ClientOutdatedError), or while it is migrated or restored in batches (HubRewritingError). The Retry-After it came
1728
+ * with, so an environment left on an older CLI, or one writing during a rewrite, asks once a while, not once a second.
1729
+ * The write stays in the outbox meanwhile, and idle does not wait for it. With the message last told of an outdated
1730
+ * CLI (a `client-outdated` problem), so the same one is told once, until a write goes through again.
1731
+ */
1732
+ heldUntil = 0;
1733
+ outdatedMessage = null;
1770
1734
  constructor(opts) {
1771
1735
  super();
1772
1736
  this.opts = opts;
1773
- this.root = opts.root;
1774
- this.paths = syncPaths(opts.root);
1737
+ this.dirs = opts.dirs;
1738
+ this.paths = syncPaths(opts.dirs);
1775
1739
  this.api = new ServerApi(opts.settings);
1776
1740
  this.firstContact = new Promise((resolve) => {
1777
1741
  this.once("synced", resolve);
1778
1742
  this.once("offline", resolve);
1743
+ this.once("held", resolve);
1779
1744
  });
1780
1745
  }
1781
1746
  trace(line) {
@@ -1788,26 +1753,13 @@ var SyncClient = class extends EventEmitter {
1788
1753
  get online() {
1789
1754
  return this.connected && !this.stopped;
1790
1755
  }
1791
- /**
1792
- * Whether the file at `path` is synced: by the sync client's own rules (src/paths.ts) and the hub's .gitignore files as
1793
- * last read (src/scope.ts). Anything else is left as it is here, whatever the server holds.
1794
- */
1756
+ /** Whether the file at `path` is synced (src/paths.ts). Anything else is left as it is here, whatever the server holds. */
1795
1757
  syncs(path) {
1796
- return this.scope.synced(path);
1758
+ return isSynced(path);
1797
1759
  }
1798
- /**
1799
- * Whether the directory at `path` is synced: one that may hold synced files and that the hub's .gitignore files leave
1800
- * in, apart from local/ itself, which every machine has of its own (src/paths.ts).
1801
- */
1760
+ /** Whether the directory at `path` is synced: one that may hold synced files, apart from local/ itself, which every machine has of its own (src/paths.ts). */
1802
1761
  syncsDir(path) {
1803
- return path !== "" && path !== "local" && this.scope.walks(path);
1804
- }
1805
- /**
1806
- * Whether the hub's .gitignore files changed since the last snapshot was asked for: a file they let in since may be on
1807
- * the server although the client took nothing of it in.
1808
- */
1809
- get scopeStale() {
1810
- return this.scope.generation !== this.scopeTaken;
1762
+ return syncedDir(path);
1811
1763
  }
1812
1764
  /** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
1813
1765
  get queued() {
@@ -1860,7 +1812,6 @@ var SyncClient = class extends EventEmitter {
1860
1812
  this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
1861
1813
  }
1862
1814
  }
1863
- this.scope.load(this.paths.data);
1864
1815
  await this.restoreKnown();
1865
1816
  this.restoreRemote();
1866
1817
  await this.whenFree(() => this.syncQueue());
@@ -1885,8 +1836,8 @@ var SyncClient = class extends EventEmitter {
1885
1836
  async restoreKnown() {
1886
1837
  this.copied = !existsSync(this.paths.known);
1887
1838
  this.blobsCompared = !this.copied && !existsSync(this.paths.copyBlobs);
1888
- for (const dir of readKnownDirs(this.root)) this.syncedDirs.add(dir);
1889
- for (const [path, hash] of Object.entries(readKnown(this.root))) {
1839
+ for (const dir of readKnownDirs(this.dirs)) this.syncedDirs.add(dir);
1840
+ for (const [path, hash] of Object.entries(readKnown(this.dirs))) {
1890
1841
  if (hash.startsWith("blob:")) {
1891
1842
  const disk = {
1892
1843
  hash: hash.slice(5),
@@ -1897,7 +1848,7 @@ var SyncClient = class extends EventEmitter {
1897
1848
  continue;
1898
1849
  }
1899
1850
  const local = await this.readLocal(path);
1900
- const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.root, hash);
1851
+ const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.dirs, hash);
1901
1852
  const disk = text === void 0 ? { hash } : {
1902
1853
  hash,
1903
1854
  text
@@ -1947,6 +1898,7 @@ var SyncClient = class extends EventEmitter {
1947
1898
  this.closing = true;
1948
1899
  this.stopped = true;
1949
1900
  clearTimeout(this.reconnectTimer);
1901
+ clearTimeout(this.snapshotTimer);
1950
1902
  clearInterval(this.pingTimer);
1951
1903
  clearInterval(this.warmTimer);
1952
1904
  clearInterval(this.rescanTimer);
@@ -1987,6 +1939,7 @@ var SyncClient = class extends EventEmitter {
1987
1939
  await new Promise((resolve) => {
1988
1940
  this.once("synced", resolve);
1989
1941
  this.once("offline", resolve);
1942
+ this.once("held", resolve);
1990
1943
  this.connect();
1991
1944
  });
1992
1945
  }
@@ -2000,7 +1953,8 @@ var SyncClient = class extends EventEmitter {
2000
1953
  this.outboxChanged();
2001
1954
  for (;;) {
2002
1955
  const work = besidesAwaiting ? [...this.work].filter((promise) => !this.awaitingEdit.has(this.workPaths.get(promise) ?? "")) : [...this.work];
2003
- if (!(work.length > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || this.online && this.nextEntry(this.queue).entry !== void 0)) return true;
1956
+ const sendable = this.online && this.heldUntil <= Date.now() && this.nextEntry(this.queue).entry !== void 0;
1957
+ if (!(work.length > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || sendable)) return true;
2004
1958
  if (Date.now() > deadline) return false;
2005
1959
  await (work.length > 0 ? Promise.race([Promise.allSettled(work), sleep$1(50)]) : sleep$1(50));
2006
1960
  }
@@ -2299,8 +2253,7 @@ var SyncClient = class extends EventEmitter {
2299
2253
  * reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
2300
2254
  * still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
2301
2255
  * server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
2302
- * after the gap are forgotten and the snapshot is taken once more, as it is. So is it when the hub's .gitignore files
2303
- * changed after it was asked for (scopeStale): a file they let in since was not taken in. The connection has its
2256
+ * after the gap are forgotten and the snapshot is taken once more, as it is. The connection has its
2304
2257
  * snapshot once one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a
2305
2258
  * fresh one, rather than send on the snapshot of an earlier connection.
2306
2259
  */
@@ -2332,11 +2285,17 @@ var SyncClient = class extends EventEmitter {
2332
2285
  this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
2333
2286
  if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
2334
2287
  this.resyncAgain = true;
2335
- } while ((this.resyncAgain || this.scopeStale) && !this.closing);
2288
+ } while (this.resyncAgain && !this.closing);
2336
2289
  this.current = this.connected && !this.closing;
2290
+ if (this.snapshotHeld) {
2291
+ this.snapshotHeld = false;
2292
+ clearTimeout(this.snapshotTimer);
2293
+ if (this.current) this.emit("synced");
2294
+ }
2337
2295
  } catch (error) {
2338
2296
  this.current = false;
2339
- this.ws?.close();
2297
+ if (error instanceof HubRewritingError && this.connected && !this.closing) this.holdSnapshot(error);
2298
+ else this.ws?.close();
2340
2299
  throw error;
2341
2300
  } finally {
2342
2301
  this.resyncing = null;
@@ -2346,6 +2305,19 @@ var SyncClient = class extends EventEmitter {
2346
2305
  return this.resyncing;
2347
2306
  }
2348
2307
  /**
2308
+ * The server held the snapshot off while it migrates or restores the hub in batches: the connection stays open, and
2309
+ * the snapshot is taken again on the reload broadcast that ends the rewrite, or after the Retry-After of the refusal,
2310
+ * whichever is first. Nothing is sent meanwhile, as before any snapshot. A client starting or reconnecting meanwhile
2311
+ * is done waiting for its first contact (`held`).
2312
+ */
2313
+ holdSnapshot(error) {
2314
+ if (!this.snapshotHeld) this.log(`${error.message}; the snapshot is taken again once it is done`);
2315
+ this.snapshotHeld = true;
2316
+ clearTimeout(this.snapshotTimer);
2317
+ this.snapshotTimer = setTimeout(() => void this.track(this.resync().catch((failed) => this.log(`could not reload the snapshot: ${failed.message}`))), error.retryAfter * 1e3);
2318
+ this.emit("held");
2319
+ }
2320
+ /**
2349
2321
  * Take the hub as of the snapshot's seq, asking only for what differs from what the client holds (holdings). The
2350
2322
  * snapshot comes in batches, each as of its own seq; the WebSocket was open before the first, so every change after
2351
2323
  * it comes as a broadcast, and the first batch's seq is the one the whole snapshot is as of. A file the server sent,
@@ -2353,8 +2325,8 @@ var SyncClient = class extends EventEmitter {
2353
2325
  * batch's seq; a file neither named nor sent is not on the server. A broadcast arriving meanwhile may be older or
2354
2326
  * newer than either; each file keeps whichever version has the higher seq. A path the server never held a file at
2355
2327
  * (`unknown`) is one it has no file at: a file here, even one never synced or one known.json names, is sent as new and
2356
- * never removed (reconcile, unheld). What the
2357
- * client does not sync (syncs), as a file the hub's .gitignore leaves out, is not taken in, whatever the server sends.
2328
+ * never removed (reconcile, unheld). What the client does not sync (syncs), as a file in a scratch/ directory, is not
2329
+ * taken in, whatever the server sends.
2358
2330
  *
2359
2331
  * A server without the hub first gets the local one. When it refuses the upload and still holds no file, the snapshot
2360
2332
  * fails without taking anything in: an empty hub would tell a copy without known.json to remove every file it holds.
@@ -2387,8 +2359,6 @@ var SyncClient = class extends EventEmitter {
2387
2359
  /** Whether the hub was uploaded, and why the server refused the upload; asked again, the snapshot may hold the hub */
2388
2360
  let uploaded = false;
2389
2361
  let refusal = null;
2390
- /** The rules' generation the snapshot is asked for under: what it holds is taken in as they leave it in */
2391
- let asked;
2392
2362
  /** Whether the snapshot is asked for with the binary files */
2393
2363
  let withBlobs = false;
2394
2364
  if (!this.probed) await this.probeBlobs();
@@ -2399,15 +2369,20 @@ var SyncClient = class extends EventEmitter {
2399
2369
  have = new Map([...held].map(([path, text]) => [path, this.hashOf(path, text)]));
2400
2370
  for (const [path, blob] of heldBlobs) have.set(path, blob.hash);
2401
2371
  for (const [path, hash] of this.remote) have.set(path, hash);
2402
- asked = this.scope.generation;
2403
2372
  withBlobs = this.blobSupport === true;
2404
2373
  if (!withBlobs && !this.blobsCompared && !existsSync(this.paths.copyBlobs)) {
2405
2374
  mkdirSync(this.paths.own, { recursive: true });
2406
2375
  writeFileSync(this.paths.copyBlobs, "");
2407
2376
  }
2377
+ const dirs = this.knownDirs();
2378
+ const digest = withBlobs && have.size > 0 ? {
2379
+ value: hubDigest(have, dirs),
2380
+ dirs
2381
+ } : void 0;
2408
2382
  parts = await this.api.snapshot(have, {
2409
2383
  listOnly: this.listOnly(this.copied && !this.synced),
2410
- blobs: this.blobSupport === true
2384
+ blobs: this.blobSupport === true,
2385
+ ...digest ? { digest } : {}
2411
2386
  });
2412
2387
  if (uploaded || holdsFiles(parts, have)) break;
2413
2388
  if (this.opts.settings.onDemand === true) {
@@ -2416,7 +2391,7 @@ var SyncClient = class extends EventEmitter {
2416
2391
  this.problem({
2417
2392
  kind: "upload-needed",
2418
2393
  path: "",
2419
- message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub, such as the one that commits it with git, to upload it; this one waits and syncs once the hub is there`
2394
+ message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub (run it there with MAINFLOW_SYNC_ON_DEMAND=0), to upload it; this one waits and syncs once the hub is there`
2420
2395
  });
2421
2396
  }
2422
2397
  throw new Error("the sync server has no copy of the hub, and this environment downloads on demand, so it waits for one that keeps every file to upload it");
@@ -2529,7 +2504,6 @@ var SyncClient = class extends EventEmitter {
2529
2504
  }, seq);
2530
2505
  }
2531
2506
  for (const path of this.syncedDirs) if (!dirs.has(path)) this.receiveDirRemoved(path, seq);
2532
- this.scopeTaken = asked;
2533
2507
  this.log(`took the snapshot at seq ${seq} in ${parts.length} batch${parts.length === 1 ? "" : "es"}: ${sent} files sent, ${present.size - sent} already held here${this.remote.size > 0 ? `, ${this.remote.size} only on the server` : ""}`);
2534
2508
  this.takenUpTo(seq);
2535
2509
  this.synced = true;
@@ -2707,7 +2681,7 @@ var SyncClient = class extends EventEmitter {
2707
2681
  if (expected?.blob && expected.hash === disk.hash) this.expected.delete(path);
2708
2682
  if (isBlob(this.cloud.get(path))) this.cloud.delete(path);
2709
2683
  }
2710
- if (forgotten.size > 0) await this.whenFree(() => underLock(this.root, () => writeKnown(this.root, this.knownFiles(), this.knownDirs())));
2684
+ if (forgotten.size > 0) await this.whenFree(() => underLock(this.dirs, () => writeKnown(this.dirs, this.knownFiles(), this.knownDirs())));
2711
2685
  return forgotten;
2712
2686
  }
2713
2687
  /** The server refused the upload, so it holds what it held: the binary files forgetBinaries forgot are known again. */
@@ -2809,7 +2783,7 @@ var SyncClient = class extends EventEmitter {
2809
2783
  const after = this.expected.get(path);
2810
2784
  if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
2811
2785
  }
2812
- const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced && !this.scopeStale && (this.blobSupport === true || !this.knownBinary(path)) ? null : void 0;
2786
+ const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced && (this.blobSupport === true || !this.knownBinary(path)) ? null : void 0;
2813
2787
  const disk = this.expected.get(path);
2814
2788
  const unheld = target === null && this.unheld.has(path);
2815
2789
  if (disk === void 0) {
@@ -2888,7 +2862,7 @@ var SyncClient = class extends EventEmitter {
2888
2862
  }
2889
2863
  if (text === null) return false;
2890
2864
  try {
2891
- return decoder.decode(bytes) === text;
2865
+ return decoder$1.decode(bytes) === text;
2892
2866
  } catch {
2893
2867
  return false;
2894
2868
  }
@@ -3000,7 +2974,7 @@ var SyncClient = class extends EventEmitter {
3000
2974
  this.tellOnce(`large:${path}:${hash}`, {
3001
2975
  kind: "skipped-large",
3002
2976
  path,
3003
- message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or add it to .mainflow/.gitignore`
2977
+ message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or move it into a scratch/ directory, which is never synced`
3004
2978
  });
3005
2979
  }
3006
2980
  /**
@@ -3315,7 +3289,7 @@ var SyncClient = class extends EventEmitter {
3315
3289
  await this.fetchPaths([path]).catch((error) => this.log(`${path}: could not download it: ${error.message}`));
3316
3290
  }
3317
3291
  restoreRemote() {
3318
- for (const [path, hash] of Object.entries(readRemote(this.root))) if (this.syncs(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
3292
+ for (const [path, hash] of Object.entries(readRemote(this.dirs))) if (this.syncs(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
3319
3293
  }
3320
3294
  /**
3321
3295
  * What remote.json lists: the files only on the server, and the artifacts whose download failed and waits for a retry.
@@ -3350,7 +3324,7 @@ var SyncClient = class extends EventEmitter {
3350
3324
  const listed = this.listedRemote();
3351
3325
  if (listed.size === 0 && !existsSync(this.paths.remote)) return;
3352
3326
  try {
3353
- writeRemote(this.root, Object.fromEntries([...listed].sort(([a], [b]) => comparePaths(a, b))));
3327
+ writeRemote(this.dirs, Object.fromEntries([...listed].sort(([a], [b]) => comparePaths(a, b))));
3354
3328
  } catch (error) {
3355
3329
  this.log(`could not save local/sync/remote.json: ${error.message}`);
3356
3330
  }
@@ -3941,10 +3915,10 @@ var SyncClient = class extends EventEmitter {
3941
3915
  * goes first, and no file the server holds there that is not written here yet, nor one only on the server. The
3942
3916
  * deepest go first, so the server finds the ones in each removed already; one holding anything holds the ones above it
3943
3917
  * too. Each is checked again in the hold of the write lock that queues it, so a directory made again meanwhile stays.
3944
- * Only once a snapshot was taken under the rules as they are, since what the server holds is unknown before.
3918
+ * Only once a snapshot was taken, since what the server holds is unknown before.
3945
3919
  */
3946
3920
  async queueRemovedDirs() {
3947
- if (!this.synced || this.scopeStale || this.closing) return;
3921
+ if (!this.synced || this.closing) return;
3948
3922
  const exists = (dir) => existsSync(join(this.paths.data, dir));
3949
3923
  const gone = [...this.syncedDirs].filter((dir) => this.syncsDir(dir) && !exists(dir));
3950
3924
  if (gone.length === 0) return;
@@ -4103,7 +4077,7 @@ var SyncClient = class extends EventEmitter {
4103
4077
  */
4104
4078
  locked(fn) {
4105
4079
  if (this.abandoned) throw new Error("abandoned after stop");
4106
- return underLock(this.root, fn);
4080
+ return underLock(this.dirs, fn);
4107
4081
  }
4108
4082
  /**
4109
4083
  * Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
@@ -4118,7 +4092,7 @@ var SyncClient = class extends EventEmitter {
4118
4092
  }
4119
4093
  /** editQueue for a caller that holds the write lock already. */
4120
4094
  editHeld(edit) {
4121
- const entries = readQueue(this.root);
4095
+ const entries = readQueue(this.dirs);
4122
4096
  const known = new Set(this.queue.map((entry) => entry.id));
4123
4097
  let changed = false;
4124
4098
  const fresh = [];
@@ -4142,7 +4116,7 @@ var SyncClient = class extends EventEmitter {
4142
4116
  this.resolveBases(entries);
4143
4117
  }
4144
4118
  if (changed) {
4145
- writeQueue(this.root, entries);
4119
+ writeQueue(this.dirs, entries);
4146
4120
  this.pruneStaged(entries);
4147
4121
  }
4148
4122
  this.queue = entries;
@@ -4340,6 +4314,8 @@ var SyncClient = class extends EventEmitter {
4340
4314
  async sendOutbox() {
4341
4315
  this.flushing = true;
4342
4316
  try {
4317
+ const held = this.heldUntil - Date.now();
4318
+ if (held > 0) return this.retryQueue(held);
4343
4319
  while (this.caughtUp) {
4344
4320
  let entry;
4345
4321
  let wake;
@@ -4664,9 +4640,28 @@ var SyncClient = class extends EventEmitter {
4664
4640
  } catch (error) {
4665
4641
  if (!(error instanceof Unreachable)) throw error;
4666
4642
  this.trace(`send ${entry.id} failed: ${error.message}`);
4643
+ if (error instanceof HubRewritingError) {
4644
+ this.heldUntil = Date.now() + error.retryAfter * 1e3;
4645
+ this.log(`${error.message}; the outbox is sent again in ${error.retryAfter} s`);
4646
+ return null;
4647
+ }
4648
+ if (error instanceof ClientOutdatedError) {
4649
+ this.heldUntil = Date.now() + error.retryAfter * 1e3;
4650
+ if (error.message !== this.outdatedMessage) {
4651
+ const paths = entryPaths(entry);
4652
+ this.problem({
4653
+ kind: "client-outdated",
4654
+ path: paths[0] ?? "",
4655
+ message: `${error.message}. ${request.op}${paths.length ? ` of ${paths.join(", ")}` : ""} and the writes after it wait in the outbox, sent again every ${error.retryAfter} s until then`
4656
+ });
4657
+ }
4658
+ this.outdatedMessage = error.message;
4659
+ return null;
4660
+ }
4667
4661
  if (error.broken && !fresh && !this.closing) return this.request(entry, request, true, true);
4668
4662
  return null;
4669
4663
  }
4664
+ this.outdatedMessage = null;
4670
4665
  if (!response.ok && response.reason === "uncertain") {
4671
4666
  const paths = entryPaths(entry);
4672
4667
  if (entry.kind !== "rmdir") this.problem({
@@ -4966,7 +4961,7 @@ var SyncClient = class extends EventEmitter {
4966
4961
  */
4967
4962
  renumberDisk(paths, renumbered) {
4968
4963
  const storage = {
4969
- ...nodeStorage(this.root),
4964
+ ...nodeStorage(this.dirs),
4970
4965
  lock: (fn) => fn()
4971
4966
  };
4972
4967
  const seen = new Map([...new Set(paths)].filter((path) => this.syncs(path)).map((path) => [path, this.expected.get(path)]));
@@ -5299,16 +5294,9 @@ var SyncClient = class extends EventEmitter {
5299
5294
  }]);
5300
5295
  if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
5301
5296
  }
5302
- /**
5303
- * The synced files here, by path. Each directory's .gitignore is read again on the way, before what the directory
5304
- * holds, so a change to one takes effect from the next scan on; a directory the rules leave out is not walked into.
5305
- * Rules changed since the last snapshot was asked for take it again: a file they let in that is on the server, as
5306
- * another environment sent it while the rules here left it out, comes then, not only on the next connection. A walk
5307
- * of a snapshot being taken leaves that to it (resync).
5308
- */
5297
+ /** The synced files here, by path: a walk into the directories that may hold them alone (src/paths.ts). */
5309
5298
  async walk() {
5310
5299
  const out = [];
5311
- const dirs = /* @__PURE__ */ new Set();
5312
5300
  const visit = async (dir) => {
5313
5301
  let entries;
5314
5302
  try {
@@ -5316,44 +5304,23 @@ var SyncClient = class extends EventEmitter {
5316
5304
  } catch {
5317
5305
  return;
5318
5306
  }
5319
- dirs.add(dir);
5320
- this.scope.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? await this.readRules(dir) : null);
5321
5307
  for (const entry of entries) {
5322
5308
  const path = dir ? `${dir}/${entry.name}` : entry.name;
5323
5309
  if (entry.isDirectory()) {
5324
- if (this.scope.walks(path)) await visit(path);
5310
+ if (mayHoldSynced(path)) await visit(path);
5325
5311
  } else if (entry.isFile() && this.syncs(path)) out.push(path);
5326
5312
  }
5327
5313
  };
5328
5314
  await visit("");
5329
- this.scope.keepOnly(dirs);
5330
- if (this.scopeStale && this.synced && this.connected && this.resyncing === null && !this.closing) {
5331
- this.log("the hub's .gitignore changed; reloading the snapshot for the files it lets in");
5332
- this.track(this.resync().catch((error) => this.log(`could not reload the snapshot: ${error.message}`)));
5333
- }
5334
5315
  return out;
5335
5316
  }
5336
- /**
5337
- * The text of the .gitignore in `dir`, which is there, as walk takes it. A text other than the rules have may be one
5338
- * being written, as `cat >` empties a file before it fills it, and a rule missing for a moment would let its files in,
5339
- * to be sent: it is read again after EMPTY_RECHECK_MS and taken only when it reads the same, empty too. Until then, and
5340
- * while the file cannot be read, the rules stay as they were; the next walk reads it again.
5341
- */
5342
- async readRules(dir) {
5343
- const held = this.scope.text(dir);
5344
- const read = () => readFile(join(this.paths.data, dir, GITIGNORE), "utf8").catch(() => null);
5345
- const text = await read();
5346
- if (text === null || text === held) return held;
5347
- await sleep$1(EMPTY_RECHECK_MS);
5348
- return await read() === text ? text : held;
5349
- }
5350
- /** The directories an upload holds: every one outside local/ that the hub's .gitignore files leave in. */
5317
+ /** The directories an upload holds: every synced one (syncsDir) outside local/. */
5351
5318
  async walkDirs(dir = "") {
5352
5319
  const out = [];
5353
5320
  const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
5354
5321
  for (const entry of entries) {
5355
5322
  const path = dir ? `${dir}/${entry.name}` : entry.name;
5356
- if (!entry.isDirectory() || path === "local" || path.startsWith("local/") || this.scope.ignored(path, true)) continue;
5323
+ if (!entry.isDirectory() || path === "local" || !this.syncsDir(path)) continue;
5357
5324
  out.push(path, ...await this.walkDirs(path));
5358
5325
  }
5359
5326
  return out;
@@ -5401,13 +5368,12 @@ var SyncClient = class extends EventEmitter {
5401
5368
  return;
5402
5369
  }
5403
5370
  if (this.syncedDirs.has(path)) this.checkDirs();
5404
- const rules = path === ".gitignore" || path.endsWith(`/.gitignore`);
5405
- if (!rules && !this.syncs(path)) return;
5371
+ if (!this.syncs(path)) return;
5406
5372
  this.trace(`watched ${path}`);
5407
5373
  clearTimeout(timers.get(path));
5408
5374
  timers.set(path, setTimeout(() => {
5409
5375
  timers.delete(path);
5410
- this.track(this.whenSettled(path).then(() => rules ? this.scan(true) : this.schedule(path)));
5376
+ this.track(this.whenSettled(path).then(() => this.schedule(path)));
5411
5377
  }, SETTLE_MS));
5412
5378
  };
5413
5379
  try {
@@ -5511,7 +5477,7 @@ var SyncClient = class extends EventEmitter {
5511
5477
  const files = this.knownFiles();
5512
5478
  const dirs = this.knownDirs();
5513
5479
  try {
5514
- underLock(this.root, () => writeKnown(this.root, files, dirs));
5480
+ underLock(this.dirs, () => writeKnown(this.dirs, files, dirs));
5515
5481
  } catch (error) {
5516
5482
  this.log(`could not save local/sync/known.json: ${error.message}`);
5517
5483
  }
@@ -5521,8 +5487,8 @@ var SyncClient = class extends EventEmitter {
5521
5487
  //#region ../sync/dist/ipc.js
5522
5488
  const SECRET_HEADER = "x-mainflow-sync";
5523
5489
  /** The running sync client of the hub at `root`, or null when none runs. */
5524
- function readDaemon(root) {
5525
- const file = syncPaths(root).daemon;
5490
+ function readDaemon(dirs) {
5491
+ const file = syncPaths(dirs).daemon;
5526
5492
  if (!existsSync(file)) return null;
5527
5493
  try {
5528
5494
  const info = JSON.parse(readFileSync(file, "utf8"));
@@ -5575,8 +5541,8 @@ async function callDaemon(daemon, route, body, timeoutMs = 6e4) {
5575
5541
  /** How long a probe waits for the sync client's answer; a client holding the write lock answers after it. */
5576
5542
  const PROBE_MS = 2e3;
5577
5543
  /** The sync client daemon.json names, asked whether it runs; null without daemon.json or a live process at its pid. */
5578
- async function probeDaemon(root, timeoutMs = PROBE_MS) {
5579
- const daemon = readDaemon(root);
5544
+ async function probeDaemon(dirs, timeoutMs = PROBE_MS) {
5545
+ const daemon = readDaemon(dirs);
5580
5546
  if (!daemon) return null;
5581
5547
  try {
5582
5548
  return {
@@ -5602,9 +5568,9 @@ const sameDaemon = (a, b) => a !== null && a.pid === b.pid && a.port === b.port
5602
5568
  * Remove daemon.json when it still says what `gone` said, under the write lock, so a sync client that claimed the hub
5603
5569
  * meanwhile keeps it. Call holding the lock.
5604
5570
  */
5605
- function forgetDaemon(root, gone) {
5606
- if (!sameDaemon(readDaemon(root), gone)) return false;
5607
- rmSync(syncPaths(root).daemon, { force: true });
5571
+ function forgetDaemon(dirs, gone) {
5572
+ if (!sameDaemon(readDaemon(dirs), gone)) return false;
5573
+ rmSync(syncPaths(dirs).daemon, { force: true });
5608
5574
  return true;
5609
5575
  }
5610
5576
  /** The Edit a request's body names, its file and its one replacement, or null. */
@@ -5628,7 +5594,7 @@ function callIn(body) {
5628
5594
  };
5629
5595
  }
5630
5596
  /** Serve `client` to this machine's mainflow commands until closed. */
5631
- async function serveIpc(root, client) {
5597
+ async function serveIpc(dirs, client) {
5632
5598
  const secret = randomBytes(16).toString("hex");
5633
5599
  const server = createServer(async (req, res) => {
5634
5600
  const reply = (status, value) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
@@ -5675,13 +5641,13 @@ async function serveIpc(root, client) {
5675
5641
  });
5676
5642
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
5677
5643
  const port = server.address().port;
5678
- const file = syncPaths(root).daemon;
5679
- const probe = await probeDaemon(root);
5680
- const other = underLock(root, () => {
5681
- const running = readDaemon(root);
5644
+ const file = syncPaths(dirs).daemon;
5645
+ const probe = await probeDaemon(dirs);
5646
+ const other = underLock(dirs, () => {
5647
+ const running = readDaemon(dirs);
5682
5648
  const reused = running !== null && probe?.state === "gone" && sameDaemon(running, probe.daemon);
5683
5649
  if (running && running.pid !== process.pid && !reused) return running;
5684
- mkdirSync(syncPaths(root).own, { recursive: true });
5650
+ mkdirSync(syncPaths(dirs).own, { recursive: true });
5685
5651
  writeFileSync(file, JSON.stringify({
5686
5652
  pid: process.pid,
5687
5653
  port,
@@ -5709,7 +5675,7 @@ async function serveIpc(root, client) {
5709
5675
  //#region ../sync/dist/daemon.js
5710
5676
  async function startDaemon(options) {
5711
5677
  const client = new SyncClient(options);
5712
- const ipc = await serveIpc(options.root, client);
5678
+ const ipc = await serveIpc(options.dirs, client);
5713
5679
  try {
5714
5680
  await client.start();
5715
5681
  } catch (error) {
@@ -5753,11 +5719,11 @@ const FETCH_TOOLS = /* @__PURE__ */ new Set([
5753
5719
  "MultiEdit"
5754
5720
  ]);
5755
5721
  /** The file of the hub at `root` that `payload` names in file_path, relative to .mainflow/; null outside it. */
5756
- function hubPath(root, payload) {
5722
+ function hubPath(dirs, payload) {
5757
5723
  const input = payload.tool_input ?? {};
5758
5724
  if (typeof input.file_path !== "string") return null;
5759
5725
  const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? process.cwd(), input.file_path);
5760
- const path = relative(join(root, DATA_DIR), file).split(sep).join("/");
5726
+ const path = relative(dirs.data, file).split(sep).join("/");
5761
5727
  if (path.startsWith("../") || path === ".." || isAbsolute(path)) return null;
5762
5728
  return path;
5763
5729
  }
@@ -5779,11 +5745,11 @@ function editOf(path, payload) {
5779
5745
  * needs nothing; an Edit of one replacement is replayed on the latest version; another file tool names the file it
5780
5746
  * wrote; anything else, such as a Bash command that may have written any file, rescans every file and names none.
5781
5747
  */
5782
- function planFlush(root, payload) {
5748
+ function planFlush(dirs, payload) {
5783
5749
  const tool = payload.tool_name ?? "";
5784
5750
  if (!FILE_TOOLS.has(tool)) return { skip: false };
5785
5751
  if (typeof (payload.tool_input ?? {}).file_path !== "string") return { skip: false };
5786
- const path = hubPath(root, payload);
5752
+ const path = hubPath(dirs, payload);
5787
5753
  if (path === null || !isSynced(path)) return { skip: true };
5788
5754
  const edit = editOf(path, payload);
5789
5755
  return edit ? {
@@ -5811,11 +5777,11 @@ function hookAnswer(result) {
5811
5777
  * What to download before the tool in `payload` runs in the hub at `root`: the artifact it reads or writes, when the file
5812
5778
  * is not here, so it may be only on the sync server. Null for anything else, which needs nothing.
5813
5779
  */
5814
- function planFetch(root, payload) {
5780
+ function planFetch(dirs, payload) {
5815
5781
  const tool = payload.tool_name ?? "";
5816
5782
  if (!FETCH_TOOLS.has(tool)) return null;
5817
- const path = hubPath(root, payload);
5818
- if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(root, ".mainflow", path))) return null;
5783
+ const path = hubPath(dirs, payload);
5784
+ if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(dirs.data, path))) return null;
5819
5785
  return {
5820
5786
  path,
5821
5787
  write: tool !== "Read"
@@ -5826,8 +5792,8 @@ function planFetch(root, payload) {
5826
5792
  * change then waits for the Edit's PostToolUse report (docs/sync.md "等 Edit 的回報"). Null for anything else, which
5827
5793
  * announces nothing: a change no Edit announced is sent at once.
5828
5794
  */
5829
- function planAnnounce(root, payload) {
5830
- const path = hubPath(root, payload);
5795
+ function planAnnounce(dirs, payload) {
5796
+ const path = hubPath(dirs, payload);
5831
5797
  if (path === null || !isSynced(path)) return null;
5832
5798
  const edit = editOf(path, payload);
5833
5799
  return edit && edit.old !== "" ? edit : null;
@@ -5839,10 +5805,53 @@ const fetchDenial = (reason) => JSON.stringify({ hookSpecificOutput: {
5839
5805
  permissionDecisionReason: `mainflow sync: ${reason}`
5840
5806
  } });
5841
5807
  //#endregion
5808
+ //#region ../sync/dist/oversized.js
5809
+ const decoder = new TextDecoder("utf-8", { fatal: true });
5810
+ /** The synced files under `data`, the hub's .mainflow/, by path: a walk into the directories that may hold them alone. */
5811
+ function syncedFiles(data) {
5812
+ const files = [];
5813
+ const visit = (dir) => {
5814
+ let entries;
5815
+ try {
5816
+ entries = readdirSync(join(data, dir), { withFileTypes: true });
5817
+ } catch {
5818
+ return;
5819
+ }
5820
+ for (const entry of entries) {
5821
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
5822
+ if (entry.isDirectory()) {
5823
+ if (mayHoldSynced(path)) visit(path);
5824
+ } else if (entry.isFile() && isSynced(path)) files.push(path);
5825
+ }
5826
+ };
5827
+ visit("");
5828
+ return files;
5829
+ }
5830
+ /**
5831
+ * The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text of the sync
5832
+ * scope. The sync client leaves them out of sync; `mainflow check` warns of them.
5833
+ */
5834
+ function oversizedFiles(dirs) {
5835
+ const data = dirs.data;
5836
+ const out = [];
5837
+ for (const path of syncedFiles(data)) {
5838
+ const file = join(data, path);
5839
+ try {
5840
+ if (statSync(file).size <= 19e5) continue;
5841
+ const bytes = textBytes(decoder.decode(readFileSync(file)));
5842
+ if (bytes > 19e5) out.push({
5843
+ path,
5844
+ bytes
5845
+ });
5846
+ } catch {}
5847
+ }
5848
+ return out;
5849
+ }
5850
+ //#endregion
5842
5851
  //#region ../sync/dist/outbox.js
5843
5852
  /** Whether the hub at `root` writes through the sync server. */
5844
- function remoteMode(root, env = process.env) {
5845
- return readDaemon(root) !== null || syncSettings(root, env) !== null;
5853
+ function remoteMode(dirs, env = process.env) {
5854
+ return readDaemon(dirs) !== null || syncSettings(dirs, env) !== null;
5846
5855
  }
5847
5856
  /** Refusals of an operation by the local files, as opposed to a write lock held too long; thrown as they are. */
5848
5857
  const refusals = /* @__PURE__ */ new WeakSet();
@@ -5853,8 +5862,8 @@ const refusedHere = (error) => error instanceof Error && refusals.has(error);
5853
5862
  * client reads the outbox under the same lock, so it never sees the files this changed without the entry that
5854
5863
  * explains them, and never takes them for an edit to send. A refusal here is thrown, and nothing is queued.
5855
5864
  */
5856
- function queueOperation(root, request) {
5857
- const storage = nodeStorage(root);
5865
+ function queueOperation(dirs, request) {
5866
+ const storage = nodeStorage(dirs);
5858
5867
  return storage.lock(() => {
5859
5868
  const before = {};
5860
5869
  const keep = (path) => {
@@ -5878,7 +5887,7 @@ function queueOperation(root, request) {
5878
5887
  const written = {};
5879
5888
  for (const change of result.changes) if (change.kind !== "makeDir" && isSynced(change.path)) written[change.path] = held.read(change.path);
5880
5889
  const id = entryId();
5881
- writeQueue(root, [...readQueue(root), {
5890
+ writeQueue(dirs, [...readQueue(dirs), {
5882
5891
  id,
5883
5892
  kind: "op",
5884
5893
  request: pending,
@@ -5892,8 +5901,8 @@ function queueOperation(root, request) {
5892
5901
  });
5893
5902
  }
5894
5903
  /** How many writes wait in the outbox for the server, as queue.json says: not the ones it refused (src/state.ts). */
5895
- function queuedCount(root) {
5896
- const file = syncPaths(root).queue;
5904
+ function queuedCount(dirs) {
5905
+ const file = syncPaths(dirs).queue;
5897
5906
  if (!existsSync(file)) return 0;
5898
5907
  try {
5899
5908
  return (JSON.parse(readFileSync(file, "utf8")).entries ?? []).filter(waiting).length;
@@ -5909,8 +5918,8 @@ const NOTIFY_MS = 200;
5909
5918
  * the server's answer, such as a hook or the interface. Without a sync client, or one that does not answer, nothing
5910
5919
  * happens: the client's watcher of queue.json and its rescan find the entry, a moment later.
5911
5920
  */
5912
- async function notifyQueued(root) {
5913
- const daemon = readDaemon(root);
5921
+ async function notifyQueued(dirs) {
5922
+ const daemon = readDaemon(dirs);
5914
5923
  if (!daemon) return;
5915
5924
  try {
5916
5925
  await callDaemon(daemon, "/send", {}, NOTIFY_MS);
@@ -5920,12 +5929,12 @@ async function notifyQueued(root) {
5920
5929
  * Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
5921
5930
  * waited for. `waiting` when it is offline, or the time ran out first.
5922
5931
  */
5923
- async function awaitOutcome(root, id, timeoutMs) {
5932
+ async function awaitOutcome(dirs, id, timeoutMs) {
5924
5933
  const deadline = Date.now() + timeoutMs;
5925
5934
  for (;;) {
5926
5935
  const left = deadline - Date.now();
5927
5936
  if (left <= 0) return { state: "waiting" };
5928
- const daemon = readDaemon(root);
5937
+ const daemon = readDaemon(dirs);
5929
5938
  if (daemon) try {
5930
5939
  return await callDaemon(daemon, "/wait", {
5931
5940
  id,
@@ -5938,6 +5947,54 @@ async function awaitOutcome(root, id, timeoutMs) {
5938
5947
  }
5939
5948
  }
5940
5949
  //#endregion
5950
+ //#region src/version-gate.ts
5951
+ const INVALID_RANGE = "mainflow in .mainflow/config.yaml is not a valid semver range, so the mainflow version is not checked; run \"mainflow check\" for details";
5952
+ const READ_ENDING = "; writes will be refused until then";
5953
+ /**
5954
+ * Compare this CLI's version with the mainflow versions the hub's config.yaml takes writes from. A write outside the
5955
+ * range is refused; a read is warned about. A `mainflow` that is no range is only warned about, whatever the command,
5956
+ * since core reads it as no limit so that a typo does not shut every writer out. A hub that names no range, has no
5957
+ * readable config.yaml or takes this version is not checked. The comparison is core's versionMismatch, the one the
5958
+ * sync server makes, and its message carries upgradeCommand.
5959
+ */
5960
+ function versionGate(dirs, version, command) {
5961
+ let requirements;
5962
+ try {
5963
+ requirements = hubRequirements(nodeStorage(dirs));
5964
+ } catch {
5965
+ return { kind: "run" };
5966
+ }
5967
+ if (requirements?.mainflowInvalid) return {
5968
+ kind: "warn",
5969
+ message: INVALID_RANGE
5970
+ };
5971
+ if (!requirements?.mainflow) return { kind: "run" };
5972
+ if (command.writes) {
5973
+ const message = versionMismatch(requirements.mainflow, version);
5974
+ return message === null ? { kind: "run" } : {
5975
+ kind: "refuse",
5976
+ message
5977
+ };
5978
+ }
5979
+ const message = versionMismatch(requirements.mainflow, version, READ_ENDING);
5980
+ return message === null ? { kind: "run" } : {
5981
+ kind: "warn",
5982
+ message
5983
+ };
5984
+ }
5985
+ /**
5986
+ * The checkpoint of every write that goes through the writer (the commands, the interface, the hooks): throws a
5987
+ * WriteError when `version` is outside the range the hub takes writes from, and warns on stderr about a range that is
5988
+ * no range. A hook's write is not checked (`hook`), since a hook has to answer the host whatever the version; the
5989
+ * `hook` command itself warns once before it runs.
5990
+ */
5991
+ function requireWritableVersion(dirs, version, options = {}) {
5992
+ if (options.hook) return;
5993
+ const verdict = versionGate(dirs, version, { writes: true });
5994
+ if (verdict.kind === "refuse") throw new WriteError(`refusing to write: ${verdict.message}`);
5995
+ if (verdict.kind === "warn") console.error(`! ${verdict.message}`);
5996
+ }
5997
+ //#endregion
5941
5998
  //#region src/remote/start.ts
5942
5999
  /** How long a sync client started in the background may take to claim the hub, its first request to the server included. */
5943
6000
  const STARTING_MS = 6e4;
@@ -5954,10 +6011,10 @@ const alive = (pid) => {
5954
6011
  * none. `env` gives the sync server and service token as the environment variables do; for a hub other than the one this
5955
6012
  * process was started in it is `{}`, so the process started reads that hub's own .mainflow/local/sync.json.
5956
6013
  */
5957
- async function startSyncInBackground(root, env = process.env) {
5958
- const probe = await probeDaemon(root);
6014
+ async function startSyncInBackground(dirs, env = process.env) {
6015
+ const probe = await probeDaemon(dirs);
5959
6016
  if (probe && probe.state !== "gone") return { state: "running" };
5960
- const paths = syncPaths(root);
6017
+ const paths = syncPaths(dirs);
5961
6018
  const cli = process.argv[1];
5962
6019
  if (process.env.MAINFLOW_NO_BACKGROUND_SYNC === "1") return {
5963
6020
  state: "failed",
@@ -5967,10 +6024,10 @@ async function startSyncInBackground(root, env = process.env) {
5967
6024
  state: "failed",
5968
6025
  reason: "this process cannot tell where the mainflow CLI is"
5969
6026
  };
5970
- const log = relative(root, paths.log);
5971
- return nodeStorage(root).lock(() => {
5972
- const reused = probe?.state === "gone" && forgetDaemon(root, probe.daemon) ? probe.daemon.pid : void 0;
5973
- if (reused === void 0 && readDaemon(root)) return { state: "starting" };
6027
+ const log = paths.log;
6028
+ return nodeStorage(dirs).lock(() => {
6029
+ const reused = probe?.state === "gone" && forgetDaemon(dirs, probe.daemon) ? probe.daemon.pid : void 0;
6030
+ if (reused === void 0 && readDaemon(dirs)) return { state: "starting" };
5974
6031
  let previous = null;
5975
6032
  try {
5976
6033
  previous = existsSync(paths.started) ? JSON.parse(readFileSync(paths.started, "utf8")) : null;
@@ -5984,7 +6041,7 @@ async function startSyncInBackground(root, env = process.env) {
5984
6041
  let pid;
5985
6042
  try {
5986
6043
  const child = spawn(process.execPath, [cli, "sync"], {
5987
- cwd: root,
6044
+ cwd: dirs.root,
5988
6045
  detached: true,
5989
6046
  stdio: [
5990
6047
  "ignore",
@@ -6026,8 +6083,8 @@ async function startSyncInBackground(root, env = process.env) {
6026
6083
  /** Added to a write the local files refused, unless the sync client runs with the server's latest state. */
6027
6084
  const STALE_COPY = "the local copy may not have caught up with the sync server yet; retry once mainflow sync has caught up (mainflow sync status)";
6028
6085
  /** Whether the hub's sync client runs and has the server's latest state, asked with a short wait. */
6029
- async function caughtUp(root) {
6030
- const daemon = readDaemon(root);
6086
+ async function caughtUp(dirs) {
6087
+ const daemon = readDaemon(dirs);
6031
6088
  if (!daemon) return false;
6032
6089
  try {
6033
6090
  return (await callDaemon(daemon, "/status", void 0, 2e3)).caughtUp === true;
@@ -6053,29 +6110,30 @@ function writtenLines(written) {
6053
6110
  * other than the one this process was started in, such as another hub the interface shows, passes `{}`, so only its
6054
6111
  * own .mainflow/local/sync.json and sync client count.
6055
6112
  */
6056
- function hubWriter(root, env = process.env, options = {}) {
6113
+ function hubWriter(dirs, env = process.env, options = {}) {
6057
6114
  const startSync = options.startSync ?? startSyncInBackground;
6058
6115
  return async (op, input) => {
6059
- if (!remoteMode(root, env)) return {
6060
- value: applyOperation(nodeStorage(root), op, input, { now: nowMinute() }).value,
6116
+ requireWritableVersion(dirs, CLI_VERSION, { hook: options.hook });
6117
+ if (!remoteMode(dirs, env)) return {
6118
+ value: applyOperation(nodeStorage(dirs), op, input, { now: nowMinute() }).value,
6061
6119
  queued: false,
6062
6120
  renumbered: [],
6063
6121
  notices: []
6064
6122
  };
6065
6123
  let queuedOp;
6066
6124
  try {
6067
- queuedOp = queueOperation(root, {
6125
+ queuedOp = queueOperation(dirs, {
6068
6126
  protocol: 3,
6069
6127
  op,
6070
6128
  input,
6071
6129
  now: requestTime()
6072
6130
  });
6073
6131
  } catch (error) {
6074
- if (refusedHere(error) && !await caughtUp(root)) error.message += `; ${STALE_COPY}`;
6132
+ if (refusedHere(error) && !await caughtUp(dirs)) error.message += `; ${STALE_COPY}`;
6075
6133
  throw error;
6076
6134
  }
6077
6135
  const { id, result } = queuedOp;
6078
- const start = await startSync(root, env);
6136
+ const start = await startSync(dirs, env);
6079
6137
  const queued = {
6080
6138
  value: result.value,
6081
6139
  queued: true,
@@ -6088,10 +6146,10 @@ function hubWriter(root, env = process.env, options = {}) {
6088
6146
  stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
6089
6147
  };
6090
6148
  if (options.confirmMs === void 0) {
6091
- await notifyQueued(root);
6149
+ await notifyQueued(dirs);
6092
6150
  return queued;
6093
6151
  }
6094
- const outcome = await awaitOutcome(root, id, options.confirmMs);
6152
+ const outcome = await awaitOutcome(dirs, id, options.confirmMs);
6095
6153
  if (outcome.state === "applied") {
6096
6154
  const { response } = outcome;
6097
6155
  return {
@@ -6111,4 +6169,4 @@ function hubWriter(root, env = process.env, options = {}) {
6111
6169
  };
6112
6170
  }
6113
6171
  //#endregion
6114
- export { isSynced as A, ServerError as C, syncSettings as D, hubName as E, SETTINGS_FILE as O, ServerApi as S, earlierToken as T, readDaemon as _, remoteMode as a, readRemote as b, planAnnounce as c, toolCallOf as d, startDaemon as f, probeDaemon as g, callDaemon as h, queuedCount as i, syncPaths as j, SYNC_DIR as k, planFetch as l, DaemonUnavailable as m, hubWriter as n, fetchDenial as o, DaemonRunning as p, writtenLines as r, hookAnswer as s, CONFIRM_MS as t, planFlush as u, silentHint as v, ENV as w, oversizedFiles as x, watchTree as y };
6172
+ export { hubName as A, readRemote as C, Unreachable as D, ServerError as E, syncPaths as F, SETTINGS_FILE as M, SYNC_DIR as N, ENV as O, isSynced as P, watchTree as S, ServerApi as T, DaemonUnavailable as _, versionGate as a, readDaemon as b, oversizedFiles as c, planAnnounce as d, planFetch as f, DaemonRunning as g, startDaemon as h, requireWritableVersion as i, syncSettings as j, earlierToken as k, fetchDenial as l, toolCallOf as m, hubWriter as n, queuedCount as o, planFlush as p, writtenLines as r, remoteMode as s, CONFIRM_MS as t, hookAnswer as u, callDaemon as v, HubRewritingError as w, silentHint as x, probeDaemon as y };