@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
package/src/manifest/schema.ts
CHANGED
|
@@ -214,7 +214,7 @@ export const EnsureSchema = z.object({
|
|
|
214
214
|
export type Ensure = z.infer<typeof EnsureSchema>;
|
|
215
215
|
|
|
216
216
|
/**
|
|
217
|
-
* A computed capability field (
|
|
217
|
+
* A computed capability field (openspec/specs/internal-dns-split-horizon/spec.md D1).
|
|
218
218
|
*
|
|
219
219
|
* Declared alongside `data`, but derived ON ACCESS from other values via the
|
|
220
220
|
* `value:` DSL (see src/variables/computed/) and never persisted. The result
|
|
@@ -275,7 +275,7 @@ export const LifecycleHookSchema = z.object({
|
|
|
275
275
|
/**
|
|
276
276
|
* Build-bus upstream-publish hook. Fires when a publish event lands
|
|
277
277
|
* on the local event bus from the receiver daemon
|
|
278
|
-
* ([[
|
|
278
|
+
* ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4). Each module can declare multiple
|
|
279
279
|
* entries, each with its own match rule + action script.
|
|
280
280
|
*
|
|
281
281
|
* The action script runs with `CELILO_EVENT_PAYLOAD` in its env (the
|
|
@@ -326,7 +326,7 @@ export const SystemResourceSchema = z.object({
|
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
|
-
* One system a module deploys (
|
|
329
|
+
* One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module
|
|
330
330
|
* declares 0..N of these under `requires.systems`. `name` is the stable
|
|
331
331
|
* authoring-time handle — referenced in templates via `$infra:<name>.…` and the
|
|
332
332
|
* per-system key in `module_systems`. `resources` carries the per-system machine
|
|
@@ -390,7 +390,7 @@ export const ModuleSubscriptionSchema = z
|
|
|
390
390
|
handler: z.string().min(1).optional(),
|
|
391
391
|
/**
|
|
392
392
|
* Name of one of THIS module's own hooks to invoke when a matching event
|
|
393
|
-
* fires (
|
|
393
|
+
* fires (openspec/specs/event-driven-hook-subscriptions/spec.md). The framework synthesizes
|
|
394
394
|
* a `celilo events run-hook` handler that runs the hook in a
|
|
395
395
|
* fault-isolated subprocess with backend access (DB, capabilities,
|
|
396
396
|
* secrets). Mutually exclusive with `handler`.
|
|
@@ -445,7 +445,7 @@ export const ModuleManifestSchema = z
|
|
|
445
445
|
|
|
446
446
|
/**
|
|
447
447
|
* How `manifest.yml#version` (the PAYLOAD version) is determined — see
|
|
448
|
-
*
|
|
448
|
+
* openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
|
|
449
449
|
* lives in `provides.capabilities[].version` and is unrelated to this.
|
|
450
450
|
*
|
|
451
451
|
* - `changeset` — first-party apps/content (lunacycle): the version is
|
|
@@ -471,7 +471,7 @@ export const ModuleManifestSchema = z
|
|
|
471
471
|
.object({
|
|
472
472
|
capabilities: z.array(CapabilityRequirementSchema).default([]),
|
|
473
473
|
/**
|
|
474
|
-
* The 0..N systems this module deploys (
|
|
474
|
+
* The 0..N systems this module deploys (openspec/specs/module-systems-addressing/spec.md).
|
|
475
475
|
* Preferred over the singular `system`. A module declaring
|
|
476
476
|
* `systems` gets one host per entry, each addressable as `$infra:<name>`.
|
|
477
477
|
*/
|
|
@@ -545,7 +545,7 @@ export const ModuleManifestSchema = z
|
|
|
545
545
|
* to (de)register a single host's A records when celilo's bridge
|
|
546
546
|
* delivers a system.created/destroyed event. Inputs (hostname,
|
|
547
547
|
* target_ip, op) come from the contract — see contracts/v1.ts and
|
|
548
|
-
* [[
|
|
548
|
+
* [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
|
|
549
549
|
*/
|
|
550
550
|
on_system_event: LifecycleHookSchema.optional(),
|
|
551
551
|
/**
|
|
@@ -553,7 +553,7 @@ export const ModuleManifestSchema = z
|
|
|
553
553
|
* change event. The caddy `public_web` provider declares this to
|
|
554
554
|
* re-render its Caddyfile from web_routes when a consumer registers or
|
|
555
555
|
* unregisters a route (ISS-0035). See
|
|
556
|
-
* [[
|
|
556
|
+
* [[openspec/specs/public-web-provider-reconcile/spec.md]].
|
|
557
557
|
*/
|
|
558
558
|
reconcile_routes: LifecycleHookSchema.optional(),
|
|
559
559
|
/**
|
|
@@ -569,7 +569,7 @@ export const ModuleManifestSchema = z
|
|
|
569
569
|
/**
|
|
570
570
|
* Build-bus upstream publish hooks. Array (a module can react
|
|
571
571
|
* to multiple upstream packages with different actions). See
|
|
572
|
-
* [[
|
|
572
|
+
* [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
|
|
573
573
|
*/
|
|
574
574
|
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
575
575
|
})
|
|
@@ -645,7 +645,7 @@ export const ModuleManifestSchema = z
|
|
|
645
645
|
|
|
646
646
|
/**
|
|
647
647
|
* Event-bus subscriptions this module wants registered. See
|
|
648
|
-
* `infra/
|
|
648
|
+
* `infra/openspec/specs/event-bus/spec.md`. Truly optional so existing
|
|
649
649
|
* test fixtures and manifests don't need a churn-pass; readers
|
|
650
650
|
* use `manifest.subscriptions ?? []`.
|
|
651
651
|
*/
|
|
@@ -654,7 +654,7 @@ export const ModuleManifestSchema = z
|
|
|
654
654
|
/**
|
|
655
655
|
* Optional base-module aspect — lightweight fleet-configuration
|
|
656
656
|
* code applied to OTHER systems in declared zones, on top of
|
|
657
|
-
* the module's primary deployment. See
|
|
657
|
+
* the module's primary deployment. See openspec/specs/base-module-aspects/spec.md.
|
|
658
658
|
*
|
|
659
659
|
* Modules without this block work exactly as today. When
|
|
660
660
|
* declared, the operator approves the aspect's scope at
|
|
@@ -723,7 +723,7 @@ export const ModuleManifestSchema = z
|
|
|
723
723
|
ansible_vars: z.record(z.string(), z.string()).optional(),
|
|
724
724
|
|
|
725
725
|
/**
|
|
726
|
-
* Optional Proxmox reconciliation (
|
|
726
|
+
* Optional Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5).
|
|
727
727
|
*
|
|
728
728
|
* When an aspect manages a setting that Proxmox also owns
|
|
729
729
|
* authoritatively (DNS resolver via `proxmox_lxc.nameserver`
|
|
@@ -779,7 +779,7 @@ export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>
|
|
|
779
779
|
export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
|
|
780
780
|
|
|
781
781
|
/**
|
|
782
|
-
* Normalize a manifest's declared systems (
|
|
782
|
+
* Normalize a manifest's declared systems (openspec/specs/module-systems-addressing/spec.md).
|
|
783
783
|
* Returns `requires.systems` if present; else the singular `requires.system`
|
|
784
784
|
* as one entry named `main`; else `[]` (config-only module like namecheap).
|
|
785
785
|
* This is the single place the system→systems sugar lives, so the rest of the
|
|
@@ -72,7 +72,7 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
|
72
72
|
'vlan', // Auto-derived from zone configuration
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
75
|
-
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (
|
|
75
|
+
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (openspec/specs/lxc-dns-at-birth/spec.md)
|
|
76
76
|
// Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
|
|
77
77
|
// disk,storage}, which are injected during resolution from the module_systems
|
|
78
78
|
// table (falling back to requires.system.*) — see variables/context.ts. Like
|
|
@@ -10,6 +10,7 @@ import { validateModuleDirectory } from '../import';
|
|
|
10
10
|
import { computeFileChecksum } from './checksum';
|
|
11
11
|
import { includeNodeModulesPath } from './package-rules';
|
|
12
12
|
import { signChecksums } from './signature';
|
|
13
|
+
import { rewriteWorkspaceDeps } from './workspace-deps';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Checksums data structure
|
|
@@ -222,6 +223,24 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
222
223
|
const manifest = parseYaml(manifestContent);
|
|
223
224
|
if (manifest.build?.command || manifest.build?.script) {
|
|
224
225
|
log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
|
|
226
|
+
|
|
227
|
+
// Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
|
|
228
|
+
// buildDir has no workspace root, so a sibling monorepo package pulled in
|
|
229
|
+
// via `workspace:^` won't resolve during the build's `bun install`.
|
|
230
|
+
// Rewrite those specs to `file:` paths pointing at the live monorepo
|
|
231
|
+
// members — always-fresh, no npm-pin lag, no hand-rolled
|
|
232
|
+
// CELILO_MODULE_SOURCE_DIR dance. Fails loudly naming any unresolvable dep.
|
|
233
|
+
try {
|
|
234
|
+
for (const rewrite of rewriteWorkspaceDeps(buildDir, sourceDir)) {
|
|
235
|
+
log.info(`Resolved workspace dep: ${rewrite}`);
|
|
236
|
+
}
|
|
237
|
+
} catch (rewriteError) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: rewriteError instanceof Error ? rewriteError.message : String(rewriteError),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
// The build runs in a staged copy of the module (buildDir), so
|
|
226
245
|
// relative paths that reach outside the module (e.g. sibling packages
|
|
227
246
|
// in a monorepo) don't resolve. Expose the ORIGINAL unstaged source
|
|
@@ -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
|
+
});
|