@celilo/cli 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "^0.4.2",
55
+ "@celilo/capabilities": "^0.5.0",
56
56
  "@celilo/cli-display": "^0.1.9",
57
57
  "@celilo/event-bus": "^0.1.7",
58
58
  "@clack/prompts": "^1.1.0",
@@ -435,6 +435,25 @@ export const COMMANDS: CommandDef[] = [
435
435
  },
436
436
  ],
437
437
  },
438
+ {
439
+ name: 'upgrade',
440
+ description:
441
+ 'Upgrade a module to its latest registry version: update → backup (posture-gated) → deploy → verify. No id = the CD poll: upgrade every auto_upgrade module with a newer version.',
442
+ args: [
443
+ {
444
+ name: 'id',
445
+ description: 'Module ID (omit to poll all auto_upgrade modules)',
446
+ completion: 'module_ids',
447
+ },
448
+ ],
449
+ flags: [
450
+ {
451
+ name: 'registry',
452
+ description: 'Registry URL (defaults to configured registry)',
453
+ takesValue: true,
454
+ },
455
+ ],
456
+ },
438
457
  {
439
458
  name: 'verify',
440
459
  description: 'Verify module integrity (signature + checksums)',
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Unit tests for the version-change classifier used by `module update`'s
3
+ * registry-sweep mode, plus integration tests for `updateOne`'s
4
+ * `displayVersion` / `quiet` opts that drive the cleaner sweep output.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { eq } from 'drizzle-orm';
12
+ import { type DbClient, getDb } from '../../db/client';
13
+ import { modules } from '../../db/schema';
14
+ import { classifyVersionChange, updateOne } from './module-update';
15
+
16
+ describe('classifyVersionChange', () => {
17
+ test('identical versions are up-to-date', () => {
18
+ expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date');
19
+ expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date');
20
+ expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date');
21
+ });
22
+
23
+ test('major bump → breaking', () => {
24
+ expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major');
25
+ expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major');
26
+ // Even a tiny step into the next major counts as breaking; the
27
+ // operator decides whether to take it.
28
+ expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major');
29
+ });
30
+
31
+ test('minor bump → non-breaking', () => {
32
+ expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor');
33
+ expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
34
+ });
35
+
36
+ test('patch bump → non-breaking', () => {
37
+ expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch');
38
+ expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
39
+ });
40
+
41
+ test('revision-only bump (+N) → patch (non-breaking)', () => {
42
+ // Exact case the user is hitting today: same code, fresh publish.
43
+ expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch');
44
+ expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major');
45
+ expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch');
46
+ });
47
+
48
+ test('installed ahead of registry → ahead (skip silently)', () => {
49
+ // Operator pushed locally without publishing — registry is stale.
50
+ expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead');
51
+ expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead');
52
+ expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead');
53
+ });
54
+
55
+ test('tolerates `v` / `=` prefixes', () => {
56
+ expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch');
57
+ expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor');
58
+ });
59
+
60
+ test('missing patch / revision segments default to 0', () => {
61
+ expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch');
62
+ expect(classifyVersionChange('1.0', '2.0')).toBe('major');
63
+ expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
64
+ });
65
+ });
66
+
67
+ describe('updateOne — displayVersion and quiet', () => {
68
+ let tempDir: string;
69
+ let srcDir: string;
70
+ let installedDir: string;
71
+ let db: DbClient;
72
+
73
+ beforeEach(() => {
74
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
75
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
76
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
77
+
78
+ // Pre-installed module landing zone (where files get copied to).
79
+ installedDir = join(tempDir, 'installed', 'testmod');
80
+ mkdirSync(installedDir, { recursive: true });
81
+
82
+ // "New" source dir we're upgrading from (mimics a registry extract).
83
+ srcDir = join(tempDir, 'src');
84
+ mkdirSync(srcDir, { recursive: true });
85
+ writeFileSync(
86
+ join(srcDir, 'manifest.yml'),
87
+ `celilo_contract: "1.0"
88
+ id: testmod
89
+ name: Test Module
90
+ version: 1.0.0
91
+ description: fixture
92
+ `,
93
+ );
94
+
95
+ db = getDb();
96
+ // Seed the modules table with the "currently installed" record.
97
+ // We deliberately put a registry-style version string in here so
98
+ // displayVersion-less upgrades preserve the previousVersion field.
99
+ db.insert(modules)
100
+ .values({
101
+ id: 'testmod',
102
+ name: 'Test Module',
103
+ sourcePath: installedDir,
104
+ version: '1.0.0+5',
105
+ manifestData: {
106
+ celilo_contract: '1.0',
107
+ id: 'testmod',
108
+ name: 'Test Module',
109
+ version: '1.0.0',
110
+ },
111
+ })
112
+ .run();
113
+ });
114
+
115
+ afterEach(() => {
116
+ rmSync(tempDir, { recursive: true, force: true });
117
+ process.env.CELILO_DB_PATH = undefined;
118
+ process.env.CELILO_ORIGINAL_CWD = undefined;
119
+ });
120
+
121
+ test('returns previousVersion + newVersion in the success outcome', async () => {
122
+ const result = await updateOne(srcDir, db, {}, { quiet: true });
123
+ expect(result.status).toBe('success');
124
+ if (result.status !== 'success') return; // narrow for ts
125
+ expect(result.moduleId).toBe('testmod');
126
+ expect(result.previousVersion).toBe('1.0.0+5');
127
+ // No displayVersion supplied → falls back to the new manifest's
128
+ // semver core (no +N visible to the operator from a path upgrade).
129
+ expect(result.newVersion).toBe('1.0.0');
130
+ });
131
+
132
+ test('displayVersion is persisted to modules.version (so +N survives)', async () => {
133
+ // The bug case: registry says 1.0.0+6, manifest says 1.0.0. Without
134
+ // displayVersion, the +6 was dropped on the floor and `module list`
135
+ // rolled back to "1.0.0", masking the actual installed revision.
136
+ const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
137
+ expect(result.status).toBe('success');
138
+ if (result.status !== 'success') return;
139
+ expect(result.newVersion).toBe('1.0.0+6');
140
+
141
+ const row = db.select().from(modules).all()[0];
142
+ expect(row.version).toBe('1.0.0+6');
143
+ });
144
+
145
+ test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
146
+ // We can't easily intercept @clack's log output without adding test
147
+ // hooks, so instead we exercise that the call simply succeeds and
148
+ // returns the structured outcome — the sweep relies on this to
149
+ // render its own output without duplicates. A non-quiet call
150
+ // exercises the same code path with the log lines enabled; both
151
+ // return the same shape.
152
+ const quietResult = await updateOne(srcDir, db, {}, { quiet: true });
153
+ expect(quietResult.status).toBe('success');
154
+ if (quietResult.status !== 'success') return;
155
+ expect(quietResult.newVersion).toBe('1.0.0');
156
+ });
157
+
158
+ // Regression for the namecheap stale-findings problem: the upgrade
159
+ // path MUST overwrite manifestData with the new manifest — otherwise
160
+ // any subsequent audit (or any code path that reads from the DB)
161
+ // sees the OLD manifest's required vars even after the operator
162
+ // upgraded to a version that removed them. The user hit this on
163
+ // celilo-mgmt: namecheap@3.1.1+6 dropped the `domains` variable, but
164
+ // post-upgrade the audit still complained "required config 'domains'
165
+ // is not set" because the DB's manifestData hadn't been refreshed.
166
+ test('persists new manifest to modules.manifestData (not just .version)', async () => {
167
+ // Write a brand-new manifest.yml in srcDir that REMOVES a
168
+ // variable the old DB record had. After upgrade, the modules
169
+ // row's manifestData should reflect the removal.
170
+ writeFileSync(
171
+ join(srcDir, 'manifest.yml'),
172
+ `celilo_contract: "1.0"
173
+ id: testmod
174
+ name: Test Module Renamed
175
+ version: 2.0.0
176
+ description: fixture v2
177
+ variables:
178
+ owns: []
179
+ imports: []
180
+ `,
181
+ );
182
+ // Simulate the DB starting in a state where manifestData has a
183
+ // `variables.owns` array — the namecheap-3.1.0 → 3.1.1 case.
184
+ db.update(modules)
185
+ .set({
186
+ manifestData: {
187
+ celilo_contract: '1.0',
188
+ id: 'testmod',
189
+ name: 'Test Module',
190
+ version: '1.0.0',
191
+ variables: { owns: [{ name: 'domains', type: 'array', required: true }], imports: [] },
192
+ },
193
+ })
194
+ .where(eq(modules.id, 'testmod'))
195
+ .run();
196
+
197
+ const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '2.0.0+1' });
198
+ expect(result.status).toBe('success');
199
+
200
+ const row = db.select().from(modules).all()[0];
201
+ // version field updates (this part already worked):
202
+ expect(row.version).toBe('2.0.0+1');
203
+ expect(row.name).toBe('Test Module Renamed');
204
+ // manifestData reflects the NEW manifest — the dropped variable
205
+ // is gone. This is the regression assertion.
206
+ const manifest = row.manifestData as {
207
+ version: string;
208
+ name: string;
209
+ variables?: { owns: unknown[] };
210
+ };
211
+ expect(manifest.version).toBe('2.0.0');
212
+ expect(manifest.name).toBe('Test Module Renamed');
213
+ expect(manifest.variables?.owns ?? []).toEqual([]);
214
+ });
215
+
216
+ // Regression for ISS-0091: `module update` used to skip event-bus
217
+ // subscription registration (only `module import` did it), so a
218
+ // refreshed module silently lost its reconcile subscriptions — found
219
+ // live when caddy's reconcile_routes subscription vanished after a
220
+ // path-based update.
221
+ test('registers the new manifest subscriptions on the bus (ISS-0091)', async () => {
222
+ process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
223
+ try {
224
+ writeFileSync(
225
+ join(srcDir, 'manifest.yml'),
226
+ `celilo_contract: "1.0"
227
+ id: testmod
228
+ name: Test Module
229
+ version: 1.1.0
230
+ description: fixture with subscriptions
231
+ subscriptions:
232
+ - name: testmod-tick
233
+ pattern: timer.tick.15m
234
+ handler: "true"
235
+ `,
236
+ );
237
+ const result = await updateOne(srcDir, db, {}, { quiet: true });
238
+ expect(result.status).toBe('success');
239
+
240
+ const { openBus, defineEvents } = await import('@celilo/event-bus');
241
+ const bus = openBus({ dbPath: join(tempDir, 'events.db'), events: defineEvents({}) });
242
+ try {
243
+ // Subscriber names are scoped `<module-id>.<sub-name>` on the bus.
244
+ expect(bus.getSubscriberByName('testmod.testmod-tick')).not.toBeNull();
245
+ } finally {
246
+ bus.close();
247
+ }
248
+ } finally {
249
+ process.env.EVENT_BUS_DB = undefined;
250
+ }
251
+ });
252
+ });