@celilo/cli 0.3.27 → 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.27",
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,6 +3,7 @@
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
8
  import { join, resolve } from 'node:path';
8
9
  import { getDataDir } from '../../config/paths';
@@ -15,6 +16,27 @@ import { celiloIntro, celiloOutro, promptConfirm, promptText } from '../prompts'
15
16
  import type { CommandResult } from '../types';
16
17
  import { validateRequired } from '../validators';
17
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
+
18
40
  /**
19
41
  * Expand leading tilde to the user's home directory.
20
42
  * Node.js fs functions don't expand ~ like the shell does.
@@ -55,16 +77,46 @@ export async function handleStorageAddLocal(
55
77
  // looked authoritative but required root to write.
56
78
  const defaultPath = join(getDataDir(), 'backups');
57
79
 
58
- const path =
59
- flagPath ??
60
- (await promptText({
61
- message: 'Storage directory path:',
62
- defaultValue: defaultPath,
63
- placeholder: defaultPath,
64
- validate: validateRequired('Storage path'),
65
- }));
66
-
67
- const resolvedPath = resolve(expandTilde(path));
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
+ }
68
120
 
69
121
  const storage = await addBackupStorage({
70
122
  name,
@@ -78,6 +130,10 @@ export async function handleStorageAddLocal(
78
130
  const { result } = await verifyBackupStorage(storage.id);
79
131
 
80
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.
81
137
  console.log(`\n✗ Verification failed: ${result.message}`);
82
138
  celiloOutro(
83
139
  `Storage '${storage.storageId}' added but not verified.\n\nFix the path and re-verify: celilo storage verify ${storage.storageId}`,
@@ -12,7 +12,12 @@
12
12
  */
13
13
 
14
14
  import { describe, expect, test } from 'bun:test';
15
- import { type BackupStorageLike, checkBackupStoragePreflight } from './system-update';
15
+ import type { ModuleSnapshot } from '../../services/update/orchestrator';
16
+ import {
17
+ type BackupStorageLike,
18
+ checkBackupStoragePreflight,
19
+ shouldTakeBackup,
20
+ } from './system-update';
16
21
 
17
22
  const verified = (id: string): BackupStorageLike => ({ storageId: id, verified: true });
18
23
  const unverified = (id: string): BackupStorageLike => ({ storageId: id, verified: false });
@@ -101,3 +106,83 @@ describe('checkBackupStoragePreflight', () => {
101
106
  expect(result.message).toContain('Verified: home-backups, s3-cold');
102
107
  });
103
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
+ });
@@ -148,6 +148,29 @@ Or skip the safety net entirely (the CLI self-update still runs):
148
148
  };
149
149
  }
150
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
+
151
174
  function readInstalledCliVersion(): string {
152
175
  const here = dirname(fileURLToPath(import.meta.url));
153
176
  const candidates = [
@@ -587,14 +610,21 @@ export async function handleSystemUpdate(
587
610
  }
588
611
 
589
612
  // Decide whether the celilo-db snapshot is even needed for this run.
590
- // If nothing's changing at the module level, there's nothing to roll
591
- // back to taking a snapshot would be pointless work, and on a fresh
592
- // box (no storage configured yet) it would actively fail. Treat the
593
- // "nothing-to-update" case as implicit --no-backup.
594
- const hasModuleUpdates = [...snapshots.values()].some(
595
- (s) => s.latestVersion && s.latestVersion !== s.installedVersion,
596
- );
597
- 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 });
598
628
 
599
629
  // Pre-flight the storage check so a missing/unusable default doesn't
600
630
  // reach the orchestrator's snapshot hook (where the throw would