@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.2
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/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/cli/command-registry.ts +33 -1
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.ts +51 -19
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +16 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +55 -5
- package/src/db/schema.ts +36 -0
- package/src/hooks/capability-loader.ts +30 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +10 -0
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/module-validator/capability-versions.test.ts +1 -1
|
@@ -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`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.2",
|
|
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.
|
|
55
|
+
"@celilo/capabilities": "^0.4.0",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
|
-
"@celilo/event-bus": "^0.1.
|
|
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
|
|
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 {
|
|
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
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
843
|
-
* run, without grepping into
|
|
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(
|
|
846
|
-
|
|
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
|
-
/**
|
|
170
|
-
|
|
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 {
|
|
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
|
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -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
|
|
269
|
-
uninstall-daemon
|
|
270
|
-
show-daemon
|
|
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)
|