@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.
- package/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +47 -5
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -8
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +19 -0
- package/src/cli/index.ts +155 -4
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- 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
|
|
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
|
-
*
|
|
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
|
|
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 (
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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 (
|
|
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 —
|
|
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
|
|
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
|
-
*
|
|
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
|
+
});
|