@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.
@@ -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.0",
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.3.0",
55
+ "@celilo/capabilities": "^0.4.0",
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",
@@ -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 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'];