@sealant/mend 0.25.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +5 -5
  2. package/dist/{chunk-4MX7ZC34.js → chunk-TBMOGB3T.js} +9 -1
  3. package/dist/{dashboard-JIWYW3EM.js → dashboard-5CBGTPSJ.js} +1 -1
  4. package/dist/main.js +613 -74
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +1 -1
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.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-sync.1 +1 -1
  14. package/man/mend-dotfiles.1 +1 -1
  15. package/man/mend-env-cluster.1 +1 -1
  16. package/man/mend-env-load.1 +1 -1
  17. package/man/mend-env-show.1 +1 -1
  18. package/man/mend-help.1 +1 -1
  19. package/man/mend-keys-init.1 +1 -1
  20. package/man/mend-keys-mode.1 +1 -1
  21. package/man/mend-keys-share.1 +1 -1
  22. package/man/mend-keys-show.1 +1 -1
  23. package/man/mend-login.1 +1 -1
  24. package/man/mend-logout.1 +1 -1
  25. package/man/mend-man.1 +1 -1
  26. package/man/mend-pair.1 +1 -1
  27. package/man/mend-projects.1 +1 -1
  28. package/man/mend-refresh.1 +1 -1
  29. package/man/mend-rejoin.1 +1 -1
  30. package/man/mend-resume.1 +1 -1
  31. package/man/mend-run.1 +1 -1
  32. package/man/mend-server-logs.1 +1 -1
  33. package/man/mend-server-restart.1 +2 -2
  34. package/man/mend-server-setup.1 +1 -1
  35. package/man/mend-server-start.1 +1 -1
  36. package/man/mend-server-status.1 +1 -1
  37. package/man/mend-server-stop.1 +2 -2
  38. package/man/mend-server-upgrade.1 +1 -1
  39. package/man/mend-server.1 +1 -1
  40. package/man/mend-service-add.1 +1 -1
  41. package/man/mend-service-connect.1 +1 -1
  42. package/man/mend-service-init.1 +1 -1
  43. package/man/mend-service-list.1 +1 -1
  44. package/man/mend-service-logs.1 +1 -1
  45. package/man/mend-service-restart.1 +1 -1
  46. package/man/mend-service-run.1 +1 -1
  47. package/man/mend-service-stop.1 +1 -1
  48. package/man/mend-sessions.1 +1 -1
  49. package/man/mend-shell.1 +1 -1
  50. package/man/mend-skills-push.1 +1 -1
  51. package/man/mend-skills.1 +1 -1
  52. package/man/mend-ssh-setup.1 +1 -1
  53. package/man/mend-ssh.1 +1 -1
  54. package/man/mend-stop.1 +1 -1
  55. package/man/mend-ui.1 +1 -1
  56. package/man/mend-uninstall.1 +35 -0
  57. package/man/mend-version.1 +1 -1
  58. package/man/mend-worktrees.1 +1 -1
  59. package/man/mend.1 +6 -3
  60. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -17,15 +17,15 @@ import {
17
17
  repositoryCloneUrlIssue,
18
18
  sessionDisplayName,
19
19
  trackBracketedPaste
20
- } from "./chunk-4MX7ZC34.js";
20
+ } from "./chunk-TBMOGB3T.js";
21
21
 
22
22
  // src/main.ts
23
23
  import { spawn as spawn3, spawnSync as spawnSync2 } from "node:child_process";
24
- import * as fs8 from "node:fs";
24
+ import * as fs9 from "node:fs";
25
25
  import { createRequire } from "node:module";
26
26
  import * as net3 from "node:net";
27
27
  import * as os6 from "node:os";
28
- import * as path9 from "node:path";
28
+ import * as path10 from "node:path";
29
29
 
30
30
  // src/agent-share.ts
31
31
  import * as net from "node:net";
@@ -1112,7 +1112,7 @@ var COMMANDS = [
1112
1112
  {
1113
1113
  name: "server stop",
1114
1114
  section: "this machine",
1115
- summary: "stop Mend and Postgres without deleting data",
1115
+ summary: "stop Mend, Postgres and Garage without deleting data",
1116
1116
  synopsis: [""],
1117
1117
  description: [
1118
1118
  "Stops only the installation's Compose services. Connections are interrupted. Workspace containers and volumes remain, but active work may lose connectivity and need reconnection. No volume deletion or Docker prune is performed."
@@ -1125,7 +1125,7 @@ var COMMANDS = [
1125
1125
  summary: "restart Mend using the same generation and pin",
1126
1126
  synopsis: ["[--offline]"],
1127
1127
  description: [
1128
- "Checks preloaded images before stopping Mend, then starts the saved generation and verifies exact-version health. Postgres stays running. Connections are interrupted; workspace containers and data remain, but active work may need reconnection."
1128
+ "Checks preloaded images before stopping Mend, then starts the saved generation and verifies exact-version health. Postgres and Garage stay running. Connections are interrupted; workspace containers and data remain, but active work may need reconnection."
1129
1129
  ],
1130
1130
  options: [{ flag: "--offline", text: "use local release images and assets" }],
1131
1131
  see: ["server status", "server logs"]
@@ -1213,6 +1213,30 @@ var COMMANDS = [
1213
1213
  description: ["Other terminals and devices keep their own tokens."],
1214
1214
  see: ["login"]
1215
1215
  },
1216
+ {
1217
+ name: "uninstall",
1218
+ section: "this machine",
1219
+ summary: "remove the server, this machine's Mend files, or both",
1220
+ synopsis: ["[--all | --server | --home] [--yes]"],
1221
+ description: [
1222
+ "Asks which scope to remove when none is given, prints exactly what will go, and requires the word delete before the server is touched. The server scope removes the local Compose installation: its containers, every volume it owns (repositories, worktrees, the database), its release image, and the private configuration with its generations and backups. The home scope revokes this terminal's device token, then removes cli.json, the workspace SSH key, and the managed block in ~/.ssh/config.",
1223
+ "Nothing else under the configuration directory is touched; a host-run store or keys root is listed and left in place. Workspace containers carry no label Mend can filter on, so they are named with the command that removes them, never removed."
1224
+ ],
1225
+ options: [
1226
+ { flag: "--all", text: "the server and this machine's Mend files" },
1227
+ { flag: "--server", text: "only the local server installation" },
1228
+ { flag: "--home", text: "only this machine's sign-in, ssh key and config block" },
1229
+ { flag: "--yes", text: "skip the confirmation; required without a terminal" }
1230
+ ],
1231
+ examples: [
1232
+ { command: "mend uninstall", text: "choose a scope, then confirm" },
1233
+ {
1234
+ command: "mend uninstall --home --yes",
1235
+ text: "forget this machine's sign-in without asking"
1236
+ }
1237
+ ],
1238
+ see: ["server stop", "logout"]
1239
+ },
1216
1240
  {
1217
1241
  name: "completions",
1218
1242
  section: "this machine",
@@ -1735,7 +1759,7 @@ var loginCommand = async (args, deps) => {
1735
1759
  };
1736
1760
 
1737
1761
  // src/server-setup.ts
1738
- import { randomBytes } from "node:crypto";
1762
+ import { createHmac, randomBytes } from "node:crypto";
1739
1763
  import { readFile } from "node:fs/promises";
1740
1764
  import * as net2 from "node:net";
1741
1765
  import * as os3 from "node:os";
@@ -1748,6 +1772,11 @@ var MEND_DOCKER_NAMESPACE = {
1748
1772
  store: "mend-store",
1749
1773
  control: "mend-control"
1750
1774
  };
1775
+ var MEND_DOCKER_NAMESPACE_WITH_GARAGE = {
1776
+ ...MEND_DOCKER_NAMESPACE,
1777
+ garage: "mend-garage"
1778
+ };
1779
+ var secondaryVolumesOf = (namespace) => namespace.garage === void 0 ? [namespace.control] : [namespace.control, namespace.garage];
1751
1780
  var SERVER_VOLUME_OWNER_LABEL = "dev.sealant.mend.installation";
1752
1781
  var ServerVolumeOwnershipError = class extends Error {
1753
1782
  /** Stable error discriminator. */
@@ -1839,9 +1868,10 @@ var inProjectNamespace = (name, project2) => ["-", "_"].some(
1839
1868
  (separator) => name.startsWith(`${project2}${separator}`) && name.length > project2.length + 1
1840
1869
  );
1841
1870
  var reservedResourceName = (name, kind, namespace) => {
1842
- if ([namespace.project, namespace.store, namespace.control].includes(name)) return true;
1871
+ if ([namespace.project, namespace.store, namespace.control, namespace.garage ?? ""].includes(name))
1872
+ return true;
1843
1873
  return ["-", "_"].some(
1844
- (separator) => (kind === "container" ? ["mend", "postgres"] : ["default"]).some((service) => {
1874
+ (separator) => (kind === "container" ? ["mend", "postgres", "garage"] : ["default"]).some((service) => {
1845
1875
  const reserved = `${namespace.project}${separator}${service}`;
1846
1876
  return name === reserved || kind === "container" && name.startsWith(reserved) && /^[-_](?:[0-9]+|run[-_][a-f0-9]+)$/.test(name.slice(reserved.length));
1847
1877
  })
@@ -1877,7 +1907,9 @@ var inspectForeignProject = async (runtime, context, kind, name, project2) => {
1877
1907
  }
1878
1908
  };
1879
1909
  var refuseOldData = async (runtime, context, namespace, volumes) => {
1880
- if (volumes.some((name) => name === namespace.control || name.startsWith(`${namespace.project}_`)))
1910
+ if (volumes.some(
1911
+ (name) => secondaryVolumesOf(namespace).includes(name) || name.startsWith(`${namespace.project}_`)
1912
+ ))
1881
1913
  return fail3("unowned-data", "existing volumes without anchor");
1882
1914
  for (const kind of ["volume", "container", "network"]) {
1883
1915
  const labelled = await listNames(runtime, context, kind, namespace.project);
@@ -1906,9 +1938,9 @@ var refuseOldData = async (runtime, context, namespace, volumes) => {
1906
1938
  };
1907
1939
  var parseOwnershipInput = (input) => {
1908
1940
  const namespace = input.namespace ?? MEND_DOCKER_NAMESPACE;
1909
- if (!input.dockerContext.trim() || input.identityBytes.byteLength === 0 || !/^[a-z0-9][a-z0-9_-]*$/.test(namespace.project) || ![namespace.store, namespace.control].every(
1941
+ if (!input.dockerContext.trim() || input.identityBytes.byteLength === 0 || !/^[a-z0-9][a-z0-9_-]*$/.test(namespace.project) || ![namespace.store, ...secondaryVolumesOf(namespace)].every(
1910
1942
  (name) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/.test(name)
1911
- ) || namespace.store === namespace.control)
1943
+ ) || (/* @__PURE__ */ new Set([namespace.store, ...secondaryVolumesOf(namespace)])).size !== 1 + secondaryVolumesOf(namespace).length)
1912
1944
  return fail3("invalid-input", "ownership inputs");
1913
1945
  const owner = createHash("sha256").update(input.identityBytes).digest("hex");
1914
1946
  return { _tag: "ok", value: { owner, namespace } };
@@ -1931,13 +1963,16 @@ var ownership = async (runtime, input, mode) => {
1931
1963
  }
1932
1964
  const current = await listNames(runtime, input.dockerContext, "volume");
1933
1965
  if (current._tag === "error") return current;
1934
- if (current.value.includes(namespace.control)) {
1935
- const control = await inspectOwner(runtime, input.dockerContext, namespace.control, owner);
1936
- if (control._tag === "error") return control;
1937
- } else {
1938
- if (mode === "verify") return fail3("missing", "control");
1939
- const control = await ensureVolume(runtime, input.dockerContext, namespace.control, owner);
1940
- if (control._tag === "error") return control;
1966
+ for (const name of secondaryVolumesOf(namespace)) {
1967
+ const role = name === namespace.control ? "control" : "garage";
1968
+ if (current.value.includes(name)) {
1969
+ const owned = await inspectOwner(runtime, input.dockerContext, name, owner);
1970
+ if (owned._tag === "error") return owned;
1971
+ } else {
1972
+ if (mode === "verify") return fail3("missing", role);
1973
+ const created = await ensureVolume(runtime, input.dockerContext, name, owner);
1974
+ if (created._tag === "error") return created;
1975
+ }
1941
1976
  }
1942
1977
  return { _tag: "ok", value: { owner, namespace } };
1943
1978
  };
@@ -2447,6 +2482,8 @@ var COMPOSE_ASSET = "compose.v2.yaml";
2447
2482
  var POSTGRES_INIT_ASSET = "postgres-init.sh";
2448
2483
  var RELEASE_BASE = "https://github.com/sealant-sh/Mend/releases/download";
2449
2484
  var LATEST_RELEASE_URL = "https://api.github.com/repos/sealant-sh/Mend/releases/latest";
2485
+ var GARAGE_IMAGE = "dxflrs/garage:v2.4.1";
2486
+ var GARAGE_BUCKET = "mend";
2450
2487
  var ServerSetupError = class extends Error {
2451
2488
  _tag = "ServerSetupError";
2452
2489
  };
@@ -2462,6 +2499,12 @@ var requiredString = (fields, key) => {
2462
2499
  }
2463
2500
  return value;
2464
2501
  };
2502
+ var requiredBucket = (fields) => {
2503
+ const value = fields.get("bucket");
2504
+ if (value !== "garage")
2505
+ throw setupError("Server config is corrupt: bucket must be garage when present.");
2506
+ return value;
2507
+ };
2465
2508
  var requiredInteger = (fields, key) => {
2466
2509
  const value = fields.get(key);
2467
2510
  if (typeof value !== "number" || !Number.isInteger(value)) {
@@ -2644,7 +2687,8 @@ var parseServerConfig = (raw) => {
2644
2687
  ),
2645
2688
  appPort: requiredInteger(fields, "appPort"),
2646
2689
  sshPort: requiredInteger(fields, "sshPort"),
2647
- ...fields.has("registryPort") ? { registryPort: requiredInteger(fields, "registryPort") } : {}
2690
+ ...fields.has("registryPort") ? { registryPort: requiredInteger(fields, "registryPort") } : {},
2691
+ ...fields.has("bucket") ? { bucket: requiredBucket(fields) } : {}
2648
2692
  };
2649
2693
  if (config.schemaVersion !== CONFIG_SCHEMA_VERSION || config.assetContract !== ASSET_CONTRACT && !LEGACY_ASSET_CONTRACTS.has(config.assetContract)) {
2650
2694
  throw setupError(
@@ -2869,10 +2913,15 @@ var resolveLatestVersion = async (runtime) => {
2869
2913
  if (version === "latest") throw setupError("GitHub returned an invalid latest release tag.");
2870
2914
  return version;
2871
2915
  };
2872
- var validateComposeAsset = (body) => {
2916
+ var composeServiceNames = (body) => {
2873
2917
  const [, afterServices = ""] = body.split(/^services:\s*$/m);
2874
2918
  const [servicesBlock = ""] = afterServices.split(/^\S/m);
2875
- const serviceNames = [...servicesBlock.matchAll(/^ {2}([0-9A-Za-z_-]+):\s*$/gm)].map((match) => match[1]).filter((name) => name !== void 0).toSorted((left, right) => left.localeCompare(right));
2919
+ return [...servicesBlock.matchAll(/^ {2}([0-9A-Za-z_-]+):\s*$/gm)].map((match) => match[1]).filter((name) => name !== void 0).toSorted((left, right) => left.localeCompare(right));
2920
+ };
2921
+ var composeBucket = (body) => composeServiceNames(body).includes("garage") ? "garage" : void 0;
2922
+ var validateComposeAsset = (body) => {
2923
+ const serviceNames = composeServiceNames(body);
2924
+ const withGarage = serviceNames.includes("garage");
2876
2925
  const volumeSections = body.split(/^volumes:[ \t]*$/m);
2877
2926
  const volumeBlock = (volumeSections[1] ?? "").split(/^\S/m)[0] ?? "";
2878
2927
  const volumeDeclarations = [
@@ -2880,7 +2929,8 @@ var validateComposeAsset = (body) => {
2880
2929
  ];
2881
2930
  for (const [name, variable] of [
2882
2931
  ["mend-store", "MEND_STORE_VOLUME_NAME"],
2883
- ["mend-control", "MEND_CONTROL_VOLUME_NAME"]
2932
+ ["mend-control", "MEND_CONTROL_VOLUME_NAME"],
2933
+ ...withGarage ? [["mend-garage", "MEND_GARAGE_VOLUME_NAME"]] : []
2884
2934
  ]) {
2885
2935
  const declarations = volumeDeclarations.filter((match) => match[1] === name);
2886
2936
  const properties = (declarations[0]?.[2] ?? "").split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
@@ -2906,9 +2956,22 @@ var validateComposeAsset = (body) => {
2906
2956
  "mend-ssh:",
2907
2957
  "mend-postgres:",
2908
2958
  "/var/lib/mend/store",
2909
- "/run/sealant/sockets"
2959
+ "/run/sealant/sockets",
2960
+ // The capture store's bucket (a release since the capture store); an older release's asset
2961
+ // has none and is still a valid v2 bundle.
2962
+ ...withGarage ? [
2963
+ `image: ${GARAGE_IMAGE}`,
2964
+ " garage:",
2965
+ "mend-garage:",
2966
+ "MEND_GARAGE_RPC_SECRET",
2967
+ "MEND_GARAGE_ADMIN_TOKEN",
2968
+ "MEND_GARAGE_KEY_ID",
2969
+ "MEND_GARAGE_KEY_SECRET",
2970
+ "MEND_BLOB_STORE",
2971
+ "MEND_SESSION_ENDPOINT_URL"
2972
+ ] : []
2910
2973
  ];
2911
- if (requiredFragments.some((fragment) => !body.includes(fragment)) || serviceNames.join(",") !== "mend,postgres") {
2974
+ if (requiredFragments.some((fragment) => !body.includes(fragment)) || serviceNames.join(",") !== "mend,postgres" && serviceNames.join(",") !== "garage,mend,postgres") {
2912
2975
  throw setupError(`Downloaded ${COMPOSE_ASSET} does not implement ${ASSET_CONTRACT}.`);
2913
2976
  }
2914
2977
  };
@@ -2938,6 +3001,27 @@ var renderIdentity = (secrets) => [
2938
3001
  `WORKSPACE_SSH_GATEWAY_TOKEN=${secrets.workspaceSshGatewayToken}`,
2939
3002
  ""
2940
3003
  ].join("\n");
3004
+ var garageSecrets = (secrets) => {
3005
+ const derive = (label) => createHmac("sha256", Buffer.from(secrets.betterAuthSecret, "hex")).update(label).digest("hex");
3006
+ return {
3007
+ rpcSecret: derive("mend-garage-rpc-secret"),
3008
+ adminToken: derive("mend-garage-admin-token"),
3009
+ keyId: `GK${derive("mend-garage-key-id").slice(0, 24)}`,
3010
+ keySecret: derive("mend-garage-key-secret")
3011
+ };
3012
+ };
3013
+ var renderGarage = (secrets, config) => {
3014
+ if (config.bucket !== "garage") return [];
3015
+ const garage = garageSecrets(secrets);
3016
+ return [
3017
+ `MEND_GARAGE_RPC_SECRET=${garage.rpcSecret}`,
3018
+ `MEND_GARAGE_ADMIN_TOKEN=${garage.adminToken}`,
3019
+ `MEND_GARAGE_KEY_ID=${garage.keyId}`,
3020
+ `MEND_GARAGE_KEY_SECRET=${garage.keySecret}`,
3021
+ "MEND_GARAGE_VOLUME_NAME=mend-garage"
3022
+ ];
3023
+ };
3024
+ var namespaceOf = (config) => config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
2941
3025
  var renderSecrets = (secrets, config) => {
2942
3026
  const sshHost = parseUrl(config.appUrl, "Server config appUrl").hostname.replace(/^\[|\]$/g, "");
2943
3027
  const composeBind = net2.isIP(config.bind) === 6 ? `[${config.bind}]` : config.bind;
@@ -2954,6 +3038,7 @@ var renderSecrets = (secrets, config) => {
2954
3038
  `SEALANT_SSH_HOST=${sshHost}`,
2955
3039
  "MEND_STORE_VOLUME_NAME=mend-store",
2956
3040
  "MEND_CONTROL_VOLUME_NAME=mend-control",
3041
+ ...renderGarage(secrets, config),
2957
3042
  `DOCKER_SOCKET_PATH=${config.dockerSocket}`,
2958
3043
  ""
2959
3044
  ].join("\n");
@@ -3123,6 +3208,17 @@ var checkLocalImages = async (runtime, config, policy = "local") => {
3123
3208
  );
3124
3209
  if (postgres.status !== 0)
3125
3210
  throw commandFailure("Preload postgres:17-alpine before continuing", postgres);
3211
+ if (config.bucket === "garage") {
3212
+ const garage = await inspectImage(
3213
+ runtime,
3214
+ config.dockerContext,
3215
+ GARAGE_IMAGE,
3216
+ "{{.Id}}",
3217
+ policy
3218
+ );
3219
+ if (garage.status !== 0)
3220
+ throw commandFailure(`Preload ${GARAGE_IMAGE} before continuing`, garage);
3221
+ }
3126
3222
  };
3127
3223
  var checkComposeImages = async (runtime, installation) => {
3128
3224
  const output = await runtime.run(
@@ -3136,14 +3232,50 @@ var checkComposeImages = async (runtime, installation) => {
3136
3232
  const images = output.stdout.trim().split(/\s+/).toSorted();
3137
3233
  const expected = [
3138
3234
  `ghcr.io/sealant-sh/mend:${installation.config.serverVersion}`,
3139
- "postgres:17-alpine"
3235
+ "postgres:17-alpine",
3236
+ ...installation.config.bucket === "garage" ? [GARAGE_IMAGE] : []
3140
3237
  ].toSorted();
3141
3238
  if (images.join("\n") !== expected.join("\n")) {
3142
3239
  throw setupError(
3143
- "Compose must use only the canonical pinned Mend image and official postgres:17-alpine."
3240
+ `Compose must use only the canonical pinned Mend image, official postgres:17-alpine${installation.config.bucket === "garage" ? ` and ${GARAGE_IMAGE}` : ""}.`
3144
3241
  );
3145
3242
  }
3146
3243
  };
3244
+ var initGarage = async (runtime, installation, secrets) => {
3245
+ if (installation.config.bucket !== "garage") return;
3246
+ const garage = garageSecrets(secrets);
3247
+ const exec = (args) => runtime.run(
3248
+ "docker",
3249
+ serverComposeArgs(
3250
+ { directory: installation.directory, dockerContext: installation.config.dockerContext },
3251
+ ["exec", "-T", "garage", "/garage", "-c", "/etc/garage.toml", ...args]
3252
+ ),
3253
+ { timeoutMs: serverProcessDeadlines.ordinary }
3254
+ );
3255
+ const status = await exec(["status"]);
3256
+ if (status.status !== 0 || status.error !== void 0)
3257
+ throw commandFailure("Garage did not answer its status", status);
3258
+ const node = status.stdout.match(/^([0-9a-f]{16})\s/m)?.[1];
3259
+ if (node === void 0) throw setupError("Garage reported no node in `garage status`.");
3260
+ await exec(["layout", "assign", "-z", "mend", "-c", "1GB", node]);
3261
+ await exec(["layout", "apply", "--version", "1"]);
3262
+ await exec(["bucket", "create", GARAGE_BUCKET]);
3263
+ await exec(["key", "import", "--yes", "-n", GARAGE_BUCKET, garage.keyId, garage.keySecret]);
3264
+ await exec([
3265
+ "bucket",
3266
+ "allow",
3267
+ "--read",
3268
+ "--write",
3269
+ "--owner",
3270
+ GARAGE_BUCKET,
3271
+ "--key",
3272
+ GARAGE_BUCKET
3273
+ ]);
3274
+ const info = await exec(["bucket", "info", GARAGE_BUCKET]);
3275
+ if (info.status !== 0 || info.error !== void 0 || !info.stdout.includes(garage.keyId))
3276
+ throw commandFailure(`Garage bucket ${GARAGE_BUCKET} is not readable by Mend's key`, info);
3277
+ runtime.writeLine(`Capture store bucket ${GARAGE_BUCKET} is laid out in Garage`);
3278
+ };
3147
3279
  var startingNotice = (runtime, version) => runtime.writeLine(
3148
3280
  `Starting Mend ${version} containers; Docker waits up to ${serverProcessDeadlines.composeWaitSeconds}s for them to report healthy`
3149
3281
  );
@@ -3190,7 +3322,7 @@ var setupServer = async (args, runtime, store) => {
3190
3322
  const operatingSystem = await checkDocker(runtime, selectedContext.name);
3191
3323
  runtime.writeLine(`Using Docker context "${selectedContext.name}" (${selectedContext.endpoint})`);
3192
3324
  const serverVersion = await resolveServerVersion(runtime, options, existing?.config ?? null);
3193
- const config = {
3325
+ const configWithoutBucket = {
3194
3326
  schemaVersion: CONFIG_SCHEMA_VERSION,
3195
3327
  assetContract: ASSET_CONTRACT,
3196
3328
  serverVersion,
@@ -3203,17 +3335,24 @@ var setupServer = async (args, runtime, store) => {
3203
3335
  ...validateExposure(existing?.config ?? null, options)
3204
3336
  };
3205
3337
  const assets = await resolveAssets(runtime, serverVersion, existing, store, options);
3338
+ const bucket = composeBucket(assets.compose);
3339
+ const config = {
3340
+ ...configWithoutBucket,
3341
+ ...bucket === void 0 ? {} : { bucket }
3342
+ };
3206
3343
  const secrets = savedSecrets ?? createSecrets(runtime);
3207
3344
  const generation = persistSetup(store, config, secrets, assets);
3208
3345
  await checkComposeImages(runtime, { directory: generation.directory, config });
3209
3346
  const ownership2 = await claimServerDockerVolumes(runtime, {
3210
3347
  dockerContext: config.dockerContext,
3211
- identityBytes: Buffer.from(generation.files.identity)
3348
+ identityBytes: Buffer.from(generation.files.identity),
3349
+ namespace: namespaceOf(config)
3212
3350
  });
3213
3351
  if (ownership2._tag === "error") throw setupError(ownership2.error.message);
3214
3352
  await checkLocalImages(runtime, config, options.offline ? "local" : "pull-missing");
3215
3353
  storeValue(store.activate(generation));
3216
3354
  await startCompose(runtime, config, generation);
3355
+ await initGarage(runtime, { directory: generation.directory, config }, secrets);
3217
3356
  await probeHealth(runtime, config.appUrl, config.serverVersion);
3218
3357
  runtime.writeLine(`Mend ${config.serverVersion} is reachable at ${config.appUrl}`);
3219
3358
  runtime.writeLine(
@@ -3278,7 +3417,7 @@ var composeCommand = async (runtime, installation, args) => {
3278
3417
  var interruptionNotice = (runtime) => runtime.writeLine(
3279
3418
  "Connections will be interrupted. Workspace containers and data are retained, but active work can lose connectivity and may need reconnection. Mend does not stop workspace containers."
3280
3419
  );
3281
- var startInstallation = async (runtime, installation) => {
3420
+ var startInstallation = async (runtime, installation, secrets) => {
3282
3421
  startingNotice(runtime, installation.config.serverVersion);
3283
3422
  await composeCommand(runtime, installation, [
3284
3423
  "up",
@@ -3288,6 +3427,7 @@ var startInstallation = async (runtime, installation) => {
3288
3427
  "never",
3289
3428
  "--no-build"
3290
3429
  ]);
3430
+ await initGarage(runtime, installation, secrets);
3291
3431
  await probeHealth(runtime, installation.config.appUrl, installation.config.serverVersion);
3292
3432
  runtime.writeLine(
3293
3433
  `Mend ${installation.config.serverVersion} is reachable at ${installation.config.appUrl}`
@@ -3325,11 +3465,17 @@ var upgradeServer = async (args, runtime, store, existing) => {
3325
3465
  const previous = storeValue(store.readActive());
3326
3466
  if (previous === null) throw setupError("The active server generation is missing.");
3327
3467
  const assets = await resolveAssets(runtime, version, existing, store, options);
3328
- const { registryPort: _legacyRegistryPort, ...carried } = existing.config;
3468
+ const {
3469
+ registryPort: _legacyRegistryPort,
3470
+ bucket: _previousBucket,
3471
+ ...carried
3472
+ } = existing.config;
3473
+ const bucket = composeBucket(assets.compose);
3329
3474
  const config = {
3330
3475
  ...carried,
3331
3476
  assetContract: ASSET_CONTRACT,
3332
- serverVersion: version
3477
+ serverVersion: version,
3478
+ ...bucket === void 0 ? {} : { bucket }
3333
3479
  };
3334
3480
  const secrets = parseSecrets(previous.files.identity);
3335
3481
  const files = {
@@ -3347,6 +3493,14 @@ var upgradeServer = async (args, runtime, store, existing) => {
3347
3493
  const target = storeValue(store.prepare(files));
3348
3494
  const installation = { directory: target.directory, config };
3349
3495
  await checkComposeImages(runtime, installation);
3496
+ if (config.bucket === "garage" && existing.config.bucket !== "garage") {
3497
+ const ownership2 = await claimServerDockerVolumes(runtime, {
3498
+ dockerContext: config.dockerContext,
3499
+ identityBytes: Buffer.from(previous.files.identity),
3500
+ namespace: namespaceOf(config)
3501
+ });
3502
+ if (ownership2._tag === "error") throw setupError(ownership2.error.message);
3503
+ }
3350
3504
  const running = await composeCommand(runtime, existing, [
3351
3505
  "ps",
3352
3506
  "--status",
@@ -3394,7 +3548,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
3394
3548
  recovery = "Could not reselect the previous generation. Inspect active before retrying any command.";
3395
3549
  else if (appWasRunning) {
3396
3550
  try {
3397
- await startInstallation(runtime, existing);
3551
+ await startInstallation(runtime, existing, secrets);
3398
3552
  recovery = "Previous pin and app recovered.";
3399
3553
  } catch {
3400
3554
  recovery = "Previous pin retained, but the old app could not recover. Run mend server start after fixing Docker.";
@@ -3406,7 +3560,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
3406
3560
  );
3407
3561
  }
3408
3562
  try {
3409
- await startInstallation(runtime, installation);
3563
+ await startInstallation(runtime, installation, secrets);
3410
3564
  } catch {
3411
3565
  throw setupError(
3412
3566
  `Mend ${version} startup, exact-version health or registry verification failed; migrations may have begun. The target pin remains active. Do NOT downgrade or restore the database automatically. Run mend server logs --tail 100 and mend server status; fix the target, then mend server start --offline. Previous generation: ${previous.directory}. Target generation: ${target.directory}. Database backup and recovery record: ${backup.directory}.`
@@ -3456,9 +3610,11 @@ var manageServer = async (command, args, runtime, store) => {
3456
3610
  if (identity === null) throw setupError("The persisted server identity is missing.");
3457
3611
  const ownership2 = await verifyServerDockerVolumes(runtime, {
3458
3612
  dockerContext: installation.config.dockerContext,
3459
- identityBytes: Buffer.from(identity)
3613
+ identityBytes: Buffer.from(identity),
3614
+ namespace: namespaceOf(installation.config)
3460
3615
  });
3461
3616
  if (ownership2._tag === "error") throw setupError(ownership2.error.message);
3617
+ const secrets = parseSecrets(identity);
3462
3618
  if (command === "upgrade") return upgradeServer(args, runtime, store, installation);
3463
3619
  if (command === "logs") {
3464
3620
  let tail = 100;
@@ -3488,7 +3644,7 @@ var manageServer = async (command, args, runtime, store) => {
3488
3644
  interruptionNotice(runtime);
3489
3645
  await composeCommand(runtime, installation, ["stop", "--timeout", "30"]);
3490
3646
  runtime.writeLine(
3491
- "Mend and Postgres stopped. Volumes, configuration and workspace containers are retained."
3647
+ `Mend, Postgres${installation.config.bucket === "garage" ? " and Garage" : ""} stopped. Volumes, configuration and workspace containers are retained.`
3492
3648
  );
3493
3649
  return;
3494
3650
  }
@@ -3497,7 +3653,7 @@ var manageServer = async (command, args, runtime, store) => {
3497
3653
  interruptionNotice(runtime);
3498
3654
  await composeCommand(runtime, installation, ["stop", "--timeout", "30", "mend"]);
3499
3655
  }
3500
- await startInstallation(runtime, installation);
3656
+ await startInstallation(runtime, installation, secrets);
3501
3657
  };
3502
3658
  var nodeServerRuntime = () => {
3503
3659
  const environment = { ...process.env };
@@ -4009,6 +4165,26 @@ var removeManagedBlock = (config, beginMarker, endMarker) => {
4009
4165
  }
4010
4166
  return success(`${config.slice(0, begin)}${after}`);
4011
4167
  };
4168
+ var stripManagedWorkspaceSshBlocks = (existing) => {
4169
+ let config = existing;
4170
+ let removed = 0;
4171
+ const aliases = [
4172
+ ...config.matchAll(/^# >>> mend workspace ssh (mend-ws-[a-z0-9-]+) \(managed\) >>>$/gm)
4173
+ ].map((match) => match[1]).filter((alias) => alias !== void 0);
4174
+ for (const alias of new Set(aliases)) {
4175
+ const stripped = removeManagedBlock(config, blockBegin(alias), blockEnd(alias));
4176
+ if (!stripped.ok) return stripped;
4177
+ config = stripped.value;
4178
+ removed += 1;
4179
+ }
4180
+ if (config.includes(LEGACY_BLOCK_BEGIN)) {
4181
+ const stripped = removeManagedBlock(config, LEGACY_BLOCK_BEGIN, LEGACY_BLOCK_END);
4182
+ if (!stripped.ok) return stripped;
4183
+ config = stripped.value;
4184
+ removed += 1;
4185
+ }
4186
+ return success({ config, removed });
4187
+ };
4012
4188
  var containsExactHost = (config, alias) => config.split("\n").some((line) => {
4013
4189
  const match = /^\s*Host\s+(.+?)\s*$/i.exec(line);
4014
4190
  return match?.[1]?.split(/\s+/).includes(alias) === true;
@@ -4353,22 +4529,308 @@ var sshCommand = async (args, api2, cliHome, serverUrl) => {
4353
4529
  }
4354
4530
  };
4355
4531
 
4532
+ // src/uninstall.ts
4533
+ import * as fs8 from "node:fs";
4534
+ import * as path9 from "node:path";
4535
+ var UNINSTALL_USAGE = "usage: mend uninstall [--all | --server | --home] [--yes]";
4536
+ var parseUninstallArgs = (args) => {
4537
+ let scope = null;
4538
+ let yes = false;
4539
+ for (const arg of args) {
4540
+ if (arg === "--yes" || arg === "-y") {
4541
+ yes = true;
4542
+ continue;
4543
+ }
4544
+ const next = arg === "--all" ? "all" : arg === "--server" ? "server" : arg === "--home" ? "home" : null;
4545
+ if (next === null) return { error: `${UNINSTALL_USAGE} \xB7 "${arg}" is not an option` };
4546
+ if (scope !== null && scope !== next) {
4547
+ return { error: `${UNINSTALL_USAGE} \xB7 pick one scope` };
4548
+ }
4549
+ scope = next;
4550
+ }
4551
+ return { scope, yes };
4552
+ };
4553
+ var SERVER_FILES = ["identity.env", "active", "generations", "backups"];
4554
+ var countEntries = (directory) => {
4555
+ try {
4556
+ return fs8.readdirSync(directory).length;
4557
+ } catch {
4558
+ return 0;
4559
+ }
4560
+ };
4561
+ var exists = (file) => {
4562
+ try {
4563
+ fs8.lstatSync(file);
4564
+ return true;
4565
+ } catch {
4566
+ return false;
4567
+ }
4568
+ };
4569
+ var managedBlockCount = (sshConfigFile) => {
4570
+ let config;
4571
+ try {
4572
+ config = fs8.readFileSync(sshConfigFile, "utf8");
4573
+ } catch {
4574
+ return 0;
4575
+ }
4576
+ const stripped = stripManagedWorkspaceSshBlocks(config);
4577
+ return stripped.ok ? stripped.value.removed : 0;
4578
+ };
4579
+ var describeUninstall = async (runtime, scope) => {
4580
+ let server = null;
4581
+ if (scope !== "home") {
4582
+ const configDir = runtime.server.configDir;
4583
+ const read = await withServerStore(
4584
+ configDir,
4585
+ async (store) => {
4586
+ const installation = readServerInstallation(store);
4587
+ if (installation._tag === "error") throw installation.error;
4588
+ return installation.value;
4589
+ },
4590
+ { create: false }
4591
+ );
4592
+ if (read._tag === "error") {
4593
+ if (!read.error.message.startsWith("No Mend server is configured")) throw read.error;
4594
+ server = "none";
4595
+ } else if (read.value === null) {
4596
+ server = exists(path9.join(configDir, "identity.env")) ? {
4597
+ version: "(no active generation)",
4598
+ appUrl: "",
4599
+ dockerContext: "",
4600
+ generations: countEntries(path9.join(configDir, "generations")),
4601
+ backups: countEntries(path9.join(configDir, "backups"))
4602
+ } : "none";
4603
+ } else {
4604
+ server = {
4605
+ version: read.value.config.serverVersion,
4606
+ appUrl: read.value.config.appUrl,
4607
+ dockerContext: read.value.config.dockerContext,
4608
+ generations: countEntries(path9.join(configDir, "generations")),
4609
+ backups: countEntries(path9.join(configDir, "backups"))
4610
+ };
4611
+ }
4612
+ }
4613
+ let home = null;
4614
+ if (scope !== "server") {
4615
+ const cliConfig = path9.join(runtime.cliHome, "cli.json");
4616
+ const sshDirectory = path9.join(runtime.cliHome, "ssh");
4617
+ home = {
4618
+ cliConfig: exists(cliConfig) ? cliConfig : null,
4619
+ sshDirectory: exists(sshDirectory) ? sshDirectory : null,
4620
+ managedSshBlocks: managedBlockCount(runtime.sshConfigFile),
4621
+ signedIn: runtime.signedIn
4622
+ };
4623
+ }
4624
+ return { scope, server, home };
4625
+ };
4626
+ var plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
4627
+ var planLines = (plan, configDir) => {
4628
+ const lines = [];
4629
+ if (plan.server === "none") {
4630
+ lines.push(`server none installed under ${configDir}`);
4631
+ } else if (plan.server !== null) {
4632
+ const { version, appUrl, dockerContext, generations, backups } = plan.server;
4633
+ lines.push(
4634
+ `server Mend ${version}${appUrl === "" ? "" : ` at ${appUrl}`}${dockerContext === "" ? "" : ` \xB7 docker context ${dockerContext}`}`
4635
+ );
4636
+ if (dockerContext !== "") {
4637
+ lines.push(
4638
+ ` containers mend, postgres, garage \xB7 volumes ${[MEND_DOCKER_NAMESPACE_WITH_GARAGE.store, ...secondaryVolumesOf(MEND_DOCKER_NAMESPACE_WITH_GARAGE), "mend-config", "mend-ssh", "mend-postgres"].join(", ")} \xB7 image ghcr.io/sealant-sh/mend:${version}`
4639
+ );
4640
+ }
4641
+ lines.push(
4642
+ ` ${configDir}: identity.env, active, ${plural(generations, "generation")}, ${plural(backups, "backup")}`
4643
+ );
4644
+ }
4645
+ if (plan.home !== null) {
4646
+ const parts = [];
4647
+ if (plan.home.cliConfig !== null) {
4648
+ parts.push(
4649
+ plan.home.signedIn === null ? plan.home.cliConfig : `${plan.home.cliConfig} (signed in to ${plan.home.signedIn.url})`
4650
+ );
4651
+ }
4652
+ if (plan.home.sshDirectory !== null) parts.push(plan.home.sshDirectory);
4653
+ if (plan.home.managedSshBlocks > 0) {
4654
+ parts.push(`${plural(plan.home.managedSshBlocks, "managed block")} in ~/.ssh/config`);
4655
+ }
4656
+ lines.push(parts.length === 0 ? "home nothing of Mend's here" : `home ${parts[0]}`);
4657
+ for (const part of parts.slice(1)) lines.push(` ${part}`);
4658
+ }
4659
+ return lines;
4660
+ };
4661
+ var planDeletesData = (plan) => plan.server !== null && plan.server !== "none";
4662
+ var dockerArgs = (context, ...rest) => [
4663
+ "--context",
4664
+ context,
4665
+ ...rest
4666
+ ];
4667
+ var removeServer = async (runtime, failures, leftovers) => {
4668
+ const { server } = runtime;
4669
+ const configDir = server.configDir;
4670
+ const result = await withServerStore(
4671
+ configDir,
4672
+ async (store) => {
4673
+ const read = readServerInstallation(store);
4674
+ if (read._tag === "error") throw read.error;
4675
+ const installation = read.value;
4676
+ if (installation !== null) {
4677
+ const context = installation.config.dockerContext;
4678
+ const down = await server.run(
4679
+ "docker",
4680
+ serverComposeArgs({ directory: installation.directory, dockerContext: context }, [
4681
+ "down",
4682
+ "--volumes",
4683
+ "--remove-orphans",
4684
+ "--timeout",
4685
+ "30"
4686
+ ]),
4687
+ { timeoutMs: serverProcessDeadlines.stop }
4688
+ );
4689
+ if (down.status !== 0) {
4690
+ throw new Error(
4691
+ `docker compose down failed: ${(down.error ?? down.stderr.trim()) || "no output"}. Containers and files are retained; fix Docker and run mend uninstall again.`
4692
+ );
4693
+ }
4694
+ server.writeLine("removed containers mend, postgres, garage and the Compose-owned volumes");
4695
+ const namespace = installation.config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
4696
+ const dataVolumes = [namespace.store, ...secondaryVolumesOf(namespace)];
4697
+ const identity = store.readIdentity();
4698
+ if (identity._tag === "error") throw identity.error;
4699
+ const owned = identity.value === null ? null : await verifyServerDockerVolumes(server, {
4700
+ dockerContext: context,
4701
+ identityBytes: Buffer.from(identity.value),
4702
+ namespace
4703
+ });
4704
+ if (owned !== null && owned._tag === "ok") {
4705
+ const removed = await server.run(
4706
+ "docker",
4707
+ dockerArgs(context, "volume", "rm", ...dataVolumes)
4708
+ );
4709
+ if (removed.status === 0) {
4710
+ server.writeLine(`removed volumes ${dataVolumes.join(", ")}`);
4711
+ } else {
4712
+ failures.push(
4713
+ `could not remove volumes ${dataVolumes.join(", ")}: ${(removed.error ?? removed.stderr.trim()) || "no output"}`
4714
+ );
4715
+ }
4716
+ } else if (owned !== null && owned.error.reason === "missing") {
4717
+ server.writeLine(`volumes ${dataVolumes.join(", ")} were already gone`);
4718
+ } else {
4719
+ leftovers.push(
4720
+ `volumes ${dataVolumes.join(", ")}: ownership could not be confirmed for this installation, so they stay (docker --context ${context} volume ls)`
4721
+ );
4722
+ }
4723
+ const image = `ghcr.io/sealant-sh/mend:${installation.config.serverVersion}`;
4724
+ const untagged = await server.run("docker", dockerArgs(context, "image", "rm", image));
4725
+ if (untagged.status === 0) server.writeLine(`removed image ${image}`);
4726
+ else leftovers.push(`image ${image}: not removed (${untagged.stderr.trim() || "absent"})`);
4727
+ const workspaces = await server.run(
4728
+ "docker",
4729
+ dockerArgs(
4730
+ context,
4731
+ "container",
4732
+ "ls",
4733
+ "--all",
4734
+ "--filter",
4735
+ "name=^sealant-",
4736
+ "--format",
4737
+ "{{.Names}}"
4738
+ )
4739
+ );
4740
+ const names = workspaces.stdout.split(/\s+/).filter((name) => name !== "");
4741
+ if (workspaces.status === 0 && names.length > 0) {
4742
+ leftovers.push(
4743
+ `${plural(names.length, "workspace container")} left on docker context ${context} (${names.slice(0, 3).join(", ")}${names.length > 3 ? ", \u2026" : ""}): docker --context ${context} rm -f ${names.join(" ")}`
4744
+ );
4745
+ }
4746
+ }
4747
+ for (const name of SERVER_FILES) {
4748
+ fs8.rmSync(path9.join(configDir, name), { recursive: true, force: true });
4749
+ }
4750
+ server.writeLine(`removed ${configDir}/{identity.env, active, generations, backups}`);
4751
+ },
4752
+ { create: false }
4753
+ );
4754
+ if (result._tag === "error") failures.push(result.error.message);
4755
+ };
4756
+ var removeHome = async (runtime, plan, failures, leftovers) => {
4757
+ const { server } = runtime;
4758
+ if (plan.cliConfig !== null) {
4759
+ fs8.rmSync(plan.cliConfig, { force: true });
4760
+ server.writeLine(`removed ${plan.cliConfig}`);
4761
+ }
4762
+ if (plan.sshDirectory !== null) {
4763
+ fs8.rmSync(plan.sshDirectory, { recursive: true, force: true });
4764
+ server.writeLine(`removed ${plan.sshDirectory}`);
4765
+ }
4766
+ if (plan.managedSshBlocks > 0) {
4767
+ try {
4768
+ const before = fs8.readFileSync(runtime.sshConfigFile, "utf8");
4769
+ const stripped = stripManagedWorkspaceSshBlocks(before);
4770
+ if (!stripped.ok) throw stripped.error;
4771
+ if (stripped.value.removed > 0) {
4772
+ fs8.writeFileSync(runtime.sshConfigFile, stripped.value.config, { mode: 384 });
4773
+ server.writeLine(
4774
+ `removed ${plural(stripped.value.removed, "managed block")} from ${runtime.sshConfigFile}`
4775
+ );
4776
+ }
4777
+ } catch (cause) {
4778
+ failures.push(
4779
+ `${runtime.sshConfigFile}: ${cause instanceof Error ? cause.message : String(cause)}`
4780
+ );
4781
+ }
4782
+ }
4783
+ let remaining = [];
4784
+ try {
4785
+ remaining = fs8.readdirSync(runtime.cliHome);
4786
+ } catch {
4787
+ return;
4788
+ }
4789
+ if (remaining.length === 0) {
4790
+ fs8.rmdirSync(runtime.cliHome);
4791
+ server.writeLine(`removed ${runtime.cliHome}`);
4792
+ } else {
4793
+ leftovers.push(
4794
+ `${runtime.cliHome} kept: ${remaining.join(", ")} ${remaining.length === 1 ? "is" : "are"} not this CLI's (a host-run server's store or keys, or another tool's files)`
4795
+ );
4796
+ }
4797
+ };
4798
+ var executeUninstall = async (runtime, plan) => {
4799
+ const failures = [];
4800
+ const leftovers = [];
4801
+ if (plan.home?.signedIn !== null && plan.home?.signedIn?.deviceId != null) {
4802
+ const failure2 = await runtime.revokeDevice();
4803
+ if (failure2 === null) {
4804
+ runtime.server.writeLine(`revoked this terminal's device on ${plan.home.signedIn.url}`);
4805
+ } else {
4806
+ leftovers.push(
4807
+ `device token on ${plan.home.signedIn.url}: ${failure2} (end it under Settings \u2192 Devices)`
4808
+ );
4809
+ }
4810
+ }
4811
+ if (plan.server !== null && plan.server !== "none") {
4812
+ await removeServer(runtime, failures, leftovers);
4813
+ }
4814
+ if (plan.home !== null) await removeHome(runtime, plan.home, failures, leftovers);
4815
+ return { failures, leftovers };
4816
+ };
4817
+
4356
4818
  // src/main.ts
4357
4819
  var mendCliHome = () => {
4358
4820
  const xdg = process.env["XDG_CONFIG_HOME"];
4359
- const preferred = path9.join(
4360
- xdg === void 0 || xdg === "" ? path9.join(os6.homedir(), ".config") : xdg,
4821
+ const preferred = path10.join(
4822
+ xdg === void 0 || xdg === "" ? path10.join(os6.homedir(), ".config") : xdg,
4361
4823
  "mend"
4362
4824
  );
4363
- const legacy = path9.join(os6.homedir(), ".mend");
4364
- return !fs8.existsSync(preferred) && fs8.existsSync(legacy) ? legacy : preferred;
4825
+ const legacy = path10.join(os6.homedir(), ".mend");
4826
+ return !fs9.existsSync(preferred) && fs9.existsSync(legacy) ? legacy : preferred;
4365
4827
  };
4366
- var CONFIG_PATH = path9.join(mendCliHome(), "cli.json");
4828
+ var CONFIG_PATH = path10.join(mendCliHome(), "cli.json");
4367
4829
  var loadConfig = () => {
4368
4830
  let fileConfig = {};
4369
- if (fs8.existsSync(CONFIG_PATH)) {
4831
+ if (fs9.existsSync(CONFIG_PATH)) {
4370
4832
  try {
4371
- fileConfig = JSON.parse(fs8.readFileSync(CONFIG_PATH, "utf8"));
4833
+ fileConfig = JSON.parse(fs9.readFileSync(CONFIG_PATH, "utf8"));
4372
4834
  } catch {
4373
4835
  fail4(`could not parse ${CONFIG_PATH}`);
4374
4836
  }
@@ -4505,7 +4967,7 @@ var adopt = async (config, args) => {
4505
4967
  }
4506
4968
  const sourceIssue = repositoryCloneUrlIssue(source);
4507
4969
  if (sourceIssue !== null) return fail4(sourceIssue);
4508
- const projectName = name ?? normalizeProjectName(path9.basename(source, ".git"));
4970
+ const projectName = name ?? normalizeProjectName(path10.basename(source, ".git"));
4509
4971
  const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== void 0 ? String(args[authFlagIndex + 1]) : null;
4510
4972
  if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
4511
4973
  return fail4(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
@@ -4550,7 +5012,7 @@ var findProject = async (config, explicit, adoptCwd = false) => {
4550
5012
  const sourceIssue = repositoryCloneUrlIssue(facts.originUrl);
4551
5013
  if (sourceIssue !== null) return fail4(sourceIssue);
4552
5014
  const adopted = await api(config, "POST", "/projects", {
4553
- name: normalizeProjectName(path9.basename(facts.repoRoot)),
5015
+ name: normalizeProjectName(path10.basename(facts.repoRoot)),
4554
5016
  source: facts.originUrl
4555
5017
  });
4556
5018
  say4(`${green4("\u2713")} adopted \xB7 ${adopted.name} \xB7 ${dim4(adopted.storePath)}`);
@@ -5341,13 +5803,13 @@ var serviceRestart = async (config, args) => {
5341
5803
  };
5342
5804
  var serviceInit = async (args) => {
5343
5805
  const root = gitTopLevel(process.cwd()) ?? process.cwd();
5344
- const target = path9.join(root, "mend.toml");
5345
- if (fs8.existsSync(target)) {
5806
+ const target = path10.join(root, "mend.toml");
5807
+ if (fs9.existsSync(target)) {
5346
5808
  return fail4(`${target} already exists \u2014 edit it directly (init never merges)`);
5347
5809
  }
5348
- const rootFiles = fs8.readdirSync(root);
5810
+ const rootFiles = fs9.readdirSync(root);
5349
5811
  const proposals = [];
5350
- const readRoot = (name) => fs8.readFileSync(path9.join(root, name), "utf8");
5812
+ const readRoot = (name) => fs9.readFileSync(path10.join(root, name), "utf8");
5351
5813
  if (rootFiles.includes("package.json")) {
5352
5814
  proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
5353
5815
  }
@@ -5357,13 +5819,13 @@ var serviceInit = async (args) => {
5357
5819
  );
5358
5820
  for (const glob of globs) {
5359
5821
  const parent = glob.replace(/\/?\*+$/, "");
5360
- const dirs = glob.endsWith("*") ? fs8.existsSync(path9.join(root, parent)) ? fs8.readdirSync(path9.join(root, parent)).map((dir) => path9.join(parent, dir)) : [] : [glob];
5822
+ const dirs = glob.endsWith("*") ? fs9.existsSync(path10.join(root, parent)) ? fs9.readdirSync(path10.join(root, parent)).map((dir) => path10.join(parent, dir)) : [] : [glob];
5361
5823
  for (const dir of dirs) {
5362
- const manifest = path9.join(root, dir, "package.json");
5363
- if (!fs8.existsSync(manifest)) continue;
5824
+ const manifest = path10.join(root, dir, "package.json");
5825
+ if (!fs9.existsSync(manifest)) continue;
5364
5826
  for (const proposal of proposeFromWorkspacePackage(
5365
- path9.basename(dir),
5366
- fs8.readFileSync(manifest, "utf8"),
5827
+ path10.basename(dir),
5828
+ fs9.readFileSync(manifest, "utf8"),
5367
5829
  rootFiles
5368
5830
  )) {
5369
5831
  if (!proposals.some((existing) => existing.name === proposal.name)) {
@@ -5407,7 +5869,7 @@ var serviceInit = async (args) => {
5407
5869
  return;
5408
5870
  }
5409
5871
  }
5410
- fs8.writeFileSync(target, toml);
5872
+ fs9.writeFileSync(target, toml);
5411
5873
  say4(`${green4("\u2713")} wrote ${target} \u2014 commit it, then: mend service run ${proposals[0]?.name}`);
5412
5874
  };
5413
5875
  var tunnelServices = async (config, services, portOverride) => {
@@ -5500,18 +5962,18 @@ var serviceCommand = async (config, args) => {
5500
5962
  }
5501
5963
  };
5502
5964
  var isProvider = (value) => value === "claude" || value === "codex" || value === "github";
5503
- var readIfExists = (file) => fs8.existsSync(file) ? fs8.readFileSync(file, "utf8") : null;
5965
+ var readIfExists = (file) => fs9.existsSync(file) ? fs9.readFileSync(file, "utf8") : null;
5504
5966
  var localCredential = (provider) => {
5505
5967
  const home = os6.homedir();
5506
5968
  switch (provider) {
5507
5969
  case "codex":
5508
5970
  return readIfExists(
5509
- path9.join(process.env["CODEX_HOME"] ?? path9.join(home, ".codex"), "auth.json")
5971
+ path10.join(process.env["CODEX_HOME"] ?? path10.join(home, ".codex"), "auth.json")
5510
5972
  );
5511
5973
  case "claude":
5512
5974
  return readIfExists(
5513
- path9.join(
5514
- process.env["CLAUDE_CONFIG_DIR"] ?? path9.join(home, ".claude"),
5975
+ path10.join(
5976
+ process.env["CLAUDE_CONFIG_DIR"] ?? path10.join(home, ".claude"),
5515
5977
  ".credentials.json"
5516
5978
  )
5517
5979
  );
@@ -5565,7 +6027,7 @@ var connectCommand = async (config, args) => {
5565
6027
  }
5566
6028
  let secret;
5567
6029
  if (flags.includes("--from-stdin")) {
5568
- secret = fs8.readFileSync(0, "utf8").trim();
6030
+ secret = fs9.readFileSync(0, "utf8").trim();
5569
6031
  if (secret === "") return fail4("nothing on stdin");
5570
6032
  } else {
5571
6033
  secret = localCredential(provider);
@@ -5586,14 +6048,14 @@ var takeFlagValue3 = (args, flag) => {
5586
6048
  return at !== -1 && args[at + 1] !== void 0 ? String(args[at + 1]) : null;
5587
6049
  };
5588
6050
  var saveCliConfig = (next) => {
5589
- fs8.mkdirSync(path9.dirname(CONFIG_PATH), { recursive: true, mode: 448 });
5590
- fs8.writeFileSync(
6051
+ fs9.mkdirSync(path10.dirname(CONFIG_PATH), { recursive: true, mode: 448 });
6052
+ fs9.writeFileSync(
5591
6053
  CONFIG_PATH,
5592
6054
  `${JSON.stringify({ url: next.url, token: next.token, deviceId: next.deviceId }, null, 2)}
5593
6055
  `,
5594
6056
  { mode: 384 }
5595
6057
  );
5596
- fs8.chmodSync(CONFIG_PATH, 384);
6058
+ fs9.chmodSync(CONFIG_PATH, 384);
5597
6059
  };
5598
6060
  var login = async (config, args) => {
5599
6061
  await loginCommand(args, {
@@ -5606,7 +6068,7 @@ var login = async (config, args) => {
5606
6068
  });
5607
6069
  };
5608
6070
  var logout = async (config) => {
5609
- if (!fs8.existsSync(CONFIG_PATH) && config.token === null) {
6071
+ if (!fs9.existsSync(CONFIG_PATH) && config.token === null) {
5610
6072
  say4(dim4("nothing saved \u2014 already signed out"));
5611
6073
  return;
5612
6074
  }
@@ -5621,6 +6083,80 @@ var logout = async (config) => {
5621
6083
  saveCliConfig({ ...config, token: null, deviceId: null });
5622
6084
  say4(`${green4("\u2713")} signed out \xB7 ${dim4(`token removed from ${CONFIG_PATH}`)}`);
5623
6085
  };
6086
+ var UNINSTALL_CHOICES = [
6087
+ { scope: "all", text: "everything \xB7 the server on this machine and this machine's Mend files" },
6088
+ { scope: "server", text: "the server only \xB7 containers, volumes, configuration, backups" },
6089
+ {
6090
+ scope: "home",
6091
+ text: "this machine's files only \xB7 sign-in, workspace ssh key, ~/.ssh/config block"
6092
+ }
6093
+ ];
6094
+ var askUninstallScope = async () => {
6095
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
6096
+ return fail4(`${UNINSTALL_USAGE} \xB7 no terminal to ask on`);
6097
+ }
6098
+ say4("what should go?");
6099
+ UNINSTALL_CHOICES.forEach((choice, index) => say4(` ${index + 1}. ${choice.text}`));
6100
+ const readline = await import("node:readline/promises");
6101
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6102
+ const answer = (await rl.question(" 1, 2 or 3: ")).trim();
6103
+ rl.close();
6104
+ const chosen = UNINSTALL_CHOICES[Number(answer) - 1];
6105
+ if (chosen === void 0) return fail4(`"${answer}" is not one of the choices`);
6106
+ return chosen.scope;
6107
+ };
6108
+ var confirmUninstall = async (word) => {
6109
+ if (process.stdin.isTTY !== true) return fail4("non-interactive \u2014 pass --yes to remove");
6110
+ const readline = await import("node:readline/promises");
6111
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6112
+ const answer = (await rl.question(word === "y" ? "remove? [y/N] " : `type ${word} to remove: `)).trim().toLowerCase();
6113
+ rl.close();
6114
+ return answer === word;
6115
+ };
6116
+ var uninstallCommand = async (config, args) => {
6117
+ const parsed = parseUninstallArgs(args);
6118
+ if ("error" in parsed) return fail4(parsed.error);
6119
+ const scope = parsed.scope ?? await askUninstallScope();
6120
+ const server = nodeServerRuntime();
6121
+ const runtime = {
6122
+ server,
6123
+ cliHome: mendCliHome(),
6124
+ sshConfigFile: path10.join(os6.homedir(), ".ssh", "config"),
6125
+ signedIn: config.token === null || !fs9.existsSync(CONFIG_PATH) ? null : { url: config.url, deviceId: config.deviceId },
6126
+ revokeDevice: async () => {
6127
+ if (config.deviceId === null) return "no device id saved";
6128
+ try {
6129
+ await request(config, "DELETE", `/me/devices/${config.deviceId}`);
6130
+ return null;
6131
+ } catch (error) {
6132
+ return error instanceof Error ? error.message : String(error);
6133
+ }
6134
+ }
6135
+ };
6136
+ const plan = await describeUninstall(runtime, scope);
6137
+ say4(dim4(`mend uninstall \xB7 ${scope === "all" ? "everything" : scope}`));
6138
+ for (const line of planLines(plan, server.configDir)) say4(` ${line}`);
6139
+ const nothing = (plan.server === null || plan.server === "none") && (plan.home === null || plan.home.cliConfig === null && plan.home.sshDirectory === null && plan.home.managedSshBlocks === 0);
6140
+ if (nothing) {
6141
+ say4(dim4("nothing to remove"));
6142
+ return;
6143
+ }
6144
+ if (planDeletesData(plan)) {
6145
+ say4("repositories, worktrees, the database and its backups are deleted with the server");
6146
+ }
6147
+ if (!parsed.yes && !await confirmUninstall(planDeletesData(plan) ? "delete" : "y")) {
6148
+ say4(dim4("nothing removed"));
6149
+ return;
6150
+ }
6151
+ const outcome = await executeUninstall(runtime, plan);
6152
+ for (const line of outcome.leftovers) say4(dim4(` kept \xB7 ${line}`));
6153
+ if (outcome.failures.length > 0) {
6154
+ return fail4(outcome.failures.join("\n"));
6155
+ }
6156
+ say4(
6157
+ `${green4("\u2713")} ${scope === "all" ? "Mend is gone from this machine" : scope === "server" ? "the server is gone from this machine" : "this machine no longer holds Mend files"}`
6158
+ );
6159
+ };
5624
6160
  var printGitKey = (key) => {
5625
6161
  if (key.publicKey === null) return;
5626
6162
  say4(key.publicKey);
@@ -5795,10 +6331,10 @@ var envLoad = async (config, args) => {
5795
6331
  const positional = args.filter(
5796
6332
  (arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project" && !consumed.has(i)
5797
6333
  );
5798
- const file = path9.resolve(positional[0] ?? ".env");
6334
+ const file = path10.resolve(positional[0] ?? ".env");
5799
6335
  let contents;
5800
6336
  try {
5801
- contents = fs8.readFileSync(file, "utf8");
6337
+ contents = fs9.readFileSync(file, "utf8");
5802
6338
  } catch {
5803
6339
  return fail4(`cannot read ${file} \u2014 pass a path: mend env load path/to/.env`);
5804
6340
  }
@@ -5814,10 +6350,10 @@ var envLoad = async (config, args) => {
5814
6350
  { contents, allSecret, secretNames }
5815
6351
  );
5816
6352
  if (report.loaded.length === 0 && report.rejected.length === 0 && report.malformedLines.length === 0) {
5817
- say4(dim4(`${path9.basename(file)} has no variables`));
6353
+ say4(dim4(`${path10.basename(file)} has no variables`));
5818
6354
  return;
5819
6355
  }
5820
- say4(`${green4("\u2713")} loaded ${path9.basename(file)} into ${project2.name}`);
6356
+ say4(`${green4("\u2713")} loaded ${path10.basename(file)} into ${project2.name}`);
5821
6357
  for (const line of formatLoadReport(report, { dim: dim4, warn: amber })) say4(line);
5822
6358
  const plaintextUrls = report.loaded.filter(
5823
6359
  (entry) => entry.lane === "configuration" && /_(URL|URI|DSN)$/.test(entry.name)
@@ -6006,7 +6542,7 @@ var skillsList = async (config, args) => {
6006
6542
  };
6007
6543
  var skillsPush = async (config, args) => {
6008
6544
  const dirFlag = takeFlagValue3(args, "--dir");
6009
- const root = dirFlag === null ? path9.join(os6.homedir(), DEFAULT_SKILLS_DIR) : path9.resolve(dirFlag);
6545
+ const root = dirFlag === null ? path10.join(os6.homedir(), DEFAULT_SKILLS_DIR) : path10.resolve(dirFlag);
6010
6546
  const scanned = scanSkillLibrary(root);
6011
6547
  if ("error" in scanned) return fail4(scanned.error);
6012
6548
  for (const note of scanned.notes) say4(dim4(` ${note.skill}: ${note.message}`));
@@ -6076,6 +6612,7 @@ _mend() {
6076
6612
  'shell:open a shell in a live session workspace'
6077
6613
  'service:reachable ports \u2014 add, list, stop'
6078
6614
  'server:local server setup, lifecycle and upgrades'
6615
+ 'uninstall:remove the server, local Mend files, or both'
6079
6616
  'keys:the machine Mend deploy key \u2014 init, show, share'
6080
6617
  'skills:skill libraries \u2014 list, push'
6081
6618
  'accounts:your connected accounts on the platform'
@@ -6115,7 +6652,7 @@ _mend "$@"
6115
6652
  var BASH_COMPLETIONS = `_mend() {
6116
6653
  local cur=\${COMP_WORDS[COMP_CWORD]}
6117
6654
  if [ "$COMP_CWORD" -eq 1 ]; then
6118
- COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service server keys skills pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
6655
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service server uninstall keys skills pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
6119
6656
  return
6120
6657
  fi
6121
6658
  case \${COMP_WORDS[1]} in
@@ -6650,7 +7187,7 @@ var dashboard = async (config) => {
6650
7187
  );
6651
7188
  process.exit(rerun.status ?? 0);
6652
7189
  }
6653
- const { runDashboard } = await import("./dashboard-JIWYW3EM.js");
7190
+ const { runDashboard } = await import("./dashboard-5CBGTPSJ.js");
6654
7191
  const agentShare = await startShareIfBridge(config);
6655
7192
  try {
6656
7193
  await runDashboard({
@@ -6720,10 +7257,10 @@ var manCommand = (words) => {
6720
7257
  }
6721
7258
  const version = cliVersion();
6722
7259
  const page = doc === null ? renderManIndex(version) : renderManPage(doc, version);
6723
- const file = path9.join(os6.tmpdir(), `mend-man-${process.pid}-${manFileName(doc)}`);
6724
- fs8.writeFileSync(file, page);
7260
+ const file = path10.join(os6.tmpdir(), `mend-man-${process.pid}-${manFileName(doc)}`);
7261
+ fs9.writeFileSync(file, page);
6725
7262
  const result = spawnSync2("man", ["-l", file], { stdio: "inherit" });
6726
- fs8.rmSync(file, { force: true });
7263
+ fs9.rmSync(file, { force: true });
6727
7264
  if (result.error !== void 0 || result.status !== 0) {
6728
7265
  say4(doc === null ? renderIndex() : renderCommand(doc));
6729
7266
  }
@@ -6760,6 +7297,8 @@ var main = async () => {
6760
7297
  return login(config, rest);
6761
7298
  case "logout":
6762
7299
  return logout(config);
7300
+ case "uninstall":
7301
+ return uninstallCommand(config, rest);
6763
7302
  case "keys":
6764
7303
  return keysCommand(config, rest);
6765
7304
  case "dotfiles":