@sealant/mend 0.36.0-next.628 → 0.36.0-next.632

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/{chunk-OTYH35OK.js → chunk-OHOMU7JP.js} +4 -1
  2. package/dist/{dashboard-P73HKQOT.js → dashboard-34ZANJ24.js} +1 -1
  3. package/dist/main.js +392 -21
  4. package/man/mend-accounts.1 +1 -1
  5. package/man/mend-adopt.1 +1 -1
  6. package/man/mend-attach.1 +1 -1
  7. package/man/mend-codex.1 +1 -1
  8. package/man/mend-completions.1 +1 -1
  9. package/man/mend-connect-pi.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-repo.1 +1 -1
  14. package/man/mend-dotfiles-sync.1 +1 -1
  15. package/man/mend-dotfiles.1 +1 -1
  16. package/man/mend-env-cluster.1 +1 -1
  17. package/man/mend-env-load.1 +1 -1
  18. package/man/mend-env-show.1 +1 -1
  19. package/man/mend-folder-create.1 +1 -1
  20. package/man/mend-folder-list.1 +1 -1
  21. package/man/mend-folder-push.1 +1 -1
  22. package/man/mend-folder-rm.1 +1 -1
  23. package/man/mend-git-author.1 +1 -1
  24. package/man/mend-help.1 +1 -1
  25. package/man/mend-invite.1 +1 -1
  26. package/man/mend-keys-init.1 +1 -1
  27. package/man/mend-keys-mode.1 +1 -1
  28. package/man/mend-keys-share.1 +1 -1
  29. package/man/mend-keys-show.1 +1 -1
  30. package/man/mend-land.1 +1 -1
  31. package/man/mend-login.1 +1 -1
  32. package/man/mend-logout.1 +1 -1
  33. package/man/mend-man.1 +1 -1
  34. package/man/mend-members.1 +1 -1
  35. package/man/mend-memory-import.1 +1 -1
  36. package/man/mend-memory-rm.1 +1 -1
  37. package/man/mend-memory-show.1 +1 -1
  38. package/man/mend-memory.1 +1 -1
  39. package/man/mend-models.1 +1 -1
  40. package/man/mend-operator-exposure.1 +1 -1
  41. package/man/mend-operator-gate.1 +1 -1
  42. package/man/mend-operator-grant-owner.1 +1 -1
  43. package/man/mend-operator-org-create.1 +1 -1
  44. package/man/mend-operator-org-invite-owner.1 +1 -1
  45. package/man/mend-operator-org-list.1 +1 -1
  46. package/man/mend-operator-org-rename.1 +1 -1
  47. package/man/mend-operator-reset-link.1 +1 -1
  48. package/man/mend-pair.1 +1 -1
  49. package/man/mend-projects.1 +1 -1
  50. package/man/mend-pull.1 +1 -1
  51. package/man/mend-refresh.1 +1 -1
  52. package/man/mend-rejoin.1 +1 -1
  53. package/man/mend-resume.1 +1 -1
  54. package/man/mend-run.1 +1 -1
  55. package/man/mend-secrets-add.1 +1 -1
  56. package/man/mend-secrets-rm.1 +1 -1
  57. package/man/mend-secrets.1 +1 -1
  58. package/man/mend-server-logs.1 +1 -1
  59. package/man/mend-server-restart.1 +1 -1
  60. package/man/mend-server-setup.1 +1 -1
  61. package/man/mend-server-start.1 +1 -1
  62. package/man/mend-server-status.1 +1 -1
  63. package/man/mend-server-stop.1 +1 -1
  64. package/man/mend-server-upgrade.1 +9 -2
  65. package/man/mend-server.1 +1 -1
  66. package/man/mend-service-add.1 +1 -1
  67. package/man/mend-service-connect.1 +1 -1
  68. package/man/mend-service-init.1 +1 -1
  69. package/man/mend-service-list.1 +1 -1
  70. package/man/mend-service-logs.1 +1 -1
  71. package/man/mend-service-restart.1 +1 -1
  72. package/man/mend-service-run.1 +1 -1
  73. package/man/mend-service-stop.1 +1 -1
  74. package/man/mend-session-share.1 +1 -1
  75. package/man/mend-sessions.1 +1 -1
  76. package/man/mend-shell.1 +1 -1
  77. package/man/mend-skills-push.1 +1 -1
  78. package/man/mend-skills.1 +1 -1
  79. package/man/mend-snake.1 +1 -1
  80. package/man/mend-ssh-setup.1 +1 -1
  81. package/man/mend-ssh.1 +1 -1
  82. package/man/mend-stop.1 +1 -1
  83. package/man/mend-ui.1 +1 -1
  84. package/man/mend-uninstall.1 +1 -1
  85. package/man/mend-version.1 +1 -1
  86. package/man/mend-worktrees-rm.1 +1 -1
  87. package/man/mend-worktrees.1 +1 -1
  88. package/man/mend.1 +1 -1
  89. package/package.json +2 -2
@@ -27127,6 +27127,9 @@ var AuditAction = Schema_exports.Literals([
27127
27127
  "session.shared_control_off",
27128
27128
  // The owner ended a session's workspace with captures still pending: work was discarded.
27129
27129
  "session.unsaved_discarded",
27130
+ // Mend stopped a session whose owner no longer has access to where it ran (mend#558): a removed
27131
+ // member, or a joiner who can no longer see the project. The session's owner is the actor.
27132
+ "session.stopped_no_access",
27130
27133
  "member.password_reset_issued",
27131
27134
  "organization.created",
27132
27135
  "organization.renamed",
@@ -28436,7 +28439,7 @@ var ProjectClusterBindingsSnapshot = class extends Schema_exports.Class(
28436
28439
  }) {
28437
28440
  };
28438
28441
 
28439
- // ../../node_modules/.pnpm/@sealant+api-contracts-next@0.39.0-next.694_effect@4.0.0-beta.93/node_modules/@sealant/api-contracts-next/dist/workspace-environment.js
28442
+ // ../../node_modules/.pnpm/@sealant+api-contracts-next@0.39.0-next.695_effect@4.0.0-beta.93/node_modules/@sealant/api-contracts-next/dist/workspace-environment.js
28440
28443
  var RESERVED_NAMES = new Map([
28441
28444
  // Identity/process roots the daemon and shells own.
28442
28445
  ...[
@@ -17,7 +17,7 @@ import {
17
17
  repositoryCloneUrlIssue,
18
18
  servicesHoldLine,
19
19
  withoutLaunchPhase
20
- } from "./chunk-OTYH35OK.js";
20
+ } from "./chunk-OHOMU7JP.js";
21
21
 
22
22
  // src/dashboard.tsx
23
23
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
package/dist/main.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  trackBracketedPaste,
53
53
  validatePiProfile,
54
54
  validateSecretFilePath
55
- } from "./chunk-OTYH35OK.js";
55
+ } from "./chunk-OHOMU7JP.js";
56
56
 
57
57
  // src/main.ts
58
58
  import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
@@ -2814,12 +2814,16 @@ var COMMANDS = [
2814
2814
  name: "server upgrade",
2815
2815
  section: "this machine",
2816
2816
  summary: "upgrade to an explicit version with a database backup",
2817
- synopsis: ["--version <target|latest> [--assets-dir <dir>] [--offline] [--from-preview]"],
2817
+ synopsis: [
2818
+ "--version <target|latest> [--assets-dir <dir>] [--offline] [--from-preview] [--keep-backups <n>]"
2819
+ ],
2818
2820
  description: [
2819
2821
  "Preflights release assets and the canonical image's version label before interrupting Mend. Downgrades are refused. latest is resolved only when explicitly requested; a same-version request does nothing.",
2820
2822
  "The installed configuration stays: the bind, the origin, the ports, the edge host and the declared exposure and tenancy are carried into the new generation, which renders the same overlays beside the new compose.yaml. An upgrade never drops the edge or the posture; mend server setup is where they change.",
2821
2823
  "Stops app writers, starts official Postgres if needed, and streams pg_dumpall into a private database backup before selecting and starting the target. Connections are interrupted. Workspace containers and data are retained, but active work can lose connectivity and need reconnection. Stop any external database writers before upgrading.",
2822
- "Preflight or backup failure retains the old pin and attempts to recover the old app if it was running. Once target startup may have begun, Mend never automatically downgrades or restores the database. The target pin, old generation and backup remain. Inspect logs, fix the target, then use mend server start. Recovery records are under the installation's backups/upgrade-UUID directory."
2824
+ "Preflight or backup failure retains the old pin and attempts to recover the old app if it was running. Once target startup may have begun, Mend never automatically downgrades or restores the database. The target pin, old generation and backup remain. Inspect logs, fix the target, then use mend server start. Recovery records are under the installation's backups/upgrade-UUID directory.",
2825
+ "After the target answers health, its recovery record is marked completed and older completed backups are removed, keeping the newest two (this one included) in the order Mend wrote them; each removal is printed with the space it freed. A failed upgrade removes nothing. A backup this release recorded as pending, or whose dump is incomplete, is always kept. Each dump holds the whole database, so it can run to gigabytes.",
2826
+ 'Backups written by releases before 0.36 carry no outcome: the first upgrade on 0.36 or later treats each one whose dump is whole as completed and keeps only the newest N, so copy any you want to keep out of backups/ first. Their removals are marked "from before 0.36, no recorded outcome".'
2823
2827
  ],
2824
2828
  options: [
2825
2829
  {
@@ -2837,6 +2841,10 @@ var COMMANDS = [
2837
2841
  {
2838
2842
  flag: "--from-preview",
2839
2843
  text: "once, from a preview numbered X.Y.Z-preview.K to a next build X.Y.Z-next.N; refused unless the target carries every migration the server applied"
2844
+ },
2845
+ {
2846
+ flag: "--keep-backups <n>",
2847
+ text: "completed upgrade backups to keep after a healthy upgrade, this one included; 0 keeps all. Default: 2"
2840
2848
  }
2841
2849
  ],
2842
2850
  examples: [
@@ -5993,6 +6001,286 @@ var commitGeneration = (paths, files) => {
5993
6001
  activateGeneration(paths, generation);
5994
6002
  return generation;
5995
6003
  };
6004
+ var renderRecovery = (record2) => `${JSON.stringify(record2, null, 2)}
6005
+ `;
6006
+ var BACKUP_NAME = /^upgrade-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
6007
+ var REMOVING_NAME = /^upgrade-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.removing$/;
6008
+ var RECOVERY_TEMPORARY = /^\.recovery-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
6009
+ var lstatOptional = (file) => {
6010
+ try {
6011
+ return fs14.lstatSync(file);
6012
+ } catch (cause) {
6013
+ if (hasCode(cause, "ENOENT")) return null;
6014
+ throw cause;
6015
+ }
6016
+ };
6017
+ var freedBy = (stat) => stat.nlink === 1 ? stat.size : 0;
6018
+ var GENERATION_NAME = /^gen-[0-9a-f-]{36}$/;
6019
+ var VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
6020
+ var generationVersion = (configDir, generation) => {
6021
+ if (!GENERATION_NAME.test(generation)) return void 0;
6022
+ try {
6023
+ const parsed = JSON.parse(
6024
+ fs14.readFileSync(path15.join(configDir, "generations", generation, "server.json"), "utf8")
6025
+ );
6026
+ return typeof parsed === "object" && parsed !== null && "serverVersion" in parsed && typeof parsed.serverVersion === "string" && VERSION.test(parsed.serverVersion) ? parsed.serverVersion : void 0;
6027
+ } catch {
6028
+ return void 0;
6029
+ }
6030
+ };
6031
+ var readRecord = (configDir, file, stat) => {
6032
+ let parsed;
6033
+ try {
6034
+ parsed = JSON.parse(fs14.readFileSync(file, "utf8"));
6035
+ } catch {
6036
+ return null;
6037
+ }
6038
+ if (typeof parsed !== "object" || parsed === null || !("previousGeneration" in parsed) || !("targetGeneration" in parsed) || typeof parsed.previousGeneration !== "string" || typeof parsed.targetGeneration !== "string")
6039
+ return null;
6040
+ const sequence = "sequence" in parsed && typeof parsed.sequence === "number" && Number.isSafeInteger(parsed.sequence) && parsed.sequence > 0 ? parsed.sequence : void 0;
6041
+ const stamped = "createdAt" in parsed && typeof parsed.createdAt === "string" ? Date.parse(parsed.createdAt) : Number.NaN;
6042
+ const targetGeneration = path15.basename(parsed.targetGeneration);
6043
+ return {
6044
+ previousGeneration: path15.basename(parsed.previousGeneration),
6045
+ targetGeneration,
6046
+ state: "state" in parsed ? parsed.state : void 0,
6047
+ sequence,
6048
+ recordsOutcome: "state" in parsed || "sequence" in parsed,
6049
+ targetVersion: generationVersion(configDir, targetGeneration),
6050
+ clock: Number.isFinite(stamped) ? stamped : stat.mtimeMs
6051
+ };
6052
+ };
6053
+ var DUMP_TRAILER = /-- PostgreSQL database cluster dump complete\r?\n(?:--\r?\n)?\s*$/;
6054
+ var dumpIsWhole = (file, size) => {
6055
+ const length = Math.min(size, 256);
6056
+ if (length === 0) return false;
6057
+ const buffer = Buffer.alloc(length);
6058
+ const fd = fs14.openSync(file, "r");
6059
+ try {
6060
+ fs14.readSync(fd, buffer, 0, length, size - length);
6061
+ } finally {
6062
+ fs14.closeSync(fd);
6063
+ }
6064
+ const tail = buffer.toString("utf8");
6065
+ const match = DUMP_TRAILER.exec(tail);
6066
+ if (match === null) return false;
6067
+ return match.index === 0 ? length === size : tail[match.index - 1] === "\n";
6068
+ };
6069
+ var readBackup = (configDir, directory) => {
6070
+ const stat = lstatOptional(directory);
6071
+ if (stat === null || !stat.isDirectory()) return { kind: "foreign" };
6072
+ if (REMOVING_NAME.test(path15.basename(directory))) return readRemoving(directory);
6073
+ if (!BACKUP_NAME.test(path15.basename(directory))) return { kind: "foreign" };
6074
+ const entries = fs14.readdirSync(directory);
6075
+ if (entries.length === 0) return { kind: "interrupted", bytes: 0 };
6076
+ const recoveryFile = path15.join(directory, "recovery.json");
6077
+ const recovery = lstatOptional(recoveryFile);
6078
+ if (recovery === null || !recovery.isFile()) return { kind: "foreign" };
6079
+ const record2 = readRecord(configDir, recoveryFile, recovery);
6080
+ if (record2 === null) return { kind: "foreign" };
6081
+ if (entries.includes("database.sql.partial"))
6082
+ return { kind: "held", reason: "unfinished", record: record2 };
6083
+ if (entries.some(
6084
+ (name) => name !== "database.sql" && name !== "recovery.json" && !RECOVERY_TEMPORARY.test(name)
6085
+ ))
6086
+ return { kind: "foreign" };
6087
+ if (record2.recordsOutcome && (record2.sequence === void 0 || record2.state === void 0))
6088
+ return {
6089
+ kind: "held",
6090
+ reason: "unreadable",
6091
+ detail: "recovery.json has a state without a valid sequence"
6092
+ };
6093
+ const dump = lstatOptional(path15.join(directory, "database.sql"));
6094
+ if (dump !== null && !dump.isFile()) return { kind: "foreign" };
6095
+ if (dump === null)
6096
+ return record2.state === "completed" ? { kind: "interrupted", bytes: freedBy(recovery) } : { kind: "held", reason: "unfinished", record: record2 };
6097
+ if (!dumpIsWhole(path15.join(directory, "database.sql"), dump.size))
6098
+ return { kind: "held", reason: "unfinished", record: record2 };
6099
+ if (record2.recordsOutcome && record2.state !== "completed")
6100
+ return { kind: "held", reason: "pending", record: record2 };
6101
+ return {
6102
+ kind: "completed",
6103
+ record: record2,
6104
+ bytes: freedBy(dump) + freedBy(recovery),
6105
+ legacy: !record2.recordsOutcome
6106
+ };
6107
+ };
6108
+ var readRemoving = (directory) => {
6109
+ let bytes = 0;
6110
+ for (const name of fs14.readdirSync(directory)) {
6111
+ const file = lstatOptional(path15.join(directory, name));
6112
+ if (file === null || !file.isFile() || !(name === "database.sql" || name === "database.sql.partial" || name === "recovery.json" || RECOVERY_TEMPORARY.test(name)))
6113
+ return { kind: "foreign" };
6114
+ bytes += freedBy(file);
6115
+ }
6116
+ return { kind: "interrupted", bytes };
6117
+ };
6118
+ var nextSequence = (configDir, backups) => {
6119
+ let highest = 0;
6120
+ for (const name of fs14.readdirSync(backups)) {
6121
+ if (!BACKUP_NAME.test(name)) continue;
6122
+ try {
6123
+ const directory = path15.join(backups, name);
6124
+ const stat = lstatOptional(directory);
6125
+ if (stat === null || !stat.isDirectory()) continue;
6126
+ const file = path15.join(directory, "recovery.json");
6127
+ const recovery = lstatOptional(file);
6128
+ if (recovery === null || !recovery.isFile()) continue;
6129
+ highest = Math.max(highest, readRecord(configDir, file, recovery)?.sequence ?? 0);
6130
+ } catch {
6131
+ continue;
6132
+ }
6133
+ }
6134
+ return highest + 1;
6135
+ };
6136
+ var versionOrder = (compareVersions, a, b) => {
6137
+ if (a === void 0 || b === void 0) return 0;
6138
+ try {
6139
+ return compareVersions(a, b);
6140
+ } catch {
6141
+ return 0;
6142
+ }
6143
+ };
6144
+ var chainDepths = (records) => {
6145
+ const depths = /* @__PURE__ */ new Map();
6146
+ const depth = (record2, visiting) => {
6147
+ const known = depths.get(record2);
6148
+ if (known !== void 0) return known;
6149
+ const next = new Set(visiting).add(record2);
6150
+ const before = records.filter(
6151
+ (other) => !next.has(other) && other.targetGeneration === record2.previousGeneration && other.targetGeneration !== other.previousGeneration
6152
+ );
6153
+ const value = Math.max(-1, ...before.map((other) => depth(other, next))) + 1;
6154
+ depths.set(record2, value);
6155
+ return value;
6156
+ };
6157
+ return (record2) => depth(record2, /* @__PURE__ */ new Set());
6158
+ };
6159
+ var newestFirst = (records, compareVersions) => {
6160
+ const depth = chainDepths(records.filter((record2) => record2.sequence === void 0));
6161
+ return (a, b) => {
6162
+ if (a.sequence !== void 0 || b.sequence !== void 0) {
6163
+ if (a.sequence === void 0) return 1;
6164
+ if (b.sequence === void 0) return -1;
6165
+ if (a.sequence !== b.sequence) return b.sequence - a.sequence;
6166
+ } else {
6167
+ const byVersion = versionOrder(compareVersions, b.targetVersion, a.targetVersion);
6168
+ if (byVersion !== 0) return byVersion;
6169
+ const byChain = depth(b) - depth(a);
6170
+ if (byChain !== 0) return byChain;
6171
+ }
6172
+ return b.clock - a.clock;
6173
+ };
6174
+ };
6175
+ var writtenAfterOutcomes = (record2, records, current, compareVersions) => {
6176
+ const sequenced = records.filter((other) => other.sequence !== void 0);
6177
+ const unsequenced = records.filter((other) => other.sequence === void 0);
6178
+ const seen = /* @__PURE__ */ new Set();
6179
+ const chainsFromSequenced = (candidate) => {
6180
+ if (seen.has(candidate)) return false;
6181
+ seen.add(candidate);
6182
+ if (sequenced.some((other) => other.targetGeneration === candidate.previousGeneration))
6183
+ return true;
6184
+ return unsequenced.some(
6185
+ (other) => other.targetGeneration === candidate.previousGeneration && chainsFromSequenced(other)
6186
+ );
6187
+ };
6188
+ return chainsFromSequenced(record2) || sequenced.some(
6189
+ (other) => other !== current && versionOrder(compareVersions, record2.targetVersion, other.targetVersion) > 0
6190
+ );
6191
+ };
6192
+ var removeBackup = (directory) => {
6193
+ const removing = REMOVING_NAME.test(path15.basename(directory)) ? directory : `${directory}.removing`;
6194
+ if (removing !== directory) fs14.renameSync(directory, removing);
6195
+ for (const name of fs14.readdirSync(removing))
6196
+ if (name === "database.sql" || name === "database.sql.partial" || name === "recovery.json" || RECOVERY_TEMPORARY.test(name))
6197
+ fs14.unlinkSync(path15.join(removing, name));
6198
+ fs14.rmdirSync(removing);
6199
+ };
6200
+ var entryOf = ({ directory, bytes, legacy }) => ({
6201
+ directory,
6202
+ bytes,
6203
+ legacy,
6204
+ interrupted: false
6205
+ });
6206
+ var failureOf = (cause) => cause instanceof Error ? cause.message : "unknown filesystem error";
6207
+ var pruneBackups = (configDir, keep, current, compareVersions) => {
6208
+ if (!Number.isSafeInteger(keep) || keep < 1)
6209
+ throw new ServerStoreError("Pruning keeps at least one upgrade backup.");
6210
+ const backups = path15.join(configDir, "backups");
6211
+ if (path15.dirname(current.directory) !== backups)
6212
+ throw new ServerStoreError("The current upgrade backup is outside this installation.");
6213
+ const currentRead = readBackup(configDir, current.directory);
6214
+ if (currentRead.kind !== "completed" || currentRead.record.sequence === void 0)
6215
+ throw new ServerStoreError("The current upgrade backup is not completed; nothing was pruned.");
6216
+ const completed = [];
6217
+ const interrupted = [];
6218
+ const held = [];
6219
+ const records = [currentRead.record];
6220
+ for (const name of fs14.readdirSync(backups).toSorted()) {
6221
+ const directory = path15.join(backups, name);
6222
+ if (directory === current.directory) continue;
6223
+ let read;
6224
+ try {
6225
+ read = readBackup(configDir, directory);
6226
+ } catch (cause) {
6227
+ if (BACKUP_NAME.test(name) || REMOVING_NAME.test(name))
6228
+ held.push({ directory, reason: "unreadable", detail: failureOf(cause) });
6229
+ continue;
6230
+ }
6231
+ if (read.kind === "held") {
6232
+ held.push({
6233
+ directory,
6234
+ reason: read.reason,
6235
+ ...read.detail === void 0 ? {} : { detail: read.detail }
6236
+ });
6237
+ if (read.record !== void 0) records.push(read.record);
6238
+ }
6239
+ if (read.kind === "interrupted")
6240
+ interrupted.push({ directory, bytes: read.bytes, legacy: false, interrupted: true });
6241
+ if (read.kind === "completed") {
6242
+ completed.push({
6243
+ directory,
6244
+ bytes: read.bytes,
6245
+ legacy: read.legacy,
6246
+ interrupted: false,
6247
+ record: read.record
6248
+ });
6249
+ records.push(read.record);
6250
+ }
6251
+ }
6252
+ const candidates = completed.filter((entry) => {
6253
+ if (!entry.legacy || !writtenAfterOutcomes(entry.record, records, currentRead.record, compareVersions))
6254
+ return true;
6255
+ held.push({ directory: entry.directory, reason: "no-outcome" });
6256
+ return false;
6257
+ });
6258
+ const compare = newestFirst(records, compareVersions);
6259
+ candidates.sort((a, b) => compare(a.record, b.record));
6260
+ const kept = [
6261
+ { directory: current.directory, bytes: currentRead.bytes, legacy: false, interrupted: false },
6262
+ ...candidates.slice(0, keep - 1).map(entryOf)
6263
+ ];
6264
+ const removed = [];
6265
+ const failed = [];
6266
+ for (const candidate of [...interrupted, ...candidates.slice(keep - 1).map(entryOf)]) {
6267
+ try {
6268
+ removeBackup(candidate.directory);
6269
+ removed.push(candidate);
6270
+ } catch (cause) {
6271
+ failed.push({ directory: candidate.directory, message: failureOf(cause) });
6272
+ }
6273
+ }
6274
+ let unsynced;
6275
+ if (removed.length > 0 || failed.length > 0) {
6276
+ try {
6277
+ syncDirectory(backups);
6278
+ } catch (cause) {
6279
+ unsynced = failureOf(cause);
6280
+ }
6281
+ }
6282
+ return { removed, kept, held, failed, ...unsynced === void 0 ? {} : { unsynced } };
6283
+ };
5996
6284
  var createStore = (configDir, lock) => {
5997
6285
  const paths = {
5998
6286
  configDir,
@@ -6014,22 +6302,19 @@ var createStore = (configDir, lock) => {
6014
6302
  const backups = path15.join(configDir, "backups");
6015
6303
  fs14.mkdirSync(backups, { recursive: true, mode: 448 });
6016
6304
  fs14.chmodSync(backups, 448);
6305
+ const sequence = nextSequence(configDir, backups);
6017
6306
  const directory = path15.join(backups, `upgrade-${randomUUID2()}`);
6018
6307
  fs14.mkdirSync(directory, { mode: 448 });
6019
- writeDurable(
6020
- path15.join(directory, "recovery.json"),
6021
- `${JSON.stringify(
6022
- {
6023
- previousGeneration: previous.directory,
6024
- targetGeneration: target.directory,
6025
- database: "database.sql",
6026
- policy: "If target is active, migrations may have begun. Never downgrade or restore automatically."
6027
- },
6028
- null,
6029
- 2
6030
- )}
6031
- `
6032
- );
6308
+ const record2 = {
6309
+ previousGeneration: previous.directory,
6310
+ targetGeneration: target.directory,
6311
+ database: "database.sql",
6312
+ sequence,
6313
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6314
+ policy: "If target is active, migrations may have begun. Never downgrade or restore automatically."
6315
+ };
6316
+ const recoveryFile = path15.join(directory, "recovery.json");
6317
+ writeDurable(recoveryFile, renderRecovery({ ...record2, state: "pending" }));
6033
6318
  syncDirectory(directory);
6034
6319
  syncDirectory(backups);
6035
6320
  syncDirectory(configDir);
@@ -6049,9 +6334,16 @@ var createStore = (configDir, lock) => {
6049
6334
  }
6050
6335
  fs14.renameSync(partialFile, path15.join(directory, "database.sql"));
6051
6336
  syncDirectory(directory);
6337
+ }),
6338
+ markCompleted: () => whileOwned(() => {
6339
+ const temporary = path15.join(directory, `.recovery-${randomUUID2()}`);
6340
+ writeDurable(temporary, renderRecovery({ ...record2, state: "completed" }));
6341
+ fs14.renameSync(temporary, recoveryFile);
6342
+ syncDirectory(directory);
6052
6343
  })
6053
6344
  };
6054
- })
6345
+ }),
6346
+ pruneBackups: (keep, current, compareVersions) => whileOwned(() => pruneBackups(configDir, keep, current, compareVersions))
6055
6347
  };
6056
6348
  };
6057
6349
  var refuseFlatLayout = (configDir) => {
@@ -7406,9 +7698,26 @@ var startInstallation = async (runtime, installation, secrets) => {
7406
7698
  await probeHealth(runtime, healthOrigin(installation.config), installation.config.serverVersion);
7407
7699
  runtime.writeLine(reachableLine(installation.config));
7408
7700
  };
7701
+ var DEFAULT_KEEP_BACKUPS = 2;
7409
7702
  var parseUpgradeOptions = (args) => {
7410
7703
  const fromPreview = args.includes("--from-preview");
7411
- const rest = args.filter((arg) => arg !== "--from-preview");
7704
+ const rest = [];
7705
+ let keepBackups = DEFAULT_KEEP_BACKUPS;
7706
+ for (let index = 0; index < args.length; index += 1) {
7707
+ const flag = args[index];
7708
+ if (flag === void 0 || flag === "--from-preview") continue;
7709
+ if (flag === "--keep-backups") {
7710
+ const value = args[index + 1];
7711
+ if (value === void 0 || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
7712
+ throw setupError(
7713
+ "--keep-backups takes a whole number: how many upgrade backups to keep, 0 for all."
7714
+ );
7715
+ keepBackups = Number(value);
7716
+ index += 1;
7717
+ continue;
7718
+ }
7719
+ rest.push(flag);
7720
+ }
7412
7721
  for (let index = 0; index < rest.length; index += 1) {
7413
7722
  const flag = rest[index];
7414
7723
  if (flag === "--offline") continue;
@@ -7421,7 +7730,63 @@ var parseUpgradeOptions = (args) => {
7421
7730
  throw setupError(
7422
7731
  "Upgrade requires --version TARGET. Use --version latest only to request the latest release explicitly."
7423
7732
  );
7424
- return { ...options, fromPreview };
7733
+ return { ...options, fromPreview, keepBackups };
7734
+ };
7735
+ var formatBytes3 = (bytes) => {
7736
+ if (bytes < 1024) return `${bytes} B`;
7737
+ const units = ["KiB", "MiB", "GiB", "TiB"];
7738
+ let value = bytes / 1024;
7739
+ let unit = 0;
7740
+ while (value >= 1024 && unit < units.length - 1) {
7741
+ value /= 1024;
7742
+ unit += 1;
7743
+ }
7744
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
7745
+ };
7746
+ var heldReason = (entry) => {
7747
+ switch (entry.reason) {
7748
+ case "pending":
7749
+ return "pending: its upgrade never recorded a healthy target";
7750
+ case "unfinished":
7751
+ return "unfinished: no complete database dump";
7752
+ case "no-outcome":
7753
+ return "no recorded outcome: written after a 0.36 backup by a CLI that records none";
7754
+ case "unreadable":
7755
+ return `unreadable: ${entry.detail ?? "not read"}`;
7756
+ }
7757
+ };
7758
+ var pruneUpgradeBackups = (runtime, store, backup, keep) => {
7759
+ const marked = backup.markCompleted();
7760
+ if (marked._tag === "error") {
7761
+ runtime.writeLine(
7762
+ `Could not record the upgrade as completed in ${backup.directory}/recovery.json; no backups were removed. ${marked.error.message}`
7763
+ );
7764
+ return;
7765
+ }
7766
+ if (keep === 0) {
7767
+ runtime.writeLine("Upgrade backups \xB7 all kept (--keep-backups 0)");
7768
+ return;
7769
+ }
7770
+ const pruned = store.pruneBackups(keep, backup, upgradeOrder);
7771
+ if (pruned._tag === "error") {
7772
+ runtime.writeLine(`Upgrade backups \xB7 not pruned: ${pruned.error.message}`);
7773
+ return;
7774
+ }
7775
+ const { removed, kept, held, failed, unsynced } = pruned.value;
7776
+ for (const entry of removed)
7777
+ runtime.writeLine(
7778
+ `Removed upgrade backup ${entry.directory} \xB7 ${formatBytes3(entry.bytes)}${entry.interrupted ? " \xB7 finishing a removal a crash cut short" : entry.legacy ? " \xB7 from before 0.36, no recorded outcome" : ""}`
7779
+ );
7780
+ const freed = removed.reduce((total, entry) => total + entry.bytes, 0);
7781
+ runtime.writeLine(
7782
+ `Upgrade backups \xB7 removed ${removed.length}${removed.length > 0 ? ` \xB7 ${formatBytes3(freed)} freed` : ""} \xB7 kept ${kept.length} (--keep-backups ${keep})`
7783
+ );
7784
+ for (const entry of failed)
7785
+ runtime.writeLine(`Could not remove upgrade backup ${entry.directory}: ${entry.message}`);
7786
+ if (unsynced !== void 0)
7787
+ runtime.writeLine(`Upgrade backups \xB7 removals made but not fsynced: ${unsynced}`);
7788
+ for (const entry of held)
7789
+ runtime.writeLine(`Kept upgrade backup ${entry.directory} \xB7 ${heldReason(entry)}`);
7425
7790
  };
7426
7791
  var LEGACY_PREVIEW = /^(\d+\.\d+\.\d+)-preview\.(0|[1-9]\d*)$/;
7427
7792
  var NEXT_BUILD = /^(\d+\.\d+\.\d+)-next\.(0|[1-9]\d*)(\.preview\.[1-9]\d*)?$/;
@@ -7430,6 +7795,11 @@ var isPreviewToNext = (from, to) => {
7430
7795
  const next = NEXT_BUILD.exec(to);
7431
7796
  return preview !== null && next !== null && preview[1] === next[1];
7432
7797
  };
7798
+ var upgradeOrder = (a, b) => {
7799
+ if (isPreviewToNext(a, b)) return -1;
7800
+ if (isPreviewToNext(b, a)) return 1;
7801
+ return compareServerVersions(a, b);
7802
+ };
7433
7803
  var drizzleMillis = (folder) => {
7434
7804
  const at = (start, end) => Number(folder.slice(start, end));
7435
7805
  return String(Date.UTC(at(0, 4), at(4, 6) - 1, at(6, 8), at(8, 10), at(10, 12), at(12, 14)));
@@ -7669,6 +8039,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
7669
8039
  );
7670
8040
  }
7671
8041
  runtime.writeLine(`Upgraded to ${version}. Retained database backup: ${backup.directory}`);
8042
+ pruneUpgradeBackups(runtime, store, backup, options.keepBackups);
7672
8043
  };
7673
8044
  var observeEdgeCertificate = async (runtime, installation, host) => {
7674
8045
  const listed = await runtime.run(
@@ -12537,7 +12908,7 @@ var dashboard = async (config, options = {}) => {
12537
12908
  );
12538
12909
  process.exit(rerun.status ?? 0);
12539
12910
  }
12540
- const { runDashboard } = await import("./dashboard-P73HKQOT.js");
12911
+ const { runDashboard } = await import("./dashboard-34ZANJ24.js");
12541
12912
  const agentShare = await startShareIfBridge(config);
12542
12913
  const tunnels = options.noTunnel === true || serverIsLocal(config) ? null : createServiceTunnels({
12543
12914
  listServices: () => fetchServices(config),
@@ -1,4 +1,4 @@
1
- .TH MEND-ACCOUNTS 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ACCOUNTS 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-accounts \- your connected accounts on the platform
4
4
  .SH SYNOPSIS
package/man/mend-adopt.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-ADOPT 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ADOPT 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-adopt \- adopt a repository into the store
4
4
  .SH SYNOPSIS
package/man/mend-attach.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-ATTACH 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ATTACH 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-attach \- reattach this terminal to a running session
4
4
  .SH SYNOPSIS
package/man/mend-codex.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-CODEX 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-CODEX 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-codex \- launch codex, claude, opencode or pi in a recorded worktree
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-COMPLETIONS 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-COMPLETIONS 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-completions \- print the TAB\-completion hook
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-CONNECT-PI 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-CONNECT-PI 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-connect\-pi \- send your pi setup to every pi session of yours
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-CONNECT 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-CONNECT 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-connect \- send this machine's provider credential to the platform
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-CONTINUE 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-CONTINUE 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-continue \- resume a session with its pending review follow\-up
4
4
  .SH SYNOPSIS
package/man/mend-doctor.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH MEND-DOCTOR 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-DOCTOR 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-doctor \- check this machine's setup, or bundle it for a bug report
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-DOTFILES-REPO 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-DOTFILES-REPO 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-dotfiles\-repo \- set or clear the repository the server clones at launch
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-DOTFILES-SYNC 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-DOTFILES-SYNC 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-dotfiles\-sync \- capture config files from this machine into your store
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-DOTFILES 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-DOTFILES 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-dotfiles \- your dotfiles on the server: repo and synced files
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-CLUSTER 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ENV-CLUSTER 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-cluster \- bind Kubernetes secrets and configmaps to the workspaces
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-LOAD 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ENV-LOAD 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-load \- load a .env into the project
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-ENV-SHOW 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-ENV-SHOW 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-env\-show \- what the project store holds: names only, never values
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-FOLDER-CREATE 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-FOLDER-CREATE 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-folder\-create \- create an organization folder
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-FOLDER-LIST 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-FOLDER-LIST 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-folder\-list \- list your organization's folders
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-FOLDER-PUSH 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-FOLDER-PUSH 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-folder\-push \- upload a local directory into a folder
4
4
  .SH SYNOPSIS
@@ -1,4 +1,4 @@
1
- .TH MEND-FOLDER-RM 1 "" "mend 0.36.0\-next.628" "mend manual"
1
+ .TH MEND-FOLDER-RM 1 "" "mend 0.36.0\-next.632" "mend manual"
2
2
  .SH NAME
3
3
  mend\-folder\-rm \- remove an organization folder
4
4
  .SH SYNOPSIS