@celilo/cli 0.5.0-alpha.9 → 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-alpha.9",
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",
@@ -6,7 +6,7 @@ import {
6
6
  findNodeForVmid,
7
7
  pollTaskUntilDone,
8
8
  selectFreeVmid,
9
- selectIsoStorage,
9
+ selectImportStorage,
10
10
  summarizeNodeCapacities,
11
11
  } from './proxmox';
12
12
 
@@ -137,38 +137,48 @@ describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu l
137
137
  });
138
138
  });
139
139
 
140
- describe('selectIsoStorage — pick a storage that accepts iso content', () => {
141
- test('returns the first active, enabled storage whose content includes iso', () => {
140
+ describe('selectImportStorage — pick a storage that accepts import content', () => {
141
+ test('returns the first active, enabled storage whose content includes import', () => {
142
142
  const storages = [
143
143
  { storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 },
144
- { storage: 'local', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
144
+ { storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 },
145
145
  ];
146
- expect(selectIsoStorage(storages)).toBe('local');
146
+ expect(selectImportStorage(storages)).toBe('local');
147
147
  });
148
148
 
149
- test('skips an iso-capable storage that is inactive', () => {
149
+ test('does NOT pick an iso-only storage (import-from rejects iso content)', () => {
150
+ // Regression guard: the cloud image must land in the import namespace, not
151
+ // iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`.
150
152
  const storages = [
151
- { storage: 'cold', content: 'iso', active: 0, enabled: 1 },
152
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
153
+ { storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 },
154
+ { storage: 'data', content: 'import,images', active: 1, enabled: 1 },
153
155
  ];
154
- expect(selectIsoStorage(storages)).toBe('local');
156
+ expect(selectImportStorage(storages)).toBe('data');
155
157
  });
156
158
 
157
- test('skips an iso-capable storage that is disabled', () => {
159
+ test('skips an import-capable storage that is inactive', () => {
158
160
  const storages = [
159
- { storage: 'off', content: 'iso', active: 1, enabled: 0 },
160
- { storage: 'local', content: 'iso', active: 1, enabled: 1 },
161
+ { storage: 'cold', content: 'import', active: 0, enabled: 1 },
162
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
161
163
  ];
162
- expect(selectIsoStorage(storages)).toBe('local');
164
+ expect(selectImportStorage(storages)).toBe('local');
163
165
  });
164
166
 
165
- test('returns null when no storage accepts iso content', () => {
167
+ test('skips an import-capable storage that is disabled', () => {
168
+ const storages = [
169
+ { storage: 'off', content: 'import', active: 1, enabled: 0 },
170
+ { storage: 'local', content: 'import', active: 1, enabled: 1 },
171
+ ];
172
+ expect(selectImportStorage(storages)).toBe('local');
173
+ });
174
+
175
+ test('returns null when no storage accepts import content', () => {
166
176
  const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }];
167
- expect(selectIsoStorage(storages)).toBeNull();
177
+ expect(selectImportStorage(storages)).toBeNull();
168
178
  });
169
179
 
170
180
  test('returns null for an empty storage list', () => {
171
- expect(selectIsoStorage([])).toBeNull();
181
+ expect(selectImportStorage([])).toBeNull();
172
182
  });
173
183
  });
174
184
 
@@ -200,15 +210,15 @@ describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', ()
200
210
  });
201
211
  });
202
212
 
203
- describe('buildCloudImageVolid — volid for a cloud image on iso storage', () => {
204
- test('places the filename under the storage iso/ namespace', () => {
213
+ describe('buildCloudImageVolid — volid for a cloud image on import storage', () => {
214
+ test('places the filename under the storage import/ namespace', () => {
205
215
  expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe(
206
- 'local:iso/noble-server-cloudimg-amd64.img',
216
+ 'local:import/noble-server-cloudimg-amd64.img',
207
217
  );
208
218
  });
209
219
 
210
220
  test('works with an arbitrary storage name', () => {
211
- expect(buildCloudImageVolid('nas-iso', 'jammy.img')).toBe('nas-iso:iso/jammy.img');
221
+ expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img');
212
222
  });
213
223
  });
214
224
 
@@ -890,30 +890,38 @@ export async function listVmTemplates(
890
890
  return { success: true, data: filterVmTemplates(result.data) };
891
891
  }
892
892
 
893
- /** Storage row shape (subset) used when picking ISO-capable storage. */
893
+ /** Storage row shape (subset) used when picking import-capable storage. */
894
894
  type ProxmoxStorageRow = { storage: string; content: string; active: number; enabled: number };
895
895
 
896
896
  /**
897
- * Pick the first active, enabled storage that accepts `iso` content. Pure
897
+ * Pick the first active, enabled storage that accepts `import` content. Pure
898
898
  * selection logic, split from the network call for testability (Rule 10).
899
899
  * Returns the storage name, or null when none qualifies.
900
+ *
901
+ * Must be `import`, NOT `iso`: the cloud image is consumed via the VM-create
902
+ * `import-from=` parameter, which Proxmox only accepts from a source on a
903
+ * storage whose content type is `images` or `import`. A `.img` downloaded to
904
+ * the `iso/` namespace is rejected with `has wrong type 'iso'`. The
905
+ * `download-url` endpoint accepts `content=import` on PVE 8.2+, so we download
906
+ * straight into the import namespace. (PVE 8.2+; node fleet is 9.x.)
900
907
  */
901
- export function selectIsoStorage(storages: ProxmoxStorageRow[]): string | null {
902
- const iso = storages.find((s) => s.active && s.enabled && s.content.includes('iso'));
903
- return iso?.storage ?? null;
908
+ export function selectImportStorage(storages: ProxmoxStorageRow[]): string | null {
909
+ const match = storages.find((s) => s.active && s.enabled && s.content.includes('import'));
910
+ return match?.storage ?? null;
904
911
  }
905
912
 
906
913
  /**
907
- * Find a storage on the node that accepts `iso` content (for downloading cloud
908
- * images). Returns the storage name, or null if none found.
914
+ * Find a storage on the node that accepts `import` content (for downloading
915
+ * cloud images that will be imported as a VM disk). Returns the storage name,
916
+ * or null if none found.
909
917
  */
910
- export async function findIsoStorage(
918
+ export async function findImportStorage(
911
919
  credentials: ProxmoxCredentials,
912
920
  nodeName: string,
913
921
  ): Promise<ProxmoxResult<string | null>> {
914
922
  const result = await listNodeStorage(credentials, nodeName);
915
923
  if (!result.success) return result;
916
- return { success: true, data: selectIsoStorage(result.data) };
924
+ return { success: true, data: selectImportStorage(result.data) };
917
925
  }
918
926
 
919
927
  /**
@@ -953,20 +961,22 @@ export async function findFreeTemplateVmid(
953
961
  }
954
962
 
955
963
  /**
956
- * Download a cloud image URL to ISO storage on the node. Returns a UPID for
957
- * status polling. Requires PVE 7.2+ (`download-url` endpoint).
964
+ * Download a cloud image URL to `import` storage on the node. Returns a UPID
965
+ * for status polling. Requires PVE 8.2+ (`download-url` with `content=import`).
966
+ * The image MUST land in the import namespace (not iso) so the VM-create
967
+ * `import-from=` accepts it — see selectImportStorage.
958
968
  */
959
969
  export async function downloadCloudImage(
960
970
  credentials: ProxmoxCredentials,
961
971
  nodeName: string,
962
- isoStorage: string,
972
+ importStorage: string,
963
973
  url: string,
964
974
  filename: string,
965
975
  ): Promise<ProxmoxResult<string>> {
966
976
  return makeProxmoxPost<string>(
967
977
  credentials,
968
- `/nodes/${nodeName}/storage/${isoStorage}/download-url`,
969
- { url, filename, content: 'iso' },
978
+ `/nodes/${nodeName}/storage/${importStorage}/download-url`,
979
+ { url, filename, content: 'import' },
970
980
  );
971
981
  }
972
982
 
@@ -1003,13 +1013,14 @@ export async function pollTaskUntilDone(
1003
1013
  }
1004
1014
 
1005
1015
  /**
1006
- * Build the volid for a cloud image downloaded to ISO storage. Pure path
1016
+ * Build the volid for a cloud image downloaded to `import` storage. Pure path
1007
1017
  * construction, split out for testability (Rule 10) — mirrors buildTemplatePath.
1008
- * Proxmox's `download-url` with `content=iso` stores the `.img` under the
1009
- * storage's `iso/` namespace, so the volid is `<storage>:iso/<filename>`.
1018
+ * Proxmox's `download-url` with `content=import` stores the `.img` under the
1019
+ * storage's `import/` namespace, so the volid is `<storage>:import/<filename>`.
1020
+ * This is the namespace the VM-create `import-from=` accepts (iso/ is rejected).
1010
1021
  */
1011
- export function buildCloudImageVolid(isoStorage: string, filename: string): string {
1012
- return `${isoStorage}:iso/${filename}`;
1022
+ export function buildCloudImageVolid(importStorage: string, filename: string): string {
1023
+ return `${importStorage}:import/${filename}`;
1013
1024
  }
1014
1025
 
1015
1026
  export interface CreateVmFromCloudImageParams {
@@ -1017,8 +1028,8 @@ export interface CreateVmFromCloudImageParams {
1017
1028
  nodeName: string;
1018
1029
  vmid: number;
1019
1030
  name: string;
1020
- /** Volume ID of the downloaded cloud image, e.g. `local:iso/noble-server-cloudimg-amd64.img` */
1021
- isoVolid: string;
1031
+ /** Volume ID of the downloaded cloud image, e.g. `local:import/noble-server-cloudimg-amd64.img` */
1032
+ imageVolid: string;
1022
1033
  /** Storage for the imported VM disk and the cloud-init drive */
1023
1034
  diskStorage: string;
1024
1035
  }
@@ -1038,7 +1049,7 @@ export interface CreateVmFromCloudImageParams {
1038
1049
  export async function createVmFromCloudImage(
1039
1050
  params: CreateVmFromCloudImageParams,
1040
1051
  ): Promise<ProxmoxResult<string>> {
1041
- const { credentials, nodeName, vmid, name, isoVolid, diskStorage } = params;
1052
+ const { credentials, nodeName, vmid, name, imageVolid, diskStorage } = params;
1042
1053
  return makeProxmoxPost<string>(credentials, `/nodes/${nodeName}/qemu`, {
1043
1054
  vmid: String(vmid),
1044
1055
  name,
@@ -1050,7 +1061,7 @@ export async function createVmFromCloudImage(
1050
1061
  serial0: 'socket',
1051
1062
  vga: 'serial0',
1052
1063
  boot: 'order=scsi0',
1053
- scsi0: `${diskStorage}:0,import-from=${isoVolid}`,
1064
+ scsi0: `${diskStorage}:0,import-from=${imageVolid}`,
1054
1065
  ide2: `${diskStorage}:cloudinit`,
1055
1066
  });
1056
1067
  }
@@ -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
+ });