@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
|
@@ -1,252 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { type DbClient, getDb } from '../../db/client';
|
|
13
|
-
import { modules } from '../../db/schema';
|
|
14
|
-
import { classifyVersionChange, upgradeOne } from './module-upgrade';
|
|
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');
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type PollCandidate,
|
|
4
|
+
pickAutoUpgrade,
|
|
5
|
+
pickUpgradePolicy,
|
|
6
|
+
selectPollTargets,
|
|
7
|
+
} from './module-upgrade';
|
|
8
|
+
|
|
9
|
+
describe('pickUpgradePolicy (ISS-0138 — config override > manifest default > by-semver)', () => {
|
|
10
|
+
test('operator config wins over the manifest default', () => {
|
|
11
|
+
expect(pickUpgradePolicy('always-safe', 'always-fast')).toBe('always-safe');
|
|
29
12
|
});
|
|
30
13
|
|
|
31
|
-
test('
|
|
32
|
-
expect(
|
|
33
|
-
expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
|
|
14
|
+
test('manifest default applies when there is no config override', () => {
|
|
15
|
+
expect(pickUpgradePolicy(undefined, 'always-fast')).toBe('always-fast');
|
|
34
16
|
});
|
|
35
17
|
|
|
36
|
-
test('
|
|
37
|
-
expect(
|
|
38
|
-
expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
|
|
18
|
+
test('defaults to by-semver when neither is set', () => {
|
|
19
|
+
expect(pickUpgradePolicy(undefined, undefined)).toBe('by-semver');
|
|
39
20
|
});
|
|
40
21
|
|
|
41
|
-
test('
|
|
42
|
-
|
|
43
|
-
expect(
|
|
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');
|
|
22
|
+
test('an unknown value falls back to by-semver (no crash on bad input)', () => {
|
|
23
|
+
expect(pickUpgradePolicy('bogus', undefined)).toBe('by-semver');
|
|
24
|
+
expect(pickUpgradePolicy(undefined, 'nonsense')).toBe('by-semver');
|
|
46
25
|
});
|
|
26
|
+
});
|
|
47
27
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
expect(
|
|
51
|
-
expect(
|
|
52
|
-
expect(
|
|
28
|
+
describe('pickAutoUpgrade (ISS-0139 — opt-in: config override > manifest default > off)', () => {
|
|
29
|
+
test('config override (string or boolean) wins', () => {
|
|
30
|
+
expect(pickAutoUpgrade('true', false)).toBe(true);
|
|
31
|
+
expect(pickAutoUpgrade('false', true)).toBe(false);
|
|
32
|
+
expect(pickAutoUpgrade(true, false)).toBe(true);
|
|
53
33
|
});
|
|
54
34
|
|
|
55
|
-
test('
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
35
|
+
test('manifest default applies with no override', () => {
|
|
36
|
+
expect(pickAutoUpgrade(undefined, true)).toBe(true);
|
|
37
|
+
expect(pickAutoUpgrade(undefined, false)).toBe(false);
|
|
58
38
|
});
|
|
59
39
|
|
|
60
|
-
test('
|
|
61
|
-
expect(
|
|
62
|
-
expect(classifyVersionChange('1.0', '2.0')).toBe('major');
|
|
63
|
-
expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
|
|
40
|
+
test('defaults to OFF (opt-in) when neither is set', () => {
|
|
41
|
+
expect(pickAutoUpgrade(undefined, undefined)).toBe(false);
|
|
64
42
|
});
|
|
65
43
|
});
|
|
66
44
|
|
|
67
|
-
describe('
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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();
|
|
45
|
+
describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)', () => {
|
|
46
|
+
const base = (over: Partial<PollCandidate>): PollCandidate => ({
|
|
47
|
+
moduleId: 'm',
|
|
48
|
+
installed: '1.0.0',
|
|
49
|
+
latest: '1.0.1',
|
|
50
|
+
autoUpgrade: true,
|
|
51
|
+
...over,
|
|
113
52
|
});
|
|
114
53
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
54
|
+
test('selects opted-in modules with a newer version', () => {
|
|
55
|
+
expect(
|
|
56
|
+
selectPollTargets([base({ moduleId: 'caddy', installed: '1.0.0', latest: '1.1.0' })]),
|
|
57
|
+
).toEqual([{ moduleId: 'caddy', from: '1.0.0', to: '1.1.0' }]);
|
|
119
58
|
});
|
|
120
59
|
|
|
121
|
-
test('
|
|
122
|
-
|
|
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');
|
|
60
|
+
test('skips modules not opted in', () => {
|
|
61
|
+
expect(selectPollTargets([base({ autoUpgrade: false })])).toEqual([]);
|
|
130
62
|
});
|
|
131
63
|
|
|
132
|
-
test('
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// rolled back to "1.0.0", masking the actual installed revision.
|
|
136
|
-
const result = await upgradeOne(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');
|
|
64
|
+
test('skips modules already up to date or ahead', () => {
|
|
65
|
+
expect(selectPollTargets([base({ installed: '1.0.1', latest: '1.0.1' })])).toEqual([]);
|
|
66
|
+
expect(selectPollTargets([base({ installed: '1.0.2', latest: '1.0.1' })])).toEqual([]);
|
|
143
67
|
});
|
|
144
68
|
|
|
145
|
-
test('
|
|
146
|
-
|
|
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 upgradeOne(srcDir, db, {}, { quiet: true });
|
|
153
|
-
expect(quietResult.status).toBe('success');
|
|
154
|
-
if (quietResult.status !== 'success') return;
|
|
155
|
-
expect(quietResult.newVersion).toBe('1.0.0');
|
|
69
|
+
test('skips modules absent from the registry (latest=null)', () => {
|
|
70
|
+
expect(selectPollTargets([base({ latest: null })])).toEqual([]);
|
|
156
71
|
});
|
|
157
72
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 upgradeOne(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 upgradeOne(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
|
-
}
|
|
73
|
+
test('picks only the eligible subset from a mixed set', () => {
|
|
74
|
+
const targets = selectPollTargets([
|
|
75
|
+
base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
|
|
76
|
+
base({ moduleId: 'b', installed: '1.0.0', latest: '2.0.0', autoUpgrade: false }),
|
|
77
|
+
base({ moduleId: 'c', installed: '1.0.0', latest: null, autoUpgrade: true }),
|
|
78
|
+
base({ moduleId: 'd', installed: '1.0.0', latest: '1.0.0', autoUpgrade: true }),
|
|
79
|
+
]);
|
|
80
|
+
expect(targets.map((t) => t.moduleId)).toEqual(['a']);
|
|
251
81
|
});
|
|
252
82
|
});
|