@celilo/cli 0.3.26 → 0.3.27
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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
|
-
import { resolve } from 'node:path';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { getDataDir } from '../../config/paths';
|
|
8
9
|
import {
|
|
9
10
|
addBackupStorage,
|
|
10
11
|
setDefaultBackupStorage,
|
|
@@ -45,11 +46,21 @@ export async function handleStorageAddLocal(
|
|
|
45
46
|
validate: validateRequired('Storage name'),
|
|
46
47
|
}));
|
|
47
48
|
|
|
49
|
+
// Default path lives under celilo's own data dir
|
|
50
|
+
// (~/.local/share/celilo/backups on Linux, ~/Library/Application
|
|
51
|
+
// Support/celilo/backups on macOS). The directory is owned by the
|
|
52
|
+
// running user, mkdir -p succeeds without sudo, and operators who
|
|
53
|
+
// want NAS / external paths can override at the prompt. This
|
|
54
|
+
// replaces the prior placeholder of "/var/backups/celilo" which
|
|
55
|
+
// looked authoritative but required root to write.
|
|
56
|
+
const defaultPath = join(getDataDir(), 'backups');
|
|
57
|
+
|
|
48
58
|
const path =
|
|
49
59
|
flagPath ??
|
|
50
60
|
(await promptText({
|
|
51
61
|
message: 'Storage directory path:',
|
|
52
|
-
|
|
62
|
+
defaultValue: defaultPath,
|
|
63
|
+
placeholder: defaultPath,
|
|
53
64
|
validate: validateRequired('Storage path'),
|
|
54
65
|
}));
|
|
55
66
|
|
|
@@ -0,0 +1,103 @@
|
|
|
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 BackupStorageLike, checkBackupStoragePreflight } from './system-update';
|
|
16
|
+
|
|
17
|
+
const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
|
|
18
|
+
const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
|
|
19
|
+
|
|
20
|
+
describe('checkBackupStoragePreflight', () => {
|
|
21
|
+
test('verified default → ok', () => {
|
|
22
|
+
const result = checkBackupStoragePreflight({
|
|
23
|
+
defaultStorage: verified('home-backups'),
|
|
24
|
+
allStorages: [verified('home-backups')],
|
|
25
|
+
});
|
|
26
|
+
expect(result.kind).toBe('ok');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('default exists but unverified → suggests `storage verify`', () => {
|
|
30
|
+
const result = checkBackupStoragePreflight({
|
|
31
|
+
defaultStorage: unverified('home-backups'),
|
|
32
|
+
allStorages: [unverified('home-backups')],
|
|
33
|
+
});
|
|
34
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
35
|
+
expect(result.message).toContain("Default backup storage 'home-backups' is not verified");
|
|
36
|
+
expect(result.message).toContain('celilo storage verify home-backups');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('no storage at all → suggests `storage add local`', () => {
|
|
40
|
+
const result = checkBackupStoragePreflight({
|
|
41
|
+
defaultStorage: null,
|
|
42
|
+
allStorages: [],
|
|
43
|
+
});
|
|
44
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
45
|
+
expect(result.message).toContain('No backup storage configured');
|
|
46
|
+
expect(result.message).toContain('celilo storage add local');
|
|
47
|
+
// Always offer the --no-backup escape so the operator knows the
|
|
48
|
+
// CLI self-update can still run on a system without storage.
|
|
49
|
+
expect(result.message).toContain('--no-backup');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The exact case the operator hit on celilo-mgmt: ran storage add,
|
|
53
|
+
// path was unwriteable (/var/backups/celilo without sudo), verify
|
|
54
|
+
// failed, the unverified row stayed in the DB and isn't default.
|
|
55
|
+
test('only unverified storage → suggests `storage verify`, not `storage add`', () => {
|
|
56
|
+
const result = checkBackupStoragePreflight({
|
|
57
|
+
defaultStorage: null,
|
|
58
|
+
allStorages: [unverified('local-backups')],
|
|
59
|
+
});
|
|
60
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
61
|
+
expect(result.message).toContain('none is verified yet');
|
|
62
|
+
expect(result.message).toContain('Storages: local-backups');
|
|
63
|
+
expect(result.message).toContain('celilo storage verify <storage-id>');
|
|
64
|
+
// Critically, this case does NOT direct the operator to run
|
|
65
|
+
// `storage add local` — they already did, the row exists.
|
|
66
|
+
expect(result.message).not.toContain('celilo storage add local');
|
|
67
|
+
// Surface the most common cause inline rather than burying it.
|
|
68
|
+
expect(result.message).toContain('elevated permissions');
|
|
69
|
+
expect(result.message).toContain('--no-backup');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('multiple unverified storages → lists all storage IDs', () => {
|
|
73
|
+
const result = checkBackupStoragePreflight({
|
|
74
|
+
defaultStorage: null,
|
|
75
|
+
allStorages: [unverified('local-a'), unverified('local-b'), unverified('local-c')],
|
|
76
|
+
});
|
|
77
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
78
|
+
expect(result.message).toContain('Storages: local-a, local-b, local-c');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('verified storage exists but none is default → suggests `storage set-default`', () => {
|
|
82
|
+
const result = checkBackupStoragePreflight({
|
|
83
|
+
defaultStorage: null,
|
|
84
|
+
allStorages: [verified('home-backups'), unverified('s3-cold')],
|
|
85
|
+
});
|
|
86
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
87
|
+
expect(result.message).toContain('verified but no default is set');
|
|
88
|
+
expect(result.message).toContain('Verified: home-backups');
|
|
89
|
+
// Doesn't mention the unverified one in the "Verified:" listing
|
|
90
|
+
// (that would mislead).
|
|
91
|
+
expect(result.message).not.toContain('Verified: home-backups, s3-cold');
|
|
92
|
+
expect(result.message).toContain('celilo storage set-default <storage-id>');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('multiple verified, no default → lists all verified IDs', () => {
|
|
96
|
+
const result = checkBackupStoragePreflight({
|
|
97
|
+
defaultStorage: null,
|
|
98
|
+
allStorages: [verified('home-backups'), verified('s3-cold'), unverified('nas')],
|
|
99
|
+
});
|
|
100
|
+
if (result.kind !== 'error') throw new Error('expected error');
|
|
101
|
+
expect(result.message).toContain('Verified: home-backups, s3-cold');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -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,103 @@ 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
|
+
|
|
53
151
|
function readInstalledCliVersion(): string {
|
|
54
152
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
55
153
|
const candidates = [
|
|
@@ -498,36 +596,20 @@ export async function handleSystemUpdate(
|
|
|
498
596
|
);
|
|
499
597
|
const effectiveNoBackup = noBackup || !hasModuleUpdates;
|
|
500
598
|
|
|
501
|
-
// Pre-flight the storage check so a missing default doesn't
|
|
502
|
-
// orchestrator's snapshot hook (where the throw would
|
|
503
|
-
// hostile stack trace). Skip when we're already not
|
|
599
|
+
// Pre-flight the storage check so a missing/unusable default doesn't
|
|
600
|
+
// reach the orchestrator's snapshot hook (where the throw would
|
|
601
|
+
// surface as a hostile stack trace). Skip when we're already not
|
|
602
|
+
// going to backup.
|
|
504
603
|
if (!effectiveNoBackup) {
|
|
505
|
-
const { getDefaultBackupStorage } = await import(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
};
|
|
604
|
+
const { getDefaultBackupStorage, listBackupStorages } = await import(
|
|
605
|
+
'../../services/backup-storage'
|
|
606
|
+
);
|
|
607
|
+
const preflight = checkBackupStoragePreflight({
|
|
608
|
+
defaultStorage: getDefaultBackupStorage(),
|
|
609
|
+
allStorages: listBackupStorages(),
|
|
610
|
+
});
|
|
611
|
+
if (preflight.kind === 'error') {
|
|
612
|
+
return { success: false, error: preflight.message };
|
|
531
613
|
}
|
|
532
614
|
}
|
|
533
615
|
|