@celilo/cli 0.11.0 → 0.12.0

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.
Files changed (137) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +52 -6
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +14 -6
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/api/protocol.test.ts +1 -1
  13. package/src/api/remote-client.test.ts +1 -1
  14. package/src/api/serve.ts +5 -5
  15. package/src/capabilities/public-web-publish.test.ts +209 -0
  16. package/src/capabilities/validation.test.ts +1 -1
  17. package/src/capabilities/well-known.test.ts +1 -1
  18. package/src/capabilities/well-known.ts +2 -2
  19. package/src/cli/command-tree-parser.ts +3 -1
  20. package/src/cli/commands/api.ts +1 -1
  21. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  22. package/src/cli/commands/apt-upgrade.ts +63 -0
  23. package/src/cli/commands/backup-pull.ts +1 -1
  24. package/src/cli/commands/commands-json.ts +29 -0
  25. package/src/cli/commands/completion.ts +1 -1
  26. package/src/cli/commands/events.ts +1 -1
  27. package/src/cli/commands/module-changeset.ts +1 -1
  28. package/src/cli/commands/module-import.ts +1 -1
  29. package/src/cli/commands/module-list.ts +16 -2
  30. package/src/cli/commands/module-publish.ts +1 -1
  31. package/src/cli/commands/module-remove.ts +26 -1
  32. package/src/cli/commands/module-types.ts +1 -1
  33. package/src/cli/commands/module-upgrade.ts +1 -1
  34. package/src/cli/commands/module-version.ts +1 -1
  35. package/src/cli/commands/module-where.test.ts +26 -0
  36. package/src/cli/commands/module-where.ts +130 -0
  37. package/src/cli/commands/proxmox-node-list.ts +1 -1
  38. package/src/cli/commands/publish/alpha.test.ts +1 -1
  39. package/src/cli/commands/publish/alpha.ts +3 -3
  40. package/src/cli/commands/publish/changesets.ts +1 -1
  41. package/src/cli/commands/publish/global-install.ts +6 -2
  42. package/src/cli/commands/publish/helpers.ts +21 -14
  43. package/src/cli/commands/publish/index.ts +4 -4
  44. package/src/cli/commands/publish/plan.ts +3 -3
  45. package/src/cli/commands/publish/preflight.ts +2 -2
  46. package/src/cli/commands/publish/types.ts +2 -2
  47. package/src/cli/commands/publish/workspace.test.ts +1 -1
  48. package/src/cli/commands/publish/workspace.ts +2 -2
  49. package/src/cli/commands/registry-owner.test.ts +166 -0
  50. package/src/cli/commands/registry-owner.ts +124 -0
  51. package/src/cli/commands/registry-token.test.ts +109 -0
  52. package/src/cli/commands/registry-token.ts +194 -0
  53. package/src/cli/commands/restore.ts +1 -1
  54. package/src/cli/commands/service-add-proxmox.ts +1 -1
  55. package/src/cli/commands/service-list.ts +15 -2
  56. package/src/cli/commands/subscribers-list.ts +1 -1
  57. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  58. package/src/cli/commands/system-apply-config.ts +1 -1
  59. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  60. package/src/cli/commands/system-init.ts +2 -2
  61. package/src/cli/commands/token.test.ts +26 -0
  62. package/src/cli/commands/token.ts +160 -0
  63. package/src/cli/completion.ts +21 -0
  64. package/src/cli/generate-zsh-completion.test.ts +22 -4
  65. package/src/cli/generate-zsh-completion.ts +7 -3
  66. package/src/cli/index.ts +170 -7
  67. package/src/cli/parser.ts +1 -1
  68. package/src/cli/restore-command.test.ts +1 -1
  69. package/src/db/client.ts +1 -1
  70. package/src/db/schema.ts +50 -7
  71. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  72. package/src/hooks/capability-loader.ts +100 -10
  73. package/src/hooks/define-hook.test.ts +24 -0
  74. package/src/hooks/executor.test.ts +1 -1
  75. package/src/hooks/executor.ts +2 -2
  76. package/src/hooks/types.ts +1 -1
  77. package/src/manifest/contracts/v1.ts +2 -2
  78. package/src/manifest/schema.ts +13 -13
  79. package/src/manifest/template-validator.ts +1 -1
  80. package/src/module/packaging/build.ts +19 -0
  81. package/src/module/packaging/workspace-deps.test.ts +94 -0
  82. package/src/module/packaging/workspace-deps.ts +185 -0
  83. package/src/module/versioning/changeset-version.ts +1 -1
  84. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  85. package/src/registry/client.test.ts +86 -0
  86. package/src/registry/client.ts +67 -1
  87. package/src/services/api-access.ts +1 -1
  88. package/src/services/aspect-approvals.ts +1 -1
  89. package/src/services/aspect-runner.ts +3 -3
  90. package/src/services/aspect-template-resolver.test.ts +1 -1
  91. package/src/services/aspect-template-resolver.ts +1 -1
  92. package/src/services/build-bus/delivery-events.ts +1 -1
  93. package/src/services/build-bus/fan-out.ts +1 -1
  94. package/src/services/build-bus/hook-dispatch.ts +1 -1
  95. package/src/services/build-bus/receiver-server.ts +1 -1
  96. package/src/services/build-bus/status.test.ts +1 -1
  97. package/src/services/build-bus/status.ts +1 -1
  98. package/src/services/build-bus/subscriber-store.ts +1 -1
  99. package/src/services/bus-interview.ts +1 -1
  100. package/src/services/celilo-events.ts +1 -1
  101. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  102. package/src/services/cross-module-read.ts +1 -1
  103. package/src/services/deploy-posture.ts +1 -1
  104. package/src/services/deployed-systems.test.ts +1 -1
  105. package/src/services/deployed-systems.ts +4 -4
  106. package/src/services/dns-provider-backfill.ts +2 -2
  107. package/src/services/events-daemon.ts +1 -1
  108. package/src/services/machine-pool.ts +3 -3
  109. package/src/services/module-deploy.ts +93 -8
  110. package/src/services/module-subscriptions.ts +1 -1
  111. package/src/services/module-types-generator.ts +1 -1
  112. package/src/services/module-validator/git-hygiene.ts +1 -1
  113. package/src/services/port-forwards.test.ts +92 -0
  114. package/src/services/port-forwards.ts +86 -0
  115. package/src/services/proxmox-reconcile.ts +2 -2
  116. package/src/services/public-web-republish.test.ts +189 -0
  117. package/src/services/public-web-republish.ts +84 -0
  118. package/src/services/restore-from-file.ts +1 -1
  119. package/src/services/restore-preflight.ts +1 -1
  120. package/src/services/system-identity.ts +1 -1
  121. package/src/services/system-init.test.ts +2 -2
  122. package/src/services/system-init.ts +2 -2
  123. package/src/services/web-route-cleanup.test.ts +250 -0
  124. package/src/services/web-route-cleanup.ts +144 -0
  125. package/src/templates/generator.ts +2 -2
  126. package/src/types/infrastructure.ts +1 -1
  127. package/src/variables/computed/computed-integration.test.ts +1 -1
  128. package/src/variables/computed/evaluate.ts +1 -1
  129. package/src/variables/computed/parse.ts +1 -1
  130. package/src/variables/context.test.ts +1 -1
  131. package/src/variables/context.ts +5 -5
  132. package/src/variables/lxc-nameserver.test.ts +1 -1
  133. package/src/variables/resolver.ts +2 -2
  134. package/src/variables/types.ts +1 -1
  135. package/src/api/protocol.ts +0 -159
  136. package/src/api/remote-client.ts +0 -218
  137. package/src/cli/command-registry.ts +0 -1488
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Module where command — host discovery: which system(s) serve a module.
3
+ *
4
+ * Answers "which host is providing services for <module>?" without an ssh
5
+ * scavenger hunt (ce-jje). Data source: the deployed-systems layer
6
+ * (module_systems) for the addressable hosts, reconciled live against Proxmox
7
+ * for the real node. Reachability is a role-based hint derived from the zone —
8
+ * never a hardcoded address (see CLAUDE.md network-model rules).
9
+ */
10
+
11
+ import { eq } from 'drizzle-orm';
12
+ import { getDb } from '../../db/client';
13
+ import type { NetworkZone } from '../../db/schema';
14
+ import { modules } from '../../db/schema';
15
+ import { getModuleSystems } from '../../services/deployed-systems';
16
+ import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
17
+ import { getArg, hasFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+
20
+ /**
21
+ * Role-based reachability hint for a zone. Says HOW to reach a host by the
22
+ * zone's firewall posture, not a literal address (subnets are operator config).
23
+ */
24
+ export function reachabilityHint(zone: NetworkZone | string): string {
25
+ switch (zone) {
26
+ case 'internal':
27
+ return 'reachable directly from the internal/home LAN';
28
+ case 'external':
29
+ return 'public zone — reachable at its own address (cloud/VPS)';
30
+ case 'dmz':
31
+ case 'app':
32
+ case 'secure':
33
+ return `firewall-segmented (${zone}) — reach via the firewall's natIp DNAT, not routable directly from the LAN`;
34
+ default:
35
+ return `zone ${zone}`;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Handle module where command.
41
+ *
42
+ * Usage: celilo module where <id> [--json]
43
+ *
44
+ * @param args - Command arguments (module id)
45
+ * @param flags - Command flags (--json)
46
+ * @returns Command result
47
+ */
48
+ export async function handleModuleWhere(
49
+ args: string[],
50
+ flags: Record<string, string | boolean> = {},
51
+ ): Promise<CommandResult> {
52
+ const error = validateRequiredArgs(args, 1);
53
+ if (error) {
54
+ return {
55
+ success: false,
56
+ error: `${error}\n\nUsage: celilo module where <id> [--json]`,
57
+ };
58
+ }
59
+
60
+ const moduleId = getArg(args, 0);
61
+ if (!moduleId) {
62
+ return { success: false, error: 'Module ID is required' };
63
+ }
64
+
65
+ const db = getDb();
66
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
67
+
68
+ if (!module) {
69
+ // CI/build infra (the celilo-builder VM, Forgejo runners) is NOT a module
70
+ // and is not in module_systems — point the operator at where that topology
71
+ // actually lives rather than a bare "not found".
72
+ return {
73
+ success: false,
74
+ error: `Module not found: ${moduleId}\n\nHost discovery covers deployed modules only. CI/build infrastructure (the celilo-builder VM, Forgejo CI runners) is not tracked here — see openspec/specs/forgejo-runner/spec.md and openspec/changes/build-bus-poll-cd/proposal.md for that topology.`,
75
+ };
76
+ }
77
+
78
+ const systems = getModuleSystems(moduleId, db);
79
+
80
+ if (systems.length === 0) {
81
+ const message = `Module '${moduleId}' has no deployed systems (API-only module, or not yet deployed).`;
82
+ if (hasFlag(flags, 'json')) {
83
+ return {
84
+ success: true,
85
+ message: JSON.stringify({ module: moduleId, systems: [] }, null, 2),
86
+ rawOutput: true,
87
+ data: { module: moduleId, systems: [] },
88
+ };
89
+ }
90
+ return { success: true, message };
91
+ }
92
+
93
+ // Reconcile the real node live from Proxmox (never throws; degrades to
94
+ // "node unknown" on outage) — same live-placement source as module status.
95
+ const placements = await reconcilePlacement(systems);
96
+
97
+ const data = placements.map(({ system, resolution }) => ({
98
+ name: system.name,
99
+ hostname: system.hostname,
100
+ ipv4_address: system.ipv4_address,
101
+ zone: system.zone,
102
+ vmid: system.infrastructure.vmid ?? null,
103
+ infra_type: system.infrastructure.type,
104
+ placement: formatPlacementLine(system, resolution),
105
+ reachability: reachabilityHint(system.zone),
106
+ }));
107
+
108
+ if (hasFlag(flags, 'json')) {
109
+ return {
110
+ success: true,
111
+ message: JSON.stringify({ module: moduleId, systems: data }, null, 2),
112
+ rawOutput: true,
113
+ data: { module: moduleId, systems: data },
114
+ };
115
+ }
116
+
117
+ const lines = [`Module '${moduleId}' is served by:`, ''];
118
+ for (const sys of data) {
119
+ lines.push(` ${sys.hostname} (${sys.ipv4_address})`);
120
+ lines.push(` placement: ${sys.placement}`);
121
+ lines.push(` reachability: ${sys.reachability}`);
122
+ lines.push('');
123
+ }
124
+
125
+ return {
126
+ success: true,
127
+ message: lines.join('\n').trimEnd(),
128
+ data: { module: moduleId, systems: data },
129
+ };
130
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo proxmox node list` — live per-node capacity from the Proxmox cluster
3
- * (ISS-0060, Phase 1 of v2/PROXMOX_CAPACITY_AND_LIFECYCLE.md).
3
+ * (ISS-0060, Phase 1 of openspec/changes/proxmox-capacity-lifecycle/proposal.md).
4
4
  *
5
5
  * Reads reality from the Proxmox API via `ProxmoxClient`, never a cached DB
6
6
  * value — the foundation for capacity-aware placement (ISS-0061) and
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Alpha-helper tests — v2/PUBLILO_CLI.md Phase 3 payoff.
2
+ * Alpha-helper tests — openspec/changes/publilo-cli/proposal.md Phase 3 payoff.
3
3
  *
4
4
  * The pure functions in alpha.ts (parsePackageSpec, stripAlphaSuffix,
5
5
  * isAlphaVersion, pickNextAlphaN, decideAlphaSkip) need no I/O to
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Alpha-tagging logic — `--alpha` publish + `--promote` graduation.
3
3
  *
4
- * The semantics live in `v2/FIRST_PUBLILO_ALPHA_TAGGING.md`; this
4
+ * The semantics live in `openspec/specs/alpha-publish-tagging/spec.md`; this
5
5
  * module is the implementation surface those flags drive. Per
6
- * v2/PUBLILO_CLI.md Phase 3, the alpha-specific helpers live here
6
+ * openspec/changes/publilo-cli/proposal.md Phase 3, the alpha-specific helpers live here
7
7
  * (not in helpers.ts) so it's clear where to look when extending the
8
- * model — e.g. to add `--beta` once `v2/BUILD_BUS.md`'s validation
8
+ * model — e.g. to add `--beta` once `openspec/changes/build-bus-poll-cd/proposal.md`'s validation
9
9
  * signal lands.
10
10
  *
11
11
  * Two-layer design for testability:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @changesets/cli integration — v2/PUBLILO_CLI.md Phase 4.
2
+ * @changesets/cli integration — openspec/changes/publilo-cli/proposal.md Phase 4.
3
3
  *
4
4
  * Changesets is the "what versions does this publish produce" piece
5
5
  * of the workflow. During dev, the operator runs `bun changeset` to
@@ -17,7 +17,7 @@
17
17
  import { spawnSync } from 'node:child_process';
18
18
  import { existsSync, readFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
- import { PACKAGES, readGlobalInstalledVersion, readPkg } from './helpers';
20
+ import { getPublishPackages, readGlobalInstalledVersion, readPkg } from './helpers';
21
21
  import type { GlobalUpdateItem, PackageJson } from './types';
22
22
 
23
23
  // ─── Planner ───────────────────────────────────────────────────────
@@ -97,7 +97,11 @@ export function planGlobalUpdate(input: PlanGlobalUpdateInput): GlobalUpdateItem
97
97
  return [];
98
98
  }
99
99
 
100
- const ourNames = new Set(PACKAGES.map((p) => readPkg(p).name).filter((n): n is string => !!n));
100
+ const ourNames = new Set(
101
+ getPublishPackages()
102
+ .map((p) => readPkg(p).name)
103
+ .filter((n): n is string => !!n),
104
+ );
101
105
  const installed = Object.keys(globalPkg.dependencies ?? {}).filter((n) => ourNames.has(n));
102
106
 
103
107
  return decideGlobalUpdates({
@@ -13,19 +13,9 @@
13
13
  import { execSync, spawnSync } from 'node:child_process';
14
14
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
+ import { publishableSet, readWorkspace } from '../../../../../../scripts/workspace-graph';
16
17
  import type { PackageJson, WorkspaceVersionMap } from './types';
17
18
 
18
- // Publish in dependency order: leaves first, then consumers.
19
- // Exported so tests can construct a synthetic packages list against the
20
- // same shape; runtime always uses these in this exact order.
21
- export const PACKAGES = [
22
- 'packages/cli-display',
23
- 'packages/capabilities',
24
- 'packages/event-bus',
25
- 'apps/celilo',
26
- 'packages/e2e',
27
- ] as const;
28
-
29
19
  // REPO_ROOT discovery — this file lives at
30
20
  // apps/celilo/src/cli/commands/publish/helpers.ts, six dirs deep.
31
21
  // Kept as a relative path (matches existing convention in
@@ -35,6 +25,23 @@ export const REPO_ROOT = resolve(import.meta.dir, '../../../../../..');
35
25
 
36
26
  export const ENV_FILE = join(REPO_ROOT, '.env');
37
27
 
28
+ /**
29
+ * The publishable workspace packages (repo-relative dirs), topologically
30
+ * ordered leaves-first — the order the executor publishes in, so a dep is on
31
+ * npm before its dependent references it. DERIVED from the live workspace
32
+ * (every non-private @celilo/* package), never a hardcoded list: a hand-list is
33
+ * exactly what left @celilo/core/mcp/visualizer invisible to the driver and let
34
+ * npm-consumer-smoke rot (apps/celilo/designs/WORKSPACE_PACKAGE_GRAPH.md).
35
+ *
36
+ * A FUNCTION, not a module-load const: the publish command ships in the
37
+ * consumer @celilo/cli, and readWorkspace() against a consumer's non-monorepo
38
+ * install would throw at import — bricking the whole CLI. Deriving lazily means
39
+ * the disk read happens only when a publish actually runs (in the monorepo).
40
+ */
41
+ export function getPublishPackages(): string[] {
42
+ return publishableSet(readWorkspace(REPO_ROOT)).map((p) => p.dir);
43
+ }
44
+
38
45
  export function readPkg(dir: string): PackageJson {
39
46
  return JSON.parse(readFileSync(join(REPO_ROOT, dir, 'package.json'), 'utf-8'));
40
47
  }
@@ -76,7 +83,7 @@ export function isAncestor(maybeAncestor: string, descendant: string): boolean {
76
83
 
77
84
  export function buildWorkspaceVersionMap(): WorkspaceVersionMap {
78
85
  const m = new Map<string, string>();
79
- for (const pkg of PACKAGES) {
86
+ for (const pkg of getPublishPackages()) {
80
87
  const { name, version } = readPkg(pkg);
81
88
  if (name && version) m.set(name, version);
82
89
  }
@@ -129,7 +136,7 @@ export function readExternalProjectPaths(): string[] {
129
136
  * Read `NPM_PUBLISH_TARGET` from .env — the registry URL `celilo publish`
130
137
  * points `bun publish --registry` at for @celilo/* packages (a deployed
131
138
  * npm-cache-node). Unset → publish to npmjs (current behavior). See
132
- * v2/NPM_CACHE_NODE.md Phase 3.1 / v2/PUBLILO_CLI.md decision 10.
139
+ * openspec/changes/private-npm-registry/proposal.md Phase 3.1 / openspec/changes/publilo-cli/proposal.md decision 10.
133
140
  */
134
141
  export function readNpmPublishTarget(): string | null {
135
142
  if (!existsSync(ENV_FILE)) return null;
@@ -230,7 +237,7 @@ export function readGlobalInstalledVersion(name: string): string | null {
230
237
  */
231
238
  export function fetchLatestVersions(): Map<string, string> {
232
239
  const out = new Map<string, string>();
233
- for (const pkg of PACKAGES) {
240
+ for (const pkg of getPublishPackages()) {
234
241
  const { name } = readPkg(pkg);
235
242
  if (!name) continue;
236
243
  const r = spawnSync('npm', ['view', name, 'version'], {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `celilo publish` entry point — thin orchestration over the
3
- * planner/executor split (v2/PUBLILO_CLI.md Phase 2).
3
+ * planner/executor split (openspec/changes/publilo-cli/proposal.md Phase 2).
4
4
  *
5
5
  * parse args → preflight → build plan → display → confirm → execute
6
6
  *
@@ -41,7 +41,7 @@ import {
41
41
  printPendingChangesets,
42
42
  } from './changesets';
43
43
  import { executePlan } from './execute';
44
- import { PACKAGES, REPO_ROOT, isPublished, readPkg } from './helpers';
44
+ import { REPO_ROOT, getPublishPackages, isPublished, readPkg } from './helpers';
45
45
  import { displayPlan, planPublish } from './plan';
46
46
  import {
47
47
  applyReleaseTouch,
@@ -170,7 +170,7 @@ async function handleDryRun(opts: PublishOptions): Promise<never> {
170
170
 
171
171
  if (opts.mode.kind === 'alpha') {
172
172
  console.log('\nAlpha publish plan:');
173
- for (const pkg of PACKAGES) {
173
+ for (const pkg of getPublishPackages()) {
174
174
  const { name, version } = readPkg(pkg);
175
175
  if (!name || !version) continue;
176
176
  const n = nextAlphaNumber(name, version);
@@ -294,7 +294,7 @@ export async function main(argv: string[]): Promise<void> {
294
294
  printPreflightReport(preflight);
295
295
  }
296
296
 
297
- // Changesets — Phase 4 of v2/PUBLILO_CLI.md. Only applies in normal
297
+ // Changesets — Phase 4 of openspec/changes/publilo-cli/proposal.md. Only applies in normal
298
298
  // mode: alpha publishes are throwaway prereleases (applying changesets
299
299
  // would burn them on alpha publishes), and promote graduates an
300
300
  // existing alpha rather than producing a new version. The
@@ -2,7 +2,7 @@
2
2
  * Umbrella planner — composes the per-phase planners into a single
3
3
  * `PublishPlan`.
4
4
  *
5
- * The planner is the value-add of v2/PUBLILO_CLI.md Phase 2: it does
5
+ * The planner is the value-add of openspec/changes/publilo-cli/proposal.md Phase 2: it does
6
6
  * all the world-reads (npm view, git log, fs walks) and produces a
7
7
  * typed description of every side effect the executor will perform.
8
8
  * Dry-run prints the plan and exits; a real publish confirms the plan
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { planConsumerPins } from './consumer-pins';
16
16
  import { planGlobalUpdate } from './global-install';
17
- import { PACKAGES, buildWorkspaceVersionMap, currentGitHead } from './helpers';
17
+ import { buildWorkspaceVersionMap, currentGitHead, getPublishPackages } from './helpers';
18
18
  import { planModulePublish } from './module-registry';
19
19
  import { runPreflight } from './preflight';
20
20
  import type { PublishOptions, PublishPlan } from './types';
@@ -45,7 +45,7 @@ export function planPublish(opts: PublishOptions): PublishPlan {
45
45
 
46
46
  const workspaceResult = planWorkspace({
47
47
  mode: opts.mode,
48
- packages: PACKAGES,
48
+ packages: getPublishPackages(),
49
49
  baseWorkspaceVersions,
50
50
  gitHead,
51
51
  });
@@ -17,8 +17,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
17
17
  import { join, relative } from 'node:path';
18
18
  import { checkModuleStale } from '../../../services/module-validator/git-hygiene';
19
19
  import {
20
- PACKAGES,
21
20
  REPO_ROOT,
21
+ getPublishPackages,
22
22
  isAncestor,
23
23
  isPublished,
24
24
  lastCommitTouching,
@@ -77,7 +77,7 @@ export function runPreflight(): PreflightReport {
77
77
  }).trim();
78
78
 
79
79
  const workspaceStale: StalenessIssue[] = [];
80
- for (const pkg of PACKAGES) {
80
+ for (const pkg of getPublishPackages()) {
81
81
  const issue = checkStaleVersion(pkg);
82
82
  if (issue) workspaceStale.push(issue);
83
83
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Type definitions for `celilo publish` — the planner/executor split per
3
- * v2/PUBLILO_CLI.md Phase 2.
3
+ * openspec/changes/publilo-cli/proposal.md Phase 2.
4
4
  *
5
5
  * The umbrella `PublishPlan` enumerates everything a publish run intends
6
6
  * to do. It's produced by the pure planner (`plan.ts`) and consumed by
@@ -239,7 +239,7 @@ export interface ModuleItem {
239
239
 
240
240
  /**
241
241
  * Umbrella plan — the central data structure of Phase 2 of the publish
242
- * CLI refactor (v2/PUBLILO_CLI.md). One plan covers all four phases of
242
+ * CLI refactor (openspec/changes/publilo-cli/proposal.md). One plan covers all four phases of
243
243
  * a publish run; the executor consumes it top-to-bottom.
244
244
  */
245
245
  export interface PublishPlan {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Workspace planner tests — v2/PUBLILO_CLI.md Phase 2 payoff.
2
+ * Workspace planner tests — openspec/changes/publilo-cli/proposal.md Phase 2 payoff.
3
3
  *
4
4
  * The planner moved out of the monolithic runPublishPhase precisely so
5
5
  * these tests would be possible: no npm subprocesses, no bun publish
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Workspace publish — Phase 1 of `celilo publish`.
3
3
  *
4
- * Planner/executor split per v2/PUBLILO_CLI.md Phase 2:
4
+ * Planner/executor split per openspec/changes/publilo-cli/proposal.md Phase 2:
5
5
  *
6
6
  * planWorkspace(opts) → WorkspaceItem[] // pure, no mutations
7
7
  * executeWorkspace(plan, ...) // mutates package.json,
@@ -325,7 +325,7 @@ export async function verifyPublishedDeps(
325
325
  * registry target is configured (a deployed npm-cache-node) AND the
326
326
  * package is @celilo/*-scoped, point bun at it via --registry; otherwise
327
327
  * publish to the default registry (npmjs — current behavior).
328
- * v2/NPM_CACHE_NODE.md Phase 3.1 / v2/PUBLILO_CLI.md decision 10.
328
+ * openspec/changes/private-npm-registry/proposal.md Phase 3.1 / openspec/changes/publilo-cli/proposal.md decision 10.
329
329
  */
330
330
  export function buildPublishArgs(
331
331
  item: Pick<WorkspaceItem, 'name' | 'tag'>,
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for `celilo registry owner list/show/set` (ce-1ch, SECURE_MODULE_PUBLISH.md D-C/D-E).
3
+ * The admin token is resolved from the local publish_tokens list; registry
4
+ * calls are stubbed so these stay hermetic.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { closeDb, getDb } from '../../db/client';
13
+ import { modules } from '../../db/schema';
14
+ import {
15
+ handleRegistryOwnerList,
16
+ handleRegistryOwnerSet,
17
+ handleRegistryOwnerShow,
18
+ } from './registry-owner';
19
+ import { handleRegistryTokenAdd } from './registry-token';
20
+
21
+ const NO_FLAGS: Record<string, string | boolean> = {};
22
+
23
+ describe('registry owner commands', () => {
24
+ let dataDir: string;
25
+ let fetchSpy: ReturnType<typeof spyOn>;
26
+
27
+ beforeEach(async () => {
28
+ dataDir = await mkdtemp(join(tmpdir(), 'celilo-registry-owner-'));
29
+ process.env.CELILO_DATA_DIR = dataDir;
30
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
31
+ getDb()
32
+ .insert(modules)
33
+ .values({
34
+ id: 'celilo-registry',
35
+ name: 'Celilo Registry',
36
+ version: '1.0.0',
37
+ sourcePath: '/test',
38
+ manifestData: {},
39
+ })
40
+ .run();
41
+ fetchSpy = spyOn(globalThis, 'fetch');
42
+ });
43
+
44
+ afterEach(async () => {
45
+ fetchSpy.mockRestore();
46
+ closeDb();
47
+ process.env.CELILO_DATA_DIR = undefined;
48
+ process.env.CELILO_DB_PATH = undefined;
49
+ if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
50
+ });
51
+
52
+ test('list errors when no admin token is configured', async () => {
53
+ const result = await handleRegistryOwnerList(NO_FLAGS);
54
+ expect(result.success).toBe(false);
55
+ if (!result.success) expect(result.error).toContain('admin publish token');
56
+ });
57
+
58
+ test('list resolves the admin token and renders the owner table', async () => {
59
+ // Seed a scoped token first (ignored) then a bare admin token (used).
60
+ await handleRegistryTokenAdd(['cpt_scoped homebridge']);
61
+ await handleRegistryTokenAdd(['admin-tok']);
62
+ fetchSpy.mockResolvedValue(
63
+ new Response(
64
+ JSON.stringify({
65
+ owners: [
66
+ {
67
+ moduleName: 'homebridge',
68
+ ownerSub: 'alice',
69
+ claimedAt: 'T',
70
+ sourceGroup: 'celilo-authors',
71
+ },
72
+ ],
73
+ }),
74
+ { status: 200 },
75
+ ),
76
+ );
77
+
78
+ const result = await handleRegistryOwnerList(NO_FLAGS);
79
+ expect(result.success).toBe(true);
80
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
81
+ // The bare admin token is used, not the scoped one.
82
+ expect((init.headers as Record<string, string>).Authorization).toBe('admin-tok');
83
+ if (result.success) expect(result.message).toContain('homebridge');
84
+ });
85
+
86
+ test('list reports an empty table cleanly', async () => {
87
+ await handleRegistryTokenAdd(['admin-tok']);
88
+ fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owners: [] }), { status: 200 }));
89
+ const result = await handleRegistryOwnerList(NO_FLAGS);
90
+ expect(result.success).toBe(true);
91
+ if (result.success) expect(result.message).toContain('No module ownerships');
92
+ });
93
+
94
+ test('show requires a module argument', async () => {
95
+ const result = await handleRegistryOwnerShow([], NO_FLAGS);
96
+ expect(result.success).toBe(false);
97
+ });
98
+
99
+ test('show reports an unclaimed name', async () => {
100
+ await handleRegistryTokenAdd(['admin-tok']);
101
+ fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
102
+ const result = await handleRegistryOwnerShow(['never-claimed'], NO_FLAGS);
103
+ expect(result.success).toBe(true);
104
+ if (result.success) expect(result.message).toContain('unclaimed');
105
+ });
106
+
107
+ test('show renders the owner of a claimed name', async () => {
108
+ await handleRegistryTokenAdd(['admin-tok']);
109
+ fetchSpy.mockResolvedValue(
110
+ new Response(
111
+ JSON.stringify({
112
+ owner: {
113
+ moduleName: 'caddy',
114
+ ownerSub: 'bob',
115
+ claimedAt: 'T',
116
+ sourceGroup: 'celilo-admins',
117
+ },
118
+ }),
119
+ { status: 200 },
120
+ ),
121
+ );
122
+ const result = await handleRegistryOwnerShow(['caddy'], NO_FLAGS);
123
+ expect(result.success).toBe(true);
124
+ if (result.success) expect(result.message).toContain('bob');
125
+ });
126
+
127
+ test('set requires both module and owner-sub', async () => {
128
+ expect((await handleRegistryOwnerSet(['homebridge'], NO_FLAGS)).success).toBe(false);
129
+ expect((await handleRegistryOwnerSet([], NO_FLAGS)).success).toBe(false);
130
+ });
131
+
132
+ test('set POSTs the reassignment with the admin token', async () => {
133
+ await handleRegistryTokenAdd(['admin-tok']);
134
+ fetchSpy.mockResolvedValue(
135
+ new Response(
136
+ JSON.stringify({
137
+ ok: true,
138
+ owner: {
139
+ moduleName: 'homebridge',
140
+ ownerSub: 'carol',
141
+ claimedAt: 'T',
142
+ sourceGroup: 'admin-reassign',
143
+ },
144
+ }),
145
+ { status: 200 },
146
+ ),
147
+ );
148
+ const result = await handleRegistryOwnerSet(['homebridge', 'carol'], NO_FLAGS);
149
+ expect(result.success).toBe(true);
150
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
151
+ expect(url).toContain('/api/v1/modules/owners/homebridge');
152
+ expect(init.method).toBe('POST');
153
+ expect((init.headers as Record<string, string>).Authorization).toBe('admin-tok');
154
+ if (result.success) expect(result.message).toContain('carol');
155
+ });
156
+
157
+ test('surfaces a registry error detail', async () => {
158
+ await handleRegistryTokenAdd(['admin-tok']);
159
+ fetchSpy.mockResolvedValue(
160
+ new Response(JSON.stringify({ errors: [{ detail: 'Unauthorized' }] }), { status: 401 }),
161
+ );
162
+ const result = await handleRegistryOwnerList(NO_FLAGS);
163
+ expect(result.success).toBe(false);
164
+ if (!result.success) expect(result.error).toContain('Unauthorized');
165
+ });
166
+ });