@the-librarian/cli 1.20.1 → 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +21 -7
  2. package/dist/runtime.d.ts.map +1 -1
  3. package/dist/runtime.js +11 -10
  4. package/dist/runtime.js.map +1 -1
  5. package/dist/server/admin.js +2 -2
  6. package/dist/server/admin.js.map +1 -1
  7. package/dist/server/autoupdate.d.ts +4 -4
  8. package/dist/server/autoupdate.d.ts.map +1 -1
  9. package/dist/server/autoupdate.js +28 -22
  10. package/dist/server/autoupdate.js.map +1 -1
  11. package/dist/server/deploy-state.d.ts +24 -5
  12. package/dist/server/deploy-state.d.ts.map +1 -1
  13. package/dist/server/deploy-state.js +78 -10
  14. package/dist/server/deploy-state.js.map +1 -1
  15. package/dist/server/deployment-image.d.ts +65 -0
  16. package/dist/server/deployment-image.d.ts.map +1 -0
  17. package/dist/server/deployment-image.js +281 -0
  18. package/dist/server/deployment-image.js.map +1 -0
  19. package/dist/server/docker.d.ts.map +1 -1
  20. package/dist/server/docker.js +34 -14
  21. package/dist/server/docker.js.map +1 -1
  22. package/dist/server/down.js +2 -2
  23. package/dist/server/down.js.map +1 -1
  24. package/dist/server/index.d.ts +2 -0
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +28 -12
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/logs.js +2 -2
  29. package/dist/server/logs.js.map +1 -1
  30. package/dist/server/preflight.d.ts +5 -3
  31. package/dist/server/preflight.d.ts.map +1 -1
  32. package/dist/server/preflight.js +11 -6
  33. package/dist/server/preflight.js.map +1 -1
  34. package/dist/server/source-repository.d.ts +6 -0
  35. package/dist/server/source-repository.d.ts.map +1 -0
  36. package/dist/server/source-repository.js +40 -0
  37. package/dist/server/source-repository.js.map +1 -0
  38. package/dist/server/status.d.ts.map +1 -1
  39. package/dist/server/status.js +26 -18
  40. package/dist/server/status.js.map +1 -1
  41. package/dist/server/up.d.ts +38 -14
  42. package/dist/server/up.d.ts.map +1 -1
  43. package/dist/server/up.js +584 -134
  44. package/dist/server/up.js.map +1 -1
  45. package/dist/server/update.d.ts +5 -17
  46. package/dist/server/update.d.ts.map +1 -1
  47. package/dist/server/update.js +737 -296
  48. package/dist/server/update.js.map +1 -1
  49. package/package.json +4 -4
package/dist/server/up.js CHANGED
@@ -1,11 +1,13 @@
1
- // `librarian server up` — build + run the all-in-one container.
1
+ // `librarian server up` — pull a stable release (or build a source ref) and run
2
+ // the all-in-one container.
2
3
  //
3
- // This is the loop-closer: on a fresh Docker host it clones the monorepo at the
4
- // resolved release tag, builds `the-librarian:<tag>`, mints the master key +
5
- // agent token into a 0600 deploy env-file, runs the all-in-one container with
6
- // `docker run --env-file` (ADR 0008 P4 — secrets off argv), waits for it to
7
- // report healthy, surfaces the CLI-MINTED master key ONCE, and prints the MCP
8
- // URL + dashboard URL + the agent token ready to paste into `librarian install`.
4
+ // This is the loop-closer: on a fresh Docker host it resolves and validates the
5
+ // stable release image (or clones/builds an arbitrary `--ref`), mints the master
6
+ // key + agent token into a 0600 deploy env-file, creates and starts the
7
+ // all-in-one container with `docker create --env-file` (ADR 0008 P4 — secrets
8
+ // off argv), waits for it to
9
+ // report healthy, surfaces the CLI-MINTED master key ONCE, and prints the MCP URL
10
+ // + dashboard URL + the agent token ready to paste into `librarian install`.
9
11
  //
10
12
  // ADR 0008 P3: there is no admin token. The admin tRPC API is served only on the
11
13
  // trusted internal listener (off the network), so `server up` neither reads back
@@ -34,7 +36,7 @@
34
36
  //
35
37
  // Security (AGENTS.md): the agent token, master key, and optional bootstrap
36
38
  // claim secret ride ONLY in the 0600 deploy env-file fed to
37
- // `docker run --env-file` (ADR 0008 P4) — never inline on argv. `--env-file`
39
+ // `docker create --env-file` (ADR 0008 P4) — never inline on argv. `--env-file`
38
40
  // keeps them off the process argv (and out of any
39
41
  // argv-echoing error); it does NOT hide them from `docker inspect .Config.Env`
40
42
  // (docker expands the file client-side into the same env list) — that's an
@@ -51,15 +53,18 @@ import { readEnvFile, writeEnvFile } from "../env.js";
51
53
  import { librarianDir } from "../paths.js";
52
54
  import { fetchLatestVersion } from "../status.js";
53
55
  import { enableBoot } from "./boot.js";
54
- import { writeDeployState } from "./deploy-state.js";
56
+ import { deployStatePath, readDeployState, writeDeployState, } from "./deploy-state.js";
57
+ import { CANONICAL_IMAGE_NAME, prepareRegistryImage, isReleasedVersionRef, selectDeploymentTarget, shortImageDigest, } from "./deployment-image.js";
55
58
  import { run, stream, which } from "./docker.js";
56
- import { preflight } from "./preflight.js";
59
+ import { dockerPreflight, sourcePreflight } from "./preflight.js";
57
60
  import { redactSecrets } from "./redact.js";
61
+ import { CANONICAL_SOURCE_REPOSITORY, isCanonicalSourceRemote, redactGitDiagnostics, } from "./source-repository.js";
62
+ import { acquireUpdateLock, updateLockPath } from "./update-lock.js";
58
63
  // Re-exported from its shared home so existing importers (`update.ts`) keep
59
64
  // working; new code should import from `./redact.js` directly.
60
65
  export { redactSecrets } from "./redact.js";
61
66
  /** The repository the deploy dir clones (same repo the latest-tag fetch targets). */
62
- export const REPO_URL = "https://github.com/code-ministry-ltd/the-librarian";
67
+ export const REPO_URL = CANONICAL_SOURCE_REPOSITORY;
63
68
  /** The container name every `server` command operates on (single instance per host). */
64
69
  export const CONTAINER_NAME = "the-librarian";
65
70
  /** The named data volume default (`--data-volume` overrides). The volume is sacred. */
@@ -118,10 +123,45 @@ export const SAVE_KEY_WARNING = "SAVE THIS KEY — excluded from backups";
118
123
  * the client `~/.librarian/env` (env.ts). 0600, by construction.
119
124
  */
120
125
  export const DEPLOY_ENV_FILE = "deploy.env";
126
+ /** Prefix for invocation-private credentials used while a candidate starts. */
127
+ export const STAGED_DEPLOY_ENV_FILE = "deploy.env.next";
121
128
  /** `<deployDir>/deploy.env` — the 0600 deploy env-file path within a deploy dir. */
122
129
  export function deployEnvFilePath(deployDir) {
123
130
  return path.join(deployDir, DEPLOY_ENV_FILE);
124
131
  }
132
+ /** An invocation-private staged env path, promoted only after health succeeds. */
133
+ export function stagedDeployEnvFilePath(deployDir, invocationId) {
134
+ if (!/^[a-zA-Z0-9_-]+$/.test(invocationId)) {
135
+ throw new UpError("Could not create a safe invocation ID for the staged deploy env-file.");
136
+ }
137
+ return path.join(deployDir, `${STAGED_DEPLOY_ENV_FILE}-${invocationId}`);
138
+ }
139
+ const realStagedEnvIdMinter = () => randomBytes(12).toString("hex");
140
+ let stagedEnvIdMinter = realStagedEnvIdMinter;
141
+ /** Inject the non-secret staged-file suffix (tests only). */
142
+ export function setStagedEnvIdMinter(next) {
143
+ stagedEnvIdMinter = next;
144
+ }
145
+ export function resetStagedEnvIdMinter() {
146
+ stagedEnvIdMinter = realStagedEnvIdMinter;
147
+ }
148
+ const realFinalizationRenamer = (source, destination) => fs.renameSync(source, destination);
149
+ let finalizationRenamer = realFinalizationRenamer;
150
+ /** Inject the two final promotion operations (tests force the second to fail). */
151
+ export function setFinalizationRenamer(next) {
152
+ finalizationRenamer = next;
153
+ }
154
+ export function resetFinalizationRenamer() {
155
+ finalizationRenamer = realFinalizationRenamer;
156
+ }
157
+ let finalizationRestorer = restoreFile;
158
+ /** Inject prior-file restoration failures (tests only). */
159
+ export function setFinalizationRestorer(next) {
160
+ finalizationRestorer = next;
161
+ }
162
+ export function resetFinalizationRestorer() {
163
+ finalizationRestorer = restoreFile;
164
+ }
125
165
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
126
166
  let sleepImpl = realSleep;
127
167
  /** Override the health-poll sleep (tests inject a no-op so polling is instant). */
@@ -175,6 +215,15 @@ export class UpError extends Error {
175
215
  this.name = "UpError";
176
216
  }
177
217
  }
218
+ /** Deployment-file promotion failed; tells update whether exact prior bytes were restored. */
219
+ export class DeploymentFinalizationError extends UpError {
220
+ priorFilesRestored;
221
+ constructor(message, priorFilesRestored) {
222
+ super(message);
223
+ this.priorFilesRestored = priorFilesRestored;
224
+ this.name = "DeploymentFinalizationError";
225
+ }
226
+ }
178
227
  /**
179
228
  * Construct the `docker run` argv (everything after `docker`). The SINGLE place
180
229
  * the run vector is assembled.
@@ -193,14 +242,14 @@ export class UpError extends Error {
193
242
  * {@link writeDeployEnvFile}), not on this argv.
194
243
  */
195
244
  export function buildRunArgs(input) {
196
- const { host, dataVolume, dashboardPort, dataDir, runAsUser, tag, envFile } = input;
245
+ const { host, dataVolume, dashboardPort, dataDir, runAsUser, restartPolicy = "unless-stopped", imageRef, envFile, } = input;
197
246
  const args = [
198
247
  "run",
199
248
  "-d",
200
249
  "--name",
201
250
  CONTAINER_NAME,
202
251
  "--restart",
203
- "unless-stopped",
252
+ restartPolicy,
204
253
  // Publish the dashboard on the chosen HOST port; the container always listens
205
254
  // on 3000 internally (image PORT=3000), so only the left side varies.
206
255
  "-p",
@@ -217,17 +266,13 @@ export function buildRunArgs(input) {
217
266
  // and writable by — the operator, not the image's default user.
218
267
  if (runAsUser)
219
268
  args.push("--user", runAsUser);
220
- args.push(`${CONTAINER_NAME}:${tag}`);
269
+ args.push(imageRef);
221
270
  return args;
222
271
  }
223
- /**
224
- * Resolve a user-supplied `--data-dir` to an absolute host path, creating it if
225
- * absent. Absolute because docker treats a RELATIVE `-v` source as a volume name.
226
- */
227
- function resolveHostDataDir(input) {
228
- const abs = path.resolve(input);
229
- fs.mkdirSync(abs, { recursive: true });
230
- return abs;
272
+ /** Build the detached candidate configuration without starting it. */
273
+ export function buildCreateArgs(input) {
274
+ const runArgs = buildRunArgs(input);
275
+ return ["create", ...runArgs.slice(2)];
231
276
  }
232
277
  /**
233
278
  * The `uid:gid` that owns a path — the user the container runs as for a
@@ -250,7 +295,7 @@ export function dirOwner(dir) {
250
295
  * raw value is safe; we reject a value with a newline (it would corrupt the file
251
296
  * / smuggle a second var) rather than emit a malformed file.
252
297
  */
253
- export function writeDeployEnvFile(deployDir, input) {
298
+ function writeDeployEnvFileAt(file, input) {
254
299
  const secretKey = input.secretKey?.trim() ?? "";
255
300
  const bootstrapClaimSecret = input.bootstrapClaimSecret ?? "";
256
301
  for (const [name, value] of [
@@ -263,7 +308,7 @@ export function writeDeployEnvFile(deployDir, input) {
263
308
  }
264
309
  }
265
310
  validateBootstrapClaimSecret(bootstrapClaimSecret);
266
- fs.mkdirSync(deployDir, { recursive: true });
311
+ fs.mkdirSync(path.dirname(file), { recursive: true });
267
312
  const lines = [`LIBRARIAN_AGENT_TOKEN=${input.agentToken}`];
268
313
  // Omit the key line entirely when absent (update's read-back-failed path) — the
269
314
  // server then resolves it from /data/secret.key, preserving encrypted secrets.
@@ -276,13 +321,18 @@ export function writeDeployEnvFile(deployDir, input) {
276
321
  if (input.host === LOCALHOST) {
277
322
  lines.push("LIBRARIAN_ALLOW_NO_AUTH=true");
278
323
  }
279
- const file = deployEnvFilePath(deployDir);
280
324
  fs.writeFileSync(file, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
281
325
  // writeFileSync only applies `mode` on create; chmod unconditionally so a
282
326
  // pre-existing looser file is tightened (env.ts discipline).
283
327
  fs.chmodSync(file, 0o600);
284
328
  return file;
285
329
  }
330
+ export function writeDeployEnvFile(deployDir, input) {
331
+ return writeDeployEnvFileAt(deployEnvFilePath(deployDir), input);
332
+ }
333
+ export function writeStagedDeployEnvFile(deployDir, input) {
334
+ return writeDeployEnvFileAt(stagedDeployEnvFilePath(deployDir, stagedEnvIdMinter()), input);
335
+ }
286
336
  /**
287
337
  * Parse an existing deploy env-file into a `KEY=VALUE` record, or `{}` when absent.
288
338
  * `up` uses this to REUSE the master key and optional bootstrap-claim secret
@@ -325,8 +375,14 @@ export async function runUp(options, deps) {
325
375
  // Progress to stderr (the stdout result carries the master key) so a long `up`
326
376
  // shows where it is + what remains, instead of a blank line.
327
377
  const log = deps.log ?? ((line) => void process.stderr.write(`${line}\n`));
328
- // 1) Preflight: docker (daemon reachable) + git, or a teaching error.
329
- await preflight(deps.platform ? { platform: deps.platform } : {});
378
+ // 1) Select before preflight: stable releases need Docker only; arbitrary refs
379
+ // keep the source path's Docker + Git contract.
380
+ const target = selectDeploymentTarget(options.ref);
381
+ const preflightOptions = deps.platform ? { platform: deps.platform } : {};
382
+ if (target.imageSource === "registry")
383
+ await dockerPreflight(preflightOptions);
384
+ else
385
+ await sourcePreflight(preflightOptions);
330
386
  // 2) Resolve the bind host (default loopback; Tailscale offer; `0.0.0.0`
331
387
  // ask-first). May throw `UpError` if the user declines a `0.0.0.0` bind —
332
388
  // BEFORE any clone/build/run, so a declined exposure leaves nothing behind.
@@ -350,89 +406,242 @@ export async function runUp(options, deps) {
350
406
  if (options.dataDir && options.dataVolume) {
351
407
  throw new UpError("Pass either --data-dir (a host directory) or --data-volume (a Docker volume), not both.");
352
408
  }
353
- const dataDir = options.dataDir ? resolveHostDataDir(options.dataDir) : undefined;
354
- const runAsUser = dataDir ? dirOwner(dataDir) : undefined;
355
- // 3) Resolve the ref (default = latest release tag), then the deploy dir.
356
- log("[1/5] Resolving the latest release…");
357
- const tag = await resolveRef(options.ref);
358
- log(`[2/5] Preparing the deploy directory at ${deployDir} (cloning the repository)…`);
359
- await prepareDeployDir(deployDir, tag);
360
- const existingDeployEnv = readDeployEnvFile(deployDir);
361
- const bootstrapClaimSecret = suppliedBootstrapClaimSecret === undefined
362
- ? existingDeployEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET
363
- : suppliedBootstrapClaimSecret || undefined;
364
- validateBootstrapClaimSecret(bootstrapClaimSecret ?? "");
365
- // 4) Mint the secrets the CLI owns (the loop-closer). Both are CSPRNG and
366
- // NEVER logged: the agent token, and — ADR 0008 P4 — the master key. The
367
- // CLI minting the master key (env wins in core's `env → file → generate`)
368
- // is what keeps it OFF `/data/secret.key`. They ride only in the 0600
369
- // deploy env-file fed to `--env-file`, never inline on argv.
370
- // The MASTER KEY must NOT be re-minted on a re-run — that orphans every
371
- // secret encrypted under the previous key. If the deploy env-file already
372
- // carries one, REUSE it; only a first deploy (no existing key) mints +
373
- // surfaces a new one. (Mirrors `update`'s preserve-don't-mint rule.)
374
- const agentToken = minter();
375
- const existingKey = existingDeployEnv.LIBRARIAN_SECRET_KEY?.trim() || undefined;
376
- const masterKey = existingKey ?? mintSecretKey();
377
- const mintedKey = existingKey === undefined;
378
- const envFile = writeDeployEnvFile(deployDir, {
379
- agentToken,
380
- secretKey: masterKey,
381
- bootstrapClaimSecret,
382
- host,
383
- });
384
- // 5) Build the image, then run the container (secrets via `--env-file`).
385
- log(`[3/5] Building the image ${CONTAINER_NAME}:${tag} — the slow step: pulling the base ` +
386
- `image, installing dependencies, and downloading the embeddings model. Expect several ` +
387
- `minutes on a first run; live build output follows.`);
388
- await build(deployDir, tag);
389
- log("[4/5] Starting the container…");
390
- await dockerRun(buildRunArgs({ host, dataVolume, dashboardPort, dataDir, runAsUser, tag, envFile }), deployDir);
391
- // 6+7) Wait for health. ANY failure in this post-`docker run` phase — a
392
- // timeout/unhealthy report or an exception from `docker inspect`/`sleep`
393
- // — MUST force-remove the container so no half-up state is left behind
394
- // (spec §11). `waitForHealthy` already rolls back on its own
395
- // timeout/unhealthy path; this guard catches the throwing cases it
396
- // can't (the second `rm -f` is best-effort + idempotent).
397
- //
398
- // ADR 0008 P4: the master key is no longer READ BACK from the container
399
- // (`docker exec cat /data/secret.key`) — the CLI minted it and supplied
400
- // it via env, so the server never writes that file. We surface the
401
- // key we minted.
402
- log("[5/5] Waiting for the server to become healthy…");
409
+ // Resolve without creating yet: a registry pull/metadata failure must leave no
410
+ // new host material. Source behavior still creates it before clone/build.
411
+ const dataDir = options.dataDir ? path.resolve(options.dataDir) : undefined;
412
+ if (target.imageSource === "source" && dataDir)
413
+ fs.mkdirSync(dataDir, { recursive: true });
414
+ // 3) Resolve the ref, then prepare either the immutable registry image or the
415
+ // source checkout/build. Stable failures never fall back to source.
416
+ if (target.imageSource === "registry") {
417
+ log(target.ref
418
+ ? `[1/5] Selecting exact published release ${target.ref}…`
419
+ : "[1/5] Resolving the latest published release…");
420
+ }
421
+ else {
422
+ log(`[1/5] Selecting source ref ${target.ref}…`);
423
+ }
424
+ const tag = target.imageSource === "registry"
425
+ ? await resolveRegistryRef(target.ref)
426
+ : await resolveRef(target.ref);
427
+ // The shared deployment lock lives inside deployDir. An initial source clone
428
+ // must therefore happen before acquiring it; otherwise the lockfile itself
429
+ // makes the clone target non-empty. Existing managed clones are prepared
430
+ // under the lock below, alongside the rest of the deployment transaction.
431
+ const sourceNeedsInitialPreparation = target.imageSource === "source" && !(await pathExists(path.join(deployDir, ".git")));
432
+ if (sourceNeedsInitialPreparation) {
433
+ log(`[2/5] Preparing the source checkout at ${deployDir} (cloning the repository)…`);
434
+ await prepareDeployDir(deployDir, tag);
435
+ }
436
+ // `server up` and every update path share one host-level critical section.
437
+ // From the first container inspect through env/state finalization, no loser
438
+ // may observe or mutate the winner's deployment files or candidate.
439
+ const deploymentLockPath = updateLockPath({ home: deps.home, dir: options.dir });
440
+ const deploymentLock = acquireUpdateLock(deploymentLockPath);
441
+ if (!deploymentLock) {
442
+ throw new UpError(`Another server deployment is already in progress (lock: ${deploymentLockPath}). ` +
443
+ "Wait for it to finish, then re-run `librarian server up`.");
444
+ }
445
+ let lockReleased = false;
446
+ const releaseDeploymentLock = () => {
447
+ if (lockReleased)
448
+ return;
449
+ lockReleased = true;
450
+ deploymentLock.release();
451
+ };
452
+ let completedDeployment;
403
453
  try {
404
- await waitForHealthy(options);
405
- log("✓ The server is healthy.");
454
+ const existingDeployEnv = readDeployEnvFile(deployDir);
455
+ const bootstrapClaimSecret = suppliedBootstrapClaimSecret === undefined
456
+ ? existingDeployEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET
457
+ : suppliedBootstrapClaimSecret || undefined;
458
+ validateBootstrapClaimSecret(bootstrapClaimSecret ?? "");
459
+ const existingContainer = await inspectContainerPresence();
460
+ if (existingContainer.exists) {
461
+ if (target.imageSource !== "registry")
462
+ throw existingContainerError([]);
463
+ const existingState = readDeployState(deployDir);
464
+ const drift = registryDeploymentDrift(deployDir, tag, existingState, existingContainer.container, {
465
+ host,
466
+ dataVolume,
467
+ dataDir,
468
+ dashboardPort,
469
+ bootstrapClaimSecret,
470
+ });
471
+ if (drift.length > 0)
472
+ throw existingContainerError(drift);
473
+ if (existingState?.imageSource !== "registry") {
474
+ throw existingContainerError(["persisted deployment state"]);
475
+ }
476
+ log("✓ The requested release is already running and healthy.");
477
+ releaseDeploymentLock();
478
+ return alreadyRunningOutput(`published ${tag} (${shortImageDigest(existingState.imageDigest)})`, host, dashboardPort, await enableBootOutput(options, deps));
479
+ }
480
+ let registryImage;
481
+ if (target.imageSource === "registry") {
482
+ log(`[2/5] Pulling and validating ${tag} from the release registry…`);
483
+ registryImage = await prepareRegistryImage(tag, (chunk) => {
484
+ if (deps.log) {
485
+ for (const line of chunk.split("\n"))
486
+ if (line.length > 0)
487
+ log(line);
488
+ }
489
+ else {
490
+ process.stderr.write(chunk);
491
+ }
492
+ });
493
+ }
494
+ else if (!sourceNeedsInitialPreparation) {
495
+ log(`[2/5] Preparing the source checkout at ${deployDir} (cloning the repository)…`);
496
+ await prepareDeployDir(deployDir, tag);
497
+ }
498
+ if (target.imageSource === "registry" && dataDir)
499
+ fs.mkdirSync(dataDir, { recursive: true });
500
+ const runAsUser = dataDir ? dirOwner(dataDir) : undefined;
501
+ // 4) Source targets build locally; registry targets are already verified and
502
+ // run only by immutable digest. No credential material is staged until this
503
+ // preparation has succeeded.
504
+ let runImageRef;
505
+ if (registryImage) {
506
+ log(`[3/5] Release image verified at ${registryImage.imageDigest}.`);
507
+ runImageRef = registryImage.imageDigest;
508
+ }
509
+ else {
510
+ log(`[3/5] Building source image ${CONTAINER_NAME}:${tag} — the slow step: pulling the base ` +
511
+ `image, installing dependencies, and downloading the embeddings model. Expect several ` +
512
+ `minutes on a first run; live build output follows.`);
513
+ await build(deployDir, tag);
514
+ runImageRef = `${CONTAINER_NAME}:${tag}`;
515
+ }
516
+ // 5) Mint the secrets the CLI owns (the loop-closer). Both are CSPRNG and
517
+ // NEVER logged: the agent token, and — ADR 0008 P4 — the master key. The
518
+ // MASTER KEY must NOT be re-minted on a re-run — that orphans every secret
519
+ // encrypted under the previous key. A candidate env-file is kept separate
520
+ // from the live one until the new container reports healthy.
521
+ const agentToken = minter();
522
+ const existingKey = existingDeployEnv.LIBRARIAN_SECRET_KEY?.trim() || undefined;
523
+ const masterKey = existingKey ?? mintSecretKey();
524
+ const mintedKey = existingKey === undefined;
525
+ const envFile = writeStagedDeployEnvFile(deployDir, {
526
+ agentToken,
527
+ secretKey: masterKey,
528
+ bootstrapClaimSecret,
529
+ host,
530
+ });
531
+ const nextDeployState = registryImage
532
+ ? {
533
+ containerName: CONTAINER_NAME,
534
+ host,
535
+ dataVolume,
536
+ dataDir,
537
+ dashboardPort,
538
+ ref: tag,
539
+ imageTag: registryImage.imageRef,
540
+ imageSource: "registry",
541
+ imageRef: registryImage.imageRef,
542
+ imageDigest: registryImage.imageDigest,
543
+ }
544
+ : {
545
+ containerName: CONTAINER_NAME,
546
+ host,
547
+ dataVolume,
548
+ dataDir,
549
+ dashboardPort,
550
+ ref: tag,
551
+ imageTag: `${CONTAINER_NAME}:${tag}`,
552
+ imageSource: "source",
553
+ imageRef: `${CONTAINER_NAME}:${tag}`,
554
+ };
555
+ log("[4/5] Starting the container…");
556
+ const createArgs = buildCreateArgs({
557
+ host,
558
+ dataVolume,
559
+ dashboardPort,
560
+ dataDir,
561
+ runAsUser,
562
+ imageRef: runImageRef,
563
+ envFile,
564
+ });
565
+ let candidateId = null;
566
+ let healthRollbackComplete = false;
567
+ try {
568
+ const createResult = await run("docker", createArgs, { cwd: deployDir });
569
+ failIfNonZero("docker", createArgs, createResult);
570
+ const returnedId = createResult.stdout.trim();
571
+ if (!/^[0-9a-f]{64}$/.test(returnedId)) {
572
+ throw new UpError("Docker created the candidate container but did not return its full immutable ID. " +
573
+ `Refusing unsafe cleanup by mutable name; inspect ${CONTAINER_NAME} manually before retrying.`);
574
+ }
575
+ candidateId = returnedId;
576
+ const startArgs = ["start", candidateId];
577
+ const startResult = await run("docker", startArgs, { cwd: deployDir });
578
+ failIfNonZero("docker", startArgs, startResult);
579
+ // 6+7) Wait for health. ANY failure in this post-`docker start` phase — a
580
+ // timeout/unhealthy report or an exception from `docker inspect`/`sleep`
581
+ // — MUST force-remove the container so no half-up state is left behind
582
+ // (spec §11). `waitForHealthy` already rolls back on its own
583
+ // timeout/unhealthy path; this guard catches the throwing cases it
584
+ // can't. Every cleanup targets the immutable candidate ID.
585
+ //
586
+ // ADR 0008 P4: the master key is no longer READ BACK from the container
587
+ // (`docker exec cat /data/secret.key`) — the CLI minted it and supplied
588
+ // it via env, so the server never writes that file. We surface the
589
+ // key we minted.
590
+ log("[5/5] Waiting for the server to become healthy…");
591
+ await waitForHealthy({ ...options, containerIdentity: candidateId });
592
+ log("✓ The server is healthy.");
593
+ // Promote the non-secret deploy state and the secret-bearing env-file as
594
+ // one recoverable operation. A failed promotion restores both exact prior
595
+ // files (or their absence) before the candidate is removed.
596
+ finalizeDeploymentFiles(deployDir, envFile, nextDeployState);
597
+ candidateId = null;
598
+ }
599
+ catch (error) {
600
+ if (error instanceof HealthRollbackCompleteError) {
601
+ healthRollbackComplete = true;
602
+ candidateId = null;
603
+ }
604
+ if (candidateId && !healthRollbackComplete) {
605
+ try {
606
+ await removeAttemptContainer(candidateId);
607
+ candidateId = null;
608
+ }
609
+ catch (cleanupError) {
610
+ removeStagedEnv(envFile);
611
+ throw cleanupError;
612
+ }
613
+ if (error instanceof CleanupError) {
614
+ removeStagedEnv(envFile);
615
+ throw new UpError("The candidate container could not start or become healthy. The first cleanup " +
616
+ "attempt failed, but a retry " +
617
+ "removed the candidate container; the data volume is untouched. Fix the startup " +
618
+ "cause, then re-run `librarian server up`.");
619
+ }
620
+ }
621
+ removeStagedEnv(envFile);
622
+ throw error;
623
+ }
624
+ completedDeployment = {
625
+ agentToken,
626
+ masterKey,
627
+ mintedKey,
628
+ deploymentIdentity: registryImage
629
+ ? `published ${tag} (${shortImageDigest(registryImage.imageDigest)})`
630
+ : `source ${tag}`,
631
+ };
406
632
  }
407
- catch (error) {
408
- await run("docker", ["rm", "-f", CONTAINER_NAME]).catch(() => undefined);
409
- throw error;
633
+ finally {
634
+ releaseDeploymentLock();
410
635
  }
411
- // 7b) Persist the NON-SECRET deploy-state so `update` can recreate the
412
- // container with the same config and `status` can report the deployed
413
- // ref reliably. This carries the bind host / data volume / ref / image
414
- // tag / container name — NEVER a token or key (deploy-state.ts whitelists
415
- // the fields). Only after a confirmed-healthy run, so the recorded state
416
- // reflects a container that actually came up.
417
- writeDeployState(deployDir, {
418
- containerName: CONTAINER_NAME,
419
- host,
420
- dataVolume,
421
- dataDir,
422
- dashboardPort,
423
- ref: tag,
424
- imageTag: `${CONTAINER_NAME}:${tag}`,
425
- });
636
+ // A successful locked body always assigns this before releasing the lock;
637
+ // every failure throws through the finally above.
638
+ const { agentToken, masterKey, mintedKey, deploymentIdentity } = completedDeployment;
426
639
  // 8) Boot persistence (opt-in, spec §5.8). With `--enable-boot`, install +
427
640
  // enable the systemd unit AFTER a healthy up (so the named container the
428
641
  // unit references actually exists). On macOS this prints the deferred
429
642
  // notice and continues — the `up` still succeeds. The unit references the
430
643
  // EXISTING container by name and carries NO secret (boot.ts).
431
- const lines = [];
432
- if (options.enableBoot) {
433
- const bootResult = await enableBoot(deps.platform ? { platform: deps.platform } : {});
434
- lines.push(bootResult.output, "");
435
- }
644
+ const lines = await enableBootOutput(options, deps);
436
645
  // 9) Close the loop: surface secrets/URLs + offer the local env write.
437
646
  await closeTheLoop(lines, {
438
647
  host,
@@ -440,6 +649,7 @@ export async function runUp(options, deps) {
440
649
  agentToken,
441
650
  masterKey,
442
651
  mintedKey,
652
+ deploymentIdentity,
443
653
  options,
444
654
  deps,
445
655
  });
@@ -559,6 +769,249 @@ async function resolveRef(ref) {
559
769
  // `fetchLatestVersion` strips the leading `v`; the tag we check out keeps it.
560
770
  return `v${latest}`;
561
771
  }
772
+ /** Resolve an omitted stable ref and reject any non-release response fail-closed. */
773
+ async function resolveRegistryRef(ref) {
774
+ if (ref !== undefined)
775
+ return ref;
776
+ const latest = await fetchLatestVersion();
777
+ const candidate = latest ? `v${latest}` : "";
778
+ if (!candidate || !isReleasedVersionRef(candidate)) {
779
+ throw new UpError("Could not resolve a valid latest stable release tag from GitHub. Check your network and " +
780
+ "GitHub availability, or pin an exact stable tag with `--ref vX.Y.Z`. Stable installs " +
781
+ "never fall back to a source build.");
782
+ }
783
+ return candidate;
784
+ }
785
+ function verifiedNotFound(result, identity = CONTAINER_NAME) {
786
+ const detail = `${result.stdout}\n${result.stderr}`;
787
+ const escapedIdentity = identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
788
+ return (new RegExp(`no such (?:object|container):?\\s*${escapedIdentity}(?:\\s|$)`, "i").test(detail) ||
789
+ new RegExp(`no container .*${escapedIdentity}.* found`, "i").test(detail));
790
+ }
791
+ /** Inspect the fixed container name without treating daemon/permission failures as absence. */
792
+ async function inspectContainerPresence() {
793
+ const result = await run("docker", [
794
+ "container",
795
+ "inspect",
796
+ "--format",
797
+ "{{json .}}",
798
+ CONTAINER_NAME,
799
+ ]);
800
+ if (result.code !== 0) {
801
+ if (verifiedNotFound(result))
802
+ return { exists: false };
803
+ const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
804
+ throw new UpError(`Could not inspect the existing ${CONTAINER_NAME} container` +
805
+ (detail ? `: ${detail}` : ".") +
806
+ " Fix Docker access, then re-run `librarian server up`.");
807
+ }
808
+ try {
809
+ const parsed = JSON.parse(result.stdout);
810
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
811
+ throw new Error();
812
+ return { exists: true, container: parsed };
813
+ }
814
+ catch {
815
+ throw new UpError(`Docker returned malformed metadata for the existing ${CONTAINER_NAME} container. ` +
816
+ "Refusing to replace it; inspect or remove it manually, then re-run `librarian server up`.");
817
+ }
818
+ }
819
+ function envRecord(value) {
820
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))
821
+ return null;
822
+ const record = {};
823
+ for (const entry of value) {
824
+ const equals = entry.indexOf("=");
825
+ if (equals > 0)
826
+ record[entry.slice(0, equals)] = entry.slice(equals + 1);
827
+ }
828
+ return record;
829
+ }
830
+ function exactPortBinding(bindings, key, host, port) {
831
+ if (!bindings || typeof bindings !== "object" || Array.isArray(bindings))
832
+ return false;
833
+ const value = bindings[key];
834
+ return (Array.isArray(value) &&
835
+ value.length === 1 &&
836
+ value[0] !== null &&
837
+ typeof value[0] === "object" &&
838
+ !Array.isArray(value[0]) &&
839
+ value[0].HostIp === host &&
840
+ value[0].HostPort === String(port));
841
+ }
842
+ /** Exact live + persisted identity check performed before any registry access. */
843
+ function registryDeploymentDrift(deployDir, ref, state, container, desired) {
844
+ const drift = [];
845
+ if (!state ||
846
+ state.imageSource !== "registry" ||
847
+ state.ref !== ref ||
848
+ state.imageRef !== `${CANONICAL_IMAGE_NAME}:${ref}` ||
849
+ state.imageTag !== `${CANONICAL_IMAGE_NAME}:${ref}` ||
850
+ !state.imageDigest ||
851
+ state.host !== desired.host ||
852
+ state.dataVolume !== desired.dataVolume ||
853
+ state.dataDir !== desired.dataDir ||
854
+ state.dashboardPort !== desired.dashboardPort) {
855
+ drift.push("persisted deployment state");
856
+ }
857
+ const digest = state?.imageSource === "registry" ? state.imageDigest : undefined;
858
+ if (container.State?.Health?.Status !== "healthy")
859
+ drift.push("health status");
860
+ if (!digest || container.Config?.Image !== digest)
861
+ drift.push("image digest");
862
+ const mounts = Array.isArray(container.Mounts)
863
+ ? container.Mounts.filter((mount) => mount !== null &&
864
+ typeof mount === "object" &&
865
+ !Array.isArray(mount) &&
866
+ mount.Destination === "/data")
867
+ : [];
868
+ const mount = mounts.length === 1 ? mounts[0] : undefined;
869
+ if (!mount ||
870
+ (desired.dataDir
871
+ ? mount.Type !== "bind" || mount.Source !== desired.dataDir
872
+ : mount.Type !== "volume" || mount.Name !== desired.dataVolume)) {
873
+ drift.push("/data mount source or type");
874
+ }
875
+ const bindings = container.HostConfig?.PortBindings;
876
+ if (!bindings ||
877
+ typeof bindings !== "object" ||
878
+ Array.isArray(bindings) ||
879
+ Object.keys(bindings).sort().join(",") !== `3000/tcp,${MCP_PUBLISHED_PORT}/tcp` ||
880
+ !exactPortBinding(bindings, "3000/tcp", desired.host, desired.dashboardPort) ||
881
+ !exactPortBinding(bindings, `${MCP_PUBLISHED_PORT}/tcp`, desired.host, MCP_PUBLISHED_PORT)) {
882
+ drift.push("published host or ports");
883
+ }
884
+ if (container.HostConfig?.RestartPolicy?.Name !== "unless-stopped") {
885
+ drift.push("restart policy");
886
+ }
887
+ let expectedUser = "node";
888
+ if (desired.dataDir) {
889
+ try {
890
+ expectedUser = dirOwner(desired.dataDir);
891
+ }
892
+ catch {
893
+ drift.push("bind-mounted data directory");
894
+ }
895
+ }
896
+ if (container.Config?.User !== expectedUser)
897
+ drift.push("container user");
898
+ const persistedEnv = readDeployEnvFile(deployDir);
899
+ const liveEnv = envRecord(container.Config?.Env);
900
+ const requiredRuntime = {
901
+ LIBRARIAN_DATA_DIR: "/data",
902
+ LIBRARIAN_HOST: ALL_INTERFACES,
903
+ LIBRARIAN_PORT: String(MCP_PUBLISHED_PORT),
904
+ PORT: "3000",
905
+ };
906
+ let envMatches = liveEnv !== null;
907
+ for (const [name, value] of Object.entries(requiredRuntime)) {
908
+ if (liveEnv?.[name] !== value)
909
+ envMatches = false;
910
+ }
911
+ for (const name of [
912
+ "LIBRARIAN_AGENT_TOKEN",
913
+ "LIBRARIAN_SECRET_KEY",
914
+ "LIBRARIAN_BOOTSTRAP_CLAIM_SECRET",
915
+ ]) {
916
+ const expected = persistedEnv[name];
917
+ if ((liveEnv?.[name] || undefined) !== (expected || undefined))
918
+ envMatches = false;
919
+ }
920
+ if (!persistedEnv.LIBRARIAN_AGENT_TOKEN || !persistedEnv.LIBRARIAN_SECRET_KEY) {
921
+ envMatches = false;
922
+ }
923
+ if ((persistedEnv.LIBRARIAN_BOOTSTRAP_CLAIM_SECRET || undefined) !==
924
+ (desired.bootstrapClaimSecret || undefined)) {
925
+ envMatches = false;
926
+ }
927
+ const expectedNoAuth = desired.host === LOCALHOST ? "true" : undefined;
928
+ if ((liveEnv?.LIBRARIAN_ALLOW_NO_AUTH || undefined) !== expectedNoAuth)
929
+ envMatches = false;
930
+ if (!envMatches)
931
+ drift.push("authentication or runtime environment");
932
+ return drift;
933
+ }
934
+ async function enableBootOutput(options, deps) {
935
+ if (!options.enableBoot)
936
+ return [];
937
+ const bootResult = await enableBoot(deps.platform ? { platform: deps.platform } : {});
938
+ return [bootResult.output, ""];
939
+ }
940
+ function existingContainerError(drift) {
941
+ const detail = drift.length > 0 ? ` (${drift.join(", ")})` : "";
942
+ return new UpError(`The existing ${CONTAINER_NAME} container differs from the ` +
943
+ `requested healthy deployment${detail}. Refusing to pull, rewrite credentials, or replace ` +
944
+ "it. Run `librarian server down` if you intend to replace it, then retry.");
945
+ }
946
+ function alreadyRunningOutput(deploymentIdentity, host, dashboardPort, bootLines) {
947
+ const lines = [...bootLines];
948
+ lines.push(`The Librarian server is already running and healthy from ${deploymentIdentity}.`, "", ` MCP URL: http://${host}:${MCP_PUBLISHED_PORT}/mcp`, ` Dashboard: http://${host}:${dashboardPort}`);
949
+ return { output: lines.join("\n") };
950
+ }
951
+ export function removeStagedEnv(stagedEnvFile) {
952
+ try {
953
+ fs.rmSync(stagedEnvFile, { force: true });
954
+ }
955
+ catch {
956
+ // The primary deployment/cleanup error is more useful than a temp-file error.
957
+ }
958
+ }
959
+ function snapshotFile(file) {
960
+ try {
961
+ const stat = fs.statSync(file);
962
+ return { contents: fs.readFileSync(file), mode: stat.mode & 0o777 };
963
+ }
964
+ catch (error) {
965
+ if (error.code === "ENOENT")
966
+ return { contents: null };
967
+ throw error;
968
+ }
969
+ }
970
+ function restoreFile(file, snapshot) {
971
+ if (snapshot.contents === null) {
972
+ fs.rmSync(file, { force: true });
973
+ return;
974
+ }
975
+ fs.writeFileSync(file, snapshot.contents, { mode: snapshot.mode });
976
+ if (snapshot.mode !== undefined)
977
+ fs.chmodSync(file, snapshot.mode);
978
+ }
979
+ /** Promote env + state as one guarded unit, restoring exact prior bytes on failure. */
980
+ export function finalizeDeploymentFiles(deployDir, stagedEnvFile, state) {
981
+ const liveEnvFile = deployEnvFilePath(deployDir);
982
+ const liveStateFile = deployStatePath(deployDir);
983
+ const priorEnv = snapshotFile(liveEnvFile);
984
+ const priorState = snapshotFile(liveStateFile);
985
+ const stagedStateDir = fs.mkdtempSync(path.join(deployDir, ".deploy-state-next-"));
986
+ const stagedStateFile = deployStatePath(stagedStateDir);
987
+ try {
988
+ writeDeployState(stagedStateDir, state);
989
+ finalizationRenamer(stagedEnvFile, liveEnvFile);
990
+ finalizationRenamer(stagedStateFile, liveStateFile);
991
+ }
992
+ catch (error) {
993
+ try {
994
+ finalizationRestorer(liveEnvFile, priorEnv);
995
+ finalizationRestorer(liveStateFile, priorState);
996
+ }
997
+ catch (rollbackError) {
998
+ const detail = redactSecrets(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
999
+ throw new DeploymentFinalizationError(`Deployment persistence failed and prior files could not be restored${detail ? `: ${detail}` : "."} ` +
1000
+ "The candidate container will be removed; restore deploy.env and deploy-state.json from backup before retrying.", false);
1001
+ }
1002
+ const detail = redactSecrets(error instanceof Error ? error.message : String(error));
1003
+ throw new DeploymentFinalizationError(`Could not finalize deployment persistence${detail ? `: ${detail}` : "."} ` +
1004
+ "Prior deploy.env and deploy-state.json content was restored.", true);
1005
+ }
1006
+ finally {
1007
+ try {
1008
+ fs.rmSync(stagedStateDir, { recursive: true, force: true });
1009
+ }
1010
+ catch {
1011
+ // Non-secret staging residue must not override persistence/rollback truth.
1012
+ }
1013
+ }
1014
+ }
562
1015
  /**
563
1016
  * Ready the deploy dir at `tag`:
564
1017
  * - absent → `git clone <repo> <dir>` then checkout the ref;
@@ -583,24 +1036,14 @@ async function prepareDeployDir(dir, tag) {
583
1036
  }
584
1037
  // It's a git repo — confirm it's OUR clone before touching it.
585
1038
  const originResult = await run("git", ["-C", dir, "remote", "get-url", "origin"]);
586
- const origin = originResult.stdout.trim();
587
- if (!sameRepo(origin, REPO_URL)) {
588
- throw new UpError(`Deploy dir ${dir} is a git repo with a different remote (${origin || "none"}). ` +
1039
+ failIfNonZero("git", ["-C", dir, "remote", "get-url", "origin"], originResult, redactGitDiagnostics);
1040
+ if (!isCanonicalSourceRemote(originResult.stdout)) {
1041
+ throw new UpError(`Deploy dir ${dir} is a git repo with a different remote. ` +
589
1042
  "Refusing to touch a clone I didn't create — choose another path with `--dir <path>`.");
590
1043
  }
591
1044
  await git(["-C", dir, "fetch", "--tags", "origin"]);
592
1045
  await checkoutRef(dir, tag);
593
1046
  }
594
- /** True iff `origin` points at the same repo as `REPO_URL` (scheme/.git tolerant). */
595
- function sameRepo(origin, repo) {
596
- const norm = (u) => u
597
- .trim()
598
- .replace(/\.git$/, "")
599
- .replace(/\/$/, "")
600
- .replace(/^git@github\.com:/, "https://github.com/")
601
- .toLowerCase();
602
- return norm(origin) === norm(repo);
603
- }
604
1047
  /**
605
1048
  * Build the all-in-one image from the deploy dir, STREAMING docker's output live.
606
1049
  * This is the slow step (base-image pull, deps install, embeddings-model download);
@@ -628,9 +1071,20 @@ async function build(deployDir, tag) {
628
1071
  "Fix the error shown above, then re-run `librarian server up`.");
629
1072
  }
630
1073
  }
631
- /** Run the container (the assembled run argv) from the deploy dir. */
632
- async function dockerRun(args, deployDir) {
633
- await dockerInDir(args, deployDir);
1074
+ class CleanupError extends UpError {
1075
+ }
1076
+ class HealthRollbackCompleteError extends UpError {
1077
+ }
1078
+ /** Remove only the container created by this attempt, and verify the outcome. */
1079
+ async function removeAttemptContainer(candidateId) {
1080
+ const result = await run("docker", ["rm", "-f", candidateId]);
1081
+ if (result.code === 0 || verifiedNotFound(result, candidateId))
1082
+ return;
1083
+ const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
1084
+ throw new CleanupError(`Could not remove failed candidate container ${candidateId}` +
1085
+ (detail ? `: ${detail}` : ".") +
1086
+ ` The data volume was not removed. Run \`docker rm -f ${candidateId}\` manually, ` +
1087
+ "then re-run `librarian server up`.");
634
1088
  }
635
1089
  /**
636
1090
  * Poll `docker inspect … Health.Status` until `healthy`, bounded. On timeout or
@@ -645,6 +1099,7 @@ export async function waitForHealthy(options) {
645
1099
  const attempts = options.healthAttempts ?? 60;
646
1100
  const intervalMs = options.healthIntervalMs ?? 2000;
647
1101
  const tail = options.logTailLines ?? 50;
1102
+ const identity = options.containerIdentity ?? CONTAINER_NAME;
648
1103
  // Track whether ANY poll yielded a non-empty status. Snap docker's confinement
649
1104
  // does not emit stdout to a non-TTY pipe, so every `docker inspect` comes back
650
1105
  // exit-0-but-EMPTY — health can never be read, the loop times out, and `docker
@@ -657,7 +1112,7 @@ export async function waitForHealthy(options) {
657
1112
  "inspect",
658
1113
  "--format",
659
1114
  "{{.State.Health.Status}}",
660
- CONTAINER_NAME,
1115
+ identity,
661
1116
  ]);
662
1117
  if (result.code !== 0)
663
1118
  allInspectsOk = false; // a failing inspect is NOT the snap signature
@@ -677,15 +1132,15 @@ export async function waitForHealthy(options) {
677
1132
  // (defense-in-depth, e.g. an older image) — none of that may reach an error
678
1133
  // message (spec §5.6 wants logs surfaced, just not secrets). Then roll back so
679
1134
  // no half-up container survives.
680
- const logs = await run("docker", ["logs", "--tail", String(tail), CONTAINER_NAME]);
681
- await run("docker", ["rm", "-f", CONTAINER_NAME]);
1135
+ const logs = await run("docker", ["logs", "--tail", String(tail), identity]);
1136
+ await removeAttemptContainer(identity);
682
1137
  // Conservative snap-docker detection: every `docker inspect` SUCCEEDED (exit 0)
683
1138
  // yet returned empty output — never a single "starting"/"healthy"/"unhealthy".
684
1139
  // That exit-0-but-empty shape is snap's pipe confinement; a FAILING inspect
685
1140
  // (exit ≠ 0) is a different problem and falls through to the normal error below.
686
1141
  // The container may well be running fine — we just can't see it through snap.
687
1142
  if (!sawAnyStatus && allInspectsOk) {
688
- throw new UpError(`Could not read the container's health — every \`docker inspect\` returned empty ` +
1143
+ throw new HealthRollbackCompleteError(`Could not read the container's health — every \`docker inspect\` returned empty ` +
689
1144
  `output. That is the signature of snap docker, whose confinement does not emit ` +
690
1145
  `stdout to a non-TTY pipe, so \`librarian server\` cannot read health or logs (the ` +
691
1146
  `container itself may be running fine). \`librarian server\` is not supported on snap ` +
@@ -694,7 +1149,7 @@ export async function waitForHealthy(options) {
694
1149
  `volume is untouched).`);
695
1150
  }
696
1151
  const detail = redactSecrets(logs.stdout.trim() || logs.stderr.trim());
697
- throw new UpError(`The server did not become healthy in time and was rolled back ` +
1152
+ throw new HealthRollbackCompleteError(`The server did not become healthy in time and was rolled back ` +
698
1153
  `(container removed; the data volume is untouched). Recent logs:\n` +
699
1154
  (detail ? detail : "(no log output captured)") +
700
1155
  `\n\nFix the cause above, then re-run \`librarian server up\`.`);
@@ -713,10 +1168,10 @@ export async function waitForHealthy(options) {
713
1168
  * (internal listener only), so there is no admin token to mint or paste anywhere.
714
1169
  */
715
1170
  async function closeTheLoop(lines, ctx) {
716
- const { host, dashboardPort, agentToken, masterKey, mintedKey, options, deps } = ctx;
1171
+ const { host, dashboardPort, agentToken, masterKey, mintedKey, deploymentIdentity, options, deps, } = ctx;
717
1172
  const mcpUrl = `http://${host}:${MCP_PUBLISHED_PORT}/mcp`;
718
1173
  const dashboardUrl = `http://${host}:${dashboardPort}`;
719
- lines.push("The Librarian server is up and healthy.", "", ` MCP URL: ${mcpUrl}`, ` Dashboard: ${dashboardUrl}`, ` Agent token: ${agentToken}`, "");
1174
+ lines.push(`The Librarian server is up and healthy from ${deploymentIdentity}.`, "", ` MCP URL: ${mcpUrl}`, ` Dashboard: ${dashboardUrl}`, ` Agent token: ${agentToken}`, "");
720
1175
  // `0.0.0.0` is a bind directive, not a connectable address — point clients at
721
1176
  // the machine's real reachable IP rather than over-engineering auto-detection.
722
1177
  if (host === ALL_INTERFACES) {
@@ -777,7 +1232,7 @@ function isYes(answer) {
777
1232
  /** Run a `git …` command from anywhere; a non-zero exit is a teaching error. */
778
1233
  async function git(args) {
779
1234
  const result = await run("git", args);
780
- failIfNonZero("git", args, result);
1235
+ failIfNonZero("git", args, result, redactGitDiagnostics);
781
1236
  }
782
1237
  /**
783
1238
  * Check out `ref` in `dir` without letting a `--…`-shaped ref inject a git
@@ -797,15 +1252,10 @@ export async function checkoutRef(dir, ref) {
797
1252
  "--end-of-options",
798
1253
  `${ref}^{commit}`,
799
1254
  ]);
800
- failIfNonZero("git", ["-C", dir, "rev-parse", ref], resolved);
1255
+ failIfNonZero("git", ["-C", dir, "rev-parse", ref], resolved, redactGitDiagnostics);
801
1256
  await git(["-C", dir, "checkout", resolved.stdout.trim()]);
802
1257
  }
803
- /** Run a `docker …` command from the deploy dir; non-zero exit → teaching error. */
804
- async function dockerInDir(args, cwd) {
805
- const result = await run("docker", args, { cwd });
806
- failIfNonZero("docker", args, result);
807
- }
808
- function failIfNonZero(cmd, args, result) {
1258
+ function failIfNonZero(cmd, args, result, redact = redactSecrets) {
809
1259
  if (result.code === 0)
810
1260
  return;
811
1261
  // Redact in case a failed docker/git step echoed a secret-shaped value. Post
@@ -813,7 +1263,7 @@ function failIfNonZero(cmd, args, result) {
813
1263
  // NOT inline on argv — so an argv-echoing `build`/`run` failure no longer
814
1264
  // carries them. We still redact defensively (e.g. a daemon that prints the
815
1265
  // expanded env, or an older code path) so no 64-hex secret reaches the message.
816
- const detail = redactSecrets(result.stderr.trim() || result.stdout.trim());
1266
+ const detail = redact(result.stderr.trim() || result.stdout.trim());
817
1267
  throw new UpError(`\`${cmd} ${args[0]}\` failed (exit ${result.code ?? "signal"})` +
818
1268
  (detail ? `:\n${detail}` : ".") +
819
1269
  "\n\nResolve the error above, then re-run `librarian server up`.");