@celilo/cli 0.3.26 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tests for `storage add local`'s pre-save write probe.
3
+ *
4
+ * Background: an operator on celilo-mgmt typed `/var/backups/celilo`
5
+ * for the path. The CLI accepted it, saved a storage row, then the
6
+ * heavyweight verify step failed with EACCES — leaving an unverified
7
+ * row that confused subsequent `system update` runs. The probe
8
+ * catches unwriteable paths before any persistent state is created.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
12
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { probePathWriteable } from './storage-add-local';
16
+
17
+ describe('probePathWriteable', () => {
18
+ let tempRoot: string;
19
+
20
+ beforeEach(() => {
21
+ tempRoot = mkdtempSync(join(tmpdir(), 'celilo-probe-test-'));
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tempRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ test('returns null for a writeable existing directory', () => {
29
+ expect(probePathWriteable(tempRoot)).toBeNull();
30
+ });
31
+
32
+ test("creates parent dirs that don't exist yet (mkdir -p semantics)", () => {
33
+ // The probe's mkdirSync uses `recursive: true`, so a path several
34
+ // levels below the temp root works on the first try. This is the
35
+ // common case for the user's typed path under their data dir.
36
+ const deep = join(tempRoot, 'a', 'b', 'c', 'backups');
37
+ expect(probePathWriteable(deep)).toBeNull();
38
+ });
39
+
40
+ test('returns an error message for a path under a read-only ancestor', () => {
41
+ const readOnly = join(tempRoot, 'readonly');
42
+ mkdirSync(readOnly);
43
+ // 0o500 = read+execute for owner, no write. mkdirSync into it
44
+ // should EACCES.
45
+ require('node:fs').chmodSync(readOnly, 0o500);
46
+ try {
47
+ const target = join(readOnly, 'celilo-backups');
48
+ const err = probePathWriteable(target);
49
+ expect(err).not.toBeNull();
50
+ expect(err).toContain('EACCES');
51
+ } finally {
52
+ // Restore permissions so afterEach can clean up.
53
+ require('node:fs').chmodSync(readOnly, 0o700);
54
+ }
55
+ });
56
+
57
+ test('cleans up the probe directory on success (no leftover state)', () => {
58
+ const dir = join(tempRoot, 'check');
59
+ expect(probePathWriteable(dir)).toBeNull();
60
+ // The probe creates `<dir>/.celilo-write-probe` then removes it.
61
+ // The directory itself stays (mkdir -p), but the probe sentinel
62
+ // doesn't.
63
+ const { existsSync } = require('node:fs');
64
+ expect(existsSync(join(dir, '.celilo-write-probe'))).toBe(false);
65
+ });
66
+ });
@@ -3,8 +3,10 @@
3
3
  * Configure a local filesystem backup storage destination
4
4
  */
5
5
 
6
+ import { mkdirSync, rmSync } from 'node:fs';
6
7
  import { homedir } from 'node:os';
7
- import { resolve } from 'node:path';
8
+ import { join, resolve } from 'node:path';
9
+ import { getDataDir } from '../../config/paths';
8
10
  import {
9
11
  addBackupStorage,
10
12
  setDefaultBackupStorage,
@@ -14,6 +16,27 @@ import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts'
14
16
  import type { CommandResult } from '../types';
15
17
  import { validateRequired } from '../validators';
16
18
 
19
+ /**
20
+ * Best-effort write probe. Attempts to mkdir -p a `.celilo-write-probe`
21
+ * subdir, then deletes it. Returns null on success, or a one-line
22
+ * error message on failure (suitable for surfacing to the operator).
23
+ *
24
+ * Exists so we can catch unwriteable paths AT INTERVIEW TIME instead
25
+ * of saving a storage row, failing the heavyweight verify step, and
26
+ * leaving a dangling unverified row that confuses subsequent
27
+ * `system update` runs (the regression that prompted this work).
28
+ */
29
+ export function probePathWriteable(path: string): string | null {
30
+ const probe = join(path, '.celilo-write-probe');
31
+ try {
32
+ mkdirSync(probe, { recursive: true });
33
+ rmSync(probe, { recursive: true, force: true });
34
+ return null;
35
+ } catch (err) {
36
+ return err instanceof Error ? err.message : String(err);
37
+ }
38
+ }
39
+
17
40
  /**
18
41
  * Expand leading tilde to the user's home directory.
19
42
  * Node.js fs functions don't expand ~ like the shell does.
@@ -45,15 +68,55 @@ export async function handleStorageAddLocal(
45
68
  validate: validateRequired('Storage name'),
46
69
  }));
47
70
 
48
- const path =
49
- flagPath ??
50
- (await promptText({
51
- message: 'Storage directory path:',
52
- placeholder: 'e.g., /mnt/nas/backups or /var/backups/celilo',
53
- validate: validateRequired('Storage path'),
54
- }));
55
-
56
- const resolvedPath = resolve(expandTilde(path));
71
+ // Default path lives under celilo's own data dir
72
+ // (~/.local/share/celilo/backups on Linux, ~/Library/Application
73
+ // Support/celilo/backups on macOS). The directory is owned by the
74
+ // running user, mkdir -p succeeds without sudo, and operators who
75
+ // want NAS / external paths can override at the prompt. This
76
+ // replaces the prior placeholder of "/var/backups/celilo" which
77
+ // looked authoritative but required root to write.
78
+ const defaultPath = join(getDataDir(), 'backups');
79
+
80
+ // Probe writability BEFORE saving the storage row. The previous
81
+ // flow (save → verify → fail with EACCES → leave dangling
82
+ // unverified row) was confusing and required the operator to know
83
+ // about `storage verify` to recover. Probing here means an
84
+ // unwriteable path never produces persistent state.
85
+ //
86
+ // Non-interactive (--name + --path): probe once, fail loud.
87
+ // Interactive: re-prompt until a writeable path is supplied.
88
+ let resolvedPath: string;
89
+ if (flagPath !== undefined) {
90
+ resolvedPath = resolve(expandTilde(flagPath));
91
+ const writeError = probePathWriteable(resolvedPath);
92
+ if (writeError !== null) {
93
+ return {
94
+ success: false,
95
+ error: `Path '${resolvedPath}' is not writeable: ${writeError}\n\nTry a path under your home directory (e.g. '${defaultPath}') or run with elevated permissions.`,
96
+ };
97
+ }
98
+ } else {
99
+ let candidate: string | undefined;
100
+ while (candidate === undefined) {
101
+ const typed = await promptText({
102
+ message: 'Storage directory path:',
103
+ defaultValue: defaultPath,
104
+ placeholder: defaultPath,
105
+ validate: validateRequired('Storage path'),
106
+ });
107
+ const resolved = resolve(expandTilde(typed));
108
+ const writeError = probePathWriteable(resolved);
109
+ if (writeError === null) {
110
+ candidate = resolved;
111
+ break;
112
+ }
113
+ console.log(
114
+ `\n✗ Path '${resolved}' is not writeable: ${writeError}\nTry a path under your home directory (default '${defaultPath}' works without sudo).\n`,
115
+ );
116
+ // Loop continues — re-prompt with the same default suggestion.
117
+ }
118
+ resolvedPath = candidate;
119
+ }
57
120
 
58
121
  const storage = await addBackupStorage({
59
122
  name,
@@ -67,6 +130,10 @@ export async function handleStorageAddLocal(
67
130
  const { result } = await verifyBackupStorage(storage.id);
68
131
 
69
132
  if (!result.success) {
133
+ // Should be unreachable in interactive mode (the pre-save probe
134
+ // covers the EACCES path); could still fire for less-common
135
+ // verify failures (full disk, etc.). Keep the dangling-row
136
+ // recovery hint as a safety net.
70
137
  console.log(`\n✗ Verification failed: ${result.message}`);
71
138
  celiloOutro(
72
139
  `Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Tests for the `system update` storage pre-flight.
3
+ *
4
+ * Background: an operator on celilo-mgmt hit a confusing chain when
5
+ * they ran `celilo storage add local /var/backups/celilo` (a system
6
+ * path that needs root). The verify step failed with EACCES, leaving
7
+ * an unverified storage row on disk. The next `celilo system update`
8
+ * said "No default backup storage configured" — technically true
9
+ * (unverified storage never auto-defaults), but misleading: there
10
+ * IS a storage, it just needs fixing. The pre-flight now distinguishes
11
+ * the cases and points at the right next command for each.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+ import type { ModuleSnapshot } from '../../services/update/orchestrator';
16
+ import {
17
+ type BackupStorageLike,
18
+ checkBackupStoragePreflight,
19
+ shouldTakeBackup,
20
+ } from './system-update';
21
+
22
+ const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
23
+ const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
24
+
25
+ describe('checkBackupStoragePreflight', () => {
26
+ test('verified default → ok', () => {
27
+ const result = checkBackupStoragePreflight({
28
+ defaultStorage: verified('home-backups'),
29
+ allStorages: [verified('home-backups')],
30
+ });
31
+ expect(result.kind).toBe('ok');
32
+ });
33
+
34
+ test('default exists but unverified → suggests `storage verify`', () => {
35
+ const result = checkBackupStoragePreflight({
36
+ defaultStorage: unverified('home-backups'),
37
+ allStorages: [unverified('home-backups')],
38
+ });
39
+ if (result.kind !== 'error') throw new Error('expected error');
40
+ expect(result.message).toContain("Default backup storage 'home-backups' is not verified");
41
+ expect(result.message).toContain('celilo storage verify home-backups');
42
+ });
43
+
44
+ test('no storage at all → suggests `storage add local`', () => {
45
+ const result = checkBackupStoragePreflight({
46
+ defaultStorage: null,
47
+ allStorages: [],
48
+ });
49
+ if (result.kind !== 'error') throw new Error('expected error');
50
+ expect(result.message).toContain('No backup storage configured');
51
+ expect(result.message).toContain('celilo storage add local');
52
+ // Always offer the --no-backup escape so the operator knows the
53
+ // CLI self-update can still run on a system without storage.
54
+ expect(result.message).toContain('--no-backup');
55
+ });
56
+
57
+ // The exact case the operator hit on celilo-mgmt: ran storage add,
58
+ // path was unwriteable (/var/backups/celilo without sudo), verify
59
+ // failed, the unverified row stayed in the DB and isn't default.
60
+ test('only unverified storage → suggests `storage verify`, not `storage add`', () => {
61
+ const result = checkBackupStoragePreflight({
62
+ defaultStorage: null,
63
+ allStorages: [unverified('local-backups')],
64
+ });
65
+ if (result.kind !== 'error') throw new Error('expected error');
66
+ expect(result.message).toContain('none is verified yet');
67
+ expect(result.message).toContain('Storages: local-backups');
68
+ expect(result.message).toContain('celilo storage verify <storage-id>');
69
+ // Critically, this case does NOT direct the operator to run
70
+ // `storage add local` — they already did, the row exists.
71
+ expect(result.message).not.toContain('celilo storage add local');
72
+ // Surface the most common cause inline rather than burying it.
73
+ expect(result.message).toContain('elevated permissions');
74
+ expect(result.message).toContain('--no-backup');
75
+ });
76
+
77
+ test('multiple unverified storages → lists all storage IDs', () => {
78
+ const result = checkBackupStoragePreflight({
79
+ defaultStorage: null,
80
+ allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')],
81
+ });
82
+ if (result.kind !== 'error') throw new Error('expected error');
83
+ expect(result.message).toContain('Storages: local-a, local-b, local-c');
84
+ });
85
+
86
+ test('verified storage exists but none is default → suggests `storage set-default`', () => {
87
+ const result = checkBackupStoragePreflight({
88
+ defaultStorage: null,
89
+ allStorages: [verified('home-backups'), unverified('s3-cold')],
90
+ });
91
+ if (result.kind !== 'error') throw new Error('expected error');
92
+ expect(result.message).toContain('verified but no default is set');
93
+ expect(result.message).toContain('Verified: home-backups');
94
+ // Doesn't mention the unverified one in the "Verified:" listing
95
+ // (that would mislead).
96
+ expect(result.message).not.toContain('Verified: home-backups, s3-cold');
97
+ expect(result.message).toContain('celilo storage set-default <storage-id>');
98
+ });
99
+
100
+ test('multiple verified, no default → lists all verified IDs', () => {
101
+ const result = checkBackupStoragePreflight({
102
+ defaultStorage: null,
103
+ allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')],
104
+ });
105
+ if (result.kind !== 'error') throw new Error('expected error');
106
+ expect(result.message).toContain('Verified: home-backups, s3-cold');
107
+ });
108
+ });
109
+
110
+ const snap = (
111
+ id: string,
112
+ installedVersion: string,
113
+ latestVersion: string | null,
114
+ ): ModuleSnapshot => ({
115
+ id,
116
+ installedVersion,
117
+ latestVersion,
118
+ installedProvides: {},
119
+ pendingRequires: {},
120
+ });
121
+
122
+ const snapshotsOf = (...entries: ModuleSnapshot[]): Map<string, ModuleSnapshot> =>
123
+ new Map(entries.map((s) => [s.id, s]));
124
+
125
+ describe('shouldTakeBackup', () => {
126
+ test('false when no module updates at all', () => {
127
+ expect(
128
+ shouldTakeBackup({
129
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+5')),
130
+ wasDeployed: new Set(['caddy']),
131
+ }),
132
+ ).toBe(false);
133
+ });
134
+
135
+ test('true when a deployed module has a registry-newer version', () => {
136
+ expect(
137
+ shouldTakeBackup({
138
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', '2.0.0+6')),
139
+ wasDeployed: new Set(['caddy']),
140
+ }),
141
+ ).toBe(true);
142
+ });
143
+
144
+ // The exact case the operator hit on celilo-mgmt: namecheap was
145
+ // imported but never deployed; system update wanted to back up the
146
+ // celilo DB even though no live state would be touched.
147
+ test('false when only IMPORTED modules have updates (live state untouched)', () => {
148
+ expect(
149
+ shouldTakeBackup({
150
+ snapshots: snapshotsOf(snap('namecheap', '3.1.0+10', '3.1.1+4')),
151
+ wasDeployed: new Set(), // nothing deployed
152
+ }),
153
+ ).toBe(false);
154
+ });
155
+
156
+ test('true when any deployed module needs updating, regardless of imported ones', () => {
157
+ expect(
158
+ shouldTakeBackup({
159
+ snapshots: snapshotsOf(
160
+ snap('namecheap', '3.1.0+10', '3.1.1+4'), // imported, has update
161
+ snap('caddy', '2.0.0+5', '2.0.0+6'), // deployed, has update
162
+ ),
163
+ wasDeployed: new Set(['caddy']),
164
+ }),
165
+ ).toBe(true);
166
+ });
167
+
168
+ test('false when registry has no info (latestVersion null)', () => {
169
+ // Network failure — we don't know if updates exist. Default to
170
+ // "no backup needed" rather than blocking the run; the per-module
171
+ // upgrade step will report individual failures if any.
172
+ expect(
173
+ shouldTakeBackup({
174
+ snapshots: snapshotsOf(snap('caddy', '2.0.0+5', null)),
175
+ wasDeployed: new Set(['caddy']),
176
+ }),
177
+ ).toBe(false);
178
+ });
179
+
180
+ test('false on empty snapshots', () => {
181
+ expect(
182
+ shouldTakeBackup({
183
+ snapshots: new Map(),
184
+ wasDeployed: new Set(),
185
+ }),
186
+ ).toBe(false);
187
+ });
188
+ });
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
20
21
  import { dirname, join } from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { eq } from 'drizzle-orm';
@@ -50,6 +51,126 @@ import { fetchAndUpgrade } from './module-upgrade';
50
51
  */
51
52
  const MANAGED_PACKAGES = ['@celilo/cli', '@celilo/event-bus', '@celilo/e2e'] as const;
52
53
 
54
+ /**
55
+ * Result of inspecting backup-storage configuration before letting
56
+ * `runSystemUpdate` take a celilo-DB snapshot. Three failure shapes
57
+ * the operator might hit, each with a distinct friendly message:
58
+ *
59
+ * 1. No storage rows at all → run `storage add local`
60
+ * 2. Storage rows exist, none verified → run `storage verify <id>`
61
+ * (the case the operator hits when an unverified storage was
62
+ * left behind by a failed verify)
63
+ * 3. Verified storage exists, none is default → run
64
+ * `storage set-default <id>`
65
+ * 4. Default exists but isn't verified → run `storage verify <id>`
66
+ *
67
+ * Pure data in / data out so it's testable without setting up an
68
+ * audit, registry, or DB.
69
+ */
70
+ export interface BackupStorageLike {
71
+ storageId: string;
72
+ verified: boolean;
73
+ }
74
+
75
+ export function checkBackupStoragePreflight(input: {
76
+ defaultStorage: BackupStorageLike | null;
77
+ allStorages: BackupStorageLike[];
78
+ }): { kind: 'ok' } | { kind: 'error'; message: string } {
79
+ const { defaultStorage, allStorages } = input;
80
+
81
+ if (defaultStorage) {
82
+ if (defaultStorage.verified) return { kind: 'ok' };
83
+ return {
84
+ kind: 'error',
85
+ message: `Default backup storage '${defaultStorage.storageId}' is not verified.
86
+
87
+ Run: celilo storage verify ${defaultStorage.storageId}
88
+
89
+ Then re-run system update.`,
90
+ };
91
+ }
92
+
93
+ if (allStorages.length === 0) {
94
+ return {
95
+ kind: 'error',
96
+ message: `No backup storage configured.
97
+
98
+ celilo system update snapshots the celilo DB before applying module
99
+ updates as a safety net. Configure backup storage first:
100
+
101
+ celilo storage add local
102
+
103
+ Or skip the safety net entirely (the CLI self-update still runs):
104
+
105
+ celilo system update --no-backup`,
106
+ };
107
+ }
108
+
109
+ const verified = allStorages.filter((s) => s.verified);
110
+ if (verified.length === 0) {
111
+ const ids = allStorages.map((s) => s.storageId).join(', ');
112
+ return {
113
+ kind: 'error',
114
+ message: `Backup storage exists but none is verified yet.
115
+
116
+ Storages: ${ids}
117
+
118
+ Verify one before re-running system update:
119
+
120
+ celilo storage verify <storage-id>
121
+
122
+ Common causes of failed verification:
123
+ - The path requires elevated permissions (try a path under your
124
+ home directory; the default '${join(homedir(), '.local/share/celilo/backups')}'
125
+ works without sudo).
126
+ - The disk is full or the mount point isn't writeable.
127
+
128
+ Or skip the safety net entirely (the CLI self-update still runs):
129
+
130
+ celilo system update --no-backup`,
131
+ };
132
+ }
133
+
134
+ const ids = verified.map((s) => s.storageId).join(', ');
135
+ return {
136
+ kind: 'error',
137
+ message: `Backup storage is verified but no default is set.
138
+
139
+ Verified: ${ids}
140
+
141
+ Set a default before re-running system update:
142
+
143
+ celilo storage set-default <storage-id>
144
+
145
+ Or skip the safety net entirely (the CLI self-update still runs):
146
+
147
+ celilo system update --no-backup`,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Should `system update` take a celilo-DB snapshot for this run?
153
+ * True iff at least one module that's currently DEPLOYED has a
154
+ * registry-newer version waiting. Pure data in / data out.
155
+ *
156
+ * Excludes IMPORTED-but-not-yet-deployed modules — refreshing their
157
+ * on-disk source has no impact on live state, so the safety net
158
+ * isn't needed (and an operator who hasn't configured storage yet
159
+ * shouldn't be blocked by a snapshot they don't need).
160
+ */
161
+ export function shouldTakeBackup(input: {
162
+ snapshots: Map<string, ModuleSnapshot>;
163
+ wasDeployed: Set<string>;
164
+ }): boolean {
165
+ for (const [id, s] of input.snapshots) {
166
+ if (!input.wasDeployed.has(id)) continue;
167
+ if (!s.latestVersion) continue;
168
+ if (s.latestVersion === s.installedVersion) continue;
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+
53
174
  function readInstalledCliVersion(): string {
54
175
  const here = dirname(fileURLToPath(import.meta.url));
55
176
  const candidates = [
@@ -489,45 +610,36 @@ export async function handleSystemUpdate(
489
610
  }
490
611
 
491
612
  // Decide whether the celilo-db snapshot is even needed for this run.
492
- // If nothing's changing at the module level, there's nothing to roll
493
- // back to taking a snapshot would be pointless work, and on a fresh
494
- // box (no storage configured yet) it would actively fail. Treat the
495
- // "nothing-to-update" case as implicit --no-backup.
496
- const hasModuleUpdates = [...snapshots.values()].some(
497
- (s) => s.latestVersion && s.latestVersion !== s.installedVersion,
498
- );
499
- const effectiveNoBackup = noBackup || !hasModuleUpdates;
613
+ //
614
+ // The snapshot is a safety net its purpose is to let `system update`
615
+ // roll back if a deploy / health step bricks a running module. So
616
+ // we ONLY need it when the run will actually upgrade-and-redeploy
617
+ // a currently-deployed module. Two cases that don't qualify:
618
+ //
619
+ // 1. Nothing has new code waiting at all (everyone's at latest).
620
+ // 2. The only modules with new code are IMPORTED-but-not-deployed.
621
+ // Their upgrade is a pure source-files-on-disk refresh; no
622
+ // live state to roll back, no risk to mitigate. Forcing a
623
+ // backup here means an operator on a fresh celilo-mgmt with
624
+ // nothing deployed yet has to configure backup storage just
625
+ // to refresh the on-disk modules they imported — which is
626
+ // exactly the friction that prompted this code path.
627
+ const effectiveNoBackup = noBackup || !shouldTakeBackup({ snapshots, wasDeployed });
500
628
 
501
- // Pre-flight the storage check so a missing default doesn't reach the
502
- // orchestrator's snapshot hook (where the throw would surface as a
503
- // hostile stack trace). Skip when we're already not going to backup.
629
+ // Pre-flight the storage check so a missing/unusable default doesn't
630
+ // reach the orchestrator's snapshot hook (where the throw would
631
+ // surface as a hostile stack trace). Skip when we're already not
632
+ // going to backup.
504
633
  if (!effectiveNoBackup) {
505
- const { getDefaultBackupStorage } = await import('../../services/backup-storage');
506
- const storage = getDefaultBackupStorage();
507
- if (!storage) {
508
- return {
509
- success: false,
510
- error: `No default backup storage configured.
511
-
512
- celilo system update snapshots the celilo DB before applying module
513
- updates as a safety net. Configure backup storage first:
514
-
515
- celilo storage add local
516
-
517
- Or skip the safety net entirely (the CLI self-update still runs):
518
-
519
- celilo system update --no-backup`,
520
- };
521
- }
522
- if (!storage.verified) {
523
- return {
524
- success: false,
525
- error: `Default backup storage '${storage.storageId}' is not verified.
526
-
527
- Run: celilo storage verify ${storage.storageId}
528
-
529
- Then re-run system update.`,
530
- };
634
+ const { getDefaultBackupStorage, listBackupStorages } = await import(
635
+ '../../services/backup-storage'
636
+ );
637
+ const preflight = checkBackupStoragePreflight({
638
+ defaultStorage: getDefaultBackupStorage(),
639
+ allStorages: listBackupStorages(),
640
+ });
641
+ if (preflight.kind === 'error') {
642
+ return { success: false, error: preflight.message };
531
643
  }
532
644
  }
533
645