@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 +2 -2
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/commands/module-update.test.ts +252 -0
- package/src/cli/commands/module-update.ts +571 -0
- package/src/cli/commands/module-upgrade.test.ts +55 -225
- package/src/cli/commands/module-upgrade.ts +205 -500
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/hooks/capability-loader.ts +7 -3
- package/src/manifest/schema.ts +3 -1
- package/src/services/deploy-posture.test.ts +106 -0
- package/src/services/deploy-posture.ts +87 -0
- package/src/services/module-subscriptions.test.ts +32 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "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.
|
|
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
|
+
});
|