dsh-mnemon 0.5.27 → 0.5.28

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.
package/lib/index.js CHANGED
@@ -541,6 +541,7 @@ var MnemonAutoBackupScheduler = class {
541
541
  lastAt;
542
542
  lastAttemptAt;
543
543
  lastError;
544
+ lastFailure;
544
545
  lastCommit;
545
546
  lastPushed;
546
547
  constructor(runtime, now = () => /* @__PURE__ */ new Date()) {
@@ -574,6 +575,7 @@ var MnemonAutoBackupScheduler = class {
574
575
  ...this.lastAt === void 0 ? {} : { lastAt: this.lastAt },
575
576
  ...this.lastAttemptAt === void 0 || !available ? {} : { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
576
577
  ...this.lastError === void 0 ? {} : { lastError: this.lastError },
578
+ ...this.lastError === void 0 || this.lastFailure === void 0 ? {} : { lastFailure: this.lastFailure },
577
579
  ...this.lastCommit === void 0 ? {} : { lastCommit: this.lastCommit },
578
580
  ...this.lastPushed === void 0 ? {} : { lastPushed: this.lastPushed }
579
581
  };
@@ -619,19 +621,26 @@ var MnemonAutoBackupScheduler = class {
619
621
  const controller = new AbortController();
620
622
  this.controller = controller;
621
623
  try {
622
- if (sync.settings().read().repoUrl === void 0) this.lastError = void 0;
623
- else if (!this.runtime.config.writeEnabled) this.lastError = "dsh-mnemon is configured read-only";
624
- else {
624
+ if (sync.settings().read().repoUrl === void 0) {
625
+ this.lastError = void 0;
626
+ this.lastFailure = void 0;
627
+ } else if (!this.runtime.config.writeEnabled) {
628
+ this.lastError = "dsh-mnemon is configured read-only";
629
+ this.lastFailure = void 0;
630
+ } else {
625
631
  const result = await sync.push({ signal: controller.signal });
626
632
  this.lastAt = this.now().toISOString();
627
633
  this.lastCommit = result.commit;
628
634
  this.lastPushed = result.pushed;
629
635
  this.lastError = result.pushed ? void 0 : result.committed ? result.reason : void 0;
636
+ this.lastFailure = this.lastError === void 0 ? void 0 : result.failure;
630
637
  }
631
638
  } catch (error) {
632
639
  const message = error instanceof Error ? error.message : String(error);
633
- if (!this.stopped && !message.includes(BUSY_MESSAGE)) {
640
+ const reason = error instanceof Error ? error.reason : void 0;
641
+ if (!this.stopped && reason !== "busy" && !message.includes(BUSY_MESSAGE)) {
634
642
  this.lastError = message;
643
+ this.lastFailure = typeof reason === "string" ? reason : void 0;
635
644
  console.warn("dsh-mnemon: automatic Git backup failed", error);
636
645
  }
637
646
  } finally {
@@ -707,7 +716,7 @@ function createStorageRoot(config, workspaceRoot) {
707
716
  const MNEMON_MACHINE_DIRECTORY = "state";
708
717
  const MNEMON_MACHINE_FILE = "machine.json";
709
718
  const MNEMON_MACHINE_VERSION = 1;
710
- const MAX_LABEL_CHARACTERS = 120;
719
+ const CONTROL = /[\p{Cc}\u2028\u2029]/u;
711
720
  function record$8(value) {
712
721
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
713
722
  }
@@ -715,16 +724,26 @@ function record$8(value) {
715
724
  function defaultMachineLabel() {
716
725
  const name = hostname().trim();
717
726
  const label = name === "" ? "this machine" : name;
718
- return label.length > MAX_LABEL_CHARACTERS ? label.slice(0, MAX_LABEL_CHARACTERS) : label;
727
+ return label.length > 120 ? label.slice(0, 120) : label;
728
+ }
729
+ /** A name a person gives this machine, as it is stored and shown. */
730
+ function machineLabel(value) {
731
+ const label = typeof value === "string" ? value.trim() : "";
732
+ if (label === "" || label.length > 120 || CONTROL.test(label)) throw new Error("machine name must be 1 to " + String(120) + " characters on one line");
733
+ return label;
719
734
  }
720
735
  /**
721
736
  * The machine identity store. One file, one identity, created on first read and
722
737
  * then stable for the life of the data directory: an id that changed would
723
738
  * make every earlier entry look like it came from another machine.
739
+ *
740
+ * The name is not part of that promise. A host-name label follows the computer's
741
+ * host name, and a person can name the machine instead. Every read goes to the
742
+ * file, because the pack manager and the sync channel each hold a store and a
743
+ * rename through one has to reach what the other stamps.
724
744
  */
725
745
  var MnemonMachineStore = class {
726
746
  root;
727
- cached;
728
747
  constructor(runner) {
729
748
  this.root = resolve(runner.effectiveDataDir());
730
749
  }
@@ -736,10 +755,42 @@ var MnemonMachineStore = class {
736
755
  }
737
756
  /** This installation's identity, creating and persisting one when none exists yet. */
738
757
  read() {
739
- if (this.cached !== void 0) return this.cached;
758
+ return this.current().identity;
759
+ }
760
+ /** The identity with the source of its name, bringing the stored record up to date. */
761
+ current() {
740
762
  const stored = this.stored();
741
- this.cached = stored ?? this.create();
742
- return this.cached;
763
+ if (stored === void 0) return this.write({
764
+ id: randomUUID(),
765
+ label: defaultMachineLabel(),
766
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
767
+ }, "hostname");
768
+ if (stored.source === "hostname") {
769
+ const label = defaultMachineLabel();
770
+ if (stored.identity.label !== label || !stored.marked) return this.write({
771
+ ...stored.identity,
772
+ label
773
+ }, "hostname");
774
+ } else if (!stored.marked) return this.write(stored.identity, "custom");
775
+ return {
776
+ identity: stored.identity,
777
+ source: stored.source
778
+ };
779
+ }
780
+ /**
781
+ * Name this machine. A typed name stays as given; null or an empty name goes back
782
+ * to the host name. The id never changes, so earlier entries keep pointing here.
783
+ */
784
+ rename(value) {
785
+ const label = value === null || value === "" ? void 0 : machineLabel(value);
786
+ const identity = this.current().identity;
787
+ return label === void 0 ? this.write({
788
+ ...identity,
789
+ label: defaultMachineLabel()
790
+ }, "hostname") : this.write({
791
+ ...identity,
792
+ label
793
+ }, "custom");
743
794
  }
744
795
  stored() {
745
796
  let value;
@@ -753,29 +804,42 @@ var MnemonMachineStore = class {
753
804
  if (typeof parsed.id !== "string" || parsed.id.trim() === "") return void 0;
754
805
  if (typeof parsed.label !== "string" || parsed.label.trim() === "") return void 0;
755
806
  if (typeof parsed.createdAt !== "string") return void 0;
756
- return {
807
+ const identity = {
757
808
  id: parsed.id,
758
809
  label: parsed.label,
759
810
  createdAt: parsed.createdAt
760
811
  };
761
- }
762
- create() {
763
- const identity = {
764
- id: randomUUID(),
765
- label: defaultMachineLabel(),
766
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
812
+ if (parsed.labelSource === "hostname" || parsed.labelSource === "custom") return {
813
+ identity,
814
+ source: parsed.labelSource,
815
+ marked: true
816
+ };
817
+ return {
818
+ identity,
819
+ source: parsed.label === defaultMachineLabel() ? "hostname" : "custom",
820
+ marked: false
767
821
  };
822
+ }
823
+ write(identity, source) {
768
824
  mkdirSync(this.directory(), {
769
825
  recursive: true,
770
826
  mode: 448
771
827
  });
772
828
  const temporary = `${this.path()}.${randomUUID()}.tmp`;
773
- writeFileSync(temporary, `${JSON.stringify({
774
- version: MNEMON_MACHINE_VERSION,
775
- ...identity
776
- }, null, 2)}\n`, { mode: 384 });
777
- renameSync(temporary, this.path());
778
- return identity;
829
+ try {
830
+ writeFileSync(temporary, `${JSON.stringify({
831
+ version: MNEMON_MACHINE_VERSION,
832
+ ...identity,
833
+ labelSource: source
834
+ }, null, 2)}\n`, { mode: 384 });
835
+ renameSync(temporary, this.path());
836
+ } finally {
837
+ rmSync(temporary, { force: true });
838
+ }
839
+ return {
840
+ identity,
841
+ source
842
+ };
779
843
  }
780
844
  };
781
845
  //#endregion
@@ -1573,6 +1637,7 @@ function mergeDocuments(root, pack) {
1573
1637
  for (const source of incoming.index.documents) {
1574
1638
  const existing = ids.get(source.id);
1575
1639
  if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
1640
+ if (existing !== void 0 && current.index.documents.some((document) => document.contentHash === source.contentHash)) continue;
1576
1641
  let document = source;
1577
1642
  let bytes = incoming.files.get(source.id);
1578
1643
  if (existing !== void 0) {
@@ -1595,7 +1660,10 @@ function mergeRegistry(root, pack) {
1595
1660
  let body = source;
1596
1661
  const sourceDb = incoming.databases.get(source.id);
1597
1662
  if (ids.has(source.id)) {
1598
- if (sha256$1(current.databases.get(source.id)) === sha256$1(sourceDb)) continue;
1663
+ const currentDb = current.databases.get(source.id);
1664
+ const incomingHash = sha256$1(sourceDb);
1665
+ if (sha256$1(currentDb) === incomingHash) continue;
1666
+ if ([...current.databases.values()].some((database) => sha256$1(database) === incomingHash)) continue;
1599
1667
  const id = randomUUID();
1600
1668
  body = {
1601
1669
  ...source,
@@ -2591,6 +2659,20 @@ function createGitHubTransport(options = {}) {
2591
2659
  });
2592
2660
  }
2593
2661
  //#endregion
2662
+ //#region src/host/sync-error.ts
2663
+ /**
2664
+ * A sync failure the settings page can name in its own language. The message
2665
+ * stays the Host's own words for logs and for anything the page cannot name.
2666
+ */
2667
+ var MnemonSyncError = class extends Error {
2668
+ reason;
2669
+ constructor(reason, message) {
2670
+ super(message);
2671
+ this.reason = reason;
2672
+ this.name = "MnemonSyncError";
2673
+ }
2674
+ };
2675
+ //#endregion
2594
2676
  //#region src/host/github-auth.ts
2595
2677
  /**
2596
2678
  * GitHub sign-in for the repository sync channel: the OAuth device flow, the
@@ -2641,7 +2723,8 @@ function codeOf(body) {
2641
2723
  }
2642
2724
  function failure$2(action, status, body) {
2643
2725
  const code = codeOf(body);
2644
- return /* @__PURE__ */ new Error(code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code);
2726
+ const message = code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code;
2727
+ return status === 401 ? new MnemonSyncError("github-unauthorized", message) : new Error(message);
2645
2728
  }
2646
2729
  /** Read one grant out of a stored record, refusing anything else. */
2647
2730
  function githubGrantFromRecord(record_) {
@@ -2920,17 +3003,20 @@ var MnemonGitHubAuth = class {
2920
3003
  /** The repositories the account may write to, most recently pushed first. */
2921
3004
  async repositories(signal) {
2922
3005
  const token = await this.requireToken();
2923
- const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
2924
- const { status, body } = await this.ask(url, {
2925
- headers: this.authorization(token),
2926
- ...signal === void 0 ? {} : { signal }
2927
- }, "listing GitHub repositories");
2928
- if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
2929
- if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
2930
3006
  const repositories = [];
2931
- for (const entry of body) {
2932
- const repository = repositoryOf(entry);
2933
- if (repository !== void 0) repositories.push(repository);
3007
+ for (let page = 1; page <= 5; page += 1) {
3008
+ const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&page=" + String(page) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
3009
+ const { status, body } = await this.ask(url, {
3010
+ headers: this.authorization(token),
3011
+ ...signal === void 0 ? {} : { signal }
3012
+ }, "listing GitHub repositories");
3013
+ if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
3014
+ if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
3015
+ for (const entry of body) {
3016
+ const repository = repositoryOf(entry);
3017
+ if (repository !== void 0 && !repositories.some((known) => known.fullName === repository.fullName)) repositories.push(repository);
3018
+ }
3019
+ if (body.length < 100) break;
2934
3020
  }
2935
3021
  return {
2936
3022
  login: await this.account(token, signal) ?? "",
@@ -3234,7 +3320,28 @@ var MnemonSyncSettingsStore = class {
3234
3320
  //#region src/host/git-sync.ts
3235
3321
  /** The oldest Git the channel drives: `archive`, `ls-remote`, `-C`, and `credential` all predate it. */
3236
3322
  const MNEMON_SYNC_MINIMUM_GIT = "2.20";
3237
- const MNEMON_SYNC_GIT_TIMEOUT_MS = 12e4;
3323
+ /**
3324
+ * How long the settings page waits to learn whether the remote answers. A page
3325
+ * that opens should not hang on a host that drops packets, and anything slower
3326
+ * than this is reported as a timeout rather than awaited.
3327
+ */
3328
+ const MNEMON_SYNC_STATUS_TIMEOUT_MS = 2e4;
3329
+ /** A push Git refused because the branch moved is folded in and tried once more. */
3330
+ const PUSH_ATTEMPTS = 2;
3331
+ /**
3332
+ * No sync runs this long, so a lock older than this is abandoned whoever holds it.
3333
+ * It also covers a process id that a restart handed to an unrelated process.
3334
+ */
3335
+ const STALE_LOCK_MS = 36e5;
3336
+ const LOCK_OWNER = "owner.json";
3337
+ /**
3338
+ * A lock without an owner record was taken by a version that kept none, and may
3339
+ * still be in use by it; it is taken over only once no such run could still last.
3340
+ */
3341
+ const OWNERLESS_LOCK_MS = 9e5;
3342
+ /** The mirror's own Git config names the repository it was filled from. */
3343
+ const ORIGIN_KEY = "mnemon.origin";
3344
+ const LAST_RUN_FILE = "last-run.json";
3238
3345
  const GIT_LABEL = "git";
3239
3346
  const MANIFEST = "manifest.json";
3240
3347
  const CHECKSUMS = "checksums.json";
@@ -3295,6 +3402,16 @@ function gitEnvironment(environment = process.env) {
3295
3402
  sanitized.GCM_INTERACTIVE = "never";
3296
3403
  return sanitized;
3297
3404
  }
3405
+ /** The host an HTTPS address hands a token to; any other address takes none. */
3406
+ function tokenHost(repoUrl) {
3407
+ if (repoUrl === void 0) return void 0;
3408
+ try {
3409
+ const url = new URL(repoUrl);
3410
+ return url.protocol === "https:" ? url.host.toLowerCase() : void 0;
3411
+ } catch {
3412
+ return;
3413
+ }
3414
+ }
3298
3415
  /** Whether a repository address is HTTPS on github.com, the only host a GitHub grant is for. */
3299
3416
  function isGitHubHttps(repoUrl) {
3300
3417
  try {
@@ -3304,6 +3421,37 @@ function isGitHubHttps(repoUrl) {
3304
3421
  return false;
3305
3422
  }
3306
3423
  }
3424
+ /**
3425
+ * What kind of failure one Git error describes. Git words these differently per
3426
+ * transport and per host, so the patterns are the phrases each transport uses:
3427
+ * a refused or missing credential, an address that leads nowhere (GitHub answers
3428
+ * "not found" for a private repository it will not show without credentials), a
3429
+ * network that does not reach the host, and a host that never answered.
3430
+ */
3431
+ function remoteIssueOf(message) {
3432
+ const text = message.toLowerCase();
3433
+ if (/did not respond within|timed out|timeout was reached/u.test(text)) return "timeout";
3434
+ if (/authentication failed|could not read (username|password)|terminal prompts disabled|permission denied|invalid username or password|bad credentials|returned error: 40[13]|host key verification failed|access denied|denied to /u.test(text)) return "auth";
3435
+ if (/repository not found|not found|does not appear to be a git repository|no such file or directory|does not exist|returned error: 404/u.test(text)) return "not-found";
3436
+ if (/could not resolve|failed to connect|couldn't connect|connection (refused|reset|closed|timed out)|network is unreachable|no route to host|unable to access|ssl|tls|proxy|empty reply/u.test(text)) return "network";
3437
+ return "unknown";
3438
+ }
3439
+ const REMOTE_FAILURE = {
3440
+ auth: "remote-auth",
3441
+ "not-found": "remote-not-found",
3442
+ network: "remote-network",
3443
+ timeout: "remote-timeout",
3444
+ unknown: void 0
3445
+ };
3446
+ /** One failed remote command as the error a caller reports: named when it can be. */
3447
+ function remoteFailure(message) {
3448
+ const reason = REMOTE_FAILURE[remoteIssueOf(message)];
3449
+ return reason === void 0 ? new Error(message) : new MnemonSyncError(reason, message);
3450
+ }
3451
+ /** Whether Git refused a push because the branch moved on since this run read it. */
3452
+ function rejectedPush(output) {
3453
+ return /\[rejected\]|fetch first|non-fast-forward/iu.test(output);
3454
+ }
3307
3455
  function record$3(value) {
3308
3456
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3309
3457
  }
@@ -3450,9 +3598,9 @@ function objectCount(output) {
3450
3598
  if (loose === null || bytes === null || packed === null || packedBytes === null) return void 0;
3451
3599
  return {
3452
3600
  loose: Number(loose[1]),
3453
- bytes: Number(bytes[1]),
3601
+ bytes: Number(bytes[1]) * 1024,
3454
3602
  packed: Number(packed[1]),
3455
- packedBytes: Number(packedBytes[1])
3603
+ packedBytes: Number(packedBytes[1]) * 1024
3456
3604
  };
3457
3605
  }
3458
3606
  /** How many commits one page asks for: the caller's window, clamped to what one listing should carry. */
@@ -3718,6 +3866,8 @@ var MnemonGitSync = class {
3718
3866
  };
3719
3867
  const commit = existsSync(this.store.mirror()) && git.available ? await this.lastCommit(signal) : void 0;
3720
3868
  const autoBackup = this.autoBackupView?.();
3869
+ const lastRun = repoUrl === void 0 ? void 0 : this.lastRun();
3870
+ const machine = this.machine.current();
3721
3871
  return {
3722
3872
  enabled: true,
3723
3873
  configured: repoUrl !== void 0,
@@ -3726,14 +3876,27 @@ var MnemonGitSync = class {
3726
3876
  mirrorPath: this.store.mirror(),
3727
3877
  git,
3728
3878
  remote,
3729
- machine: this.machine.read(),
3879
+ machine: machine.identity,
3880
+ machineLabel: {
3881
+ source: machine.source,
3882
+ hostname: defaultMachineLabel()
3883
+ },
3730
3884
  ...commit === void 0 ? {} : { lastCommit: commit },
3885
+ ...lastRun === void 0 ? {} : { lastRun },
3731
3886
  ...autoBackup === void 0 ? {} : { autoBackup }
3732
3887
  };
3733
3888
  }
3734
3889
  async configure(patch) {
3735
- const next = this.store.patch(patch);
3890
+ const previous = this.store.read();
3891
+ const fields = record$3(patch);
3892
+ const { machineLabel: name, ...settings } = fields ?? {};
3893
+ const naming = fields !== void 0 && "machineLabel" in fields;
3894
+ if (naming && name !== null && name !== "") machineLabel(name);
3895
+ const next = this.store.patch(fields === void 0 ? patch : settings);
3896
+ if (next.token !== void 0 && previous.repoUrl !== void 0 && fields !== void 0 && !("token" in fields) && tokenHost(previous.repoUrl) !== tokenHost(next.repoUrl)) delete next.token;
3736
3897
  this.store.write(next);
3898
+ if (naming) this.machine.rename(name);
3899
+ if (previous.repoUrl !== next.repoUrl) rmSync(this.lastRunPath(), { force: true });
3737
3900
  return this.credentialView(next);
3738
3901
  }
3739
3902
  /**
@@ -3747,106 +3910,202 @@ var MnemonGitSync = class {
3747
3910
  await this.requireGit(input.signal);
3748
3911
  const message = commitMessage(input.message, this.now());
3749
3912
  return this.lock(async () => {
3750
- const prepared = await this.ensureMirror(settings, input.signal);
3751
- await this.packs.recordDeletions();
3752
- const remote = await this.readRemote(settings, input.signal);
3753
- let merged;
3754
- if (remote !== void 0) {
3755
- const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
3756
- mode: "merge",
3757
- tombstones: {
3758
- version: 1,
3759
- tombstones: remote.tombstones
3760
- }
3761
- });
3762
- merged = {
3763
- commit: remote.commit,
3764
- ...remote.machine === void 0 ? {} : { machine: {
3765
- id: remote.machine.id,
3766
- label: remote.machine.label
3767
- } },
3768
- components: imported.components,
3769
- summary: imported.summary,
3770
- tombstones: remote.tombstones.length
3771
- };
3913
+ let result = await this.pushOnce(settings, message, input.signal);
3914
+ let added = result.merged?.runtime?.added ?? 0;
3915
+ let held = result.merged?.runtime?.held ?? 0;
3916
+ for (let attempt = 1; attempt < PUSH_ATTEMPTS && result.failure === "push-rejected"; attempt += 1) {
3917
+ result = await this.pushOnce(settings, message, input.signal);
3918
+ added += result.merged?.runtime?.added ?? 0;
3919
+ held = result.merged?.runtime?.held ?? held;
3772
3920
  }
3773
- const exported = await this.packs.exportPack("full", { stamp: true });
3774
- const entries = unpack(Buffer.from(exported.base64, "base64"));
3775
- const extension = {
3776
- channel: "git",
3777
- branch: settings.branch,
3778
- subdir: settings.subdir,
3779
- pushedAt: exported.manifest.exportedAt,
3780
- machine: this.machine.read(),
3781
- tombstones: this.packs.tombstones().tombstones
3921
+ if (result.merged !== void 0 && (added > 0 || result.merged.runtime !== void 0)) result = {
3922
+ ...result,
3923
+ merged: {
3924
+ ...result.merged,
3925
+ runtime: {
3926
+ added,
3927
+ held
3928
+ }
3929
+ }
3782
3930
  };
3783
- entries[MANIFEST] = new TextEncoder().encode(JSON.stringify({
3784
- ...exported.manifest,
3785
- [SYNC_EXTENSION]: extension
3786
- }, null, 2) + "\n");
3787
- const before = readFiles(this.payloadRoot(settings.subdir));
3788
- if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
3789
- const staged = await this.git([
3790
- "add",
3791
- "--all",
3792
- "--",
3793
- settings.subdir
3794
- ], {
3795
- cwd: this.store.mirror(),
3796
- signal: input.signal
3931
+ if (result.pushed || !result.committed) this.recordRun({
3932
+ kind: "sync",
3933
+ commit: result.commit,
3934
+ pushed: result.pushed,
3935
+ ...result.merged?.runtime === void 0 ? {} : { added: result.merged.runtime.added }
3797
3936
  });
3798
- if (staged.exitCode !== 0) throw new Error("git add failed: " + tail(staged.stderr || staged.stdout));
3799
- const committed = (await this.git([
3800
- "status",
3801
- "--porcelain",
3937
+ return result;
3938
+ });
3939
+ }
3940
+ async pushOnce(settings, message, signal) {
3941
+ const prepared = await this.ensureMirror(settings, signal);
3942
+ await this.packs.recordDeletions();
3943
+ const remote = await this.readPrepared(settings, prepared);
3944
+ let merged;
3945
+ if (remote !== void 0) {
3946
+ const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
3947
+ mode: "merge",
3948
+ tombstones: {
3949
+ version: 1,
3950
+ tombstones: remote.tombstones
3951
+ }
3952
+ });
3953
+ merged = {
3954
+ commit: remote.commit,
3955
+ ...remote.machine === void 0 ? {} : { machine: {
3956
+ id: remote.machine.id,
3957
+ label: remote.machine.label
3958
+ } },
3959
+ components: imported.components,
3960
+ summary: imported.summary,
3961
+ tombstones: remote.tombstones.length,
3962
+ ...imported.runtime === void 0 ? {} : { runtime: imported.runtime }
3963
+ };
3964
+ }
3965
+ const exported = await this.packs.exportPack("full", { stamp: true });
3966
+ const entries = unpack(Buffer.from(exported.base64, "base64"));
3967
+ const extension = {
3968
+ channel: "git",
3969
+ branch: settings.branch,
3970
+ subdir: settings.subdir,
3971
+ pushedAt: exported.manifest.exportedAt,
3972
+ machine: this.machine.read(),
3973
+ tombstones: this.packs.tombstones().tombstones
3974
+ };
3975
+ entries[MANIFEST] = new TextEncoder().encode(JSON.stringify({
3976
+ ...exported.manifest,
3977
+ [SYNC_EXTENSION]: extension
3978
+ }, null, 2) + "\n");
3979
+ const before = readFiles(this.payloadRoot(settings.subdir));
3980
+ if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
3981
+ const staged = await this.git([
3982
+ "add",
3983
+ "--all",
3984
+ "--",
3985
+ settings.subdir
3986
+ ], {
3987
+ cwd: this.store.mirror(),
3988
+ signal
3989
+ });
3990
+ if (staged.exitCode !== 0) throw new Error("git add failed: " + tail(staged.stderr || staged.stdout));
3991
+ const committed = (await this.git([
3992
+ "status",
3993
+ "--porcelain",
3994
+ "--",
3995
+ settings.subdir
3996
+ ], {
3997
+ cwd: this.store.mirror(),
3998
+ signal
3999
+ })).stdout.trim() !== "";
4000
+ if (committed) {
4001
+ const identity = await this.commitIdentity(settings, signal);
4002
+ const result = await this.git([
4003
+ ...identity,
4004
+ "commit",
4005
+ "--quiet",
4006
+ "-m",
4007
+ message,
3802
4008
  "--",
3803
4009
  settings.subdir
3804
4010
  ], {
3805
4011
  cwd: this.store.mirror(),
3806
- signal: input.signal
3807
- })).stdout.trim() !== "";
3808
- if (committed) {
3809
- const identity = [...settings.authorName === "" ? [] : ["-c", "user.name=" + settings.authorName], ...settings.authorEmail === "" ? [] : ["-c", "user.email=" + settings.authorEmail]];
3810
- const result = await this.git([
3811
- ...identity,
3812
- "commit",
3813
- "--quiet",
3814
- "-m",
3815
- message,
3816
- "--",
3817
- settings.subdir
3818
- ], {
3819
- cwd: this.store.mirror(),
3820
- signal: input.signal
3821
- });
3822
- if (result.exitCode !== 0) throw new Error("git commit failed: " + tail(result.stderr || result.stdout));
3823
- }
3824
- const head = await this.head(input.signal);
3825
- const published = committed ? await this.publish(settings, await this.credential(settings), input.signal) : {
3826
- pushed: false,
3827
- reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
3828
- };
3829
- const compaction = await this.compact(input.signal);
3830
- const summary = exported.manifest.summary.map((entry) => ({
3831
- ...entry,
3832
- changed: componentChanged(before, entries, entry.component)
3833
- }));
4012
+ signal
4013
+ });
4014
+ if (result.exitCode !== 0) throw new Error("git commit failed: " + tail(result.stderr || result.stdout));
4015
+ }
4016
+ const head = await this.head(signal);
4017
+ const published = committed ? await this.publish(settings, await this.credential(settings), signal) : {
4018
+ pushed: false,
4019
+ reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
4020
+ };
4021
+ const compaction = await this.compact(signal);
4022
+ const summary = exported.manifest.summary.map((entry) => ({
4023
+ ...entry,
4024
+ changed: componentChanged(before, entries, entry.component)
4025
+ }));
4026
+ return {
4027
+ repoUrl: settings.repoUrl,
4028
+ branch: settings.branch,
4029
+ subdir: settings.subdir,
4030
+ commit: head,
4031
+ committed,
4032
+ message,
4033
+ files: Object.keys(entries).length,
4034
+ bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
4035
+ summary,
4036
+ pushed: published.pushed,
4037
+ ...merged === void 0 ? {} : { merged },
4038
+ ...published.reason === void 0 ? {} : { reason: published.reason },
4039
+ ...published.failure === void 0 ? {} : { failure: published.failure },
4040
+ ...compaction === void 0 ? {} : { compaction }
4041
+ };
4042
+ }
4043
+ /**
4044
+ * The identity a sync commit is recorded under: the one configured here, else the
4045
+ * Git identity this machine has. A machine without one gets the channel's own
4046
+ * rather than a failed commit, and rather than the account and host names Git
4047
+ * would otherwise guess and publish.
4048
+ */
4049
+ async commitIdentity(settings, signal) {
4050
+ const name = settings.authorName !== "" ? settings.authorName : await this.configured("user.name", signal) ?? "dsh-mnemon sync";
4051
+ const email = settings.authorEmail !== "" ? settings.authorEmail : await this.configured("user.email", signal) ?? "mnemon@localhost";
4052
+ return [
4053
+ "-c",
4054
+ "user.name=" + name,
4055
+ "-c",
4056
+ "user.email=" + email
4057
+ ];
4058
+ }
4059
+ /** One Git setting as this machine resolves it, or nothing when no config sets it. */
4060
+ async configured(key, signal) {
4061
+ const result = await this.git([
4062
+ "config",
4063
+ "--get",
4064
+ key
4065
+ ], {
4066
+ cwd: this.store.mirror(),
4067
+ signal
4068
+ });
4069
+ const value = result.exitCode === 0 ? result.stdout.trim() : "";
4070
+ return value === "" ? void 0 : value;
4071
+ }
4072
+ /** When memory last travelled through this channel, or nothing when it never did. */
4073
+ lastRun() {
4074
+ try {
4075
+ const value = record$3(JSON.parse(readFileSync(this.lastRunPath(), "utf8")));
4076
+ if (value === void 0 || typeof value.at !== "string" || typeof value.commit !== "string") return void 0;
4077
+ if (value.kind !== "sync" && value.kind !== "merge") return void 0;
3834
4078
  return {
3835
- repoUrl: settings.repoUrl,
3836
- branch: settings.branch,
3837
- subdir: settings.subdir,
3838
- commit: head,
3839
- committed,
3840
- message,
3841
- files: Object.keys(entries).length,
3842
- bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
3843
- summary,
3844
- pushed: published.pushed,
3845
- ...merged === void 0 ? {} : { merged },
3846
- ...published.reason === void 0 ? {} : { reason: published.reason },
3847
- ...compaction === void 0 ? {} : { compaction }
4079
+ at: value.at,
4080
+ kind: value.kind,
4081
+ commit: value.commit,
4082
+ pushed: value.pushed === true,
4083
+ ...typeof value.added === "number" && Number.isInteger(value.added) && value.added >= 0 ? { added: value.added } : {}
3848
4084
  };
3849
- });
4085
+ } catch {
4086
+ return;
4087
+ }
4088
+ }
4089
+ lastRunPath() {
4090
+ return join(this.store.directory(), "sync", LAST_RUN_FILE);
4091
+ }
4092
+ /** Remember one finished run. Bookkeeping never fails the run it describes. */
4093
+ recordRun(run) {
4094
+ const path = this.lastRunPath();
4095
+ const temporary = path + "." + String(process.pid) + ".tmp";
4096
+ try {
4097
+ mkdirSync(join(path, ".."), {
4098
+ recursive: true,
4099
+ mode: 448
4100
+ });
4101
+ writeFileSync(temporary, JSON.stringify({
4102
+ at: this.now().toISOString(),
4103
+ ...run
4104
+ }, null, 2) + "\n", { mode: 384 });
4105
+ renameSync(temporary, path);
4106
+ } catch {
4107
+ rmSync(temporary, { force: true });
4108
+ }
3850
4109
  }
3851
4110
  /** Read the remote payload and report what it would change, without importing anything. */
3852
4111
  async preview(signal) {
@@ -3855,7 +4114,7 @@ var MnemonGitSync = class {
3855
4114
  const exported = await this.packs.exportPack("full");
3856
4115
  return this.lock(async () => {
3857
4116
  const remote = await this.readRemote(settings, signal);
3858
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4117
+ if (remote === void 0) throw this.absentPayload(settings);
3859
4118
  const local = unpack(Buffer.from(exported.base64, "base64"));
3860
4119
  const components = MNEMON_PACK_COMPONENTS.map((component) => {
3861
4120
  const published = remote.manifest.summary.find((entry) => entry.component === component);
@@ -3930,7 +4189,7 @@ var MnemonGitSync = class {
3930
4189
  const exported = await this.packs.exportPack("full");
3931
4190
  return this.lock(async () => {
3932
4191
  const remote = await this.readRemote(settings, signal);
3933
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4192
+ if (remote === void 0) throw this.absentPayload(settings);
3934
4193
  const local = unpack(Buffer.from(exported.base64, "base64"));
3935
4194
  const limits = this.runtimeLimits();
3936
4195
  const mine = runtimeOf(local, limits, "the local");
@@ -4000,7 +4259,7 @@ var MnemonGitSync = class {
4000
4259
  };
4001
4260
  return this.lock(async () => {
4002
4261
  const remote = await this.readRemote(settings, input.signal);
4003
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4262
+ if (remote === void 0) throw this.absentPayload(settings);
4004
4263
  const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
4005
4264
  ...options,
4006
4265
  tombstones: {
@@ -4008,6 +4267,12 @@ var MnemonGitSync = class {
4008
4267
  tombstones: remote.tombstones
4009
4268
  }
4010
4269
  });
4270
+ this.recordRun({
4271
+ kind: "merge",
4272
+ commit: remote.commit,
4273
+ pushed: false,
4274
+ ...imported.runtime === void 0 ? {} : { added: imported.runtime.added }
4275
+ });
4011
4276
  return {
4012
4277
  imported: true,
4013
4278
  mode: imported.mode,
@@ -4025,11 +4290,11 @@ var MnemonGitSync = class {
4025
4290
  });
4026
4291
  }
4027
4292
  absentPayload(settings) {
4028
- return "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first";
4293
+ return new MnemonSyncError("remote-empty", "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first");
4029
4294
  }
4030
4295
  requireRepository() {
4031
4296
  const settings = this.store.read();
4032
- if (settings.repoUrl === void 0) throw new Error("no sync repository is configured; set repoUrl in " + this.store.path() + " first");
4297
+ if (settings.repoUrl === void 0) throw new MnemonSyncError("no-repository", "no sync repository is configured; set repoUrl in " + this.store.path() + " first");
4033
4298
  return {
4034
4299
  ...settings,
4035
4300
  repoUrl: settings.repoUrl
@@ -4067,12 +4332,12 @@ var MnemonGitSync = class {
4067
4332
  }
4068
4333
  async requireGit(signal) {
4069
4334
  const status = await this.gitStatus(signal);
4070
- if (!status.available) throw new Error("Git is not available on this Host: " + (status.issue ?? "unknown reason"));
4335
+ if (!status.available) throw new MnemonSyncError("git-missing", "Git is not available on this Host: " + (status.issue ?? "unknown reason"));
4071
4336
  }
4072
4337
  async remoteStatus(settings, signal) {
4073
4338
  const credential = await this.credential(settings);
4074
4339
  try {
4075
- const tip = await this.lsRemote(settings, credential, signal);
4340
+ const tip = await this.lsRemote(settings, credential, signal, MNEMON_SYNC_STATUS_TIMEOUT_MS);
4076
4341
  return tip === void 0 ? {
4077
4342
  reachable: true,
4078
4343
  branchExists: false
@@ -4082,26 +4347,40 @@ var MnemonGitSync = class {
4082
4347
  commit: tip
4083
4348
  };
4084
4349
  } catch (error) {
4350
+ const message = this.mask(error instanceof Error ? error.message : String(error), credential);
4085
4351
  return {
4086
4352
  reachable: false,
4087
4353
  branchExists: false,
4088
- error: this.mask(error instanceof Error ? error.message : String(error), credential)
4354
+ error: message,
4355
+ issue: remoteIssueOf(message)
4089
4356
  };
4090
4357
  }
4091
4358
  }
4092
- async lsRemote(settings, credential, signal) {
4359
+ /**
4360
+ * The branch tip the remote holds, or nothing when it holds no such branch. A
4361
+ * remote that cannot be read fails with the kind of failure it was, the token
4362
+ * masked, so the page can say "sign in" rather than print Git's trace.
4363
+ */
4364
+ async lsRemote(settings, credential, signal, timeoutMs) {
4093
4365
  const reference = "refs/heads/" + settings.branch;
4094
- const result = await this.git([
4095
- "ls-remote",
4096
- "--heads",
4097
- settings.repoUrl,
4098
- reference
4099
- ], {
4100
- signal,
4101
- token: credential.token,
4102
- authenticated: true
4103
- });
4104
- if (result.exitCode !== 0) throw new Error(tail(result.stderr || result.stdout) || "git ls-remote failed");
4366
+ let result;
4367
+ try {
4368
+ result = await this.git([
4369
+ "ls-remote",
4370
+ "--heads",
4371
+ settings.repoUrl,
4372
+ reference
4373
+ ], {
4374
+ signal,
4375
+ token: credential.token,
4376
+ authenticated: true,
4377
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
4378
+ });
4379
+ } catch (error) {
4380
+ if (signal?.aborted === true) throw error;
4381
+ throw remoteFailure(this.mask(error instanceof Error ? error.message : String(error), credential));
4382
+ }
4383
+ if (result.exitCode !== 0) throw remoteFailure(this.mask(tail(result.stderr || result.stdout) || "git ls-remote failed", credential));
4105
4384
  for (const line of result.stdout.split("\n")) {
4106
4385
  const [commit, name] = line.trim().split(/\s+/u);
4107
4386
  if (name === reference && commit !== void 0) return commit;
@@ -4218,6 +4497,20 @@ var MnemonGitSync = class {
4218
4497
  recursive: true,
4219
4498
  mode: 448
4220
4499
  });
4500
+ if (existsSync(join(mirror, ".git"))) {
4501
+ const origin = await this.git([
4502
+ "config",
4503
+ "--get",
4504
+ ORIGIN_KEY
4505
+ ], {
4506
+ cwd: mirror,
4507
+ signal
4508
+ });
4509
+ if (origin.exitCode !== 0 || origin.stdout.trim() !== settings.repoUrl) rmSync(mirror, {
4510
+ recursive: true,
4511
+ force: true
4512
+ });
4513
+ }
4221
4514
  if (!existsSync(join(mirror, ".git"))) {
4222
4515
  rmSync(mirror, {
4223
4516
  recursive: true,
@@ -4226,10 +4519,18 @@ var MnemonGitSync = class {
4226
4519
  const initialized = await this.git([
4227
4520
  "init",
4228
4521
  "--quiet",
4229
- "--initial-branch=" + settings.branch,
4230
4522
  mirror
4231
4523
  ], { signal });
4232
4524
  if (initialized.exitCode !== 0) throw new Error("git init failed: " + tail(initialized.stderr || initialized.stdout));
4525
+ const marked = await this.git([
4526
+ "config",
4527
+ ORIGIN_KEY,
4528
+ settings.repoUrl
4529
+ ], {
4530
+ cwd: mirror,
4531
+ signal
4532
+ });
4533
+ if (marked.exitCode !== 0) throw new Error("git config failed: " + tail(marked.stderr || marked.stdout));
4233
4534
  }
4234
4535
  const current = await this.git([
4235
4536
  "symbolic-ref",
@@ -4251,7 +4552,10 @@ var MnemonGitSync = class {
4251
4552
  if (renamed.exitCode !== 0) throw new Error("git symbolic-ref failed: " + tail(renamed.stderr || renamed.stdout));
4252
4553
  }
4253
4554
  const tip = await this.lsRemote(settings, credential, signal);
4254
- if (tip === void 0) return {};
4555
+ if (tip === void 0) {
4556
+ await this.startHistory(settings.branch, signal);
4557
+ return {};
4558
+ }
4255
4559
  const fetched = await this.git([
4256
4560
  "fetch",
4257
4561
  "--no-tags",
@@ -4264,7 +4568,7 @@ var MnemonGitSync = class {
4264
4568
  token: credential.token,
4265
4569
  authenticated: true
4266
4570
  });
4267
- if (fetched.exitCode !== 0) throw new Error("git fetch failed: " + this.mask(tail(fetched.stderr || fetched.stdout), credential));
4571
+ if (fetched.exitCode !== 0) throw remoteFailure("git fetch failed: " + this.mask(tail(fetched.stderr || fetched.stdout), credential));
4268
4572
  const reset = await this.git([
4269
4573
  "reset",
4270
4574
  "--hard",
@@ -4278,6 +4582,32 @@ var MnemonGitSync = class {
4278
4582
  return { tip };
4279
4583
  }
4280
4584
  /**
4585
+ * Point the mirror at a branch the remote does not hold. The next commit starts a
4586
+ * history of its own: continuing the one this mirror kept, from a branch the remote
4587
+ * has since deleted, would publish every earlier payload all over again.
4588
+ */
4589
+ async startHistory(branch, signal) {
4590
+ const mirror = this.store.mirror();
4591
+ await this.git([
4592
+ "update-ref",
4593
+ "-d",
4594
+ "refs/heads/" + branch
4595
+ ], {
4596
+ cwd: mirror,
4597
+ signal
4598
+ });
4599
+ const emptied = await this.git(["read-tree", "--empty"], {
4600
+ cwd: mirror,
4601
+ signal
4602
+ });
4603
+ if (emptied.exitCode !== 0) throw new Error("git read-tree failed: " + tail(emptied.stderr || emptied.stdout));
4604
+ const cleaned = await this.git(["clean", "-fdxq"], {
4605
+ cwd: mirror,
4606
+ signal
4607
+ });
4608
+ if (cleaned.exitCode !== 0) throw new Error("git clean failed: " + tail(cleaned.stderr || cleaned.stdout));
4609
+ }
4610
+ /**
4281
4611
  * The payload directory inside the mirror. A link where that directory or one of
4282
4612
  * its parents should be would lead every read, removal and write out of the mirror,
4283
4613
  * so a path that crosses one is refused before anything touches it.
@@ -4318,26 +4648,37 @@ var MnemonGitSync = class {
4318
4648
  }
4319
4649
  async publish(settings, credential, signal) {
4320
4650
  const reference = "refs/heads/" + settings.branch + ":refs/heads/" + settings.branch;
4321
- const result = await this.git([
4322
- "push",
4323
- "--porcelain",
4324
- settings.repoUrl,
4325
- reference
4326
- ], {
4327
- cwd: this.store.mirror(),
4328
- signal,
4329
- token: credential.token,
4330
- authenticated: true
4331
- });
4651
+ let result;
4652
+ try {
4653
+ result = await this.git([
4654
+ "push",
4655
+ "--porcelain",
4656
+ settings.repoUrl,
4657
+ reference
4658
+ ], {
4659
+ cwd: this.store.mirror(),
4660
+ signal,
4661
+ token: credential.token,
4662
+ authenticated: true
4663
+ });
4664
+ } catch (error) {
4665
+ if (signal?.aborted === true) throw error;
4666
+ const reason = this.mask(error instanceof Error ? error.message : String(error), credential);
4667
+ const failure = REMOTE_FAILURE[remoteIssueOf(reason)];
4668
+ return {
4669
+ pushed: false,
4670
+ reason,
4671
+ ...failure === void 0 ? {} : { failure }
4672
+ };
4673
+ }
4332
4674
  if (result.exitCode === 0) return { pushed: true };
4333
- const reason = this.mask(tail(result.stderr || result.stdout), credential);
4334
- if (credential.token === void 0) return {
4335
- pushed: false,
4336
- reason: "the commit stays local because no token is available: " + (reason || "git push failed")
4337
- };
4675
+ const output = this.mask(result.stdout + "\n" + result.stderr, credential);
4676
+ const reason = this.mask(tail(result.stderr || result.stdout), credential) || "git push failed";
4677
+ const failure = rejectedPush(output) ? "push-rejected" : REMOTE_FAILURE[remoteIssueOf(output)];
4338
4678
  return {
4339
4679
  pushed: false,
4340
- reason: reason || "git push failed"
4680
+ reason: credential.token === void 0 && failure !== "push-rejected" ? "the commit stays local because no token is available: " + reason : reason,
4681
+ ...failure === void 0 ? {} : { failure }
4341
4682
  };
4342
4683
  }
4343
4684
  /**
@@ -4410,9 +4751,12 @@ var MnemonGitSync = class {
4410
4751
  };
4411
4752
  }
4412
4753
  }
4413
- /** Validate the remote payload and rebuild the exact pack archive the ZIP path would have produced. */
4754
+ /** Refresh the mirror, then read the payload the branch holds. */
4414
4755
  async readRemote(settings, signal) {
4415
- const prepared = await this.ensureMirror(settings, signal);
4756
+ return this.readPrepared(settings, await this.ensureMirror(settings, signal));
4757
+ }
4758
+ /** Validate the payload a refreshed mirror holds and rebuild the exact pack archive the ZIP path would have produced. */
4759
+ async readPrepared(settings, prepared) {
4416
4760
  if (prepared.tip === void 0) return void 0;
4417
4761
  const files = readFiles(this.payloadRoot(settings.subdir));
4418
4762
  const manifestBytes = files[MANIFEST];
@@ -4465,13 +4809,36 @@ var MnemonGitSync = class {
4465
4809
  recursive: true,
4466
4810
  mode: 448
4467
4811
  });
4468
- return withDirectoryLock(join(this.store.directory(), ".sync.lock"), operation);
4812
+ return withDirectoryLock(join(this.store.directory(), ".sync.lock"), async () => {
4813
+ this.sweepCredentials();
4814
+ return operation();
4815
+ });
4816
+ }
4817
+ /**
4818
+ * Remove the one-command credential files a Host that stopped mid-command left
4819
+ * behind. Each holds a token, so one whose process is gone is deleted rather
4820
+ * than kept until the next restart of that process id.
4821
+ */
4822
+ sweepCredentials() {
4823
+ let names;
4824
+ try {
4825
+ names = readdirSync(this.store.directory());
4826
+ } catch {
4827
+ return;
4828
+ }
4829
+ for (const name of names) {
4830
+ const match = /^\.credentials\.(\d+)\.\d+\.tmp$/u.exec(name);
4831
+ if (match === null) continue;
4832
+ const pid = Number(match[1]);
4833
+ if (pid === process.pid || processAlive(pid)) continue;
4834
+ rmSync(join(this.store.directory(), name), { force: true });
4835
+ }
4469
4836
  }
4470
4837
  async git(args, options) {
4471
4838
  const prepared = this.credentials(args, options);
4472
4839
  try {
4473
4840
  return await this.run(GIT_LABEL, prepared.args, {
4474
- timeoutMs: MNEMON_SYNC_GIT_TIMEOUT_MS,
4841
+ timeoutMs: options.timeoutMs ?? 12e4,
4475
4842
  env: gitEnvironment(),
4476
4843
  ...options.cwd === void 0 ? {} : { cwd: options.cwd },
4477
4844
  ...options.signal === void 0 ? {} : { signal: options.signal },
@@ -4520,11 +4887,17 @@ var MnemonGitSync = class {
4520
4887
  };
4521
4888
  }
4522
4889
  };
4890
+ /**
4891
+ * How the payload files differ. The manifest and the checksum inventory describe a
4892
+ * payload rather than hold memory, and the manifest's export time differs on every
4893
+ * read, so counting them would report a difference where every component agrees.
4894
+ */
4523
4895
  function compareFiles(published, local) {
4524
4896
  let changed = 0;
4525
4897
  let added = 0;
4526
4898
  let removed = 0;
4527
- for (const path of /* @__PURE__ */ new Set([...Object.keys(published), ...Object.keys(local)])) {
4899
+ const paths = new Set([...Object.keys(published), ...Object.keys(local)].filter((path) => path !== MANIFEST && path !== CHECKSUMS));
4900
+ for (const path of paths) {
4528
4901
  const remote = published[path];
4529
4902
  const mine = local[path];
4530
4903
  if (remote === void 0) added += 1;
@@ -4532,13 +4905,46 @@ function compareFiles(published, local) {
4532
4905
  else if (sha256Hex(remote) !== sha256Hex(mine)) changed += 1;
4533
4906
  }
4534
4907
  return {
4535
- total: (/* @__PURE__ */ new Set([...Object.keys(published), ...Object.keys(local)])).size,
4908
+ total: paths.size,
4536
4909
  changed,
4537
4910
  added,
4538
4911
  removed
4539
4912
  };
4540
4913
  }
4541
- /** A single-writer lock for one storage root's sync work; the pack locks cover the shared data. */
4914
+ /** Whether a process of this machine still runs; one the Host may not signal is assumed alive. */
4915
+ function processAlive(pid) {
4916
+ try {
4917
+ process.kill(pid, 0);
4918
+ return true;
4919
+ } catch (error) {
4920
+ return error.code === "EPERM";
4921
+ }
4922
+ }
4923
+ /**
4924
+ * Whether a lock was left by an operation that can no longer release it: its owner
4925
+ * process is gone, or it is older than any sync takes. The age also covers a
4926
+ * process id that a restart handed to an unrelated process.
4927
+ */
4928
+ function abandoned(path, now) {
4929
+ let age;
4930
+ try {
4931
+ age = now - statSync(path).mtimeMs;
4932
+ } catch {
4933
+ return false;
4934
+ }
4935
+ if (age > STALE_LOCK_MS) return true;
4936
+ try {
4937
+ const pid = record$3(JSON.parse(readFileSync(join(path, LOCK_OWNER), "utf8")))?.pid;
4938
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && pid !== process.pid && !processAlive(pid);
4939
+ } catch {
4940
+ return age > OWNERLESS_LOCK_MS;
4941
+ }
4942
+ }
4943
+ /**
4944
+ * A single-writer lock for one storage root's sync work; the pack locks cover the
4945
+ * shared data. The lock records its owner, so a Host that stopped mid-operation
4946
+ * does not leave every later sync refused until someone deletes the directory.
4947
+ */
4542
4948
  async function withDirectoryLock(path, operation) {
4543
4949
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
4544
4950
  let held = false;
@@ -4549,9 +4955,28 @@ async function withDirectoryLock(path, operation) {
4549
4955
  });
4550
4956
  held = true;
4551
4957
  } catch {
4958
+ if (abandoned(path, Date.now())) {
4959
+ const aside = path + ".abandoned." + String(process.pid) + "." + String(Date.now());
4960
+ try {
4961
+ if (abandoned(path, Date.now())) renameSync(path, aside);
4962
+ } catch {
4963
+ continue;
4964
+ }
4965
+ rmSync(aside, {
4966
+ recursive: true,
4967
+ force: true
4968
+ });
4969
+ continue;
4970
+ }
4552
4971
  await new Promise((resolveReady) => setTimeout(resolveReady, 25));
4553
4972
  }
4554
- if (!held) throw new Error("another Mnemon sync operation is still running");
4973
+ if (!held) throw new MnemonSyncError("busy", "another Mnemon sync operation is still running");
4974
+ try {
4975
+ writeFileSync(join(path, LOCK_OWNER), JSON.stringify({
4976
+ pid: process.pid,
4977
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
4978
+ }) + "\n", { mode: 384 });
4979
+ } catch {}
4555
4980
  try {
4556
4981
  return await operation();
4557
4982
  } finally {
@@ -11056,12 +11481,13 @@ function success$1(value) {
11056
11481
  };
11057
11482
  }
11058
11483
  function failure$1(error) {
11484
+ const reason = error instanceof Error ? error.reason : void 0;
11059
11485
  return {
11060
11486
  ok: false,
11061
11487
  error: {
11062
11488
  code: "internal",
11063
11489
  message: error instanceof Error ? error.message : String(error),
11064
- details: {}
11490
+ details: typeof reason === "string" ? { reason } : {}
11065
11491
  }
11066
11492
  };
11067
11493
  }