@celilo/cli 0.11.0-alpha.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 (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  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 +8 -8
  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/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
@@ -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
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Registry module-ownership administration (ce-1ch, SECURE_MODULE_PUBLISH.md D-C/D-E).
3
+ *
4
+ * The registry-server owns the allow/deny decision; these commands are the
5
+ * first-class operator front door for the module-owner table:
6
+ * celilo registry owner list — dump the whole table
7
+ * celilo registry owner show <module> — who owns one module name
8
+ * celilo registry owner set <module> <sub> — reassign (admin overwrite)
9
+ *
10
+ * Auth: the admin/bootstrap publish token is read from the local
11
+ * publish_tokens list (resolveAdminToken) — these run on-mgr where that secret
12
+ * lives, never resurfacing the raw admin value to a laptop. The registry URL
13
+ * comes from --registry / CELILO_REGISTRY_URL / the built-in default.
14
+ */
15
+
16
+ import { RegistryClient } from '../../registry/client';
17
+ import { getArg, getFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+ import { resolveAdminToken } from './registry-token';
20
+
21
+ type CommandError = Extract<CommandResult, { success: false }>;
22
+
23
+ function isError(v: unknown): v is CommandError {
24
+ return typeof v === 'object' && v !== null && 'success' in v && v.success === false;
25
+ }
26
+
27
+ function clientFrom(flags: Record<string, string | boolean>): RegistryClient {
28
+ const registryUrl = getFlag(flags, 'registry', '');
29
+ return new RegistryClient(registryUrl || undefined);
30
+ }
31
+
32
+ function registryError(err: unknown): CommandError {
33
+ return {
34
+ success: false,
35
+ error: `Registry request failed: ${err instanceof Error ? err.message : String(err)}`,
36
+ };
37
+ }
38
+
39
+ /** `celilo registry owner list` — dump the module-owner table. */
40
+ export async function handleRegistryOwnerList(
41
+ flags: Record<string, string | boolean>,
42
+ ): Promise<CommandResult> {
43
+ const token = await resolveAdminToken();
44
+ if (isError(token)) return token;
45
+
46
+ const client = clientFrom(flags);
47
+ try {
48
+ const owners = await client.listOwners(token);
49
+ if (owners.length === 0) {
50
+ return { success: true, message: 'No module ownerships recorded yet' };
51
+ }
52
+ const nameWidth = Math.max(6, ...owners.map((o) => o.moduleName.length));
53
+ const subWidth = Math.max(5, ...owners.map((o) => o.ownerSub.length));
54
+ const header = `${'MODULE'.padEnd(nameWidth)} ${'OWNER'.padEnd(subWidth)} CLAIMED`;
55
+ const rows = owners
56
+ .slice()
57
+ .sort((a, b) => a.moduleName.localeCompare(b.moduleName))
58
+ .map(
59
+ (o) => `${o.moduleName.padEnd(nameWidth)} ${o.ownerSub.padEnd(subWidth)} ${o.claimedAt}`,
60
+ );
61
+ return { success: true, message: [header, ...rows].join('\n') };
62
+ } catch (err) {
63
+ return registryError(err);
64
+ }
65
+ }
66
+
67
+ /** `celilo registry owner show <module>` — the owner of one module name. */
68
+ export async function handleRegistryOwnerShow(
69
+ args: string[],
70
+ flags: Record<string, string | boolean>,
71
+ ): Promise<CommandResult> {
72
+ const argErr = validateRequiredArgs(args, 1);
73
+ if (argErr) {
74
+ return { success: false, error: `${argErr}\n\nUsage: celilo registry owner show <module>` };
75
+ }
76
+ const name = getArg(args, 0)?.trim();
77
+ if (!name) return { success: false, error: 'Module name is required' };
78
+
79
+ const token = await resolveAdminToken();
80
+ if (isError(token)) return token;
81
+
82
+ const client = clientFrom(flags);
83
+ try {
84
+ const owner = await client.getOwner(name, token);
85
+ if (!owner) {
86
+ return { success: true, message: `${name} is unclaimed (first verified publisher owns it)` };
87
+ }
88
+ return {
89
+ success: true,
90
+ message: `${name}\n owner: ${owner.ownerSub}\n claimed: ${owner.claimedAt}\n via: ${owner.sourceGroup}`,
91
+ };
92
+ } catch (err) {
93
+ return registryError(err);
94
+ }
95
+ }
96
+
97
+ /** `celilo registry owner set <module> <sub>` — admin reassignment. */
98
+ export async function handleRegistryOwnerSet(
99
+ args: string[],
100
+ flags: Record<string, string | boolean>,
101
+ ): Promise<CommandResult> {
102
+ const argErr = validateRequiredArgs(args, 2);
103
+ if (argErr) {
104
+ return {
105
+ success: false,
106
+ error: `${argErr}\n\nUsage: celilo registry owner set <module> <owner-sub>`,
107
+ };
108
+ }
109
+ const name = getArg(args, 0)?.trim();
110
+ const ownerSub = getArg(args, 1)?.trim();
111
+ if (!name) return { success: false, error: 'Module name is required' };
112
+ if (!ownerSub) return { success: false, error: 'Owner subject is required' };
113
+
114
+ const token = await resolveAdminToken();
115
+ if (isError(token)) return token;
116
+
117
+ const client = clientFrom(flags);
118
+ try {
119
+ const owner = await client.setOwner(name, ownerSub, token);
120
+ return { success: true, message: `Reassigned ${owner.moduleName} → ${owner.ownerSub}` };
121
+ } catch (err) {
122
+ return registryError(err);
123
+ }
124
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Regression tests for F2 (SECURE_MODULE_PUBLISH.md): `module secret set
3
+ * celilo-registry publish_tokens` clobbers the whole newline-separated list.
4
+ * `registry token add`/`rm` must read-modify-write instead.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, 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 { CrossModuleDataManager } from '../../services/cross-module-data-manager';
15
+ import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
16
+
17
+ /** Read back the stored publish_tokens list via the production read path. */
18
+ async function readStoredTokens(): Promise<string[]> {
19
+ const manager = new CrossModuleDataManager();
20
+ await manager.initialize();
21
+ const raw = manager.getSecret('celilo-registry', 'publish_tokens');
22
+ if (!raw) return [];
23
+ return raw
24
+ .split('\n')
25
+ .map((l) => l.trim())
26
+ .filter((l) => l.length > 0);
27
+ }
28
+
29
+ describe('registry token add/rm (F2 clobber fix)', () => {
30
+ let dataDir: string;
31
+
32
+ beforeEach(async () => {
33
+ dataDir = await mkdtemp(join(tmpdir(), 'celilo-registry-token-'));
34
+ process.env.CELILO_DATA_DIR = dataDir;
35
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
36
+
37
+ // Seed the celilo-registry module row (FK target for the secrets row).
38
+ getDb()
39
+ .insert(modules)
40
+ .values({
41
+ id: 'celilo-registry',
42
+ name: 'Celilo Registry',
43
+ version: '1.0.0',
44
+ sourcePath: '/test',
45
+ manifestData: {},
46
+ })
47
+ .run();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ closeDb();
52
+ process.env.CELILO_DATA_DIR = undefined;
53
+ process.env.CELILO_DB_PATH = undefined;
54
+ if (existsSync(dataDir)) {
55
+ await rm(dataDir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('add appends without clobbering existing tokens (F2 regression)', async () => {
60
+ await handleRegistryTokenAdd(['token-a']);
61
+ await handleRegistryTokenAdd(['token-b']);
62
+ await handleRegistryTokenAdd(['token-c']);
63
+
64
+ const result = await handleRegistryTokenAdd(['token-d']);
65
+ expect(result.success).toBe(true);
66
+
67
+ // The whole point of F2: adding token-d must NOT wipe a/b/c.
68
+ expect((await readStoredTokens()).sort()).toEqual(['token-a', 'token-b', 'token-c', 'token-d']);
69
+ });
70
+
71
+ test('rm removes one line, leaves the others', async () => {
72
+ await handleRegistryTokenAdd(['token-a']);
73
+ await handleRegistryTokenAdd(['token-b']);
74
+ await handleRegistryTokenAdd(['token-c']);
75
+
76
+ const result = await handleRegistryTokenRm(['token-b']);
77
+ expect(result.success).toBe(true);
78
+
79
+ expect((await readStoredTokens()).sort()).toEqual(['token-a', 'token-c']);
80
+ });
81
+
82
+ test('add is idempotent — no duplicate line', async () => {
83
+ await handleRegistryTokenAdd(['token-a']);
84
+ const result = await handleRegistryTokenAdd(['token-a']);
85
+ expect(result.success).toBe(true);
86
+
87
+ expect(await readStoredTokens()).toEqual(['token-a']);
88
+ });
89
+
90
+ test('rm of an absent token errors', async () => {
91
+ await handleRegistryTokenAdd(['token-a']);
92
+ const result = await handleRegistryTokenRm(['token-x']);
93
+ expect(result.success).toBe(false);
94
+ });
95
+
96
+ test('add requires a token argument', async () => {
97
+ const result = await handleRegistryTokenAdd([]);
98
+ expect(result.success).toBe(false);
99
+ });
100
+
101
+ test('errors when celilo-registry module is not installed', async () => {
102
+ getDb().delete(modules).run();
103
+ const result = await handleRegistryTokenAdd(['token-a']);
104
+ expect(result.success).toBe(false);
105
+ if (!result.success) {
106
+ expect(result.error).toContain('celilo-registry');
107
+ }
108
+ });
109
+ });