mainflow 0.41.0 → 0.43.0-alpha.1

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,6 +1,7 @@
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
- import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
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";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { execFileSync, spawn, spawnSync } from "node:child_process";
@@ -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;
@@ -900,7 +856,8 @@ const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.i
900
856
  const waiting = (entry) => entry.refused === void 0;
901
857
  /** A new entry's id. */
902
858
  const entryId = () => randomBytes(8).toString("hex");
903
- const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : [entry.path];
859
+ /** The files an entry writes; none for a directory's removal. */
860
+ const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : entry.kind === "rmdir" ? [] : [entry.path];
904
861
  /** A local minute without an offset, the `now` of protocol 1 */
905
862
  const LOCAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
906
863
  /** Protocols whose write requests read the same as the current one's: protocol 3 changed only how a snapshot is taken. */
@@ -953,13 +910,18 @@ function writeJson(file, value) {
953
910
  writeFileSync(temp, JSON.stringify(value, null, 1));
954
911
  renameSync(temp, file);
955
912
  }
956
- const readKnown = (root) => readJson(syncPaths(root).known, {}).files ?? {};
913
+ const readKnown = (dirs) => readJson(syncPaths(dirs).known, {}).files ?? {};
914
+ /** The directories known.json names; none from a client before it kept them. */
915
+ const readKnownDirs = (hub) => {
916
+ const dirs = readJson(syncPaths(hub).known, {}).dirs;
917
+ return Array.isArray(dirs) ? dirs.filter((dir) => typeof dir === "string") : [];
918
+ };
957
919
  /** What known.json writes before a binary file's sha256, apart from a text's content hash. */
958
920
  const BLOB_PREFIX = "blob:";
959
921
  /** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
960
- function readKnownText(root, hash) {
922
+ function readKnownText(dirs, hash) {
961
923
  try {
962
- const text = readFileSync(join(syncPaths(root).knownTexts, hash), "utf8");
924
+ const text = readFileSync(join(syncPaths(dirs).knownTexts, hash), "utf8");
963
925
  return contentHash(text) === hash ? text : void 0;
964
926
  } catch {
965
927
  return;
@@ -970,9 +932,10 @@ function readKnownText(root, hash) {
970
932
  * there yet; the texts of versions it no longer names are removed. A version whose text the client does not have keeps
971
933
  * the text known/ holds, if any. The new texts go first, then known.json, then the removals, so a client killed at any
972
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.
935
+ * `dirs` are the directories it names.
973
936
  */
974
- function writeKnown(root, files) {
975
- const dir = syncPaths(root).knownTexts;
937
+ function writeKnown(hub, files, dirs = []) {
938
+ const dir = syncPaths(hub).knownTexts;
976
939
  mkdirSync(dir, { recursive: true });
977
940
  const named = new Set(Object.values(files).map((file) => file.hash));
978
941
  const held = new Set(readdirSync(dir));
@@ -982,26 +945,29 @@ function writeKnown(root, files) {
982
945
  renameSync(join(dir, `${hash}.tmp`), join(dir, hash));
983
946
  held.add(hash);
984
947
  }
985
- const known = { files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])) };
986
- writeJson(syncPaths(root).known, known);
948
+ const known = {
949
+ files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])),
950
+ ...dirs.length > 0 ? { dirs } : {}
951
+ };
952
+ writeJson(syncPaths(hub).known, known);
987
953
  for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
988
954
  }
989
- const writeQueue = (root, entries) => writeJson(syncPaths(root).queue, { entries });
990
- const readRemote = (root) => readJson(syncPaths(root).remote, {}).files ?? {};
991
- 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 });
992
958
  /** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
993
- function readQueue(root) {
994
- const entries = readJson(syncPaths(root).queue, {}).entries ?? [];
959
+ function readQueue(dirs) {
960
+ const entries = readJson(syncPaths(dirs).queue, {}).entries ?? [];
995
961
  if (entries.every((entry) => typeof entry.id === "string")) return entries;
996
962
  const named = entries.map((entry) => ({
997
963
  ...entry,
998
964
  id: entry.id ?? entryId()
999
965
  }));
1000
- writeQueue(root, named);
966
+ writeQueue(dirs, named);
1001
967
  return named;
1002
968
  }
1003
969
  /** Run `fn` under the hub's write lock, the one every mainflow writer of this machine takes. */
1004
- const underLock = (root, fn) => nodeStorage(root).lock(fn);
970
+ const underLock = (dirs, fn) => nodeStorage(dirs).lock(fn);
1005
971
  //#endregion
1006
972
  //#region ../sync/dist/swap-helper.js
1007
973
  const HELPER_SOURCE = "mainflow-swap.c";
@@ -1376,14 +1342,14 @@ const SWAP_PAUSE_MS = 6e4;
1376
1342
  /** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
1377
1343
  const OUTBOX = `${SYNC_DIR}/queue.json`;
1378
1344
  const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1379
- const decoder = new TextDecoder("utf-8", { fatal: true });
1345
+ const decoder$1 = new TextDecoder("utf-8", { fatal: true });
1380
1346
  const sameLocal = (a, b) => a.kind === b.kind && (a.kind !== "text" || a.text === b.text) && (a.kind !== "binary" || a.hash === b.hash);
1381
1347
  /** What `bytes` read from a file are: a text when they are UTF-8, a binary file otherwise. */
1382
1348
  function contentOf(bytes) {
1383
1349
  try {
1384
1350
  return {
1385
1351
  kind: "text",
1386
- text: decoder.decode(bytes)
1352
+ text: decoder$1.decode(bytes)
1387
1353
  };
1388
1354
  } catch {
1389
1355
  return {
@@ -1510,7 +1476,7 @@ function heldBase(entry) {
1510
1476
  */
1511
1477
  function textAt(entry, path) {
1512
1478
  if (entry.kind === "op") return entry.written[path];
1513
- if (entry.path !== path || entry.kind === "blob") return void 0;
1479
+ if (entry.path !== path || entry.kind === "blob" || entry.kind === "rmdir") return void 0;
1514
1480
  if (entry.kind === "remove") return null;
1515
1481
  return entry.text;
1516
1482
  }
@@ -1556,7 +1522,7 @@ function holdsFiles(parts, held) {
1556
1522
  }
1557
1523
  var SyncClient = class extends EventEmitter {
1558
1524
  opts;
1559
- root;
1525
+ dirs;
1560
1526
  paths;
1561
1527
  api;
1562
1528
  problems = [];
@@ -1654,14 +1620,6 @@ var SyncClient = class extends EventEmitter {
1654
1620
  uploadWanted = false;
1655
1621
  /** Why the server refused the upload of this environment's copy, as last reported (snapshotNow); null once it holds the hub */
1656
1622
  uploadRefusal = null;
1657
- /** The hub's .gitignore files, which leave a file out of sync as they leave it out of git (src/scope.ts) */
1658
- scope = new SyncScope();
1659
- /**
1660
- * The generation of the rules (SyncScope.generation) the last snapshot was asked for under. A file the rules left out
1661
- * then was not taken in, whatever the server sent, nor a broadcast of it since; rules changed after it may let such a
1662
- * file in, so the snapshot is taken again (walk), and a file not taken in from the server waits for it (scopeStale).
1663
- */
1664
- scopeTaken = -1;
1665
1623
  /**
1666
1624
  * The paths the server last named `unknown`: it holds no file there and never removed one. A file here at such a path
1667
1625
  * is new to the server, whatever known.json says, as when another environment uploaded the hub to a new server
@@ -1670,6 +1628,15 @@ var SyncClient = class extends EventEmitter {
1670
1628
  unheld = /* @__PURE__ */ new Set();
1671
1629
  /** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
1672
1630
  fileSeq = /* @__PURE__ */ new Map();
1631
+ /** The hub's seq of each directory as the server last named it, held or removed, as fileSeq is of a file */
1632
+ dirSeq = /* @__PURE__ */ new Map();
1633
+ /**
1634
+ * The directories the server held that the client had here, as known.json keeps them: one gone here is sent as removed
1635
+ * (queueRemovedDirs), and one a snapshot names is made here only when it is not among them
1636
+ */
1637
+ syncedDirs = /* @__PURE__ */ new Set();
1638
+ /** The check for directories gone here that is to run a moment from now (checkDirs) */
1639
+ dirCheck = null;
1673
1640
  /** The hub's seq up to which the client has taken in every change: the last snapshot and the broadcasts after it */
1674
1641
  hubSeq = -1;
1675
1642
  /** The seqs of the broadcasts on the current connection above hubSeq with a change between that never arrived */
@@ -1723,6 +1690,13 @@ var SyncClient = class extends EventEmitter {
1723
1690
  flushRun = null;
1724
1691
  resyncing = null;
1725
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;
1726
1700
  /** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
1727
1701
  synced = false;
1728
1702
  reconnectTimer;
@@ -1748,15 +1722,25 @@ var SyncClient = class extends EventEmitter {
1748
1722
  backoff = 500;
1749
1723
  lastPong = 0;
1750
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;
1751
1734
  constructor(opts) {
1752
1735
  super();
1753
1736
  this.opts = opts;
1754
- this.root = opts.root;
1755
- this.paths = syncPaths(opts.root);
1737
+ this.dirs = opts.dirs;
1738
+ this.paths = syncPaths(opts.dirs);
1756
1739
  this.api = new ServerApi(opts.settings);
1757
1740
  this.firstContact = new Promise((resolve) => {
1758
1741
  this.once("synced", resolve);
1759
1742
  this.once("offline", resolve);
1743
+ this.once("held", resolve);
1760
1744
  });
1761
1745
  }
1762
1746
  trace(line) {
@@ -1769,19 +1753,13 @@ var SyncClient = class extends EventEmitter {
1769
1753
  get online() {
1770
1754
  return this.connected && !this.stopped;
1771
1755
  }
1772
- /**
1773
- * Whether the file at `path` is synced: by the sync client's own rules (src/paths.ts) and the hub's .gitignore files as
1774
- * last read (src/scope.ts). Anything else is left as it is here, whatever the server holds.
1775
- */
1756
+ /** Whether the file at `path` is synced (src/paths.ts). Anything else is left as it is here, whatever the server holds. */
1776
1757
  syncs(path) {
1777
- return this.scope.synced(path);
1758
+ return isSynced(path);
1778
1759
  }
1779
- /**
1780
- * Whether the hub's .gitignore files changed since the last snapshot was asked for: a file they let in since may be on
1781
- * the server although the client took nothing of it in.
1782
- */
1783
- get scopeStale() {
1784
- return this.scope.generation !== this.scopeTaken;
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). */
1761
+ syncsDir(path) {
1762
+ return syncedDir(path);
1785
1763
  }
1786
1764
  /** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
1787
1765
  get queued() {
@@ -1834,7 +1812,6 @@ var SyncClient = class extends EventEmitter {
1834
1812
  this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
1835
1813
  }
1836
1814
  }
1837
- this.scope.load(this.paths.data);
1838
1815
  await this.restoreKnown();
1839
1816
  this.restoreRemote();
1840
1817
  await this.whenFree(() => this.syncQueue());
@@ -1859,7 +1836,8 @@ var SyncClient = class extends EventEmitter {
1859
1836
  async restoreKnown() {
1860
1837
  this.copied = !existsSync(this.paths.known);
1861
1838
  this.blobsCompared = !this.copied && !existsSync(this.paths.copyBlobs);
1862
- 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))) {
1863
1841
  if (hash.startsWith("blob:")) {
1864
1842
  const disk = {
1865
1843
  hash: hash.slice(5),
@@ -1870,7 +1848,7 @@ var SyncClient = class extends EventEmitter {
1870
1848
  continue;
1871
1849
  }
1872
1850
  const local = await this.readLocal(path);
1873
- 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);
1874
1852
  const disk = text === void 0 ? { hash } : {
1875
1853
  hash,
1876
1854
  text
@@ -1920,6 +1898,7 @@ var SyncClient = class extends EventEmitter {
1920
1898
  this.closing = true;
1921
1899
  this.stopped = true;
1922
1900
  clearTimeout(this.reconnectTimer);
1901
+ clearTimeout(this.snapshotTimer);
1923
1902
  clearInterval(this.pingTimer);
1924
1903
  clearInterval(this.warmTimer);
1925
1904
  clearInterval(this.rescanTimer);
@@ -1960,6 +1939,7 @@ var SyncClient = class extends EventEmitter {
1960
1939
  await new Promise((resolve) => {
1961
1940
  this.once("synced", resolve);
1962
1941
  this.once("offline", resolve);
1942
+ this.once("held", resolve);
1963
1943
  this.connect();
1964
1944
  });
1965
1945
  }
@@ -1973,7 +1953,8 @@ var SyncClient = class extends EventEmitter {
1973
1953
  this.outboxChanged();
1974
1954
  for (;;) {
1975
1955
  const work = besidesAwaiting ? [...this.work].filter((promise) => !this.awaitingEdit.has(this.workPaths.get(promise) ?? "")) : [...this.work];
1976
- 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;
1977
1958
  if (Date.now() > deadline) return false;
1978
1959
  await (work.length > 0 ? Promise.race([Promise.allSettled(work), sleep$1(50)]) : sleep$1(50));
1979
1960
  }
@@ -2113,7 +2094,9 @@ var SyncClient = class extends EventEmitter {
2113
2094
  this.track(this.resync());
2114
2095
  return;
2115
2096
  }
2116
- for (const event of message.files) this.receive(event, seq);
2097
+ for (const event of message.files) if (event.kind === "dir") this.receiveDir(event.path, seq, true);
2098
+ else if (event.kind === "dirRemoved") this.receiveDirRemoved(event.path, seq);
2099
+ else this.receive(event, seq);
2117
2100
  if (this.ahead.size > 0 && this.synced && this.resyncing === null) {
2118
2101
  this.log(`missed the hub's changes ${this.hubSeq + 1} to ${Math.min(...this.ahead) - 1}; reloading the snapshot`);
2119
2102
  this.track(this.resync());
@@ -2135,10 +2118,6 @@ var SyncClient = class extends EventEmitter {
2135
2118
  * as a listed text does in a snapshot.
2136
2119
  */
2137
2120
  receive(event, seq) {
2138
- if (event.kind === "dir") {
2139
- if ((event.path.split("/")[0] !== "local" || event.path === "local/sessions") && !this.scope.ignored(event.path, true)) this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
2140
- return;
2141
- }
2142
2121
  if (!this.syncs(event.path)) return;
2143
2122
  const file = event.kind === "file" ? event.text : event.kind === "blob" ? {
2144
2123
  kind: "blob",
@@ -2180,6 +2159,58 @@ var SyncClient = class extends EventEmitter {
2180
2159
  const mainline = MAINLINE_FILE.exec(event.path)?.[1];
2181
2160
  if (mainline !== void 0 && this.remote.size > 0) this.fetchOpened(mainline);
2182
2161
  }
2162
+ /**
2163
+ * A directory the server holds as of the hub's `seq`, from a broadcast (`live`) or a snapshot. One the client had here
2164
+ * and that is gone now, named by a snapshot, was removed here: it is not made again, and its removal is queued
2165
+ * (queueRemovedDirs). Any other is made here and known from then on; a broadcast's always is, an operation having just
2166
+ * made it. A directory the client does not sync, and a version no newer than the one the client holds, as a snapshot's
2167
+ * after the broadcast of its removal, are left alone.
2168
+ */
2169
+ receiveDir(path, seq, live) {
2170
+ if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
2171
+ this.dirSeq.set(path, seq);
2172
+ const dir = join(this.paths.data, path);
2173
+ if (!live && this.syncedDirs.has(path) && !existsSync(dir)) {
2174
+ this.trace(`dir ${path} seq ${seq}: removed here`);
2175
+ return;
2176
+ }
2177
+ this.knowDir(path);
2178
+ this.track(mkdir(dir, { recursive: true }));
2179
+ }
2180
+ /**
2181
+ * The server removed the directory at `path` as of the hub's `seq`, as a broadcast tells, or a snapshot that no longer
2182
+ * names it: removed here too when nothing is in it, under the write lock, so no mainflow write puts a file in it
2183
+ * meanwhile. One that holds anything, as a file written here not sent yet, stays; the file's write makes it on the
2184
+ * server again.
2185
+ */
2186
+ receiveDirRemoved(path, seq) {
2187
+ if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
2188
+ this.dirSeq.set(path, seq);
2189
+ this.forgetDir(path);
2190
+ this.trace(`dir ${path} removed seq ${seq}`);
2191
+ this.track(this.whenFree(() => this.locked(() => this.removeEmptyDir(path))).catch((error) => this.log(`${path}/: ${error.message}`)));
2192
+ }
2193
+ /** Remove the directory at `path` when nothing is in it; one gone already, or holding anything, stays as it is. True when it went. */
2194
+ removeEmptyDir(path) {
2195
+ try {
2196
+ rmdirSync(join(this.paths.data, path));
2197
+ return true;
2198
+ } catch (error) {
2199
+ const code = error.code;
2200
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST" && code !== "ENOTDIR") throw error;
2201
+ return false;
2202
+ }
2203
+ }
2204
+ /** The server holds the directory at `path`, and the client has it here: known from now on. */
2205
+ knowDir(path) {
2206
+ if (this.syncedDirs.has(path)) return;
2207
+ this.syncedDirs.add(path);
2208
+ this.saveKnown();
2209
+ }
2210
+ /** The server no longer holds the directory at `path`. */
2211
+ forgetDir(path) {
2212
+ if (this.syncedDirs.delete(path)) this.saveKnown();
2213
+ }
2183
2214
  /** Whether the client holds `path` as of the hub's `seq` or later. */
2184
2215
  reached(path, seq) {
2185
2216
  return this.hubSeq >= seq || (this.fileSeq.get(path) ?? -1) >= seq;
@@ -2222,8 +2253,7 @@ var SyncClient = class extends EventEmitter {
2222
2253
  * reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
2223
2254
  * still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
2224
2255
  * server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
2225
- * after the gap are forgotten and the snapshot is taken once more, as it is. So is it when the hub's .gitignore files
2226
- * 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
2227
2257
  * snapshot once one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a
2228
2258
  * fresh one, rather than send on the snapshot of an earlier connection.
2229
2259
  */
@@ -2247,6 +2277,7 @@ var SyncClient = class extends EventEmitter {
2247
2277
  this.log(`the sync server's snapshot stays without the hub's changes ${missing}; taking it as complete`);
2248
2278
  this.ahead.clear();
2249
2279
  for (const [path, seq] of this.fileSeq) if (seq > this.hubSeq) this.fileSeq.delete(path);
2280
+ for (const [path, seq] of this.dirSeq) if (seq > this.hubSeq) this.dirSeq.delete(path);
2250
2281
  stalled = 0;
2251
2282
  this.resyncAgain = true;
2252
2283
  continue;
@@ -2254,11 +2285,17 @@ var SyncClient = class extends EventEmitter {
2254
2285
  this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
2255
2286
  if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
2256
2287
  this.resyncAgain = true;
2257
- } while ((this.resyncAgain || this.scopeStale) && !this.closing);
2288
+ } while (this.resyncAgain && !this.closing);
2258
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
+ }
2259
2295
  } catch (error) {
2260
2296
  this.current = false;
2261
- this.ws?.close();
2297
+ if (error instanceof HubRewritingError && this.connected && !this.closing) this.holdSnapshot(error);
2298
+ else this.ws?.close();
2262
2299
  throw error;
2263
2300
  } finally {
2264
2301
  this.resyncing = null;
@@ -2268,6 +2305,19 @@ var SyncClient = class extends EventEmitter {
2268
2305
  return this.resyncing;
2269
2306
  }
2270
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
+ /**
2271
2321
  * Take the hub as of the snapshot's seq, asking only for what differs from what the client holds (holdings). The
2272
2322
  * snapshot comes in batches, each as of its own seq; the WebSocket was open before the first, so every change after
2273
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,
@@ -2275,8 +2325,8 @@ var SyncClient = class extends EventEmitter {
2275
2325
  * batch's seq; a file neither named nor sent is not on the server. A broadcast arriving meanwhile may be older or
2276
2326
  * newer than either; each file keeps whichever version has the higher seq. A path the server never held a file at
2277
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
2278
- * never removed (reconcile, unheld). What the
2279
- * 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.
2280
2330
  *
2281
2331
  * A server without the hub first gets the local one. When it refuses the upload and still holds no file, the snapshot
2282
2332
  * fails without taking anything in: an empty hub would tell a copy without known.json to remove every file it holds.
@@ -2309,8 +2359,6 @@ var SyncClient = class extends EventEmitter {
2309
2359
  /** Whether the hub was uploaded, and why the server refused the upload; asked again, the snapshot may hold the hub */
2310
2360
  let uploaded = false;
2311
2361
  let refusal = null;
2312
- /** The rules' generation the snapshot is asked for under: what it holds is taken in as they leave it in */
2313
- let asked;
2314
2362
  /** Whether the snapshot is asked for with the binary files */
2315
2363
  let withBlobs = false;
2316
2364
  if (!this.probed) await this.probeBlobs();
@@ -2321,15 +2369,20 @@ var SyncClient = class extends EventEmitter {
2321
2369
  have = new Map([...held].map(([path, text]) => [path, this.hashOf(path, text)]));
2322
2370
  for (const [path, blob] of heldBlobs) have.set(path, blob.hash);
2323
2371
  for (const [path, hash] of this.remote) have.set(path, hash);
2324
- asked = this.scope.generation;
2325
2372
  withBlobs = this.blobSupport === true;
2326
2373
  if (!withBlobs && !this.blobsCompared && !existsSync(this.paths.copyBlobs)) {
2327
2374
  mkdirSync(this.paths.own, { recursive: true });
2328
2375
  writeFileSync(this.paths.copyBlobs, "");
2329
2376
  }
2377
+ const dirs = this.knownDirs();
2378
+ const digest = withBlobs && have.size > 0 ? {
2379
+ value: hubDigest(have, dirs),
2380
+ dirs
2381
+ } : void 0;
2330
2382
  parts = await this.api.snapshot(have, {
2331
2383
  listOnly: this.listOnly(this.copied && !this.synced),
2332
- blobs: this.blobSupport === true
2384
+ blobs: this.blobSupport === true,
2385
+ ...digest ? { digest } : {}
2333
2386
  });
2334
2387
  if (uploaded || holdsFiles(parts, have)) break;
2335
2388
  if (this.opts.settings.onDemand === true) {
@@ -2338,7 +2391,7 @@ var SyncClient = class extends EventEmitter {
2338
2391
  this.problem({
2339
2392
  kind: "upload-needed",
2340
2393
  path: "",
2341
- 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`
2342
2395
  });
2343
2396
  }
2344
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");
@@ -2362,6 +2415,7 @@ var SyncClient = class extends EventEmitter {
2362
2415
  if (seq < before) {
2363
2416
  this.log(`the sync server's seq went back from ${before} to ${seq}; taking its snapshot as it is`);
2364
2417
  this.fileSeq.clear();
2418
+ this.dirSeq.clear();
2365
2419
  this.hubSeq = -1;
2366
2420
  this.ahead.clear();
2367
2421
  }
@@ -2372,8 +2426,16 @@ var SyncClient = class extends EventEmitter {
2372
2426
  this.snapshotAnswers.clear();
2373
2427
  const fromCopy = this.copied && !this.synced;
2374
2428
  const blobsFromCopy = withBlobs && !this.blobsCompared;
2429
+ /** The directories the snapshot names: every one the server holds */
2430
+ const dirs = /* @__PURE__ */ new Set();
2375
2431
  for (const part of parts) for (const event of part.files) {
2376
- if (event.kind !== "dir" && !this.syncs(event.path)) continue;
2432
+ if (event.kind === "dir") {
2433
+ named.add(event.path);
2434
+ dirs.add(event.path);
2435
+ this.receiveDir(event.path, part.seq, false);
2436
+ continue;
2437
+ }
2438
+ if (!this.syncs(event.path)) continue;
2377
2439
  if (event.kind === "listed") {
2378
2440
  named.add(event.path);
2379
2441
  if (this.mayStay(event.path, held)) this.keepRemote(event.path, event.hash, part.seq);
@@ -2441,7 +2503,7 @@ var SyncClient = class extends EventEmitter {
2441
2503
  path
2442
2504
  }, seq);
2443
2505
  }
2444
- this.scopeTaken = asked;
2506
+ for (const path of this.syncedDirs) if (!dirs.has(path)) this.receiveDirRemoved(path, seq);
2445
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` : ""}`);
2446
2508
  this.takenUpTo(seq);
2447
2509
  this.synced = true;
@@ -2456,6 +2518,7 @@ var SyncClient = class extends EventEmitter {
2456
2518
  for (const path of await this.walk()) this.schedule(path);
2457
2519
  for (const path of this.stalledDownloads) this.schedule(path);
2458
2520
  this.stalledDownloads.clear();
2521
+ await this.queueRemovedDirs();
2459
2522
  }
2460
2523
  /**
2461
2524
  * Ask the server whether it keeps binary files (blobSupport), once per connection, before its snapshot. A probe that
@@ -2618,7 +2681,7 @@ var SyncClient = class extends EventEmitter {
2618
2681
  if (expected?.blob && expected.hash === disk.hash) this.expected.delete(path);
2619
2682
  if (isBlob(this.cloud.get(path))) this.cloud.delete(path);
2620
2683
  }
2621
- if (forgotten.size > 0) await this.whenFree(() => underLock(this.root, () => writeKnown(this.root, this.knownFiles())));
2684
+ if (forgotten.size > 0) await this.whenFree(() => underLock(this.dirs, () => writeKnown(this.dirs, this.knownFiles(), this.knownDirs())));
2622
2685
  return forgotten;
2623
2686
  }
2624
2687
  /** The server refused the upload, so it holds what it held: the binary files forgetBinaries forgot are known again. */
@@ -2720,7 +2783,7 @@ var SyncClient = class extends EventEmitter {
2720
2783
  const after = this.expected.get(path);
2721
2784
  if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
2722
2785
  }
2723
- 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;
2724
2787
  const disk = this.expected.get(path);
2725
2788
  const unheld = target === null && this.unheld.has(path);
2726
2789
  if (disk === void 0) {
@@ -2799,7 +2862,7 @@ var SyncClient = class extends EventEmitter {
2799
2862
  }
2800
2863
  if (text === null) return false;
2801
2864
  try {
2802
- return decoder.decode(bytes) === text;
2865
+ return decoder$1.decode(bytes) === text;
2803
2866
  } catch {
2804
2867
  return false;
2805
2868
  }
@@ -2844,6 +2907,9 @@ var SyncClient = class extends EventEmitter {
2844
2907
  * now on (I4). A version only written here, still in the outbox, is never confirmed.
2845
2908
  */
2846
2909
  confirm(path, disk) {
2910
+ if (disk !== null) {
2911
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) if (this.syncsDir(path.slice(0, at))) this.knowDir(path.slice(0, at));
2912
+ }
2847
2913
  const held = this.confirmed.get(path);
2848
2914
  if (held === void 0 ? false : held === null ? disk === null : disk !== null && held.hash === disk.hash && (held.text !== void 0 || disk.text === void 0)) return;
2849
2915
  this.confirmed.set(path, disk);
@@ -2908,7 +2974,7 @@ var SyncClient = class extends EventEmitter {
2908
2974
  this.tellOnce(`large:${path}:${hash}`, {
2909
2975
  kind: "skipped-large",
2910
2976
  path,
2911
- 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`
2912
2978
  });
2913
2979
  }
2914
2980
  /**
@@ -3065,7 +3131,7 @@ var SyncClient = class extends EventEmitter {
3065
3131
  const fetched = [];
3066
3132
  for (const part of parts) for (const event of part.files) {
3067
3133
  if (event.kind === "dir") {
3068
- this.receive(event, part.seq);
3134
+ this.receiveDir(event.path, part.seq, false);
3069
3135
  continue;
3070
3136
  }
3071
3137
  mentioned.add(event.path);
@@ -3223,7 +3289,7 @@ var SyncClient = class extends EventEmitter {
3223
3289
  await this.fetchPaths([path]).catch((error) => this.log(`${path}: could not download it: ${error.message}`));
3224
3290
  }
3225
3291
  restoreRemote() {
3226
- 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);
3227
3293
  }
3228
3294
  /**
3229
3295
  * What remote.json lists: the files only on the server, and the artifacts whose download failed and waits for a retry.
@@ -3258,7 +3324,7 @@ var SyncClient = class extends EventEmitter {
3258
3324
  const listed = this.listedRemote();
3259
3325
  if (listed.size === 0 && !existsSync(this.paths.remote)) return;
3260
3326
  try {
3261
- 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))));
3262
3328
  } catch (error) {
3263
3329
  this.log(`could not save local/sync/remote.json: ${error.message}`);
3264
3330
  }
@@ -3843,6 +3909,88 @@ var SyncClient = class extends EventEmitter {
3843
3909
  this.trace(`removal of ${path} changed before it was queued; reconciling it again`);
3844
3910
  this.schedule(path);
3845
3911
  }
3912
+ /**
3913
+ * Queue the removal of each directory the client had here that is gone (syncedDirs), with nothing the client knows of
3914
+ * in it: no file the client left there and has not sent the removal of, no write in the outbox of a file there, which
3915
+ * goes first, and no file the server holds there that is not written here yet, nor one only on the server. The
3916
+ * deepest go first, so the server finds the ones in each removed already; one holding anything holds the ones above it
3917
+ * too. Each is checked again in the hold of the write lock that queues it, so a directory made again meanwhile stays.
3918
+ * Only once a snapshot was taken, since what the server holds is unknown before.
3919
+ */
3920
+ async queueRemovedDirs() {
3921
+ if (!this.synced || this.closing) return;
3922
+ const exists = (dir) => existsSync(join(this.paths.data, dir));
3923
+ const gone = [...this.syncedDirs].filter((dir) => this.syncsDir(dir) && !exists(dir));
3924
+ if (gone.length === 0) return;
3925
+ gone.sort((a, b) => b.split("/").length - a.split("/").length || comparePaths(a, b));
3926
+ const queued = [];
3927
+ await this.whenFree(() => this.editQueue((entries) => {
3928
+ const files = [
3929
+ ...[...this.expected].flatMap(([path, disk]) => disk === null ? [] : [path]),
3930
+ ...[...this.cloud].flatMap(([path, file]) => file === null ? [] : [path]),
3931
+ ...this.remote.keys(),
3932
+ ...this.awaiting,
3933
+ ...entries.flatMap(entryPaths)
3934
+ ];
3935
+ const held = /* @__PURE__ */ new Set();
3936
+ const hold = (dir) => {
3937
+ for (let at = dir.length; at > 0; at = dir.lastIndexOf("/", at - 1)) held.add(dir.slice(0, at));
3938
+ };
3939
+ for (const path of files) hold(path.slice(0, Math.max(0, path.lastIndexOf("/"))));
3940
+ for (const dir of gone) {
3941
+ if (held.has(dir) || exists(dir)) {
3942
+ hold(dir);
3943
+ continue;
3944
+ }
3945
+ if (entries.some((entry) => entry.kind === "rmdir" && entry.path === dir)) continue;
3946
+ entries.push({
3947
+ kind: "rmdir",
3948
+ path: dir,
3949
+ id: entryId()
3950
+ });
3951
+ queued.push(dir);
3952
+ }
3953
+ return queued.length > 0;
3954
+ }));
3955
+ if (queued.length === 0) return;
3956
+ this.log(`queued rmdir ${queued.join(", ")} (${this.queue.length} waiting)`);
3957
+ if (this.online) this.track(this.flushQueue());
3958
+ }
3959
+ /**
3960
+ * Queue the removal of the directories gone here a moment from now, once for every call in that moment; counted as
3961
+ * work under way meanwhile, so idle() waits for it.
3962
+ */
3963
+ checkDirs() {
3964
+ if (this.closing || this.dirCheck) return;
3965
+ this.dirCheck = this.track(sleep$1(SETTLE_MS).then(() => {
3966
+ this.dirCheck = null;
3967
+ return this.queueRemovedDirs();
3968
+ }).catch((error) => this.log(`directories: ${error.message}`)));
3969
+ }
3970
+ /**
3971
+ * What the server answered to the removal of a directory: removed, or not there, it is known no more. Kept, as when
3972
+ * another environment has just put a file in it, it is made here again, as the server holds it, so it is not sent
3973
+ * again; the file comes as the server's broadcast. Refused, as by a server from before dir.remove (`unknown-operation`),
3974
+ * it is known no more either: the next snapshot makes it here again, as before, rather than it being sent again and again.
3975
+ */
3976
+ async settleRmdir(entry, answer) {
3977
+ const { path } = entry;
3978
+ const response = answer;
3979
+ this.trace(`response ${path}/ ${response.ok ? response.value : `${response.reason}: ${response.message}`}`);
3980
+ if (!response.ok) {
3981
+ this.forgetDir(path);
3982
+ this.dirSeq.delete(path);
3983
+ this.info(path, `the directory was removed here and the sync server did not remove it (${response.message}); it comes back with the next snapshot`);
3984
+ return;
3985
+ }
3986
+ if (response.value === "kept") {
3987
+ for (const notice of response.notices) this.info(notice.path, `${notice.message} (removed here; the directory is back)`);
3988
+ await this.whenFree(() => this.locked(() => mkdirSync(join(this.paths.data, path), { recursive: true })));
3989
+ return;
3990
+ }
3991
+ this.forgetDir(path);
3992
+ if (response.files.length > 0) await this.delivery(path, response.seq);
3993
+ }
3846
3994
  async settleRemove(entry, response) {
3847
3995
  const { path } = entry;
3848
3996
  if (!response.ok) return this.refuse(entry, [{
@@ -3920,7 +4068,7 @@ var SyncClient = class extends EventEmitter {
3920
4068
  hash: entry.hash,
3921
4069
  blob: true
3922
4070
  });
3923
- else this.expected.set(entry.path, null);
4071
+ else if (entry.kind === "remove") this.expected.set(entry.path, null);
3924
4072
  }
3925
4073
  /**
3926
4074
  * Hold the write lock for `fn`; work stop() abandoned gets an error ('abandoned after stop') instead, so it leaves
@@ -3929,7 +4077,7 @@ var SyncClient = class extends EventEmitter {
3929
4077
  */
3930
4078
  locked(fn) {
3931
4079
  if (this.abandoned) throw new Error("abandoned after stop");
3932
- return underLock(this.root, fn);
4080
+ return underLock(this.dirs, fn);
3933
4081
  }
3934
4082
  /**
3935
4083
  * Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
@@ -3944,7 +4092,7 @@ var SyncClient = class extends EventEmitter {
3944
4092
  }
3945
4093
  /** editQueue for a caller that holds the write lock already. */
3946
4094
  editHeld(edit) {
3947
- const entries = readQueue(this.root);
4095
+ const entries = readQueue(this.dirs);
3948
4096
  const known = new Set(this.queue.map((entry) => entry.id));
3949
4097
  let changed = false;
3950
4098
  const fresh = [];
@@ -3968,7 +4116,7 @@ var SyncClient = class extends EventEmitter {
3968
4116
  this.resolveBases(entries);
3969
4117
  }
3970
4118
  if (changed) {
3971
- writeQueue(this.root, entries);
4119
+ writeQueue(this.dirs, entries);
3972
4120
  this.pruneStaged(entries);
3973
4121
  }
3974
4122
  this.queue = entries;
@@ -4166,6 +4314,8 @@ var SyncClient = class extends EventEmitter {
4166
4314
  async sendOutbox() {
4167
4315
  this.flushing = true;
4168
4316
  try {
4317
+ const held = this.heldUntil - Date.now();
4318
+ if (held > 0) return this.retryQueue(held);
4169
4319
  while (this.caughtUp) {
4170
4320
  let entry;
4171
4321
  let wake;
@@ -4379,7 +4529,7 @@ var SyncClient = class extends EventEmitter {
4379
4529
  if (at < 0) return false;
4380
4530
  entries.splice(at, 1);
4381
4531
  for (const later of entries.slice(at)) {
4382
- if (later.kind === "op" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
4532
+ if (later.kind === "op" || later.kind === "rmdir" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
4383
4533
  if (later.kind === "remove" && entry.base === null) continue;
4384
4534
  later.base = entry.base;
4385
4535
  }
@@ -4422,6 +4572,7 @@ var SyncClient = class extends EventEmitter {
4422
4572
  this.renumbering.delete(entry.id);
4423
4573
  if (renumbered) this.log(`renumbered ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
4424
4574
  for (const path of entryPaths(entry)) this.schedule(path);
4575
+ if (entry.kind !== "rmdir") this.checkDirs();
4425
4576
  this.emit("settled", entry.id);
4426
4577
  }
4427
4578
  /**
@@ -4489,12 +4640,31 @@ var SyncClient = class extends EventEmitter {
4489
4640
  } catch (error) {
4490
4641
  if (!(error instanceof Unreachable)) throw error;
4491
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
+ }
4492
4661
  if (error.broken && !fresh && !this.closing) return this.request(entry, request, true, true);
4493
4662
  return null;
4494
4663
  }
4664
+ this.outdatedMessage = null;
4495
4665
  if (!response.ok && response.reason === "uncertain") {
4496
4666
  const paths = entryPaths(entry);
4497
- this.problem({
4667
+ if (entry.kind !== "rmdir") this.problem({
4498
4668
  kind: "uncertain",
4499
4669
  path: paths[0] ?? "",
4500
4670
  message: `${request.op} of ${paths.join(", ")} was sent before without an answer, and the sync server no longer keeps its answers from then (${response.message}); it is sent again as a new request and may apply twice, so check the result for a duplicate`
@@ -4505,8 +4675,8 @@ var SyncClient = class extends EventEmitter {
4505
4675
  }
4506
4676
  /**
4507
4677
  * The request an outbox entry sends, made when it is first sent, from the entry alone: an operation on the current
4508
- * protocol, a deletion with the time it is first sent, an Edit's replacement to be made again on the server's latest
4509
- * version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
4678
+ * protocol, a deletion with the time it is first sent, a directory's removal, an Edit's replacement to be made again on
4679
+ * the server's latest version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
4510
4680
  * refused or did not take as it was, so its base never became known, or moving it onto the server's merge overlaps.
4511
4681
  * Every write ahead of it was answered, so a base still unknown never will be.
4512
4682
  */
@@ -4529,6 +4699,11 @@ var SyncClient = class extends EventEmitter {
4529
4699
  size: entry.size,
4530
4700
  base: entry.base
4531
4701
  };
4702
+ if (entry.kind === "rmdir") return {
4703
+ protocol: 3,
4704
+ op: "dir.remove",
4705
+ path: entry.path
4706
+ };
4532
4707
  if (entry.replace) return {
4533
4708
  protocol: 3,
4534
4709
  op: "text.replace",
@@ -4675,6 +4850,7 @@ var SyncClient = class extends EventEmitter {
4675
4850
  last: entry.replaced
4676
4851
  }, request.text);
4677
4852
  else if (entry.kind === "blob") await this.settleBlob(entry, response);
4853
+ else if (entry.kind === "rmdir") await this.settleRmdir(entry, response);
4678
4854
  return true;
4679
4855
  } finally {
4680
4856
  for (const path of paths) {
@@ -4785,7 +4961,7 @@ var SyncClient = class extends EventEmitter {
4785
4961
  */
4786
4962
  renumberDisk(paths, renumbered) {
4787
4963
  const storage = {
4788
- ...nodeStorage(this.root),
4964
+ ...nodeStorage(this.dirs),
4789
4965
  lock: (fn) => fn()
4790
4966
  };
4791
4967
  const seen = new Map([...new Set(paths)].filter((path) => this.syncs(path)).map((path) => [path, this.expected.get(path)]));
@@ -4827,6 +5003,26 @@ var SyncClient = class extends EventEmitter {
4827
5003
  text
4828
5004
  });
4829
5005
  }
5006
+ for (const path of [...seen.keys(), ...binary.map(([path]) => path)]) this.pruneMoved(path, renumberPath(path, renumbered));
5007
+ }
5008
+ /**
5009
+ * Remove the directories above `from` that its move to `to` emptied, the deepest first, up to the first one that still
5010
+ * holds anything or is above `to` as well. One the server holds goes on the server too, as any directory gone here
5011
+ * (queueRemovedDirs). It does not take the write lock: its caller holds it.
5012
+ */
5013
+ pruneMoved(from, to) {
5014
+ if (from === to) return;
5015
+ for (let at = from.lastIndexOf("/"); at > 0; at = from.lastIndexOf("/", at - 1)) {
5016
+ const dir = from.slice(0, at);
5017
+ if (to.startsWith(`${dir}/`)) return;
5018
+ try {
5019
+ if (!this.removeEmptyDir(dir)) return;
5020
+ } catch (error) {
5021
+ this.log(`${dir}/: could not remove it after renumbering: ${error.message}`);
5022
+ return;
5023
+ }
5024
+ this.trace(`dir ${dir} emptied by renumbering, removed here`);
5025
+ }
4830
5026
  }
4831
5027
  commit(storage, changes) {
4832
5028
  for (const change of changes) if (change.kind === "write") storage.write(change.path, change.text);
@@ -5098,16 +5294,9 @@ var SyncClient = class extends EventEmitter {
5098
5294
  }]);
5099
5295
  if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
5100
5296
  }
5101
- /**
5102
- * The synced files here, by path. Each directory's .gitignore is read again on the way, before what the directory
5103
- * holds, so a change to one takes effect from the next scan on; a directory the rules leave out is not walked into.
5104
- * Rules changed since the last snapshot was asked for take it again: a file they let in that is on the server, as
5105
- * another environment sent it while the rules here left it out, comes then, not only on the next connection. A walk
5106
- * of a snapshot being taken leaves that to it (resync).
5107
- */
5297
+ /** The synced files here, by path: a walk into the directories that may hold them alone (src/paths.ts). */
5108
5298
  async walk() {
5109
5299
  const out = [];
5110
- const dirs = /* @__PURE__ */ new Set();
5111
5300
  const visit = async (dir) => {
5112
5301
  let entries;
5113
5302
  try {
@@ -5115,51 +5304,31 @@ var SyncClient = class extends EventEmitter {
5115
5304
  } catch {
5116
5305
  return;
5117
5306
  }
5118
- dirs.add(dir);
5119
- this.scope.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? await this.readRules(dir) : null);
5120
5307
  for (const entry of entries) {
5121
5308
  const path = dir ? `${dir}/${entry.name}` : entry.name;
5122
5309
  if (entry.isDirectory()) {
5123
- if (this.scope.walks(path)) await visit(path);
5310
+ if (mayHoldSynced(path)) await visit(path);
5124
5311
  } else if (entry.isFile() && this.syncs(path)) out.push(path);
5125
5312
  }
5126
5313
  };
5127
5314
  await visit("");
5128
- this.scope.keepOnly(dirs);
5129
- if (this.scopeStale && this.synced && this.connected && this.resyncing === null && !this.closing) {
5130
- this.log("the hub's .gitignore changed; reloading the snapshot for the files it lets in");
5131
- this.track(this.resync().catch((error) => this.log(`could not reload the snapshot: ${error.message}`)));
5132
- }
5133
5315
  return out;
5134
5316
  }
5135
- /**
5136
- * The text of the .gitignore in `dir`, which is there, as walk takes it. A text other than the rules have may be one
5137
- * being written, as `cat >` empties a file before it fills it, and a rule missing for a moment would let its files in,
5138
- * to be sent: it is read again after EMPTY_RECHECK_MS and taken only when it reads the same, empty too. Until then, and
5139
- * while the file cannot be read, the rules stay as they were; the next walk reads it again.
5140
- */
5141
- async readRules(dir) {
5142
- const held = this.scope.text(dir);
5143
- const read = () => readFile(join(this.paths.data, dir, GITIGNORE), "utf8").catch(() => null);
5144
- const text = await read();
5145
- if (text === null || text === held) return held;
5146
- await sleep$1(EMPTY_RECHECK_MS);
5147
- return await read() === text ? text : held;
5148
- }
5149
- /** 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/. */
5150
5318
  async walkDirs(dir = "") {
5151
5319
  const out = [];
5152
5320
  const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
5153
5321
  for (const entry of entries) {
5154
5322
  const path = dir ? `${dir}/${entry.name}` : entry.name;
5155
- if (!entry.isDirectory() || path === "local" || path.startsWith("local/") || this.scope.ignored(path, true)) continue;
5323
+ if (!entry.isDirectory() || path === "local" || !this.syncsDir(path)) continue;
5156
5324
  out.push(path, ...await this.walkDirs(path));
5157
5325
  }
5158
5326
  return out;
5159
5327
  }
5160
5328
  /**
5161
5329
  * Compare every synced file's size, modification time and inode with the last scan and reconcile what changed,
5162
- * plus every file expected on disk that is gone. A file modified in the last moment may still be being written,
5330
+ * plus every file expected on disk that is gone, and queue the removal of the directories gone here
5331
+ * (queueRemovedDirs). A file modified in the last moment may still be being written,
5163
5332
  * so it waits for the next scan unless `immediate`. `baseline` only records what is there. An immediate scan waits for
5164
5333
  * the reconciles it started unless `settle` is false, for a caller that waits for them itself (flushTool).
5165
5334
  */
@@ -5180,6 +5349,7 @@ var SyncClient = class extends EventEmitter {
5180
5349
  this.signatures.delete(path);
5181
5350
  this.schedule(path);
5182
5351
  }
5352
+ await this.queueRemovedDirs();
5183
5353
  if (settle) await Promise.allSettled(this.chains.values());
5184
5354
  }
5185
5355
  /**
@@ -5197,13 +5367,13 @@ var SyncClient = class extends EventEmitter {
5197
5367
  }, SETTLE_MS));
5198
5368
  return;
5199
5369
  }
5200
- const rules = path === ".gitignore" || path.endsWith(`/.gitignore`);
5201
- if (!rules && !this.syncs(path)) return;
5370
+ if (this.syncedDirs.has(path)) this.checkDirs();
5371
+ if (!this.syncs(path)) return;
5202
5372
  this.trace(`watched ${path}`);
5203
5373
  clearTimeout(timers.get(path));
5204
5374
  timers.set(path, setTimeout(() => {
5205
5375
  timers.delete(path);
5206
- this.track(this.whenSettled(path).then(() => rules ? this.scan(true) : this.schedule(path)));
5376
+ this.track(this.whenSettled(path).then(() => this.schedule(path)));
5207
5377
  }, SETTLE_MS));
5208
5378
  };
5209
5379
  try {
@@ -5296,13 +5466,18 @@ var SyncClient = class extends EventEmitter {
5296
5466
  for (const [path, disk] of this.confirmed) if (disk) files[path] = disk.blob ? { hash: `${BLOB_PREFIX}${disk.hash}` } : disk;
5297
5467
  return files;
5298
5468
  }
5469
+ /** The directories known.json names (syncedDirs), in path order. */
5470
+ knownDirs() {
5471
+ return [...this.syncedDirs].sort(comparePaths);
5472
+ }
5299
5473
  saveKnownNow(final = false) {
5300
5474
  if (this.abandoned && !final) return;
5301
5475
  clearTimeout(this.knownTimer);
5302
5476
  this.knownTimer = void 0;
5303
5477
  const files = this.knownFiles();
5478
+ const dirs = this.knownDirs();
5304
5479
  try {
5305
- underLock(this.root, () => writeKnown(this.root, files));
5480
+ underLock(this.dirs, () => writeKnown(this.dirs, files, dirs));
5306
5481
  } catch (error) {
5307
5482
  this.log(`could not save local/sync/known.json: ${error.message}`);
5308
5483
  }
@@ -5312,8 +5487,8 @@ var SyncClient = class extends EventEmitter {
5312
5487
  //#region ../sync/dist/ipc.js
5313
5488
  const SECRET_HEADER = "x-mainflow-sync";
5314
5489
  /** The running sync client of the hub at `root`, or null when none runs. */
5315
- function readDaemon(root) {
5316
- const file = syncPaths(root).daemon;
5490
+ function readDaemon(dirs) {
5491
+ const file = syncPaths(dirs).daemon;
5317
5492
  if (!existsSync(file)) return null;
5318
5493
  try {
5319
5494
  const info = JSON.parse(readFileSync(file, "utf8"));
@@ -5366,8 +5541,8 @@ async function callDaemon(daemon, route, body, timeoutMs = 6e4) {
5366
5541
  /** How long a probe waits for the sync client's answer; a client holding the write lock answers after it. */
5367
5542
  const PROBE_MS = 2e3;
5368
5543
  /** The sync client daemon.json names, asked whether it runs; null without daemon.json or a live process at its pid. */
5369
- async function probeDaemon(root, timeoutMs = PROBE_MS) {
5370
- const daemon = readDaemon(root);
5544
+ async function probeDaemon(dirs, timeoutMs = PROBE_MS) {
5545
+ const daemon = readDaemon(dirs);
5371
5546
  if (!daemon) return null;
5372
5547
  try {
5373
5548
  return {
@@ -5393,9 +5568,9 @@ const sameDaemon = (a, b) => a !== null && a.pid === b.pid && a.port === b.port
5393
5568
  * Remove daemon.json when it still says what `gone` said, under the write lock, so a sync client that claimed the hub
5394
5569
  * meanwhile keeps it. Call holding the lock.
5395
5570
  */
5396
- function forgetDaemon(root, gone) {
5397
- if (!sameDaemon(readDaemon(root), gone)) return false;
5398
- 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 });
5399
5574
  return true;
5400
5575
  }
5401
5576
  /** The Edit a request's body names, its file and its one replacement, or null. */
@@ -5419,7 +5594,7 @@ function callIn(body) {
5419
5594
  };
5420
5595
  }
5421
5596
  /** Serve `client` to this machine's mainflow commands until closed. */
5422
- async function serveIpc(root, client) {
5597
+ async function serveIpc(dirs, client) {
5423
5598
  const secret = randomBytes(16).toString("hex");
5424
5599
  const server = createServer(async (req, res) => {
5425
5600
  const reply = (status, value) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
@@ -5466,13 +5641,13 @@ async function serveIpc(root, client) {
5466
5641
  });
5467
5642
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
5468
5643
  const port = server.address().port;
5469
- const file = syncPaths(root).daemon;
5470
- const probe = await probeDaemon(root);
5471
- const other = underLock(root, () => {
5472
- 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);
5473
5648
  const reused = running !== null && probe?.state === "gone" && sameDaemon(running, probe.daemon);
5474
5649
  if (running && running.pid !== process.pid && !reused) return running;
5475
- mkdirSync(syncPaths(root).own, { recursive: true });
5650
+ mkdirSync(syncPaths(dirs).own, { recursive: true });
5476
5651
  writeFileSync(file, JSON.stringify({
5477
5652
  pid: process.pid,
5478
5653
  port,
@@ -5500,7 +5675,7 @@ async function serveIpc(root, client) {
5500
5675
  //#region ../sync/dist/daemon.js
5501
5676
  async function startDaemon(options) {
5502
5677
  const client = new SyncClient(options);
5503
- const ipc = await serveIpc(options.root, client);
5678
+ const ipc = await serveIpc(options.dirs, client);
5504
5679
  try {
5505
5680
  await client.start();
5506
5681
  } catch (error) {
@@ -5544,11 +5719,11 @@ const FETCH_TOOLS = /* @__PURE__ */ new Set([
5544
5719
  "MultiEdit"
5545
5720
  ]);
5546
5721
  /** The file of the hub at `root` that `payload` names in file_path, relative to .mainflow/; null outside it. */
5547
- function hubPath(root, payload) {
5722
+ function hubPath(dirs, payload) {
5548
5723
  const input = payload.tool_input ?? {};
5549
5724
  if (typeof input.file_path !== "string") return null;
5550
5725
  const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? process.cwd(), input.file_path);
5551
- const path = relative(join(root, DATA_DIR), file).split(sep).join("/");
5726
+ const path = relative(dirs.data, file).split(sep).join("/");
5552
5727
  if (path.startsWith("../") || path === ".." || isAbsolute(path)) return null;
5553
5728
  return path;
5554
5729
  }
@@ -5570,11 +5745,11 @@ function editOf(path, payload) {
5570
5745
  * needs nothing; an Edit of one replacement is replayed on the latest version; another file tool names the file it
5571
5746
  * wrote; anything else, such as a Bash command that may have written any file, rescans every file and names none.
5572
5747
  */
5573
- function planFlush(root, payload) {
5748
+ function planFlush(dirs, payload) {
5574
5749
  const tool = payload.tool_name ?? "";
5575
5750
  if (!FILE_TOOLS.has(tool)) return { skip: false };
5576
5751
  if (typeof (payload.tool_input ?? {}).file_path !== "string") return { skip: false };
5577
- const path = hubPath(root, payload);
5752
+ const path = hubPath(dirs, payload);
5578
5753
  if (path === null || !isSynced(path)) return { skip: true };
5579
5754
  const edit = editOf(path, payload);
5580
5755
  return edit ? {
@@ -5602,11 +5777,11 @@ function hookAnswer(result) {
5602
5777
  * What to download before the tool in `payload` runs in the hub at `root`: the artifact it reads or writes, when the file
5603
5778
  * is not here, so it may be only on the sync server. Null for anything else, which needs nothing.
5604
5779
  */
5605
- function planFetch(root, payload) {
5780
+ function planFetch(dirs, payload) {
5606
5781
  const tool = payload.tool_name ?? "";
5607
5782
  if (!FETCH_TOOLS.has(tool)) return null;
5608
- const path = hubPath(root, payload);
5609
- 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;
5610
5785
  return {
5611
5786
  path,
5612
5787
  write: tool !== "Read"
@@ -5617,8 +5792,8 @@ function planFetch(root, payload) {
5617
5792
  * change then waits for the Edit's PostToolUse report (docs/sync.md "等 Edit 的回報"). Null for anything else, which
5618
5793
  * announces nothing: a change no Edit announced is sent at once.
5619
5794
  */
5620
- function planAnnounce(root, payload) {
5621
- const path = hubPath(root, payload);
5795
+ function planAnnounce(dirs, payload) {
5796
+ const path = hubPath(dirs, payload);
5622
5797
  if (path === null || !isSynced(path)) return null;
5623
5798
  const edit = editOf(path, payload);
5624
5799
  return edit && edit.old !== "" ? edit : null;
@@ -5630,10 +5805,53 @@ const fetchDenial = (reason) => JSON.stringify({ hookSpecificOutput: {
5630
5805
  permissionDecisionReason: `mainflow sync: ${reason}`
5631
5806
  } });
5632
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
5633
5851
  //#region ../sync/dist/outbox.js
5634
5852
  /** Whether the hub at `root` writes through the sync server. */
5635
- function remoteMode(root, env = process.env) {
5636
- return readDaemon(root) !== null || syncSettings(root, env) !== null;
5853
+ function remoteMode(dirs, env = process.env) {
5854
+ return readDaemon(dirs) !== null || syncSettings(dirs, env) !== null;
5637
5855
  }
5638
5856
  /** Refusals of an operation by the local files, as opposed to a write lock held too long; thrown as they are. */
5639
5857
  const refusals = /* @__PURE__ */ new WeakSet();
@@ -5644,8 +5862,8 @@ const refusedHere = (error) => error instanceof Error && refusals.has(error);
5644
5862
  * client reads the outbox under the same lock, so it never sees the files this changed without the entry that
5645
5863
  * explains them, and never takes them for an edit to send. A refusal here is thrown, and nothing is queued.
5646
5864
  */
5647
- function queueOperation(root, request) {
5648
- const storage = nodeStorage(root);
5865
+ function queueOperation(dirs, request) {
5866
+ const storage = nodeStorage(dirs);
5649
5867
  return storage.lock(() => {
5650
5868
  const before = {};
5651
5869
  const keep = (path) => {
@@ -5669,7 +5887,7 @@ function queueOperation(root, request) {
5669
5887
  const written = {};
5670
5888
  for (const change of result.changes) if (change.kind !== "makeDir" && isSynced(change.path)) written[change.path] = held.read(change.path);
5671
5889
  const id = entryId();
5672
- writeQueue(root, [...readQueue(root), {
5890
+ writeQueue(dirs, [...readQueue(dirs), {
5673
5891
  id,
5674
5892
  kind: "op",
5675
5893
  request: pending,
@@ -5683,8 +5901,8 @@ function queueOperation(root, request) {
5683
5901
  });
5684
5902
  }
5685
5903
  /** How many writes wait in the outbox for the server, as queue.json says: not the ones it refused (src/state.ts). */
5686
- function queuedCount(root) {
5687
- const file = syncPaths(root).queue;
5904
+ function queuedCount(dirs) {
5905
+ const file = syncPaths(dirs).queue;
5688
5906
  if (!existsSync(file)) return 0;
5689
5907
  try {
5690
5908
  return (JSON.parse(readFileSync(file, "utf8")).entries ?? []).filter(waiting).length;
@@ -5700,8 +5918,8 @@ const NOTIFY_MS = 200;
5700
5918
  * the server's answer, such as a hook or the interface. Without a sync client, or one that does not answer, nothing
5701
5919
  * happens: the client's watcher of queue.json and its rescan find the entry, a moment later.
5702
5920
  */
5703
- async function notifyQueued(root) {
5704
- const daemon = readDaemon(root);
5921
+ async function notifyQueued(dirs) {
5922
+ const daemon = readDaemon(dirs);
5705
5923
  if (!daemon) return;
5706
5924
  try {
5707
5925
  await callDaemon(daemon, "/send", {}, NOTIFY_MS);
@@ -5711,12 +5929,12 @@ async function notifyQueued(root) {
5711
5929
  * Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
5712
5930
  * waited for. `waiting` when it is offline, or the time ran out first.
5713
5931
  */
5714
- async function awaitOutcome(root, id, timeoutMs) {
5932
+ async function awaitOutcome(dirs, id, timeoutMs) {
5715
5933
  const deadline = Date.now() + timeoutMs;
5716
5934
  for (;;) {
5717
5935
  const left = deadline - Date.now();
5718
5936
  if (left <= 0) return { state: "waiting" };
5719
- const daemon = readDaemon(root);
5937
+ const daemon = readDaemon(dirs);
5720
5938
  if (daemon) try {
5721
5939
  return await callDaemon(daemon, "/wait", {
5722
5940
  id,
@@ -5729,6 +5947,54 @@ async function awaitOutcome(root, id, timeoutMs) {
5729
5947
  }
5730
5948
  }
5731
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
5732
5998
  //#region src/remote/start.ts
5733
5999
  /** How long a sync client started in the background may take to claim the hub, its first request to the server included. */
5734
6000
  const STARTING_MS = 6e4;
@@ -5745,10 +6011,10 @@ const alive = (pid) => {
5745
6011
  * none. `env` gives the sync server and service token as the environment variables do; for a hub other than the one this
5746
6012
  * process was started in it is `{}`, so the process started reads that hub's own .mainflow/local/sync.json.
5747
6013
  */
5748
- async function startSyncInBackground(root, env = process.env) {
5749
- const probe = await probeDaemon(root);
6014
+ async function startSyncInBackground(dirs, env = process.env) {
6015
+ const probe = await probeDaemon(dirs);
5750
6016
  if (probe && probe.state !== "gone") return { state: "running" };
5751
- const paths = syncPaths(root);
6017
+ const paths = syncPaths(dirs);
5752
6018
  const cli = process.argv[1];
5753
6019
  if (process.env.MAINFLOW_NO_BACKGROUND_SYNC === "1") return {
5754
6020
  state: "failed",
@@ -5758,10 +6024,10 @@ async function startSyncInBackground(root, env = process.env) {
5758
6024
  state: "failed",
5759
6025
  reason: "this process cannot tell where the mainflow CLI is"
5760
6026
  };
5761
- const log = relative(root, paths.log);
5762
- return nodeStorage(root).lock(() => {
5763
- const reused = probe?.state === "gone" && forgetDaemon(root, probe.daemon) ? probe.daemon.pid : void 0;
5764
- 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" };
5765
6031
  let previous = null;
5766
6032
  try {
5767
6033
  previous = existsSync(paths.started) ? JSON.parse(readFileSync(paths.started, "utf8")) : null;
@@ -5775,7 +6041,7 @@ async function startSyncInBackground(root, env = process.env) {
5775
6041
  let pid;
5776
6042
  try {
5777
6043
  const child = spawn(process.execPath, [cli, "sync"], {
5778
- cwd: root,
6044
+ cwd: dirs.root,
5779
6045
  detached: true,
5780
6046
  stdio: [
5781
6047
  "ignore",
@@ -5817,8 +6083,8 @@ async function startSyncInBackground(root, env = process.env) {
5817
6083
  /** Added to a write the local files refused, unless the sync client runs with the server's latest state. */
5818
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)";
5819
6085
  /** Whether the hub's sync client runs and has the server's latest state, asked with a short wait. */
5820
- async function caughtUp(root) {
5821
- const daemon = readDaemon(root);
6086
+ async function caughtUp(dirs) {
6087
+ const daemon = readDaemon(dirs);
5822
6088
  if (!daemon) return false;
5823
6089
  try {
5824
6090
  return (await callDaemon(daemon, "/status", void 0, 2e3)).caughtUp === true;
@@ -5844,29 +6110,30 @@ function writtenLines(written) {
5844
6110
  * other than the one this process was started in, such as another hub the interface shows, passes `{}`, so only its
5845
6111
  * own .mainflow/local/sync.json and sync client count.
5846
6112
  */
5847
- function hubWriter(root, env = process.env, options = {}) {
6113
+ function hubWriter(dirs, env = process.env, options = {}) {
5848
6114
  const startSync = options.startSync ?? startSyncInBackground;
5849
6115
  return async (op, input) => {
5850
- if (!remoteMode(root, env)) return {
5851
- 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,
5852
6119
  queued: false,
5853
6120
  renumbered: [],
5854
6121
  notices: []
5855
6122
  };
5856
6123
  let queuedOp;
5857
6124
  try {
5858
- queuedOp = queueOperation(root, {
6125
+ queuedOp = queueOperation(dirs, {
5859
6126
  protocol: 3,
5860
6127
  op,
5861
6128
  input,
5862
6129
  now: requestTime()
5863
6130
  });
5864
6131
  } catch (error) {
5865
- if (refusedHere(error) && !await caughtUp(root)) error.message += `; ${STALE_COPY}`;
6132
+ if (refusedHere(error) && !await caughtUp(dirs)) error.message += `; ${STALE_COPY}`;
5866
6133
  throw error;
5867
6134
  }
5868
6135
  const { id, result } = queuedOp;
5869
- const start = await startSync(root, env);
6136
+ const start = await startSync(dirs, env);
5870
6137
  const queued = {
5871
6138
  value: result.value,
5872
6139
  queued: true,
@@ -5879,10 +6146,10 @@ function hubWriter(root, env = process.env, options = {}) {
5879
6146
  stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
5880
6147
  };
5881
6148
  if (options.confirmMs === void 0) {
5882
- await notifyQueued(root);
6149
+ await notifyQueued(dirs);
5883
6150
  return queued;
5884
6151
  }
5885
- const outcome = await awaitOutcome(root, id, options.confirmMs);
6152
+ const outcome = await awaitOutcome(dirs, id, options.confirmMs);
5886
6153
  if (outcome.state === "applied") {
5887
6154
  const { response } = outcome;
5888
6155
  return {
@@ -5902,4 +6169,4 @@ function hubWriter(root, env = process.env, options = {}) {
5902
6169
  };
5903
6170
  }
5904
6171
  //#endregion
5905
- 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 };