@celilo/cli 0.11.0 → 0.12.1
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 +52 -6
- 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 +13 -5
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/api/protocol.test.ts +1 -1
- package/src/api/remote-client.test.ts +1 -1
- package/src/api/serve.ts +5 -5
- 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/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.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-list.ts +16 -2
- 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/service-list.ts +15 -2
- 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 +21 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +170 -7
- package/src/cli/parser.ts +1 -1
- 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
- package/src/api/protocol.ts +0 -159
- package/src/api/remote-client.ts +0 -218
- package/src/cli/command-registry.ts +0 -1488
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packager resolves `workspace:` build deps in an isolated staging dir by
|
|
3
|
+
* rewriting them to `file:` paths pointing at the live monorepo member
|
|
4
|
+
* (ISS-0147 / celilo#216).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { rewriteWorkspaceDeps } from './workspace-deps';
|
|
12
|
+
|
|
13
|
+
describe('rewriteWorkspaceDeps', () => {
|
|
14
|
+
let repo: string; // fake monorepo root
|
|
15
|
+
let sourceDir: string; // module source under the monorepo
|
|
16
|
+
let buildDir: string; // isolated staging copy
|
|
17
|
+
|
|
18
|
+
function write(base: string, rel: string, obj: unknown): void {
|
|
19
|
+
const full = join(base, rel);
|
|
20
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
21
|
+
writeFileSync(full, typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
repo = mkdtempSync(join(tmpdir(), 'celilo-ws-repo-'));
|
|
26
|
+
// Monorepo root with workspace globs mirroring the real repo.
|
|
27
|
+
write(repo, 'package.json', {
|
|
28
|
+
name: 'infra',
|
|
29
|
+
private: true,
|
|
30
|
+
workspaces: ['packages/*', 'modules/*/site'],
|
|
31
|
+
});
|
|
32
|
+
// A sibling workspace package.
|
|
33
|
+
write(repo, 'packages/visualizer/package.json', {
|
|
34
|
+
name: '@celilo/visualizer',
|
|
35
|
+
version: '0.1.0',
|
|
36
|
+
});
|
|
37
|
+
sourceDir = join(repo, 'modules/website');
|
|
38
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
// Isolated staging copy of the module (what the packager builds in).
|
|
41
|
+
buildDir = mkdtempSync(join(tmpdir(), 'celilo-ws-stage-'));
|
|
42
|
+
write(buildDir, 'site/package.json', {
|
|
43
|
+
name: 'website',
|
|
44
|
+
dependencies: { '@celilo/visualizer': 'workspace:^', astro: '^5.7.0' },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const d of [repo, buildDir]) {
|
|
50
|
+
try {
|
|
51
|
+
rmSync(d, { recursive: true, force: true });
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rewrites a workspace: spec to a file: path at the live member', () => {
|
|
59
|
+
const rewrites = rewriteWorkspaceDeps(buildDir, sourceDir);
|
|
60
|
+
|
|
61
|
+
const pkg = JSON.parse(readFileSync(join(buildDir, 'site/package.json'), 'utf-8'));
|
|
62
|
+
expect(pkg.dependencies['@celilo/visualizer']).toBe(
|
|
63
|
+
`file:${join(repo, 'packages/visualizer')}`,
|
|
64
|
+
);
|
|
65
|
+
// Non-workspace deps untouched.
|
|
66
|
+
expect(pkg.dependencies.astro).toBe('^5.7.0');
|
|
67
|
+
expect(rewrites).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is a no-op when there are no workspace deps', () => {
|
|
71
|
+
write(buildDir, 'site/package.json', {
|
|
72
|
+
name: 'website',
|
|
73
|
+
dependencies: { astro: '^5.7.0' },
|
|
74
|
+
});
|
|
75
|
+
expect(rewriteWorkspaceDeps(buildDir, sourceDir)).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('fails loudly naming an unresolvable workspace dep', () => {
|
|
79
|
+
write(buildDir, 'site/package.json', {
|
|
80
|
+
name: 'website',
|
|
81
|
+
dependencies: { '@celilo/ghost': 'workspace:*' },
|
|
82
|
+
});
|
|
83
|
+
expect(() => rewriteWorkspaceDeps(buildDir, sourceDir)).toThrow(/@celilo\/ghost/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles the workspaces:{packages:[]} object form', () => {
|
|
87
|
+
write(repo, 'package.json', {
|
|
88
|
+
name: 'infra',
|
|
89
|
+
workspaces: { packages: ['packages/*', 'modules/*/site'] },
|
|
90
|
+
});
|
|
91
|
+
const rewrites = rewriteWorkspaceDeps(buildDir, sourceDir);
|
|
92
|
+
expect(rewrites).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve bun `workspace:` protocol build deps in an isolated staging build
|
|
3
|
+
* (ISS-0147 / celilo#216).
|
|
4
|
+
*
|
|
5
|
+
* `celilo package <module>` stages the module into an isolated temp dir and
|
|
6
|
+
* runs the manifest `build:` there. A module whose build depends on a sibling
|
|
7
|
+
* monorepo package via `workspace:` (e.g. celilo-website's site/package.json
|
|
8
|
+
* `@celilo/visualizer: workspace:^`) then fails: the staging dir has no
|
|
9
|
+
* workspace root, so `bun install` errors with "Workspace dependency not
|
|
10
|
+
* found".
|
|
11
|
+
*
|
|
12
|
+
* Fix: before the staged build runs, rewrite every `workspace:` spec in the
|
|
13
|
+
* staged package.json files to a `file:<abs path>` pointing at the live
|
|
14
|
+
* monorepo member. bun installs local path deps from the current source — so
|
|
15
|
+
* the bundle reflects the CURRENT workspace version, never a stale published
|
|
16
|
+
* one (unlike an npm pin). No hand-rolled CELILO_MODULE_SOURCE_DIR dance.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type Dirent, existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const DEP_FIELDS = [
|
|
23
|
+
'dependencies',
|
|
24
|
+
'devDependencies',
|
|
25
|
+
'optionalDependencies',
|
|
26
|
+
'peerDependencies',
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
interface WorkspaceInfo {
|
|
30
|
+
root: string;
|
|
31
|
+
/** package name -> absolute member directory */
|
|
32
|
+
members: Map<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Walk up from `startDir` to find the monorepo root — the nearest ancestor
|
|
37
|
+
* package.json declaring a `workspaces` field — and map every member package
|
|
38
|
+
* name to its absolute directory. Returns null if no workspace root exists.
|
|
39
|
+
*/
|
|
40
|
+
function findWorkspaceRoot(startDir: string): WorkspaceInfo | null {
|
|
41
|
+
let dir = resolve(startDir);
|
|
42
|
+
for (;;) {
|
|
43
|
+
const pkgPath = join(dir, 'package.json');
|
|
44
|
+
if (existsSync(pkgPath)) {
|
|
45
|
+
const patterns = readWorkspacePatterns(pkgPath);
|
|
46
|
+
if (patterns) {
|
|
47
|
+
return { root: dir, members: collectMembers(dir, patterns) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const parent = dirname(dir);
|
|
51
|
+
if (parent === dir) return null;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readWorkspacePatterns(pkgPath: string): string[] | null {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
59
|
+
// npm/bun accept both `workspaces: [...]` and `workspaces: { packages: [...] }`.
|
|
60
|
+
if (Array.isArray(pkg.workspaces)) return pkg.workspaces;
|
|
61
|
+
if (Array.isArray(pkg.workspaces?.packages)) return pkg.workspaces.packages;
|
|
62
|
+
return null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectMembers(root: string, patterns: string[]): Map<string, string> {
|
|
69
|
+
const members = new Map<string, string>();
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
for (const memberDir of expandGlob(root, pattern)) {
|
|
72
|
+
const pkgPath = join(memberDir, 'package.json');
|
|
73
|
+
if (!existsSync(pkgPath)) continue;
|
|
74
|
+
try {
|
|
75
|
+
const name = JSON.parse(readFileSync(pkgPath, 'utf-8')).name;
|
|
76
|
+
if (typeof name === 'string' && name.length > 0) members.set(name, memberDir);
|
|
77
|
+
} catch {
|
|
78
|
+
/* member without a valid package.json — skip */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return members;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Expand a workspace glob against `root`. Supports only the single `*`
|
|
87
|
+
* per-segment wildcard bun/npm workspace patterns actually use (e.g.
|
|
88
|
+
* "packages/[star]" or "modules/[star]/site"). ponytail: no globstar/brace
|
|
89
|
+
* support — no workspace config uses them; add if one ever does.
|
|
90
|
+
*/
|
|
91
|
+
function expandGlob(root: string, pattern: string): string[] {
|
|
92
|
+
let dirs = [root];
|
|
93
|
+
for (const segment of pattern.split('/')) {
|
|
94
|
+
const next: string[] = [];
|
|
95
|
+
for (const dir of dirs) {
|
|
96
|
+
if (segment === '*') {
|
|
97
|
+
let entries: Dirent[];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.isDirectory()) next.push(join(dir, entry.name));
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const candidate = join(dir, segment);
|
|
108
|
+
if (existsSync(candidate)) next.push(candidate);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
dirs = next;
|
|
112
|
+
}
|
|
113
|
+
return dirs;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findPackageJsonFiles(dir: string): string[] {
|
|
117
|
+
const found: string[] = [];
|
|
118
|
+
let entries: Dirent[];
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
121
|
+
} catch {
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
126
|
+
const full = join(dir, entry.name);
|
|
127
|
+
if (entry.isDirectory()) {
|
|
128
|
+
found.push(...findPackageJsonFiles(full));
|
|
129
|
+
} else if (entry.name === 'package.json') {
|
|
130
|
+
found.push(full);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Rewrite every `workspace:` dep in the staged build dir's package.json files
|
|
138
|
+
* to a `file:` path pointing at the live monorepo member.
|
|
139
|
+
*
|
|
140
|
+
* @param buildDir - isolated staging dir being built
|
|
141
|
+
* @param sourceDir - original (unstaged) module dir, used to locate the monorepo root
|
|
142
|
+
* @returns human-readable list of rewrites performed (for logging)
|
|
143
|
+
* @throws if a `workspace:` dep can't be resolved to a monorepo member —
|
|
144
|
+
* loud failure naming the offending dep, never a cryptic downstream error.
|
|
145
|
+
*/
|
|
146
|
+
export function rewriteWorkspaceDeps(buildDir: string, sourceDir: string): string[] {
|
|
147
|
+
const rewrites: string[] = [];
|
|
148
|
+
let workspace: WorkspaceInfo | null | undefined;
|
|
149
|
+
|
|
150
|
+
for (const pkgPath of findPackageJsonFiles(buildDir)) {
|
|
151
|
+
let pkg: Record<string, unknown>;
|
|
152
|
+
try {
|
|
153
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let changed = false;
|
|
159
|
+
for (const field of DEP_FIELDS) {
|
|
160
|
+
const deps = pkg[field];
|
|
161
|
+
if (!deps || typeof deps !== 'object') continue;
|
|
162
|
+
for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
|
|
163
|
+
if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue;
|
|
164
|
+
|
|
165
|
+
// Lazily locate the workspace root only once a workspace dep exists.
|
|
166
|
+
if (workspace === undefined) workspace = findWorkspaceRoot(sourceDir);
|
|
167
|
+
const memberDir = workspace?.members.get(name);
|
|
168
|
+
if (!memberDir) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Module build depends on workspace package "${name}" (${field}: "${spec}"), ` +
|
|
171
|
+
`but it could not be resolved to a monorepo member from ${sourceDir}.\n` +
|
|
172
|
+
`Ensure "${name}" is a workspace member, or replace the "workspace:" spec with a published version range.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
(deps as Record<string, string>)[name] = `file:${memberDir}`;
|
|
176
|
+
rewrites.push(`${name}: ${spec} -> file:${memberDir}`);
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (changed) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return rewrites;
|
|
185
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Changeset-driven module versioning (ISS-0151 /
|
|
2
|
+
* Changeset-driven module versioning (ISS-0151 / openspec/changes/module-version-semantics/proposal.md).
|
|
3
3
|
*
|
|
4
4
|
* For `version_source: { kind: changeset }` modules, the payload version is
|
|
5
5
|
* authored via `.changeset/*.md` files — the same on-disk format the changesets
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate for openspec/changes/unified-management-no-ssh/proposal.md: **modules never hand-build SSH.**
|
|
3
|
+
*
|
|
4
|
+
* Module hooks reach a remote box through the typed primitives in
|
|
5
|
+
* `@celilo/capabilities` (probe / serviceCtl / applyRenderedConfig / … — see
|
|
6
|
+
* apps/celilo/MODULE_PRIMITIVES.md), never a raw `ssh root@…` string, `ssh2`, or
|
|
7
|
+
* an ad-hoc `child_process` shell-out. This test scans every production module
|
|
8
|
+
* script and fails if a banned pattern reappears, so the SSH-elimination work
|
|
9
|
+
* can't silently erode.
|
|
10
|
+
*
|
|
11
|
+
* Precise on purpose: `noRestrictedImports` can't see raw ssh *strings* (the real
|
|
12
|
+
* risk). We ban the SSH shapes themselves — an `ssh root@…` string catches
|
|
13
|
+
* hand-built SSH however it's invoked (child_process, execSync, or the Runner),
|
|
14
|
+
* and `ssh2` catches the library route. We do NOT ban `child_process` outright:
|
|
15
|
+
* many modules' on_install legitimately shell LOCAL `celilo`/system commands
|
|
16
|
+
* (`celilo system apply-config`), which isn't remote SSH.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
|
|
24
|
+
function repoRoot(): string {
|
|
25
|
+
let dir = import.meta.dir;
|
|
26
|
+
for (let i = 0; i < 8; i++) {
|
|
27
|
+
if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
|
|
28
|
+
dir = resolve(dir, '..');
|
|
29
|
+
}
|
|
30
|
+
throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every production `.ts` under modules/<m>/scripts/ (excludes node_modules + tests). */
|
|
34
|
+
function moduleScripts(): string[] {
|
|
35
|
+
const modulesRoot = join(repoRoot(), 'modules');
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
const walk = (dir: string) => {
|
|
38
|
+
for (const entry of readdirSync(dir)) {
|
|
39
|
+
if (entry === 'node_modules') continue;
|
|
40
|
+
const p = join(dir, entry);
|
|
41
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
42
|
+
else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) out.push(p);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
for (const mod of readdirSync(modulesRoot)) {
|
|
46
|
+
const scripts = join(modulesRoot, mod, 'scripts');
|
|
47
|
+
if (existsSync(scripts) && statSync(scripts).isDirectory()) walk(scripts);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BANNED = [
|
|
53
|
+
{
|
|
54
|
+
name: 'raw ssh invocation (StrictHostKeyChecking)',
|
|
55
|
+
re: /StrictHostKeyChecking/,
|
|
56
|
+
hint: 'Use a remote-ops primitive (remoteExec/probe/serviceCtl/…). See MODULE_PRIMITIVES.md.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'raw ssh invocation (ssh … root@)',
|
|
60
|
+
re: /\bssh\s+(?:-\S+\s+|\S*root@)/,
|
|
61
|
+
hint: 'Use a remote-ops primitive, not a hand-built ssh string. See MODULE_PRIMITIVES.md.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "'ssh2' import",
|
|
65
|
+
re: /(?:from|require\()\s*['"]ssh2['"]/,
|
|
66
|
+
hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
describe('recurrence gate: modules never hand-build SSH', () => {
|
|
71
|
+
const scripts = moduleScripts();
|
|
72
|
+
|
|
73
|
+
test('scans a non-trivial set of module scripts (sanity — the scan actually ran)', () => {
|
|
74
|
+
expect(scripts.length).toBeGreaterThan(10);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('no production module script hand-builds SSH (ssh string / ssh2)', () => {
|
|
78
|
+
const violations: string[] = [];
|
|
79
|
+
for (const file of scripts) {
|
|
80
|
+
const src = readFileSync(file, 'utf-8');
|
|
81
|
+
for (const b of BANNED) {
|
|
82
|
+
if (b.re.test(src)) violations.push(`${file}\n → ${b.name}. ${b.hint}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
expect(
|
|
86
|
+
violations,
|
|
87
|
+
`Hand-built SSH found in module scripts:\n ${violations.join('\n ')}`,
|
|
88
|
+
).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -226,3 +226,89 @@ describe('RegistryClient.publish — Cargo binary protocol', () => {
|
|
|
226
226
|
).rejects.toThrow('token invalid');
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
// ── module-owner admin endpoints (ce-1ch) ─────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe('RegistryClient owner methods', () => {
|
|
233
|
+
let fetchSpy: ReturnType<typeof spyOn>;
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
fetchSpy = spyOn(globalThis, 'fetch');
|
|
237
|
+
});
|
|
238
|
+
afterEach(() => {
|
|
239
|
+
fetchSpy.mockRestore();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('listOwners GETs /owners with the admin token and returns the array', async () => {
|
|
243
|
+
const owners = [
|
|
244
|
+
{
|
|
245
|
+
moduleName: 'homebridge',
|
|
246
|
+
ownerSub: 'alice',
|
|
247
|
+
claimedAt: 'T',
|
|
248
|
+
sourceGroup: 'celilo-authors',
|
|
249
|
+
},
|
|
250
|
+
];
|
|
251
|
+
fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owners }), { status: 200 }));
|
|
252
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
253
|
+
const result = await client.listOwners('admin-tok');
|
|
254
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
255
|
+
expect(url).toBe('https://reg.example.com/api/v1/modules/owners');
|
|
256
|
+
expect((init.headers as Record<string, string>).Authorization).toBe('admin-tok');
|
|
257
|
+
expect(result).toEqual(owners);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('listOwners throws the server error detail on failure', async () => {
|
|
261
|
+
fetchSpy.mockResolvedValue(
|
|
262
|
+
new Response(JSON.stringify({ errors: [{ detail: 'Unauthorized' }] }), { status: 401 }),
|
|
263
|
+
);
|
|
264
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
265
|
+
await expect(client.listOwners('nope')).rejects.toThrow('Unauthorized');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('getOwner returns null on 404 (unclaimed)', async () => {
|
|
269
|
+
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
|
|
270
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
271
|
+
expect(await client.getOwner('never-claimed', 'admin-tok')).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('getOwner returns the owner entry on 200', async () => {
|
|
275
|
+
const owner = {
|
|
276
|
+
moduleName: 'caddy',
|
|
277
|
+
ownerSub: 'bob',
|
|
278
|
+
claimedAt: 'T',
|
|
279
|
+
sourceGroup: 'celilo-admins',
|
|
280
|
+
};
|
|
281
|
+
fetchSpy.mockResolvedValue(new Response(JSON.stringify({ owner }), { status: 200 }));
|
|
282
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
283
|
+
expect(await client.getOwner('caddy', 'admin-tok')).toEqual(owner);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('setOwner POSTs ownerSub and returns the updated entry', async () => {
|
|
287
|
+
const owner = {
|
|
288
|
+
moduleName: 'homebridge',
|
|
289
|
+
ownerSub: 'carol',
|
|
290
|
+
claimedAt: 'T',
|
|
291
|
+
sourceGroup: 'admin-reassign',
|
|
292
|
+
};
|
|
293
|
+
fetchSpy.mockResolvedValue(new Response(JSON.stringify({ ok: true, owner }), { status: 200 }));
|
|
294
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
295
|
+
const result = await client.setOwner('homebridge', 'carol', 'admin-tok');
|
|
296
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
297
|
+
expect(url).toBe('https://reg.example.com/api/v1/modules/owners/homebridge');
|
|
298
|
+
expect(init.method).toBe('POST');
|
|
299
|
+
expect(JSON.parse(init.body as string)).toEqual({ ownerSub: 'carol' });
|
|
300
|
+
expect(result).toEqual(owner);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('setOwner throws the server error detail on failure', async () => {
|
|
304
|
+
fetchSpy.mockResolvedValue(
|
|
305
|
+
new Response(JSON.stringify({ errors: [{ detail: 'ownerSub is required' }] }), {
|
|
306
|
+
status: 400,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
const client = new RegistryClient('https://reg.example.com');
|
|
310
|
+
await expect(client.setOwner('homebridge', '', 'admin-tok')).rejects.toThrow(
|
|
311
|
+
'ownerSub is required',
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
package/src/registry/client.ts
CHANGED
|
@@ -40,6 +40,14 @@ export interface ModuleMetadata {
|
|
|
40
40
|
versions: Array<{ num: string; yanked: boolean; created_at: string }>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** A module-owner table entry (ce-1ch). Mirrors the registry-server shape. */
|
|
44
|
+
export interface ModuleOwner {
|
|
45
|
+
moduleName: string;
|
|
46
|
+
ownerSub: string;
|
|
47
|
+
claimedAt: string;
|
|
48
|
+
sourceGroup: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export class RegistryClient {
|
|
44
52
|
readonly baseUrl: string;
|
|
45
53
|
|
|
@@ -158,10 +166,68 @@ export class RegistryClient {
|
|
|
158
166
|
return resp.json() as Promise<{ ok: boolean; name: string; vers: string }>;
|
|
159
167
|
}
|
|
160
168
|
|
|
169
|
+
// ── Module ownership (admin — ce-1ch) ──────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** List the whole module-owner table. Requires an admin token. */
|
|
172
|
+
async listOwners(token: string): Promise<ModuleOwner[]> {
|
|
173
|
+
const resp = await this.authed(`${this.baseUrl}/api/v1/modules/owners`, token);
|
|
174
|
+
const body = (await resp.json()) as { owners: ModuleOwner[] };
|
|
175
|
+
return body.owners;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Show the owner of one module name, or null if unclaimed. Requires an admin token. */
|
|
179
|
+
async getOwner(name: string, token: string): Promise<ModuleOwner | null> {
|
|
180
|
+
const url = `${this.baseUrl}/api/v1/modules/owners/${encodeURIComponent(name)}`;
|
|
181
|
+
const resp = await fetch(url, {
|
|
182
|
+
headers: { Authorization: token },
|
|
183
|
+
signal: AbortSignal.timeout(30_000),
|
|
184
|
+
});
|
|
185
|
+
if (resp.status === 404) return null;
|
|
186
|
+
if (!resp.ok) throw new Error(await this.errorDetail(resp));
|
|
187
|
+
const body = (await resp.json()) as { owner: ModuleOwner };
|
|
188
|
+
return body.owner;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reassign a module name to `ownerSub` (admin overwrite). Requires an admin token. */
|
|
192
|
+
async setOwner(name: string, ownerSub: string, token: string): Promise<ModuleOwner> {
|
|
193
|
+
const url = `${this.baseUrl}/api/v1/modules/owners/${encodeURIComponent(name)}`;
|
|
194
|
+
const resp = await fetch(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({ ownerSub }),
|
|
198
|
+
signal: AbortSignal.timeout(30_000),
|
|
199
|
+
});
|
|
200
|
+
if (!resp.ok) throw new Error(await this.errorDetail(resp));
|
|
201
|
+
const body = (await resp.json()) as { owner: ModuleOwner };
|
|
202
|
+
return body.owner;
|
|
203
|
+
}
|
|
204
|
+
|
|
161
205
|
// ── Internal ──────────────────────────────────────────────────────────────
|
|
162
206
|
|
|
207
|
+
/** GET with an Authorization header, throwing the server's error detail on failure. */
|
|
208
|
+
private async authed(url: string, token: string): Promise<Response> {
|
|
209
|
+
const resp = await fetch(url, {
|
|
210
|
+
headers: { Authorization: token },
|
|
211
|
+
signal: AbortSignal.timeout(30_000),
|
|
212
|
+
});
|
|
213
|
+
if (!resp.ok) throw new Error(await this.errorDetail(resp));
|
|
214
|
+
return resp;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Extract `errors[0].detail` from a Cargo-protocol error body, or fall back to the status. */
|
|
218
|
+
private async errorDetail(resp: Response): Promise<string> {
|
|
219
|
+
const body = (await resp.json().catch(() => ({}))) as {
|
|
220
|
+
errors?: Array<{ detail: string }>;
|
|
221
|
+
};
|
|
222
|
+
return body.errors?.[0]?.detail ?? `HTTP ${resp.status}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
163
225
|
private async get(url: string): Promise<Response> {
|
|
164
|
-
|
|
226
|
+
// 30s (was 15s): a module .netapp can be tens of MB and the download must
|
|
227
|
+
// finish within one window. 15s was too aggressive on slow links — the
|
|
228
|
+
// download of a ~19MB module over a multi-hop path (e2e sim NAT, real WAN)
|
|
229
|
+
// intermittently timed out mid-transfer.
|
|
230
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
165
231
|
if (!resp.ok) {
|
|
166
232
|
throw new Error(`Registry error at ${url}: HTTP ${resp.status}`);
|
|
167
233
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote API access control (see
|
|
2
|
+
* Remote API access control (see openspec/changes/replace-ssh-cli-api/proposal.md, Slice 2a).
|
|
3
3
|
*
|
|
4
4
|
* Stores API principals (name + SSH public key + grants), answers authz
|
|
5
5
|
* queries (deny-by-default), and renders the API account's `authorized_keys`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Aspect approvals — operator consent for a module's base-module aspect.
|
|
3
3
|
*
|
|
4
|
-
* Per
|
|
4
|
+
* Per openspec/specs/base-module-aspects/spec.md D2: when an operator imports a module that
|
|
5
5
|
* declares a `base_module_aspect`, they consent to that aspect's
|
|
6
6
|
* scope (applicable_zones + triggers) ONCE at import time. The
|
|
7
7
|
* consent is recorded in the `aspect_approvals` table and consulted
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Aspect runner — executes a module's base-module aspect across
|
|
3
3
|
* the fleet.
|
|
4
4
|
*
|
|
5
|
-
* Per
|
|
5
|
+
* Per openspec/specs/base-module-aspects/spec.md Phase 1: when a module with an approved
|
|
6
6
|
* `base_module_aspect` triggers a fan-out event, the runner walks
|
|
7
7
|
* every non-`api_only` system in the aspect's `applicable_zones`,
|
|
8
8
|
* materializes an Ansible inventory + playbook in a scratch
|
|
@@ -97,7 +97,7 @@ export interface AspectRunOptions {
|
|
|
97
97
|
/**
|
|
98
98
|
* Hostnames to exclude from fan-out, on top of `api_only`. The
|
|
99
99
|
* primary deploy's own host(s) typically don't need the aspect
|
|
100
|
-
* (per
|
|
100
|
+
* (per openspec/specs/base-module-aspects/spec.md D3 — aspect authors handle this when
|
|
101
101
|
* they want it, the framework doesn't force a skip).
|
|
102
102
|
*/
|
|
103
103
|
excludeHostnames?: string[];
|
|
@@ -301,7 +301,7 @@ export async function materializeAspectAnsible(args: {
|
|
|
301
301
|
/**
|
|
302
302
|
* Execution-phase function: orchestrates plan + materialize + run.
|
|
303
303
|
*
|
|
304
|
-
* Failure semantics (
|
|
304
|
+
* Failure semantics (openspec/specs/base-module-aspects/spec.md D4): aspects are idempotent
|
|
305
305
|
* and forward-progress only. A failed fan-out is reported and the
|
|
306
306
|
* partial state (some systems updated, others not) is preserved —
|
|
307
307
|
* no rollback. The caller (deploy planner, in SC4) decides how to
|
|
@@ -14,7 +14,7 @@ const TEST_DB_PATH = './test-aspect-resolver.db';
|
|
|
14
14
|
* resolver (separate from variables/resolver.ts); it previously handled
|
|
15
15
|
* only $self:/$system:/$capability:, so knot's migrated
|
|
16
16
|
* `knot_server_ip: $infra:main.ipv4_address` leaked into resolv.conf
|
|
17
|
-
* unresolved and broke DNS on every fleet machine.
|
|
17
|
+
* unresolved and broke DNS on every fleet machine. openspec/specs/module-systems-addressing/spec.md.
|
|
18
18
|
*/
|
|
19
19
|
function seedModuleWithSystem(db: DbClient, moduleId: string, ip: string): void {
|
|
20
20
|
db.insert(modules)
|
|
@@ -59,7 +59,7 @@ export async function resolveAspectTemplate(
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// $infra:<system-name>.<field> — the provider's deployed system, by name
|
|
62
|
-
// (
|
|
62
|
+
// (openspec/specs/module-systems-addressing/spec.md). This is how an aspect references the
|
|
63
63
|
// provider's own host IP (e.g. knot's dns-client-config aspect sets every
|
|
64
64
|
// fleet machine's nameserver to $infra:main.ipv4_address). Without this the
|
|
65
65
|
// literal template string would leak into resolv.conf.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local-bus event emitters for build-bus delivery outcomes.
|
|
3
3
|
*
|
|
4
|
-
* Phase 6 of [[
|
|
4
|
+
* Phase 6 of [[openspec/changes/build-bus-poll-cd/proposal.md]] wants `celilo subscribers status`
|
|
5
5
|
* to show last-delivery state + recent failures per subscriber. The
|
|
6
6
|
* data source for that view is the local SQLite event bus — every
|
|
7
7
|
* publisher-side fan-out call emits one `webhook.delivered` or
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Webhook fan-out for the build bus ([[
|
|
2
|
+
* Webhook fan-out for the build bus ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 2).
|
|
3
3
|
*
|
|
4
4
|
* Given a PublishEvent and a subscriber list, this module:
|
|
5
5
|
* 1. Filters subscribers whose match rule fires for the event.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook-dispatch planning + execution for the build bus
|
|
3
|
-
* ([[
|
|
3
|
+
* ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4).
|
|
4
4
|
*
|
|
5
5
|
* Pure: planHookDispatch(event, modules) → HookDispatchPlan[].
|
|
6
6
|
* Decides which module hooks match the event without spawning
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP receiver daemon for the build bus
|
|
3
|
-
* ([[
|
|
3
|
+
* ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 3).
|
|
4
4
|
*
|
|
5
5
|
* Verifies signed webhook envelopes against a per-receiver shared
|
|
6
6
|
* secret, then re-emits the contained PublishEvent onto the local
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status-aggregator tests — Phase 6 of [[
|
|
2
|
+
* Status-aggregator tests — Phase 6 of [[openspec/changes/build-bus-poll-cd/proposal.md]].
|
|
3
3
|
*
|
|
4
4
|
* The aggregator is pure: takes subscribers + delivery records,
|
|
5
5
|
* returns per-subscriber summaries. The formatter is pure too.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Delivery-history aggregator for `celilo subscribers status`
|
|
3
|
-
* ([[
|
|
3
|
+
* ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 6).
|
|
4
4
|
*
|
|
5
5
|
* Reads recent `webhook.delivered` + `webhook.failed` events from
|
|
6
6
|
* the local event bus, groups by subscriber URL, and produces a
|