@celilo/cli 0.11.0-alpha.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +8 -8
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
@@ -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 (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D1).
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
- * ([[v2/BUILD_BUS.md]] Phase 4). Each module can declare multiple
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 (v2/MODULE_SYSTEMS_ADDRESSING.md). A module
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 (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). The framework synthesizes
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
- * v2/MODULE_VERSIONING.md / ISS-0151. The capability *contract* version
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 (v2/MODULE_SYSTEMS_ADDRESSING.md).
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
- * [[v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md]] D5.
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
- * [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
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
- * [[v2/BUILD_BUS.md]] Phase 4.
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/design/SQLITE_EVENT_BUS.md`. Truly optional so existing
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 v2/CELILO_BASE.md.
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 (v2/CELILO_BASE.md D5).
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 (v2/MODULE_SYSTEMS_ADDRESSING.md).
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 (v2/LXC_INTERNAL_DNS.md)
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 / v2/MODULE_VERSIONING.md).
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
+ });