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

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.
@@ -0,0 +1,13 @@
1
+ CREATE TABLE `dns_registrations` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `provider_module_id` text NOT NULL,
4
+ `consumer_module_id` text NOT NULL,
5
+ `fqdn` text NOT NULL,
6
+ `ip` text,
7
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
8
+ `refreshed_at` integer,
9
+ FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
10
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE UNIQUE INDEX `dns_registrations_provider_fqdn_idx` ON `dns_registrations` (`provider_module_id`,`fqdn`);
@@ -64,6 +64,13 @@
64
64
  "when": 1781064000000,
65
65
  "tag": "0008_aspect_consent",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1781280898000,
72
+ "tag": "0009_dns_registrations",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
- }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.5.0-alpha.1",
3
+ "version": "0.5.0-alpha.3",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
- "@celilo/capabilities": "^0.3.0",
55
+ "@celilo/capabilities": "^0.4.1",
56
56
  "@celilo/cli-display": "^0.1.9",
57
- "@celilo/event-bus": "^0.1.5",
57
+ "@celilo/event-bus": "^0.1.6",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
60
60
  "drizzle-orm": "^0.36.4",
@@ -227,7 +227,7 @@ export const COMMANDS: CommandDef[] = [
227
227
  },
228
228
  {
229
229
  name: 'install-daemon',
230
- description: 'Write a systemd/launchd user unit for the dispatcher',
230
+ description: 'Write a systemd/launchd unit for the dispatcher',
231
231
  flags: [
232
232
  {
233
233
  name: 'celilo-path',
@@ -237,15 +237,47 @@ export const COMMANDS: CommandDef[] = [
237
237
  },
238
238
  { name: 'poll-ms', description: 'Polling interval in ms', takesValue: true },
239
239
  { name: 'concurrency', description: 'Max parallel handlers', takesValue: true },
240
+ {
241
+ name: 'system',
242
+ description:
243
+ 'System scope: /etc/systemd/system unit or /Library/LaunchDaemons plist, run as the celilo state-dir owner (management-plane shape)',
244
+ takesValue: false,
245
+ },
246
+ {
247
+ name: 'print',
248
+ description:
249
+ 'Render the unit to stdout without writing it (used by the celilo-mgmt role)',
250
+ takesValue: false,
251
+ },
240
252
  ],
241
253
  },
242
254
  {
243
255
  name: 'uninstall-daemon',
244
256
  description: 'Remove the installed supervisor unit',
257
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
245
258
  },
246
259
  {
247
260
  name: 'show-daemon',
248
261
  description: 'Print the currently installed unit file',
262
+ flags: [{ name: 'system', description: 'Target the system-scope unit', takesValue: false }],
263
+ },
264
+ ],
265
+ },
266
+ {
267
+ name: 'dns',
268
+ description: 'View DNS bookkeeping (registrations ledger)',
269
+ subcommands: [
270
+ {
271
+ name: 'registrations',
272
+ description: 'List the DNS registration ledger',
273
+ flags: [
274
+ {
275
+ name: 'provider',
276
+ description: 'Only show registrations owned by one provider module',
277
+ takesValue: true,
278
+ valueHint: 'module_ids',
279
+ },
280
+ ],
249
281
  },
250
282
  ],
251
283
  },
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `celilo dns` — operator views over celilo's DNS bookkeeping.
3
+ *
4
+ * `registrations` lists the dns_registrations ledger: every (provider,
5
+ * fqdn) the framework has registered via dns_registrar.registerHost,
6
+ * with the consumer that asked for it and when it was last re-asserted
7
+ * by the provider's refresh_registrations hook. Read-only; names only
8
+ * (module ids), never UUIDs. See
9
+ * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B2b).
10
+ */
11
+
12
+ import { getDb } from '../../db/client';
13
+ import { listDnsRegistrations } from '../../services/dns-registrations';
14
+ import type { CommandResult } from '../types';
15
+
16
+ function formatAge(from: Date | null, now: Date): string {
17
+ if (!from) return 'never';
18
+ const mins = Math.floor((now.getTime() - from.getTime()) / 60_000);
19
+ if (mins < 1) return 'just now';
20
+ if (mins < 60) return `${mins}m ago`;
21
+ const hours = Math.floor(mins / 60);
22
+ if (hours < 48) return `${hours}h ago`;
23
+ return `${Math.floor(hours / 24)}d ago`;
24
+ }
25
+
26
+ export async function handleDnsRegistrations(
27
+ _args: string[],
28
+ flags: Record<string, string | boolean>,
29
+ ): Promise<CommandResult> {
30
+ const db = getDb();
31
+ const provider = typeof flags.provider === 'string' ? flags.provider : undefined;
32
+ const rows = listDnsRegistrations(db, { providerModuleId: provider });
33
+
34
+ if (rows.length === 0) {
35
+ const scope = provider ? ` for provider '${provider}'` : '';
36
+ return {
37
+ success: true,
38
+ message: `No DNS registrations recorded${scope}\n\nRegistrations are recorded when a module registers a hostname via the dns_registrar capability (e.g. during deploy).`,
39
+ };
40
+ }
41
+
42
+ const now = new Date();
43
+ const header = ['FQDN', 'IP', 'PROVIDER', 'CONSUMER', 'REGISTERED', 'REFRESHED'];
44
+ const table = rows.map((r) => [
45
+ r.fqdn,
46
+ r.ip ?? 'auto',
47
+ r.providerModuleId,
48
+ r.consumerModuleId,
49
+ formatAge(r.registeredAt, now),
50
+ formatAge(r.refreshedAt, now),
51
+ ]);
52
+ const widths = header.map((h, i) => Math.max(h.length, ...table.map((row) => row[i].length)));
53
+ const render = (row: string[]) => row.map((cell, i) => cell.padEnd(widths[i])).join(' ');
54
+
55
+ const lines = [render(header), ...table.map(render)];
56
+ return { success: true, message: lines.join('\n'), data: rows };
57
+ }
@@ -36,7 +36,12 @@ import { runNamedHook } from '../../hooks/run-named-hook';
36
36
  import type { HookName } from '../../hooks/types';
37
37
  import type { ModuleManifest } from '../../manifest/schema';
38
38
  import type { EnsureRequiredPayload } from '../../services/bus-interview';
39
- import { installDaemon, readInstalledUnit, uninstallDaemon } from '../../services/events-daemon';
39
+ import {
40
+ installDaemon,
41
+ planDaemonInstall,
42
+ readInstalledUnit,
43
+ uninstallDaemon,
44
+ } from '../../services/events-daemon';
40
45
  import { getArg, hasFlag } from '../parser';
41
46
  import type { CommandResult } from '../types';
42
47
 
@@ -778,27 +783,46 @@ function parseDurationMs(flag: string | boolean | undefined, defaultMs: number):
778
783
  }
779
784
  }
780
785
 
786
+ function daemonScopeFrom(flags: Record<string, string | boolean>): 'user' | 'system' {
787
+ return flags.system ? 'system' : 'user';
788
+ }
789
+
781
790
  /**
782
- * `celilo events install-daemon` — write a systemd user unit (Linux)
783
- * or launchd plist (macOS) that runs the dispatcher under supervision.
784
- * Writes the file but doesn't touch supervisor state the operator
785
- * runs `systemctl --user enable --now ...` themselves so any change
786
- * is visible.
791
+ * `celilo events install-daemon` — write a systemd unit (Linux) or
792
+ * launchd plist (macOS) that runs the dispatcher under supervision.
793
+ * Default is a per-user unit; `--system` writes the system-scope unit
794
+ * (/etc/systemd/system, or a /Library/LaunchDaemons LaunchDaemon on
795
+ * macOS) that survives logout/reboot and runs as the celilo state-dir
796
+ * owner — the management-plane shape. Writes the file but doesn't
797
+ * touch supervisor state — the operator (or the celilo-mgmt Ansible
798
+ * role) runs the enable/bootstrap steps themselves so any change is
799
+ * visible.
787
800
  */
788
801
  export async function handleEventsInstallDaemon(
789
802
  _args: string[],
790
803
  flags: Record<string, string | boolean>,
791
804
  ): Promise<CommandResult> {
792
805
  try {
793
- const result = installDaemon({
806
+ const options = {
794
807
  celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
795
808
  pollMs: flags['poll-ms'] ? Number(flags['poll-ms']) : undefined,
796
809
  concurrency: flags.concurrency ? Number(flags.concurrency) : undefined,
797
- });
810
+ scope: daemonScopeFrom(flags),
811
+ };
812
+ // --print: render-only, raw unit content on stdout. The celilo-mgmt
813
+ // Ansible role captures this and does the root-owned write itself
814
+ // (the deb wrapper runs celilo as the unprivileged celilo user, so
815
+ // a direct --system write would EACCES on /etc/systemd/system).
816
+ if (flags.print) {
817
+ const plan = planDaemonInstall(options);
818
+ return { success: true, message: plan.unitContent, rawOutput: true, data: plan };
819
+ }
820
+ const result = installDaemon(options);
798
821
  const lines = [
799
- `Wrote ${result.platform} unit: ${shortenPath(result.unitPath)}`,
822
+ `Wrote ${result.platform} ${result.scope} unit: ${shortenPath(result.unitPath)}`,
800
823
  ` celilo: ${shortenPath(result.celiloPath)}`,
801
824
  ` bus DB: ${shortenPath(result.busDbPath)}`,
825
+ ...(result.runAsUser ? [` runs as: ${result.runAsUser}`] : []),
802
826
  '',
803
827
  'Next steps (run these yourself — install does not touch supervisor state):',
804
828
  ...result.nextSteps.map((s) => ` ${s}`),
@@ -813,13 +837,16 @@ export async function handleEventsInstallDaemon(
813
837
  }
814
838
 
815
839
  /**
816
- * `celilo events uninstall-daemon` — remove the supervisor unit file.
817
- * Symmetrical: prints the operator-facing disable steps, doesn't run
818
- * them.
840
+ * `celilo events uninstall-daemon` — remove the supervisor unit file
841
+ * (`--system` for the system-scope unit). Symmetrical: prints the
842
+ * operator-facing disable steps, doesn't run them.
819
843
  */
820
- export async function handleEventsUninstallDaemon(): Promise<CommandResult> {
844
+ export async function handleEventsUninstallDaemon(
845
+ _args: string[],
846
+ flags: Record<string, string | boolean>,
847
+ ): Promise<CommandResult> {
821
848
  try {
822
- const result = uninstallDaemon();
849
+ const result = uninstallDaemon({ scope: daemonScopeFrom(flags) });
823
850
  const lines = [
824
851
  result.removed
825
852
  ? `Removed unit: ${shortenPath(result.unitPath)}`
@@ -839,15 +866,20 @@ export async function handleEventsUninstallDaemon(): Promise<CommandResult> {
839
866
 
840
867
  /**
841
868
  * `celilo events show-daemon` — print whatever unit file is currently
842
- * installed so the operator can see exactly what the supervisor will
843
- * run, without grepping into platform-specific paths.
869
+ * installed (`--system` for the system-scope unit) so the operator can
870
+ * see exactly what the supervisor will run, without grepping into
871
+ * platform-specific paths.
844
872
  */
845
- export async function handleEventsShowDaemon(): Promise<CommandResult> {
846
- const result = readInstalledUnit();
873
+ export async function handleEventsShowDaemon(
874
+ _args: string[],
875
+ flags: Record<string, string | boolean>,
876
+ ): Promise<CommandResult> {
877
+ const scope = daemonScopeFrom(flags);
878
+ const result = readInstalledUnit({ scope });
847
879
  if (!result.exists) {
848
880
  return {
849
881
  success: true,
850
- message: `No supervisor unit installed at ${shortenPath(result.path)}\n\nRun \`celilo events install-daemon\` to create one.`,
882
+ message: `No supervisor unit installed at ${shortenPath(result.path)}\n\nRun \`celilo events install-daemon${scope === 'system' ? ' --system' : ''}\` to create one.`,
851
883
  };
852
884
  }
853
885
  return {
@@ -212,4 +212,41 @@ variables:
212
212
  expect(manifest.name).toBe('Test Module Renamed');
213
213
  expect(manifest.variables?.owns ?? []).toEqual([]);
214
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
+ }
251
+ });
215
252
  });
@@ -293,6 +293,22 @@ export async function upgradeOne(
293
293
  }
294
294
  }
295
295
 
296
+ // (Re-)register event-bus subscriptions from the new manifest —
297
+ // mirrors import.ts (ISS-0091: update used to skip this, so a
298
+ // refreshed module silently lost its reconcile subscriptions).
299
+ // registerModuleSubscriptions is idempotent; best-effort like import:
300
+ // a bus problem shouldn't wedge the upgrade, but must be loud.
301
+ try {
302
+ const { registerModuleSubscriptions } = await import('../../services/module-subscriptions');
303
+ registerModuleSubscriptions(newManifest, installedPath);
304
+ } catch (error) {
305
+ const msg = error instanceof Error ? error.message : String(error);
306
+ log.warn(` ${moduleId}: failed to register event-bus subscriptions: ${msg}`);
307
+ log.warn(
308
+ ' Module upgraded, but reactive flows on the event bus will not fire until this is fixed.',
309
+ );
310
+ }
311
+
296
312
  if (!opts.quiet) {
297
313
  log.success(`Upgraded ${moduleId} (${previousVersion} → ${newVersion})`);
298
314
  }
@@ -14,6 +14,7 @@ import {
14
14
  isAlphaVersion,
15
15
  parsePackageSpec,
16
16
  pickNextAlphaN,
17
+ prereleaseDistTag,
17
18
  stripAlphaSuffix,
18
19
  } from './alpha';
19
20
 
@@ -80,6 +81,31 @@ describe('isAlphaVersion', () => {
80
81
  });
81
82
  });
82
83
 
84
+ describe('prereleaseDistTag (ISS-0083)', () => {
85
+ test('stable versions get no override → @latest', () => {
86
+ expect(prereleaseDistTag('0.5.0')).toBeUndefined();
87
+ expect(prereleaseDistTag('1.0.0')).toBeUndefined();
88
+ expect(prereleaseDistTag('10.20.30')).toBeUndefined();
89
+ });
90
+
91
+ test('prerelease versions derive their dist-tag from the identifier', () => {
92
+ expect(prereleaseDistTag('0.5.0-alpha.0')).toBe('alpha');
93
+ expect(prereleaseDistTag('0.5.0-alpha.3')).toBe('alpha');
94
+ expect(prereleaseDistTag('1.0.0-beta.2')).toBe('beta');
95
+ expect(prereleaseDistTag('1.0.0-rc.1')).toBe('rc');
96
+ });
97
+
98
+ test('prerelease without a dotted counter still yields its identifier', () => {
99
+ expect(prereleaseDistTag('1.0.0-alpha')).toBe('alpha');
100
+ expect(prereleaseDistTag('1.0.0-next')).toBe('next');
101
+ });
102
+
103
+ test('a prerelease NEVER maps to latest', () => {
104
+ expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBe('latest');
105
+ expect(prereleaseDistTag('0.5.0-alpha.0')).not.toBeUndefined();
106
+ });
107
+ });
108
+
83
109
  describe('pickNextAlphaN', () => {
84
110
  test('returns 0 when no alphas exist for the target core', () => {
85
111
  expect(pickNextAlphaN([], '0.7.14')).toBe(0);
@@ -52,6 +52,29 @@ export function isAlphaVersion(version: string): boolean {
52
52
  return /-alpha\.\d+$/.test(version);
53
53
  }
54
54
 
55
+ /**
56
+ * ISS-0083: the npm dist-tag a version must publish under, derived from
57
+ * its semver prerelease identifier. A prerelease (e.g. `0.5.0-alpha.0`,
58
+ * `1.0.0-beta.2`, `1.0.0-rc.1`) MUST be tagged with its prerelease name
59
+ * (`alpha`/`beta`/`rc`/…), NEVER `latest` — otherwise `npm install <pkg>`
60
+ * (no tag) pulls a prerelease, and the prerelease tag the .deb resolves
61
+ * (`@celilo/cli@alpha`) goes stale. A stable version returns undefined
62
+ * (publish defaults to `latest`, which is correct for stable).
63
+ *
64
+ * The tag is the FIRST dot-separated token of the prerelease component:
65
+ * 0.5.0-alpha.0 → "alpha"
66
+ * 1.0.0-beta.2 → "beta"
67
+ * 1.0.0-rc.1 → "rc"
68
+ * 1.0.0 → undefined (→ latest)
69
+ */
70
+ export function prereleaseDistTag(version: string): string | undefined {
71
+ const dash = version.indexOf('-');
72
+ if (dash === -1) return undefined;
73
+ const prerelease = version.slice(dash + 1);
74
+ const identifier = prerelease.split('.')[0];
75
+ return identifier || undefined;
76
+ }
77
+
55
78
  /**
56
79
  * Pure inner of `nextAlphaNumber`. Given the full list of versions
57
80
  * for a package (whatever `npm view <name> versions --json` returned)
@@ -166,8 +166,13 @@ export interface WorkspaceItem {
166
166
  * `X.Y.Z-alpha.N` for alpha mode, base-stripped for promote mode.
167
167
  */
168
168
  versionToPublish: string;
169
- /** npm dist-tag override (only `'alpha'` for alpha mode; undefined → `@latest`). */
170
- tag?: 'alpha';
169
+ /**
170
+ * npm dist-tag override. `'alpha'` for alpha mode; for normal mode a
171
+ * prerelease version (e.g. `0.5.0-alpha.0`) derives its tag from the
172
+ * prerelease identifier (ISS-0083) so it never lands on `@latest`.
173
+ * undefined → `@latest` (only for stable versions).
174
+ */
175
+ tag?: string;
171
176
  /**
172
177
  * Rewrite recipe applied to the package's package.json immediately
173
178
  * before `bun publish`. Restored from `originalPackageJson` after.
@@ -29,7 +29,13 @@ import {
29
29
  writeFileSync,
30
30
  } from 'node:fs';
31
31
  import { join, relative } from 'node:path';
32
- import { ALPHA_TAG, alphaSkipDecision, nextAlphaNumber, stripAlphaSuffix } from './alpha';
32
+ import {
33
+ ALPHA_TAG,
34
+ alphaSkipDecision,
35
+ nextAlphaNumber,
36
+ prereleaseDistTag,
37
+ stripAlphaSuffix,
38
+ } from './alpha';
33
39
  import { REPO_ROOT, isPublished, readPkg } from './helpers';
34
40
  import type {
35
41
  PackageJson,
@@ -140,6 +146,10 @@ export function planWorkspace(opts: PlanWorkspaceInput): PlanWorkspaceOutput {
140
146
  workspaceVersions.set(name, versionToPublish);
141
147
  } else {
142
148
  versionToPublish = version;
149
+ // ISS-0083: a prerelease version in package.json (e.g. 0.5.0-alpha.0)
150
+ // must publish under its prerelease dist-tag, never `latest`. Only
151
+ // stable versions get `latest` (tag stays undefined).
152
+ tag = prereleaseDistTag(versionToPublish);
143
153
  if (isPublished(name, versionToPublish)) {
144
154
  skipReason = 'already published';
145
155
  }
@@ -31,6 +31,7 @@ export async function getCompletions(words: string[], current: number): Promise<
31
31
  'audit',
32
32
  'backup',
33
33
  'capability',
34
+ 'dns',
34
35
  'completion',
35
36
  'events',
36
37
  'help',
@@ -53,6 +54,11 @@ export async function getCompletions(words: string[], current: number): Promise<
53
54
 
54
55
  const command = args[0];
55
56
 
57
+ // DNS subcommands
58
+ if (command === 'dns' && currentIndex === 1) {
59
+ return filterSuggestions(['registrations'], args[1] || '');
60
+ }
61
+
56
62
  // Capability subcommands
57
63
  if (command === 'capability' && currentIndex === 1) {
58
64
  const subcommands = ['list', 'info'];
package/src/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import { COMMANDS, type CommandDef } from './command-registry';
10
10
  import { handleCapabilityInfo } from './commands/capability-info';
11
11
  import { handleCapabilityList } from './commands/capability-list';
12
12
  import { handleCompletion } from './commands/completion';
13
+ import { handleDnsRegistrations } from './commands/dns';
13
14
  import {
14
15
  handleEventsAck,
15
16
  handleEventsDrain,
@@ -162,6 +163,7 @@ Commands:
162
163
  audit Top-level alias for 'system audit'
163
164
  events SQLite event-bus operations (status, tail, run dispatcher, etc.)
164
165
  capability View registered module capabilities
166
+ dns View DNS bookkeeping (registrations ledger)
165
167
  package Create distributable .netapp packages from module source
166
168
  module Manage modules (import, list, configure, build, generate)
167
169
  service Manage container services (Proxmox, Digital Ocean)
@@ -265,9 +267,9 @@ Subcommands:
265
267
  repair Crash-recovery sweep without starting the dispatcher
266
268
  resume Alias for repair (acknowledges halt-on-recovery)
267
269
  respond Run the terminal responder; answer deploy prompts from another shell
268
- install-daemon Write a systemd/launchd user unit for the dispatcher
269
- uninstall-daemon Remove the installed supervisor unit
270
- show-daemon Print the currently installed unit file
270
+ install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
271
+ uninstall-daemon [--system] Remove the installed supervisor unit
272
+ show-daemon [--system] Print the currently installed unit file
271
273
 
272
274
  Description:
273
275
  The event bus is a SQLite-backed pub/sub layer for celilo modules.
@@ -380,6 +382,32 @@ Related Commands:
380
382
  };
381
383
  }
382
384
 
385
+ function displayDnsHelp(): CommandResult {
386
+ const helpText = `
387
+ Celilo - DNS Bookkeeping
388
+
389
+ Usage:
390
+ celilo dns <subcommand> [options]
391
+
392
+ Subcommands:
393
+ registrations List the DNS registration ledger
394
+
395
+ Description:
396
+ Every successful dns_registrar.registerHost (e.g. a deploy registering
397
+ its public hostname with namecheap) is recorded in the registration
398
+ ledger. The provider's refresh_registrations hook re-asserts these on
399
+ a timer; REFRESHED shows when that last succeeded.
400
+
401
+ Options:
402
+ --provider <module-id> Only show registrations owned by one provider
403
+
404
+ Examples:
405
+ celilo dns registrations
406
+ celilo dns registrations --provider namecheap
407
+ `;
408
+ return { success: true, message: helpText };
409
+ }
410
+
383
411
  function displayCapabilityHelp(): CommandResult {
384
412
  const helpText = `
385
413
  Celilo - Capability Management
@@ -1075,6 +1103,28 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1075
1103
  };
1076
1104
  }
1077
1105
 
1106
+ if (parsed.command === 'dns') {
1107
+ if (parsed.flags.help || parsed.flags.h) {
1108
+ return displayDnsHelp();
1109
+ }
1110
+
1111
+ if (!parsed.subcommand) {
1112
+ return {
1113
+ success: false,
1114
+ error: 'DNS subcommand required\n\nRun "celilo dns --help" for usage',
1115
+ };
1116
+ }
1117
+
1118
+ if (parsed.subcommand === 'registrations') {
1119
+ return handleDnsRegistrations(parsed.args, parsed.flags);
1120
+ }
1121
+
1122
+ return {
1123
+ success: false,
1124
+ error: `Unknown dns subcommand: ${parsed.subcommand}\n\nRun "celilo dns --help" for usage`,
1125
+ };
1126
+ }
1127
+
1078
1128
  if (parsed.command === 'capability') {
1079
1129
  if (parsed.flags.help || parsed.flags.h) {
1080
1130
  return displayCapabilityHelp();
@@ -1168,9 +1218,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1168
1218
  case 'install-daemon':
1169
1219
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1170
1220
  case 'uninstall-daemon':
1171
- return handleEventsUninstallDaemon();
1221
+ return handleEventsUninstallDaemon(parsed.args, parsed.flags);
1172
1222
  case 'show-daemon':
1173
- return handleEventsShowDaemon();
1223
+ return handleEventsShowDaemon(parsed.args, parsed.flags);
1174
1224
  default:
1175
1225
  return {
1176
1226
  success: false,
package/src/db/schema.ts CHANGED
@@ -432,6 +432,42 @@ export const webRoutes = sqliteTable(
432
432
  }),
433
433
  );
434
434
 
435
+ /**
436
+ * DNS registration ledger — one row per (provider, fqdn) the framework
437
+ * has successfully registered via dns_registrar.registerHost. Written
438
+ * framework-side by the capability loader (the only layer that knows
439
+ * both consumer and provider), read back to drive the provider's
440
+ * periodic refresh_registrations hook and the `celilo dns
441
+ * registrations` view. Rows die with either module via FK cascade; the
442
+ * remote DNS record itself stays (Namecheap DDNS has no delete API).
443
+ * See designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B2).
444
+ */
445
+ export const dnsRegistrations = sqliteTable(
446
+ 'dns_registrations',
447
+ {
448
+ id: integer('id').primaryKey({ autoIncrement: true }),
449
+ providerModuleId: text('provider_module_id')
450
+ .notNull()
451
+ .references(() => modules.id, { onDelete: 'cascade' }),
452
+ consumerModuleId: text('consumer_module_id')
453
+ .notNull()
454
+ .references(() => modules.id, { onDelete: 'cascade' }),
455
+ fqdn: text('fqdn').notNull(),
456
+ /** null = provider auto-detected the request's source IP (Namecheap-style). */
457
+ ip: text('ip'),
458
+ registeredAt: integer('registered_at', { mode: 'timestamp' })
459
+ .notNull()
460
+ .default(sql`(unixepoch())`),
461
+ refreshedAt: integer('refreshed_at', { mode: 'timestamp' }),
462
+ },
463
+ (table) => ({
464
+ providerFqdnUnique: uniqueIndex('dns_registrations_provider_fqdn_idx').on(
465
+ table.providerModuleId,
466
+ table.fqdn,
467
+ ),
468
+ }),
469
+ );
470
+
435
471
  /**
436
472
  * Backup storage providers - destinations for backup archives
437
473
  * Supports local filesystem and S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi)