@celilo/cli 0.5.0-alpha.3 → 0.5.0-alpha.4

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.3",
3
+ "version": "0.5.0-alpha.4",
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.1",
55
+ "@celilo/capabilities": "^0.4.2",
56
56
  "@celilo/cli-display": "^0.1.9",
57
57
  "@celilo/event-bus": "^0.1.6",
58
58
  "@clack/prompts": "^1.1.0",
@@ -106,6 +106,11 @@ export const COMMANDS: CommandDef[] = [
106
106
  ],
107
107
  },
108
108
  { name: 'list-subscribers', description: 'List persistent bus subscribers' },
109
+ {
110
+ name: 'resync-subscriptions',
111
+ description:
112
+ "Rebuild subscribers from deployed modules' manifests (after a restore/migration)",
113
+ },
109
114
  {
110
115
  name: 'list-pending',
111
116
  description: 'List pending deliveries',
@@ -192,6 +192,34 @@ export async function handleEventsListSubscribers(): Promise<CommandResult> {
192
192
  }
193
193
  }
194
194
 
195
+ /**
196
+ * `celilo events resync-subscriptions` — rebuild the bus `subscribers` table
197
+ * from every deployed module's manifest. The reactive layer is registered at
198
+ * DEPLOY time and lives in the bus, which a restore/migration starts EMPTY — so
199
+ * after a cutover this re-establishes who-reacts-to-what without redeploying
200
+ * every module (ISS-0088). Idempotent.
201
+ */
202
+ export async function handleEventsResyncSubscriptions(): Promise<CommandResult> {
203
+ try {
204
+ const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
205
+ const result = resyncAllSubscriptions();
206
+ const lines = [
207
+ `Re-registered ${result.registered} subscription(s) from ${result.modules} deployed module(s).`,
208
+ ];
209
+ if (result.failures.length > 0) {
210
+ const detail = result.failures.map((f) => ` ${f.moduleId}: ${f.error}`).join('\n');
211
+ return {
212
+ success: false,
213
+ error: `${result.failures.length} module(s) failed to re-register subscriptions:\n${detail}`,
214
+ };
215
+ }
216
+ lines.push('', 'A running dispatcher will now deliver events to these handlers.');
217
+ return { success: true, message: lines.join('\n'), data: result };
218
+ } catch (err) {
219
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
220
+ }
221
+ }
222
+
195
223
  export async function handleEventsListPending(
196
224
  _args: string[],
197
225
  flags: Record<string, string | boolean>,
@@ -238,6 +238,30 @@ export async function publishOneModule(
238
238
  }
239
239
  }
240
240
 
241
+ // ISS-0104: a module's hooks resolve @celilo/* from its OWN bundled
242
+ // scripts/node_modules (gitignored, prone to going stale). Refresh that
243
+ // closure and refuse to ship a stale capability SDK — a stale bundle
244
+ // silently runs old capability code even after a clean republish.
245
+ const { refreshAndVerifyBundledDeps } = await import(
246
+ '../../services/module-validator/bundled-deps'
247
+ );
248
+ const depCheck = refreshAndVerifyBundledDeps(resolvedDir);
249
+ if (depCheck.mismatches.length > 0 && !opts.allowStale) {
250
+ return {
251
+ moduleDir,
252
+ status: 'failed',
253
+ message: [
254
+ `${moduleDir}: bundled @celilo dependency is stale vs the workspace (ISS-0104):`,
255
+ ...depCheck.mismatches.map(
256
+ (m) => ` ${m.pkg}: bundled ${m.bundled}, workspace ${m.workspace}`,
257
+ ),
258
+ ' The deployed module would run the bundled (older) capability code.',
259
+ ` Fix: cd ${join(moduleDir, 'scripts')} && bun install (then retry)`,
260
+ ' --allow-stale skips this check.',
261
+ ].join('\n'),
262
+ };
263
+ }
264
+
241
265
  // Assemble release metadata for the .netapp.
242
266
  const releaseMetadata = buildReleaseMetadata({
243
267
  moduleId: name,
@@ -93,9 +93,38 @@ export async function handleRestore(
93
93
  );
94
94
  }
95
95
 
96
+ // Re-register event-bus subscriptions from the restored modules' manifests.
97
+ // The bus (events.db) is NOT in the backup envelope, so a restore/migration
98
+ // starts with an EMPTY subscribers table — every event-driven reconcile (caddy
99
+ // public_web, dns register, namecheap's 15m DDNS refresh, …) is dormant until
100
+ // subscriptions are re-registered (ISS-0088). Reconstruct them from durable
101
+ // celilo.db state rather than requiring a redeploy of every module. Best-effort:
102
+ // the DB was just swapped + reopened, so on an unhappy reopen, point the
103
+ // operator at the standalone command (which runs in a fresh process).
104
+ let subsResynced: { modules: number; registered: number } | undefined;
105
+ try {
106
+ const { resyncAllSubscriptions } = await import('../../services/module-subscriptions');
107
+ const r = resyncAllSubscriptions();
108
+ subsResynced = { modules: r.modules, registered: r.registered };
109
+ if (r.failures.length > 0) {
110
+ log.warn(
111
+ `Re-registered ${r.registered} subscription(s); ${r.failures.length} module(s) failed — run \`celilo events resync-subscriptions\` to retry.`,
112
+ );
113
+ }
114
+ } catch (err) {
115
+ log.warn(
116
+ `Restore landed but event-bus subscriptions were not re-registered: ${err instanceof Error ? err.message : String(err)}. Event-driven reconciles stay dormant until you run \`celilo events resync-subscriptions\`.`,
117
+ );
118
+ }
119
+
96
120
  // Build the success message.
97
121
  const lines: string[] = ['Restore complete.'];
98
122
  if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
123
+ if (subsResynced) {
124
+ lines.push(
125
+ ` • re-registered ${subsResynced.registered} event-bus subscription(s) from ${subsResynced.modules} deployed module(s)`,
126
+ );
127
+ }
99
128
  if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
100
129
  if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
101
130
  if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
@@ -82,6 +82,7 @@ export async function getCompletions(words: string[], current: number): Promise<
82
82
  'status',
83
83
  'tail',
84
84
  'list-subscribers',
85
+ 'resync-subscriptions',
85
86
  'list-pending',
86
87
  'drain',
87
88
  'run',
package/src/cli/index.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  handleEventsRepair,
23
23
  handleEventsReply,
24
24
  handleEventsRespond,
25
+ handleEventsResyncSubscriptions,
25
26
  handleEventsRun,
26
27
  handleEventsRunHook,
27
28
  handleEventsShowDaemon,
@@ -257,6 +258,7 @@ Subcommands:
257
258
  status Print bus health as JSON
258
259
  tail [--type T] [--limit N] Recent events as JSON
259
260
  list-subscribers List persistent bus subscribers
261
+ resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
260
262
  list-pending [--subscriber] List pending deliveries
261
263
  drain [--concurrency N] Process pending deliveries once and return
262
264
  run [--poll-ms N] Run the long-running dispatcher (foreground)
@@ -1194,6 +1196,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1194
1196
  return handleEventsTail(parsed.args, parsed.flags);
1195
1197
  case 'list-subscribers':
1196
1198
  return handleEventsListSubscribers();
1199
+ case 'resync-subscriptions':
1200
+ return handleEventsResyncSubscriptions();
1197
1201
  case 'list-pending':
1198
1202
  return handleEventsListPending(parsed.args, parsed.flags);
1199
1203
  case 'drain':
@@ -62,6 +62,10 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
62
62
  script: 'scripts/idp-functions.ts',
63
63
  legacyFactoryName: 'createIdp',
64
64
  },
65
+ git_forge: {
66
+ script: 'scripts/git-forge-functions.ts',
67
+ legacyFactoryName: 'createForgejoGitForge',
68
+ },
65
69
  dhcp_server: {
66
70
  script: 'scripts/dhcp-server-functions.ts',
67
71
  legacyFactoryName: 'default',
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import { closeDb, getDb } from '../db/client';
6
7
  import { ModuleSubscriptionSchema } from '../manifest/schema';
7
8
  import type { ModuleManifest } from '../manifest/schema';
9
+ import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
8
10
  import {
9
11
  registerModuleSubscriptions,
10
12
  resolveSubscription,
13
+ resyncAllSubscriptions,
11
14
  unregisterModuleSubscriptions,
12
15
  } from './module-subscriptions';
13
16
 
@@ -253,3 +256,88 @@ describe('register / unregister roundtrip', () => {
253
256
  }
254
257
  });
255
258
  });
259
+
260
+ describe('resyncAllSubscriptions (ISS-0088)', () => {
261
+ let dir: string;
262
+ let busPath: string;
263
+
264
+ beforeEach(async () => {
265
+ closeDb();
266
+ dir = mkdtempSync(join(tmpdir(), 'resync-test-'));
267
+ busPath = join(dir, 'events.db');
268
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
269
+ process.env.EVENT_BUS_DB = busPath;
270
+ // Migrate the celilo.db file getDb() will open (resyncAllSubscriptions reads it).
271
+ const setupDb = await migrateDbFile(join(dir, 'celilo.db'));
272
+ setupDb.$client.close();
273
+ });
274
+ afterEach(() => {
275
+ closeDb();
276
+ process.env.CELILO_DB_PATH = undefined;
277
+ process.env.EVENT_BUS_DB = undefined;
278
+ try {
279
+ rmSync(dir, { recursive: true, force: true });
280
+ } catch {
281
+ /* ignore */
282
+ }
283
+ });
284
+
285
+ function seedModule(
286
+ id: string,
287
+ state: string,
288
+ subs: Array<{ name: string; pattern: string; handler: string }>,
289
+ ): void {
290
+ const manifest = JSON.stringify({
291
+ celilo_contract: '1.0',
292
+ id,
293
+ name: id,
294
+ version: '1.0.0',
295
+ requires: { capabilities: [] },
296
+ provides: { capabilities: [] },
297
+ variables: { owns: [], imports: [] },
298
+ subscriptions: subs,
299
+ });
300
+ getDb().$client.run(
301
+ 'INSERT INTO modules (id, name, version, source_path, manifest_data, state) VALUES (?, ?, ?, ?, ?, ?)',
302
+ [id, id, '1.0.0', '/p', manifest, state],
303
+ );
304
+ }
305
+
306
+ function subscriberNames(): string[] {
307
+ const bus = openBus({ dbPath: busPath, events: defineEvents({}) });
308
+ try {
309
+ return bus.db
310
+ .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
311
+ .all()
312
+ .map((r) => r.name);
313
+ } finally {
314
+ bus.close();
315
+ }
316
+ }
317
+
318
+ it('registers subs for DEPLOYED modules only, skipping imported/undeployed', () => {
319
+ seedModule('caddy', 'VERIFIED', [
320
+ { name: 'reconcile', pattern: 'public_web.routes_changed', handler: 'echo' },
321
+ ]);
322
+ seedModule('technitium', 'INSTALLED', [
323
+ { name: 'dns', pattern: 'system.created.*', handler: 'echo' },
324
+ ]);
325
+ seedModule('forgejo', 'IMPORTED', [{ name: 'x', pattern: 'y', handler: 'echo' }]);
326
+ seedModule('greenwave', 'VERIFIED', []); // deployed but declares no subs
327
+
328
+ const result = resyncAllSubscriptions();
329
+
330
+ expect(result.modules).toBe(2); // caddy + technitium (forgejo not deployed, greenwave has none)
331
+ expect(result.registered).toBe(2);
332
+ expect(result.failures).toEqual([]);
333
+ // forgejo.x absent — it was IMPORTED, not deployed.
334
+ expect(subscriberNames()).toEqual(['caddy.reconcile', 'technitium.dns']);
335
+ });
336
+
337
+ it('is idempotent — a second resync does not duplicate rows', () => {
338
+ seedModule('caddy', 'VERIFIED', [{ name: 'reconcile', pattern: 'a', handler: 'echo' }]);
339
+ resyncAllSubscriptions();
340
+ resyncAllSubscriptions();
341
+ expect(subscriberNames()).toEqual(['caddy.reconcile']);
342
+ });
343
+ });
@@ -14,8 +14,12 @@
14
14
  * colliding.
15
15
  */
16
16
 
17
+ import { join } from 'node:path';
17
18
  import { defineEvents, openBus } from '@celilo/event-bus';
18
- import { getEventBusPath } from '../config/paths';
19
+ import { inArray } from 'drizzle-orm';
20
+ import { getEventBusPath, getModuleStoragePath } from '../config/paths';
21
+ import { getDb } from '../db/client';
22
+ import { modules } from '../db/schema';
19
23
  import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
20
24
 
21
25
  /**
@@ -126,6 +130,51 @@ export function unregisterModuleSubscriptions(moduleId: string): {
126
130
  }
127
131
  }
128
132
 
133
+ /**
134
+ * Rebuild the event-bus `subscribers` table from every deployed module's
135
+ * manifest `subscriptions:`. The reactive layer (who-reacts-to-what) is
136
+ * registered at module DEPLOY time and lives in the bus (events.db), which a
137
+ * restore/migration starts EMPTY — so after a cutover, event-driven reconciles
138
+ * (caddy public_web, dns register, …) are dead until every module redeploys
139
+ * (ISS-0088). This reconstructs them from durable celilo.db state instead.
140
+ *
141
+ * Idempotent (registerModuleSubscriptions upserts by subscriber name). Per-module
142
+ * failures are collected, not thrown, so one bad manifest doesn't abort the rest.
143
+ */
144
+ export function resyncAllSubscriptions(): {
145
+ modules: number;
146
+ registered: number;
147
+ failures: Array<{ moduleId: string; error: string }>;
148
+ } {
149
+ const db = getDb();
150
+ const deployed = db
151
+ .select()
152
+ .from(modules)
153
+ .where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
154
+ .all();
155
+
156
+ let modulesWithSubs = 0;
157
+ let registered = 0;
158
+ const failures: Array<{ moduleId: string; error: string }> = [];
159
+
160
+ for (const mod of deployed) {
161
+ const manifest = mod.manifestData as unknown as ModuleManifest;
162
+ if (!manifest?.subscriptions?.length) continue;
163
+ // Code always lives at ${getModuleStoragePath()}/<id> by construction
164
+ // (import.ts) — the same path module-upgrade re-registers from (ISS-0091).
165
+ const modulePath = join(getModuleStoragePath(), mod.id);
166
+ try {
167
+ const result = registerModuleSubscriptions(manifest, modulePath);
168
+ registered += result.registered;
169
+ modulesWithSubs += 1;
170
+ } catch (err) {
171
+ failures.push({ moduleId: mod.id, error: err instanceof Error ? err.message : String(err) });
172
+ }
173
+ }
174
+
175
+ return { modules: modulesWithSubs, registered, failures };
176
+ }
177
+
129
178
  function scopedName(moduleId: string, subName: string): string {
130
179
  return `${moduleId}.${subName}`;
131
180
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { findStaleBundledDeps, refreshAndVerifyBundledDeps } from './bundled-deps';
3
+
4
+ describe('findStaleBundledDeps (ISS-0104)', () => {
5
+ test('flags a bundled @celilo dep older than the workspace', () => {
6
+ const mismatches = findStaleBundledDeps(
7
+ { '@celilo/capabilities': '0.1.8' },
8
+ { '@celilo/capabilities': '0.4.1' },
9
+ );
10
+ expect(mismatches).toEqual([
11
+ { pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
12
+ ]);
13
+ });
14
+
15
+ test('no mismatch when bundled matches workspace', () => {
16
+ expect(
17
+ findStaleBundledDeps(
18
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
19
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
20
+ ),
21
+ ).toEqual([]);
22
+ });
23
+
24
+ test('ignores workspace packages the module does not bundle', () => {
25
+ // Module bundles only capabilities; cli-display is a workspace package but
26
+ // not in this module's closure — must not be flagged.
27
+ expect(
28
+ findStaleBundledDeps(
29
+ { '@celilo/capabilities': '0.4.1' },
30
+ { '@celilo/capabilities': '0.4.1', '@celilo/cli-display': '0.1.9' },
31
+ ),
32
+ ).toEqual([]);
33
+ });
34
+
35
+ test('flags each stale dep independently', () => {
36
+ const mismatches = findStaleBundledDeps(
37
+ { '@celilo/capabilities': '0.1.8', '@celilo/event-bus': '0.1.6' },
38
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
39
+ );
40
+ expect(mismatches).toEqual([
41
+ { pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
42
+ ]);
43
+ });
44
+ });
45
+
46
+ describe('refreshAndVerifyBundledDeps (ISS-0104)', () => {
47
+ test('no-op for a module without scripts/package.json', () => {
48
+ let installs = 0;
49
+ const result = refreshAndVerifyBundledDeps('/tmp/does-not-exist-module-xyz', () => {
50
+ installs++;
51
+ });
52
+ expect(result).toEqual({ refreshed: false, mismatches: [] });
53
+ expect(installs).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ISS-0104: a module's hooks resolve `@celilo/*` from the module's OWN
3
+ * `scripts/node_modules` (nearest-wins), and those are gitignored local
4
+ * installs that go stale. The publish bundles them as-is, so a deployed module
5
+ * can silently run capability code months older than what was just published
6
+ * (e.g. caddy shipped capabilities@0.1.8 while the workspace was at 0.4.1, so
7
+ * ISS-0102 never reached the rendered Caddyfile).
8
+ *
9
+ * Before packing, the publish refreshes a module's `scripts/` deps and verifies
10
+ * the bundled `@celilo/*` versions match the workspace — the versions being
11
+ * co-published. A mismatch fails the publish (unless --allow-stale).
12
+ */
13
+
14
+ import { execSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ /** The @celilo workspace packages a module may bundle (by published name). */
19
+ const CELILO_WORKSPACE_PACKAGES = ['capabilities', 'cli-display', 'event-bus'] as const;
20
+
21
+ export interface DepMismatch {
22
+ pkg: string;
23
+ bundled: string;
24
+ workspace: string;
25
+ }
26
+
27
+ /**
28
+ * Pure: which bundled `@celilo/*` versions differ from the workspace. Only deps
29
+ * the module actually bundles AND the workspace owns are compared — a module
30
+ * that doesn't bundle a given package, or a package the workspace doesn't own,
31
+ * is ignored.
32
+ */
33
+ export function findStaleBundledDeps(
34
+ bundled: Record<string, string>,
35
+ workspace: Record<string, string>,
36
+ ): DepMismatch[] {
37
+ const mismatches: DepMismatch[] = [];
38
+ for (const [pkg, wsVersion] of Object.entries(workspace)) {
39
+ const bundledVersion = bundled[pkg];
40
+ if (bundledVersion && bundledVersion !== wsVersion) {
41
+ mismatches.push({ pkg, bundled: bundledVersion, workspace: wsVersion });
42
+ }
43
+ }
44
+ return mismatches;
45
+ }
46
+
47
+ function readVersion(packageJsonPath: string): string | undefined {
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string };
50
+ return parsed.version;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Find the monorepo root (the dir with `packages/capabilities`) by walking up. */
57
+ function findWorkspaceRoot(start: string): string | undefined {
58
+ let dir = start;
59
+ for (let depth = 0; depth < 8; depth++) {
60
+ if (existsSync(join(dir, 'packages', 'capabilities', 'package.json'))) return dir;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ export interface RefreshResult {
69
+ /** Whether the module had a scripts/ package to refresh. */
70
+ refreshed: boolean;
71
+ /** Bundled @celilo/* that don't match the workspace (empty = clean). */
72
+ mismatches: DepMismatch[];
73
+ }
74
+
75
+ /**
76
+ * Refresh a module's `scripts/` deps (so the bundled hook runtime is current)
77
+ * and verify the bundled `@celilo/*` versions match the workspace. No-op for a
78
+ * module without `scripts/package.json`, or when run outside the monorepo (a
79
+ * standalone module has no workspace to compare against — refresh only).
80
+ *
81
+ * `run` is injected so tests can stub the `bun install` side effect.
82
+ */
83
+ export function refreshAndVerifyBundledDeps(
84
+ moduleDir: string,
85
+ run: (cmd: string, cwd: string) => void = (cmd, cwd) =>
86
+ void execSync(cmd, { cwd, stdio: 'pipe' }),
87
+ ): RefreshResult {
88
+ const scriptsDir = join(moduleDir, 'scripts');
89
+ if (!existsSync(join(scriptsDir, 'package.json'))) {
90
+ return { refreshed: false, mismatches: [] };
91
+ }
92
+
93
+ // Reconcile node_modules with the lockfile + package.json pins so the pack
94
+ // bundles a CURRENT closure, not whatever the operator last installed.
95
+ run('bun install', scriptsDir);
96
+
97
+ const workspaceRoot = findWorkspaceRoot(moduleDir);
98
+ if (!workspaceRoot) {
99
+ return { refreshed: true, mismatches: [] };
100
+ }
101
+
102
+ const workspace: Record<string, string> = {};
103
+ for (const name of CELILO_WORKSPACE_PACKAGES) {
104
+ const version = readVersion(join(workspaceRoot, 'packages', name, 'package.json'));
105
+ if (version) workspace[`@celilo/${name}`] = version;
106
+ }
107
+
108
+ const bundled: Record<string, string> = {};
109
+ for (const pkg of Object.keys(workspace)) {
110
+ const version = readVersion(join(scriptsDir, 'node_modules', pkg, 'package.json'));
111
+ if (version) bundled[pkg] = version;
112
+ }
113
+
114
+ return { refreshed: true, mismatches: findStaleBundledDeps(bundled, workspace) };
115
+ }