@celilo/cli 0.16.1 → 0.17.0

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.
@@ -197,6 +197,7 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
197
197
  - **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: for each eligible module, is its declared cadence due → back it up → apply declared retention) + `ensureBackupSweepSubscriber`. Driven by `celilo backup sweep` on `timer.tick.1h` — the coarsest tick that can still serve an `hourly` cadence. Armed automatically: `registerModuleSubscriptions` registers the subscriber for any module declaring an `on_backup` hook, so it appears on install or `module update`. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
198
198
  - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
199
199
  - **CLI** — `apps/celilo/src/cli/commands/` — `backup-sweep.ts`, `backup-create.ts` (also `celilo module backup`), `backup-list.ts`, `backup-restore.ts`, `backup-prune.ts`, `backup-delete.ts`, `backup-import.ts`, `backup-pull.ts`, `backup-name.ts`.
200
+ - **Storage destinations CLI** — `storage-add-local.ts`, `storage-add-s3.ts`, `storage-list.ts`, `storage-verify.ts`, `storage-set-default.ts`, `storage-set-path.ts` (relocate a local destination, migrating existing archives unless `--no-migrate`), `storage-remove.ts`. Any credential change goes through `updateStorageCredentials` in `backup-storage.ts`, which clears the verification stamp — a `✓ Verified` must never describe a destination it was not measured against (#566).
200
201
 
201
202
  ## Persistence
202
203
 
@@ -218,7 +219,8 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
218
219
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
219
220
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
220
221
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
221
- - **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`.
222
+ - **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
223
+ - **Module auto-upgrade (registry-poll CD)** — the *pull* half of continuous deployment: celilo-mgr polls the registry and upgrades opted-in modules unattended. Spec: `openspec/specs/module-auto-upgrade/spec.md`. Entry points: `apps/celilo/src/cli/commands/module-upgrade.ts` — `runRegistryPoll` (the `--poll` path), `selectPollTargets` (pure: `autoUpgrade && latest && change ∉ {up-to-date, ahead}`), `upgradeOneModule` (update → backup → deploy → verify), `needsPreUpgradeBackup`, `pickAutoUpgrade`/`pickUpgradePolicy` (both fail closed/safe); `classifyVersionChange` in `module-update.ts` (treats a registry `+N` revision as a patch); `resolveDeployPosture` in `apps/celilo/src/services/deploy-posture.ts`. Trigger: celilo-mgmt's `registry-poll` subscription (`modules/celilo-mgmt/manifest.yml`) on `timer.tick.15m` with handler **`celilo module upgrade --poll`** — the flag is REQUIRED, since the dispatcher appends the event id positionally and a bare handler would consume it as the optional module name (silent: 3108 deliveries, 0 successes). Operator controls are framework config keys settable on ANY module (`FRAMEWORK_CONFIG_KEYS` in `module-config.ts`): `auto_upgrade` (opt-in, default false) and `upgrade_policy` (`by-semver`|`always-safe`|`always-fast`), validated at set time because both readers fail open. ⚠️ `always-safe` guarantees safe *posture*, NOT a backup — `needsPreUpgradeBackup` also requires the TARGET manifest to declare an `on_backup` hook, else it warns and proceeds. Confirm a data-bearing module declares `on_backup` before enabling `auto_upgrade` on it. The *build* half (app CI publishing a `.netapp` on merge) is not yet shipped — `openspec/changes/build-bus-poll-cd`.
222
224
  - **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `openspec/changes/celilo-mcp-service/proposal.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
223
225
 
224
226
  ## E2E simulation
@@ -227,4 +229,5 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
227
229
  - **Run wrapper** — `infra/scripts/cele2e-run.sh` lives in the separate `infra/` clone, **not** in this repo. See the cele2e section of the repo-root `CLAUDE.md` for the operator workflow.
228
230
  - **Signal simulators** — three containers, three jobs. `docker/Dockerfile.signal-cli` runs the REAL unlinked daemon (`network().withSignalCli()`, reachable at `signal-cli.lab`) so `e2e/tests/signal-contract.test.ts` can re-check celilo's understanding of the JSON-RPC surface against the actual binary — the pebble pattern. `docker/Dockerfile.signal-sim` runs `simulators/signal-cli/server.ts` (`withSignalSim()`, `signal-sim.lab`), the drivable stand-in with a control surface (`/_control/inbound`, `/_control/sent`, `/_control/unlink`). `/_control/inbound` from the LINKED account's own number emits a `syncMessage.sentMessage` transcript rather than a `dataMessage`, because that is the only shape the real daemon delivers a note-to-self in — and a note-to-self is what every reply is in the default single-operator setup (#460). `docker/Dockerfile.signal-release` (`withSignalRelease()`, `signal-release.lab`) serves the signal-cli release tarball so the module's deploy-time download resolves inside the sealed network — the download is served, never skipped. The libsignal aarch64 native is compiled at `build-infra` time from the module's own recipe (`packages/e2e/scripts/stage-libsignal.ts` → `modules/signal/build/`) and staged into the apt-repo pool, so the recipe is exercised for real on every rebuild while the deploy stays fast and the network stays sealed.
229
231
  - **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `100.100.0.100`. The ISP edge `fw-ext` ROUTES the customer's `100.100.0.0/24` and must never re-NAT it (`-s 100.100.0.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
232
+ - **Simulated address plan** — `packages/e2e/src/types.ts` — `SIM_PRIVATE_SUPERNET` (`10.226.0.0/16`), `zoneIp(zone, host)`, `ZONE_SUBNETS`, `ZONE_GATEWAYS`. Every simulated PRIVATE zone is derived from this one table; the compose generator, `zone-classifier.ts` and the harness's `system init` all read it, so renumbering the whole sim is a one-line change. The sim deliberately does NOT reuse a real fleet's zone /24s (#539): the previous plan was byte-identical to production's, so a stack leaked on celilo's own forgejo-builder — which lives in the real dmz — claimed the builder's own subnet and blackholed every containerized CI job's route to the forge for ~135s. Teardown cannot prevent that (a SIGKILL runs no handler), so the addresses moved instead; the second, better reason is that a suite which only passes on production's exact octets is asserting one site's address plan rather than celilo's behaviour. `src/address-plan.test.ts` is the recurrence gate — it fails if a zone leaves the supernet, or if any retired fleet prefix reappears anywhere in `packages/e2e`, `e2e/tests` or `modules/*/e2e`.
230
233
  - **MCP server (agent-driven e2e)** — `packages/mcp-server/src/index.ts` — stdio MCP server exposing `start_run`/`run_status`/`run_result`/`stop_run` (detached cele2e runs read off the event bus, no ANSI scraping) + `env_check` (docker VM, run-lock, shared-infra, mgmt image CLI version, netapps). Dev/ops tool, `private`, not shipped to consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,8 +59,8 @@
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
60
  "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
- "@celilo/core": "^0.3.0",
63
- "@celilo/event-bus": "^0.1.9",
62
+ "@celilo/core": "^0.3.1",
63
+ "@celilo/event-bus": "^0.1.10",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -17,6 +17,7 @@ import { systemConfig } from '../../db/schema';
17
17
  import { makeReceiver, pollInbound } from '../../services/alerting/inbound-poller';
18
18
  import { startNotificationResponder } from '../../services/alerting/notification-responder';
19
19
  import { listRoutes } from '../../services/alerting/people';
20
+ import { readLastRead, writeLastRead } from '../../services/alerting/read-records';
20
21
  import { loadNotificationTransport } from '../../services/alerting/transport-loader';
21
22
  import type { CommandResult } from '../types';
22
23
 
@@ -52,6 +53,26 @@ function writeCursor(
52
53
  }
53
54
  }
54
55
 
56
+ /**
57
+ * " (last read OK 3h ago)" or " (never read successfully)".
58
+ *
59
+ * Appended to a read failure because the failure alone does not say how bad it
60
+ * is. One failed poll is a blip; a transport that has not been readable since
61
+ * Tuesday is an outage nobody was told about, and those two produced identical
62
+ * output until this record existed.
63
+ */
64
+ function sinceLastSuccess(db: ReturnType<typeof getDb>, transportModuleId: string): string {
65
+ const last = readLastRead(db, transportModuleId)?.lastSuccessAt;
66
+ if (!last) return ' (never read successfully)';
67
+ const ms = Date.now() - new Date(last).getTime();
68
+ const mins = Math.floor(ms / 60_000);
69
+ if (mins < 60) return ` (last read OK ${mins}m ago)`;
70
+ const hours = Math.floor(mins / 60);
71
+ return hours < 48
72
+ ? ` (last read OK ${hours}h ago)`
73
+ : ` (last read OK ${Math.floor(hours / 24)}d ago)`;
74
+ }
75
+
55
76
  export async function handleAlertsPoll(
56
77
  flags: Record<string, string | boolean> = {},
57
78
  ): Promise<CommandResult> {
@@ -97,6 +118,7 @@ export async function handleAlertsPoll(
97
118
  receiveFrom: makeReceiver(db),
98
119
  readCursor: (t) => readCursor(db, t),
99
120
  writeCursor: (t, c) => writeCursor(db, t, c),
121
+ recordRead: (t, r) => writeLastRead(db, t, r),
100
122
  now: () => new Date(),
101
123
  transportFor: (route) => loadNotificationTransport(db, route.transportModuleId),
102
124
  answerInterview: (eventId, value) => responder.answer(eventId, value),
@@ -122,7 +144,10 @@ export async function handleAlertsPoll(
122
144
  // an operator has no way to tell which — the ambiguity that made the Signal
123
145
  // ack path take a week to diagnose.
124
146
  const detail = [
125
- ...report.failures.map((f) => `${f.transportModuleId} COULD NOT BE READ: ${f.error}`),
147
+ ...report.failures.map(
148
+ (f) =>
149
+ `${f.transportModuleId} COULD NOT BE READ: ${f.error}${sinceLastSuccess(db, f.transportModuleId)}`,
150
+ ),
126
151
  ...report.unheard.map((u) => `not heard from ${u.senderAddress}: ${u.reason}`),
127
152
  ...askErrors,
128
153
  ];
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tests for `storage set-path`.
3
+ *
4
+ * The motivating case (#566) is celilo-mgr: `local-backups` points at
5
+ * `/Users/pbanka/hobby/backups/celilo-backups/`, a macOS path that came
6
+ * across when the database was restored onto a Linux host. The directory
7
+ * does not exist there. Relocating must therefore succeed with nothing
8
+ * migrated — not error, and not claim files were moved — and must not
9
+ * carry the four-month-old `✓ Verified` stamp onto the new path.
10
+ *
11
+ * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR are set before the SUT is
12
+ * imported, so nothing touches the production database.
13
+ */
14
+
15
+ import { afterAll, describe, expect, test } from 'bun:test';
16
+ import {
17
+ chmodSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ mkdtempSync,
21
+ rmSync,
22
+ statSync,
23
+ utimesSync,
24
+ writeFileSync,
25
+ } from 'node:fs';
26
+ import { homedir, tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
28
+
29
+ const testRoot = mkdtempSync(join(tmpdir(), 'celilo-setpath-'));
30
+ process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db');
31
+ process.env.CELILO_DATA_DIR = join(testRoot, 'data');
32
+
33
+ const {
34
+ addBackupStorage,
35
+ getBackupStorageByStorageId,
36
+ getStorageCredentials,
37
+ updateStorageCredentials,
38
+ } = await import('../../services/backup-storage');
39
+ const { handleStorageSetPath, inspectSourceDir, planRelocation, resolveTargetPath } = await import(
40
+ './storage-set-path'
41
+ );
42
+
43
+ afterAll(() => {
44
+ rmSync(testRoot, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('planRelocation (pure)', () => {
48
+ const base = { sourceDir: '/old/celilo-backups', targetDir: '/new/celilo-backups' };
49
+
50
+ test('migrates when the source has files', () => {
51
+ const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: true });
52
+ expect(plan.migrate).toBe(true);
53
+ expect(plan.note).toContain('Migrating archives');
54
+ });
55
+
56
+ test('PRODUCTION CASE: source missing — skips migration, says so, does not error', () => {
57
+ const plan = planRelocation({ ...base, sourceState: 'missing', migrateRequested: true });
58
+ expect(plan.migrate).toBe(false);
59
+ expect(plan.note).toContain('does not exist');
60
+ expect(plan.note).toContain('nothing to migrate');
61
+ });
62
+
63
+ test('source unreadable — skips migration and says nothing was moved', () => {
64
+ const plan = planRelocation({ ...base, sourceState: 'unreadable', migrateRequested: true });
65
+ expect(plan.migrate).toBe(false);
66
+ expect(plan.note).toContain('not readable');
67
+ });
68
+
69
+ test('source empty — skips migration', () => {
70
+ const plan = planRelocation({ ...base, sourceState: 'empty', migrateRequested: true });
71
+ expect(plan.migrate).toBe(false);
72
+ expect(plan.note).toContain('empty');
73
+ });
74
+
75
+ test('--no-migrate wins even over a populated source', () => {
76
+ const plan = planRelocation({ ...base, sourceState: 'populated', migrateRequested: false });
77
+ expect(plan.migrate).toBe(false);
78
+ expect(plan.note).toContain('--no-migrate');
79
+ });
80
+ });
81
+
82
+ describe('resolveTargetPath (pure)', () => {
83
+ test('rejects a path identical to the current one', () => {
84
+ const result = resolveTargetPath('/var/backups', '/var/backups/');
85
+ expect(result).toMatchObject({ success: false });
86
+ });
87
+
88
+ test('preserves a path containing a space', () => {
89
+ const result = resolveTargetPath('/tmp/back ups/celilo', '/somewhere/else');
90
+ expect(result).toEqual({ path: '/tmp/back ups/celilo' });
91
+ });
92
+
93
+ test('expands a leading tilde', () => {
94
+ const result = resolveTargetPath('~/backups', '/somewhere/else');
95
+ expect(result).toMatchObject({ path: join(homedir(), 'backups') });
96
+ });
97
+ });
98
+
99
+ describe('inspectSourceDir', () => {
100
+ test('missing directory reports missing, not unreadable', () => {
101
+ expect(inspectSourceDir(join(testRoot, 'no-such-dir'))).toBe('missing');
102
+ });
103
+
104
+ test('empty directory reports empty', () => {
105
+ const dir = join(testRoot, 'empty-dir');
106
+ mkdirSync(dir, { recursive: true });
107
+ expect(inspectSourceDir(dir)).toBe('empty');
108
+ });
109
+
110
+ test('directory with files reports populated', () => {
111
+ const dir = join(testRoot, 'full-dir');
112
+ mkdirSync(dir, { recursive: true });
113
+ writeFileSync(join(dir, 'a.backup'), 'x');
114
+ expect(inspectSourceDir(dir)).toBe('populated');
115
+ });
116
+
117
+ test('unreadable directory reports unreadable, not missing', () => {
118
+ const parent = join(testRoot, 'locked');
119
+ const dir = join(parent, 'celilo-backups');
120
+ mkdirSync(dir, { recursive: true });
121
+ chmodSync(parent, 0o000);
122
+ try {
123
+ expect(inspectSourceDir(dir)).toBe('unreadable');
124
+ } finally {
125
+ chmodSync(parent, 0o700);
126
+ }
127
+ });
128
+ });
129
+
130
+ describe('updateStorageCredentials', () => {
131
+ test('clears the verification stamp, so a stale ✓ never survives a credential change (#566)', async () => {
132
+ const storage = await addBackupStorage({
133
+ name: 'Stamp Backups',
134
+ providerName: 'local',
135
+ credentials: { path: '/Users/nobody/old' },
136
+ });
137
+
138
+ const { getDb } = await import('../../db/client');
139
+ const { backupStorages } = await import('../../db/schema');
140
+ const { eq } = await import('drizzle-orm');
141
+ getDb()
142
+ .update(backupStorages)
143
+ .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
144
+ .where(eq(backupStorages.id, storage.id))
145
+ .run();
146
+
147
+ await updateStorageCredentials(storage.id, { path: '/var/lib/celilo/backups' });
148
+
149
+ // Asserted WITHOUT a follow-up verify: even if celilo dies between
150
+ // the path change and re-verification, the row must not still claim
151
+ // the destination was verified.
152
+ const after = getBackupStorageByStorageId(storage.storageId);
153
+ expect(after?.verified).toBe(false);
154
+ expect(after?.verifiedAt).toBeNull();
155
+ });
156
+ });
157
+
158
+ describe('handleStorageSetPath (end to end, isolated DB)', () => {
159
+ test('PRODUCTION CASE: current path does not exist — path changes, nothing migrated, re-verified against the new path', async () => {
160
+ const storage = await addBackupStorage({
161
+ name: 'Ghost Backups',
162
+ providerName: 'local',
163
+ credentials: { path: '/Users/nobody/hobby/backups' },
164
+ });
165
+
166
+ // Simulate the stale stamp: the row says Verified from a host that
167
+ // no longer exists.
168
+ const { getDb } = await import('../../db/client');
169
+ const { backupStorages } = await import('../../db/schema');
170
+ const { eq } = await import('drizzle-orm');
171
+ getDb()
172
+ .update(backupStorages)
173
+ .set({ verified: true, verifiedAt: new Date('2026-04-08T02:24:43.000Z') })
174
+ .where(eq(backupStorages.id, storage.id))
175
+ .run();
176
+
177
+ const newPath = join(testRoot, 'relocated');
178
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
179
+
180
+ expect(result.success).toBe(true);
181
+
182
+ const creds = await getStorageCredentials(storage.id);
183
+ expect(creds).toMatchObject({ path: newPath });
184
+
185
+ // The stamp must describe the NEW path, not the dead one.
186
+ const after = getBackupStorageByStorageId(storage.storageId);
187
+ expect(after?.verified).toBe(true);
188
+ expect(after?.verifiedAt?.getTime()).toBeGreaterThan(
189
+ new Date('2026-04-08T02:24:43.000Z').getTime(),
190
+ );
191
+ });
192
+
193
+ test('moves existing archives to the new location', async () => {
194
+ const oldPath = join(testRoot, 'movable-old');
195
+ const newPath = join(testRoot, 'movable-new');
196
+ mkdirSync(join(oldPath, 'celilo-backups', '2026-08-01'), { recursive: true });
197
+ writeFileSync(join(oldPath, 'celilo-backups', '2026-08-01', 'x.backup'), 'payload');
198
+
199
+ const storage = await addBackupStorage({
200
+ name: 'Movable Backups',
201
+ providerName: 'local',
202
+ credentials: { path: oldPath },
203
+ });
204
+
205
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
206
+
207
+ expect(result.success).toBe(true);
208
+ expect(existsSync(join(newPath, 'celilo-backups', '2026-08-01', 'x.backup'))).toBe(true);
209
+ expect(existsSync(join(oldPath, 'celilo-backups'))).toBe(false);
210
+ });
211
+
212
+ test('preserves archive mtimes — a move does not restamp backups', async () => {
213
+ const oldPath = join(testRoot, 'mtime-old');
214
+ const newPath = join(testRoot, 'mtime-new');
215
+ const archive = join(oldPath, 'celilo-backups', 'a.backup');
216
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
217
+ writeFileSync(archive, 'payload');
218
+ const stamp = new Date('2026-04-26T01:37:53.740Z');
219
+ utimesSync(archive, stamp, stamp);
220
+
221
+ const storage = await addBackupStorage({
222
+ name: 'Mtime Backups',
223
+ providerName: 'local',
224
+ credentials: { path: oldPath },
225
+ });
226
+
227
+ await handleStorageSetPath([storage.storageId, newPath]);
228
+
229
+ const moved = statSync(join(newPath, 'celilo-backups', 'a.backup'));
230
+ expect(Math.round(moved.mtimeMs)).toBe(stamp.getTime());
231
+ });
232
+
233
+ test('--no-migrate leaves the old archives where they are', async () => {
234
+ const oldPath = join(testRoot, 'kept-old');
235
+ const newPath = join(testRoot, 'kept-new');
236
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
237
+ writeFileSync(join(oldPath, 'celilo-backups', 'y.backup'), 'payload');
238
+
239
+ const storage = await addBackupStorage({
240
+ name: 'Kept Backups',
241
+ providerName: 'local',
242
+ credentials: { path: oldPath },
243
+ });
244
+
245
+ const result = await handleStorageSetPath([storage.storageId, newPath], {
246
+ 'no-migrate': true,
247
+ });
248
+
249
+ expect(result.success).toBe(true);
250
+ expect(existsSync(join(oldPath, 'celilo-backups', 'y.backup'))).toBe(true);
251
+ expect(existsSync(join(newPath, 'celilo-backups', 'y.backup'))).toBe(false);
252
+ });
253
+
254
+ test('handles a new path containing a space', async () => {
255
+ const oldPath = join(testRoot, 'spacey-old');
256
+ const newPath = join(testRoot, 'back ups', "Bob's celilo");
257
+ mkdirSync(join(oldPath, 'celilo-backups'), { recursive: true });
258
+ writeFileSync(join(oldPath, 'celilo-backups', 'z.backup'), 'payload');
259
+
260
+ const storage = await addBackupStorage({
261
+ name: 'Spacey Backups',
262
+ providerName: 'local',
263
+ credentials: { path: oldPath },
264
+ });
265
+
266
+ const result = await handleStorageSetPath([storage.storageId, newPath]);
267
+
268
+ expect(result.success).toBe(true);
269
+ expect(existsSync(join(newPath, 'celilo-backups', 'z.backup'))).toBe(true);
270
+ });
271
+
272
+ test('rejects an unknown storage id', async () => {
273
+ const result = await handleStorageSetPath(['no-such-storage', join(testRoot, 'x')]);
274
+ expect(result).toMatchObject({ success: false });
275
+ });
276
+
277
+ test('requires both a storage id and a path', async () => {
278
+ const result = await handleStorageSetPath(['only-one-arg']);
279
+ expect(result).toMatchObject({ success: false });
280
+ });
281
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Storage Set Path Command
3
+ * Relocate a local storage destination to a new directory, migrating
4
+ * any archives that are actually there.
5
+ *
6
+ * Motivating case (#566): celilo-mgr's `local-backups` points at
7
+ * `/Users/pbanka/...`, a macOS path carried across when the database was
8
+ * restored onto a Linux host. The directory does not exist there, so the
9
+ * relocation must complete cleanly with nothing to migrate rather than
10
+ * erroring — and must not carry the old `✓ Verified` stamp onto the new
11
+ * path.
12
+ */
13
+
14
+ import { cpSync, readdirSync, rmSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, resolve } from 'node:path';
17
+ import {
18
+ getBackupStorageByStorageId,
19
+ getStorageCredentials,
20
+ updateStorageCredentials,
21
+ verifyBackupStorage,
22
+ } from '../../services/backup-storage';
23
+ import { BACKUP_PREFIX } from '../../services/storage-providers/local';
24
+ import { celiloIntro, celiloOutro } from '../prompts';
25
+ import type { CommandResult } from '../types';
26
+ import { probePathWriteable } from './storage-add-local';
27
+
28
+ /** What the current archive directory turned out to be, on disk. */
29
+ export type SourceState = 'missing' | 'unreadable' | 'empty' | 'populated';
30
+
31
+ export interface RelocationPlan {
32
+ /** Whether to copy the source tree to the target. */
33
+ migrate: boolean;
34
+ /** One line for the operator explaining what will (not) happen. */
35
+ note: string;
36
+ }
37
+
38
+ /**
39
+ * Decide whether to migrate. Pure — takes an already-observed source
40
+ * state so it is testable without a filesystem (Rule 10.4).
41
+ */
42
+ export function planRelocation(input: {
43
+ sourceState: SourceState;
44
+ sourceDir: string;
45
+ targetDir: string;
46
+ migrateRequested: boolean;
47
+ }): RelocationPlan {
48
+ const { sourceState, sourceDir, targetDir, migrateRequested } = input;
49
+
50
+ if (!migrateRequested) {
51
+ return { migrate: false, note: `Migration skipped (--no-migrate). ${sourceDir} left as-is.` };
52
+ }
53
+
54
+ switch (sourceState) {
55
+ case 'missing':
56
+ return { migrate: false, note: `${sourceDir} does not exist — nothing to migrate.` };
57
+ case 'unreadable':
58
+ return { migrate: false, note: `${sourceDir} is not readable — nothing migrated.` };
59
+ case 'empty':
60
+ return { migrate: false, note: `${sourceDir} is empty — nothing to migrate.` };
61
+ case 'populated':
62
+ return { migrate: true, note: `Migrating archives from ${sourceDir} to ${targetDir}.` };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Validate and normalise the requested path. Pure apart from `~`
68
+ * expansion, which reads the environment but touches no filesystem.
69
+ */
70
+ export function resolveTargetPath(
71
+ raw: string,
72
+ currentPath: string,
73
+ ): CommandResult | { path: string } {
74
+ const expanded = raw.startsWith('~/') || raw === '~' ? raw.replace('~', homedir()) : raw;
75
+ const resolved = resolve(expanded);
76
+
77
+ if (resolved === resolve(currentPath)) {
78
+ return { success: false, error: `Storage path is already '${resolved}' — nothing to do.` };
79
+ }
80
+
81
+ return { path: resolved };
82
+ }
83
+
84
+ /** Observe the archive directory. Distinguishes absent from unreadable. */
85
+ export function inspectSourceDir(dir: string): SourceState {
86
+ try {
87
+ return readdirSync(dir).length === 0 ? 'empty' : 'populated';
88
+ } catch (error) {
89
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unreadable';
90
+ }
91
+ }
92
+
93
+ export async function handleStorageSetPath(
94
+ args: string[],
95
+ flags: Record<string, boolean | string> = {},
96
+ ): Promise<CommandResult> {
97
+ try {
98
+ celiloIntro('Relocate Backup Storage');
99
+
100
+ const storageId = args[0];
101
+ const newPathArg = args[1];
102
+ if (!storageId || !newPathArg) {
103
+ return {
104
+ success: false,
105
+ error:
106
+ 'Storage ID and new path are required\n\nUsage: celilo storage set-path <storage-id> <new-path> [--no-migrate]',
107
+ };
108
+ }
109
+
110
+ const storage = getBackupStorageByStorageId(storageId);
111
+ if (!storage) {
112
+ return { success: false, error: `Storage not found: ${storageId}` };
113
+ }
114
+ if (storage.providerName !== 'local') {
115
+ return {
116
+ success: false,
117
+ error: `'${storage.storageId}' is a ${storage.providerName} destination — set-path only applies to local storage.`,
118
+ };
119
+ }
120
+
121
+ const credentials = await getStorageCredentials(storage.id);
122
+ if (!('path' in credentials)) {
123
+ return { success: false, error: `Storage '${storage.storageId}' has no path configured.` };
124
+ }
125
+ const currentPath = credentials.path;
126
+
127
+ const resolved = resolveTargetPath(newPathArg, currentPath);
128
+ if ('success' in resolved) return resolved;
129
+ const newPath = resolved.path;
130
+
131
+ const writeError = probePathWriteable(newPath);
132
+ if (writeError !== null) {
133
+ return { success: false, error: `Path '${newPath}' is not writeable: ${writeError}` };
134
+ }
135
+
136
+ const sourceDir = join(currentPath, BACKUP_PREFIX);
137
+ const targetDir = join(newPath, BACKUP_PREFIX);
138
+ const plan = planRelocation({
139
+ sourceState: inspectSourceDir(sourceDir),
140
+ sourceDir,
141
+ targetDir,
142
+ migrateRequested: flags['no-migrate'] !== true,
143
+ });
144
+
145
+ console.log(`\n${plan.note}`);
146
+
147
+ if (plan.migrate) {
148
+ // A backup archive's mtime is part of what it is; a move must not
149
+ // restamp it. Bun's cpSync already preserves timestamps, so the
150
+ // flag is a no-op today — it is here for Node semantics, where the
151
+ // default is the other way. The mtime test guards the behavior,
152
+ // not this flag.
153
+ cpSync(sourceDir, targetDir, { recursive: true, force: true, preserveTimestamps: true });
154
+ }
155
+
156
+ await updateStorageCredentials(storage.id, { ...credentials, path: newPath });
157
+ console.log(`✓ Path updated: ${currentPath} → ${newPath}`);
158
+
159
+ if (plan.migrate) {
160
+ // Only after the DB points at the copy, so a failure here leaves
161
+ // archives duplicated rather than orphaned.
162
+ try {
163
+ rmSync(sourceDir, { recursive: true, force: true });
164
+ } catch (error) {
165
+ console.log(
166
+ `⚠ Copied, but could not remove ${sourceDir}: ${error instanceof Error ? error.message : String(error)}`,
167
+ );
168
+ }
169
+ }
170
+
171
+ const { result } = await verifyBackupStorage(storage.id);
172
+ if (!result.success) {
173
+ console.log(`✗ ${result.message}`);
174
+ celiloOutro(
175
+ `Path changed but verification failed.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
176
+ );
177
+ return { success: false, error: result.message };
178
+ }
179
+
180
+ console.log(`✓ ${result.message}`);
181
+ celiloOutro(`'${storage.storageId}' now stores backups at ${targetDir}/`);
182
+
183
+ return { success: true, message: `Relocated ${storage.storageId} to ${newPath}` };
184
+ } catch (error) {
185
+ return {
186
+ success: false,
187
+ error: `Failed to set storage path: ${error instanceof Error ? error.message : String(error)}`,
188
+ };
189
+ }
190
+ }
@@ -35,6 +35,7 @@ import type { ModuleManifest } from '../../manifest/schema';
35
35
  import { RegistryClient } from '../../registry/client';
36
36
  import { decryptSecret } from '../../secrets/encryption';
37
37
  import { getOrCreateMasterKey } from '../../secrets/master-key';
38
+ import { readAllTransportStatuses } from '../../services/alerting/read-records';
38
39
  import { runAudit } from '../../services/audit';
39
40
  import type { DriftFinding, SystemAuditReport } from '../../services/audit';
40
41
  import { loadBackupAuditInfo } from '../../services/audit/backup-source';
@@ -378,6 +379,17 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
378
379
  secretsDecryptable: { results: secretResults },
379
380
  servicesReachable: { results: serviceReachableResults },
380
381
  machinesReachable: { results: machineReachableResults },
382
+ // Reads the record the poller already writes — this check never performs a
383
+ // read of its own. One that did would drain the queue and eat the
384
+ // acknowledgement it exists to protect (#541).
385
+ transportReads: {
386
+ statuses: readAllTransportStatuses(db),
387
+ now: new Date(),
388
+ // Six missed polls. The poller runs every five minutes, so this absorbs a
389
+ // slow sweep, a restart, and a missed tick without crying wolf — while
390
+ // still catching a transport that has genuinely stopped being readable.
391
+ staleAfterMs: 30 * 60_000,
392
+ },
381
393
  trustedSources: { firewalls: collectFirewallReach(db) },
382
394
  };
383
395
  }
@@ -585,6 +585,7 @@ export async function handleSystemUpdate(
585
585
  secretsDecryptable: { results: [] },
586
586
  servicesReachable: { results: [] },
587
587
  machinesReachable: { results: [] },
588
+ transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
588
589
  trustedSources: { firewalls: [] },
589
590
  };
590
591
 
@@ -486,7 +486,7 @@ export async function getCompletions(words: string[], current: number): Promise<
486
486
 
487
487
  // Storage subcommands
488
488
  if (command === 'storage' && currentIndex === 1) {
489
- const subcommands = ['add', 'list', 'remove', 'verify', 'set-default'];
489
+ const subcommands = ['add', 'list', 'remove', 'verify', 'set-default', 'set-path'];
490
490
  return filterSuggestions(subcommands, args[1] || '');
491
491
  }
492
492
 
@@ -496,10 +496,13 @@ export async function getCompletions(words: string[], current: number): Promise<
496
496
  return filterSuggestions(providers, args[2] || '');
497
497
  }
498
498
 
499
- // Storage remove/verify/set-default - complete with storage IDs
499
+ // Storage remove/verify/set-default/set-path - complete with storage IDs
500
500
  if (
501
501
  command === 'storage' &&
502
- (args[1] === 'remove' || args[1] === 'verify' || args[1] === 'set-default') &&
502
+ (args[1] === 'remove' ||
503
+ args[1] === 'verify' ||
504
+ args[1] === 'set-default' ||
505
+ args[1] === 'set-path') &&
503
506
  currentIndex === 2
504
507
  ) {
505
508
  const storages = listBackupStorages();