@celilo/cli 0.5.0-alpha.0 → 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/api-clients/proxmox.test.ts +30 -0
- package/src/api-clients/proxmox.ts +57 -0
- 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
- package/src/templates/generator.test.ts +30 -3
- package/src/templates/generator.ts +80 -5
|
@@ -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",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { findNodeForVmid } from './proxmox';
|
|
3
|
+
|
|
4
|
+
describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
|
|
5
|
+
// A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage
|
|
6
|
+
// rows do not (only a node name).
|
|
7
|
+
const resources = [
|
|
8
|
+
{ node: 'node2' }, // node row — no vmid
|
|
9
|
+
{ node: 'node3' }, // storage row — no vmid
|
|
10
|
+
{ vmid: 200, node: 'node2' }, // caddy
|
|
11
|
+
{ vmid: 201, node: 'node3' }, // authentik
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
test('returns the node a vmid currently lives on', () => {
|
|
15
|
+
expect(findNodeForVmid(resources, 200)).toBe('node2');
|
|
16
|
+
expect(findNodeForVmid(resources, 201)).toBe('node3');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns null when the vmid is absent — container not created yet (first deploy)', () => {
|
|
20
|
+
expect(findNodeForVmid(resources, 999)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('never matches a node/storage row that has no vmid', () => {
|
|
24
|
+
expect(findNodeForVmid([{ node: 'node2' }, { node: 'node3' }], 200)).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns null for an empty inventory', () => {
|
|
28
|
+
expect(findNodeForVmid([], 200)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -114,6 +114,18 @@ async function makeProxmoxRequest<T>(
|
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// Fail fast instead of hanging on an unreachable host (no implicit timeout
|
|
118
|
+
// on https.request). Callers treat a failed result as "couldn't reach
|
|
119
|
+
// Proxmox" and fall back accordingly.
|
|
120
|
+
req.setTimeout(15_000, () => {
|
|
121
|
+
req.destroy();
|
|
122
|
+
resolve({
|
|
123
|
+
success: false,
|
|
124
|
+
message: 'Request timed out',
|
|
125
|
+
details: { timeoutMs: 15_000 },
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
117
129
|
req.end();
|
|
118
130
|
} catch (error) {
|
|
119
131
|
resolve({
|
|
@@ -486,6 +498,51 @@ export async function listNodeStorage(
|
|
|
486
498
|
return makeProxmoxRequest(credentials, `/nodes/${nodeName}/storage`);
|
|
487
499
|
}
|
|
488
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Find which Proxmox node a given VMID currently lives on.
|
|
503
|
+
*
|
|
504
|
+
* Queries the cluster resource inventory (`/cluster/resources`), which lists
|
|
505
|
+
* every guest across all nodes with its current node, and matches by VMID
|
|
506
|
+
* (unique cluster-wide). Returns the node name, or `null` if the VMID isn't
|
|
507
|
+
* present — i.e. the container hasn't been created yet (a first deploy).
|
|
508
|
+
*
|
|
509
|
+
* ISS-0090: this is celilo's source of truth for WHERE a system currently is.
|
|
510
|
+
* A redeploy must target the node Proxmox reports here, NOT re-derive placement
|
|
511
|
+
* from the service's `default_target_node` (which only governs new placement) —
|
|
512
|
+
* otherwise a changed default tries to relocate every running container.
|
|
513
|
+
*/
|
|
514
|
+
export async function getNodeForVmid(
|
|
515
|
+
credentials: ProxmoxCredentials,
|
|
516
|
+
vmid: number,
|
|
517
|
+
): Promise<ProxmoxResult<string | null>> {
|
|
518
|
+
// makeProxmoxRequest sends only url.pathname, so a `?type=vm` filter would be
|
|
519
|
+
// dropped — fetch the full inventory and match by vmid client-side instead.
|
|
520
|
+
const result = await makeProxmoxRequest<Array<{ vmid?: number; node?: string }>>(
|
|
521
|
+
credentials,
|
|
522
|
+
'/cluster/resources',
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (!result.success) {
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { success: true, data: findNodeForVmid(result.data, vmid) };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Find the node a VMID lives on within a Proxmox cluster-resource list. Pure
|
|
534
|
+
* matching logic, split out from the network call for testability (Rule 10).
|
|
535
|
+
* Non-guest entries (storage/node rows) have no `vmid` and are skipped. Returns
|
|
536
|
+
* the node name, or `null` when the VMID isn't present.
|
|
537
|
+
*/
|
|
538
|
+
export function findNodeForVmid(
|
|
539
|
+
resources: Array<{ vmid?: number; node?: string }>,
|
|
540
|
+
vmid: number,
|
|
541
|
+
): string | null {
|
|
542
|
+
const match = resources.find((r) => typeof r.vmid === 'number' && r.vmid === vmid);
|
|
543
|
+
return match?.node ?? null;
|
|
544
|
+
}
|
|
545
|
+
|
|
489
546
|
/**
|
|
490
547
|
* List available LXC templates in storage
|
|
491
548
|
*/
|
|
@@ -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'];
|