@celilo/cli 2.1.0 → 2.2.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.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test fixture: an on_restore hook in the celilo-mgmt staging shape, minus
3
+ * everything the real one owns but the staging itself. Copies the artifact's
4
+ * celilo.db into restore_dir/system/ so the Phase 4 wrapper's
5
+ * applyStagedSystemFiles has something to swap.
6
+ */
7
+
8
+ import { cpSync, existsSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { defineHook } from '@celilo/capabilities';
11
+
12
+ export default defineHook({
13
+ hook: 'on_restore',
14
+ requires: [],
15
+ optional: [],
16
+ handler: async (ctx) => {
17
+ const restoreDir = (ctx as unknown as { restore_dir: string }).restore_dir;
18
+ const systemDir = join(restoreDir, 'system');
19
+ mkdirSync(systemDir, { recursive: true });
20
+ const artifactDb = join(restoreDir, 'celilo.db');
21
+ if (existsSync(artifactDb)) {
22
+ cpSync(artifactDb, join(systemDir, 'celilo.db'));
23
+ }
24
+ return { restored_items: 1 };
25
+ },
26
+ });
@@ -0,0 +1,47 @@
1
+ import type { HookStore, HookStoreBackedMap } from '@celilo/capabilities';
2
+
3
+ /**
4
+ * In-memory hook-owned-state stores for tests that build a HookContext
5
+ * without a broker. The four methods mutate the record directly; the
6
+ * manifest validation a real store applies lives broker-side
7
+ * (hook-store.ts) and has its own tests.
8
+ *
9
+ * Methods are defined non-enumerably, so the result spreads, serializes and
10
+ * `toEqual`s as the plain record the test wrote — the same shape a real
11
+ * broker-side context carries when executeHookScript frames it.
12
+ */
13
+ export function configStore(
14
+ values: Record<string, unknown> = {},
15
+ ): Record<string, unknown> & HookStore {
16
+ return attach(values);
17
+ }
18
+
19
+ export function secretStore(values: Record<string, string> = {}): HookStoreBackedMap {
20
+ // Downcast: the caller passed Record<string, string>; attach only adds
21
+ // methods and never writes, so the value type is unchanged.
22
+ return attach(values) as HookStoreBackedMap;
23
+ }
24
+
25
+ function attach(values: Record<string, unknown>): Record<string, unknown> & HookStore {
26
+ const store: HookStore = {
27
+ get: async (name) => values[name] as string | undefined,
28
+ set: async (name, value) => {
29
+ values[name] = value;
30
+ },
31
+ delete: async (name) => {
32
+ delete values[name];
33
+ },
34
+ transaction: async (fn) => {
35
+ await fn(store);
36
+ },
37
+ };
38
+ for (const [name, method] of Object.entries(store)) {
39
+ Object.defineProperty(values, name, {
40
+ value: method,
41
+ enumerable: false,
42
+ writable: true,
43
+ configurable: true,
44
+ });
45
+ }
46
+ return Object.assign(values, store);
47
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Test fixture: a hook that exercises the hook-owned-state accessor across
3
+ * the process boundary — set/get round trip on both stores, delete, a
4
+ * committed transaction, a discarded transaction, and the undeclared-name
5
+ * throw the whole design exists to produce.
6
+ */
7
+
8
+ import { defineHook } from '@celilo/capabilities';
9
+
10
+ export default defineHook({
11
+ hook: 'on_install',
12
+ requires: [],
13
+ handler: async (ctx) => {
14
+ const outputs: Record<string, unknown> = {};
15
+
16
+ // Round trip on each store.
17
+ await ctx.secrets.set('bot_token', 'token-value');
18
+ outputs.secretRoundTrip = await ctx.secrets.get('bot_token');
19
+
20
+ await ctx.config.set('public_ip', '203.0.113.7');
21
+ outputs.configRoundTrip = await ctx.config.get('public_ip');
22
+
23
+ // The map surface still answers from the values the context carried.
24
+ outputs.mapRead = ctx.config.mapOnlyValue;
25
+
26
+ // Delete removes; deleting a declared-but-never-written name is a no-op.
27
+ // (An UNDECLARED name on delete throws — that is 3.4, tested below.
28
+ // Declared-and-absent is the no-op case 3.7 pins.)
29
+ await ctx.secrets.delete('bot_token');
30
+ await ctx.secrets.set('api_key', 'to-delete');
31
+ await ctx.secrets.delete('api_key');
32
+ outputs.deletedIsGone = await ctx.secrets.get('api_key');
33
+
34
+ // Committed transaction: both writes land.
35
+ await ctx.secrets.transaction((s) => {
36
+ s.set('bot_token', 'committed-a');
37
+ s.set('api_key', 'committed-b');
38
+ });
39
+ outputs.transactionA = await ctx.secrets.get('bot_token');
40
+ outputs.transactionB = await ctx.secrets.get('api_key');
41
+
42
+ // Discarded transaction: nothing lands, including the overwrite of the
43
+ // committed value above.
44
+ try {
45
+ await ctx.secrets.transaction(async (s) => {
46
+ s.set('bot_token', 'discarded');
47
+ throw new Error('hook failed midway');
48
+ });
49
+ } catch (error) {
50
+ outputs.discarded = (error as Error).message;
51
+ }
52
+ outputs.afterDiscard = await ctx.secrets.get('bot_token');
53
+
54
+ // An undeclared name throws, naming the module and the declared set.
55
+ try {
56
+ await ctx.secrets.set('not_declared', 'x');
57
+ } catch (error) {
58
+ outputs.undeclaredSecret = (error as Error).message;
59
+ }
60
+
61
+ return outputs;
62
+ },
63
+ });
@@ -26,6 +26,7 @@ import { executeHookScript, hookChildEnv } from './executor';
26
26
  import type { MountSetWire } from './hook-protocol';
27
27
  import { createCapturingLogger } from './logger';
28
28
  import { deriveMountSet } from './mount-set';
29
+ import { configStore, secretStore } from './test-fixtures/store-backed';
29
30
  import type { HookContext } from './types';
30
31
  import { classifyAccess, mountSetEnvValue, parseLintMountSet } from './unjailed-lint';
31
32
 
@@ -54,10 +55,25 @@ function scratchModule(script: string): string {
54
55
 
55
56
  const describeSet = (overrides: Partial<MountSetWire>): MountSetWire => ({
56
57
  entries: [
57
- { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run' },
58
- { path: '/srv/celilo/mod', mode: 'ro', reason: "the module's own tree" },
59
- { path: '/srv/celilo/mod/state', mode: 'rw', reason: 'the sanctioned writable directory' },
60
- { path: '/srv/staged', mode: 'rw', reason: "contract input 'backup_dir' (write)" },
58
+ { path: '/tmp', mode: 'tmpfs', reason: 'private scratch, per run', absence: 'runtime' },
59
+ {
60
+ path: '/srv/celilo/mod',
61
+ mode: 'ro',
62
+ reason: "the module's own tree",
63
+ absence: 'required',
64
+ },
65
+ {
66
+ path: '/srv/celilo/mod/state',
67
+ mode: 'rw',
68
+ reason: 'the sanctioned writable directory',
69
+ absence: 'required',
70
+ },
71
+ {
72
+ path: '/srv/staged',
73
+ mode: 'rw',
74
+ reason: "contract input 'backup_dir' (write)",
75
+ absence: 'required',
76
+ },
61
77
  ],
62
78
  chdir: '/srv/celilo/mod',
63
79
  ...overrides,
@@ -159,8 +175,8 @@ describe('the lint inside a real hook run', () => {
159
175
  mkdirSync(join(root, 'state'), { recursive: true });
160
176
  const { logger, messages } = createCapturingLogger();
161
177
  const context: HookContext = {
162
- config: {},
163
- secrets: {},
178
+ config: configStore(),
179
+ secrets: secretStore(),
164
180
  systems: [],
165
181
  logger,
166
182
  debug: false,
@@ -17,6 +17,7 @@ export const VariableSourceSchema = z.enum([
17
17
  'system',
18
18
  'terraform',
19
19
  'infrastructure',
20
+ 'hook',
20
21
  ]);
21
22
 
22
23
  /**
@@ -13,7 +13,6 @@ import {
13
13
  } from '../../services/module-instances';
14
14
  import { computeFileChecksum } from './checksum';
15
15
  import type { IntegrityViolation } from './extract';
16
- import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
17
16
  import { type HostPlaneResult, verifyModuleOnHosts } from './host-plane';
18
17
  import { classifyModulePath } from './package-rules';
19
18
 
@@ -204,30 +203,11 @@ export async function auditModule(
204
203
  }
205
204
  }
206
205
 
207
- // Plane two: is what we would deploy built from what we installed? Only
208
- // verbatim role assets have a meaningful expected digest — Ansible
209
- // templates the rest, and a `.j2` in `generated/` is SUPPOSED to differ.
210
- // A module that has never been generated has nothing to compare and is not
211
- // a finding; it is simply pre-deploy.
212
- const generatedDir = join(moduleDir, 'generated');
213
- if (existsSync(generatedDir)) {
214
- const differences = compareVerbatimRoleAssets(
215
- await readVerbatimRoleAssets(moduleDir),
216
- await readVerbatimRoleAssets(generatedDir),
217
- );
218
- for (const difference of differences) {
219
- violations.push({
220
- type: 'stale-generated',
221
- path: difference.relPath,
222
- expectedDigest: difference.installedDigest,
223
- actualDigest: difference.generatedDigest,
224
- message:
225
- difference.reason === 'missing'
226
- ? `Generated project is missing ${difference.relPath} — the next deploy would ship nothing for it. Run 'celilo module generate ${moduleId}'.`
227
- : `Generated project holds different bytes for ${difference.relPath} (generated ${difference.generatedDigest}, installed ${difference.installedDigest}) — the next deploy would ship the wrong ones. Run 'celilo module generate ${moduleId}'.`,
228
- });
229
- }
230
- }
206
+ // The second plane (is what we would deploy built from what we installed?)
207
+ // retired here: the generated tree is ephemeral now (D4 of
208
+ // control-plane-stops-building-modules) — rendered for a deploy and
209
+ // deleted on success — so a persistent generated copy that verbatim role
210
+ // assets could be compared against no longer exists to compare.
231
211
 
232
212
  // Check for extra files (not in checksums)
233
213
  const actualFiles = await scanDirectory(moduleDir, moduleDir);
@@ -244,7 +224,10 @@ export async function auditModule(
244
224
  }
245
225
 
246
226
  // Plane three: is what is running what we generated? One SSH per system,
247
- // so it is asked only when the caller says so.
227
+ // so it is asked only when the caller says so. With the generated tree
228
+ // ephemeral (D4), a deep audit after a successful deploy finds no playbook
229
+ // and says so — the remedy is generate, which is cheap and never builds.
230
+ const generatedDir = join(moduleDir, 'generated');
248
231
  let hostPlane: HostPlaneResult | undefined;
249
232
  if (options.deep) {
250
233
  hostPlane = await verifyModuleOnHosts({
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { resolveBuildCommandPaths } from './build-paths';
7
+
8
+ /** Repo root: this file sits at apps/celilo/src/module/packaging/. */
9
+ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..', '..');
10
+
11
+ /** Every module in the repo that declares a `build:` block (design, Context). */
12
+ const BUILD_MODULES = [
13
+ 'celilo-registry',
14
+ 'celilo-web-console',
15
+ 'celilo-website',
16
+ 'forgejo',
17
+ 'npm-cache-node',
18
+ 'wireguard-manager',
19
+ ] as const;
20
+
21
+ function buildCommand(moduleId: string): string {
22
+ const manifestPath = join(REPO_ROOT, 'modules', moduleId, 'manifest.yml');
23
+ if (!existsSync(manifestPath)) throw new Error(`fixture missing: ${manifestPath}`);
24
+ const manifest = parseYaml(readFileSync(manifestPath, 'utf8')) as {
25
+ build?: { command?: string };
26
+ };
27
+ const command = manifest.build?.command;
28
+ if (!command) throw new Error(`${moduleId} declares no build command`);
29
+ return command;
30
+ }
31
+
32
+ describe('publish-time build path gate', () => {
33
+ test('flags celilo-registry when packaged outside the monorepo (the control-plane shape)', () => {
34
+ // The known-bad form, per celilo#1307: the script reaches a sibling of the
35
+ // module source (`../../packages/registry-server`). Inside the monorepo
36
+ // that resolves; from a tree where the module stands alone — a control
37
+ // plane, or any packaging that is not the source checkout — it does not.
38
+ // The spec scenario pins this: "packaged from a tree where that directory
39
+ // does not exist".
40
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
41
+ const moduleSourceDir = join(scratch, 'celilo-registry');
42
+ cpSync(join(REPO_ROOT, 'modules', 'celilo-registry'), moduleSourceDir, {
43
+ recursive: true,
44
+ });
45
+ const violations = resolveBuildCommandPaths(buildCommand('celilo-registry'), {
46
+ moduleSourceDir,
47
+ buildDir: moduleSourceDir,
48
+ });
49
+ expect(violations).toHaveLength(1);
50
+ expect(violations[0].rawPath).toContain('../../packages/registry-server');
51
+ expect(violations[0].resolvedPath).not.toContain(REPO_ROOT);
52
+ });
53
+
54
+ test('flags a plain relative escape that does not resolve (spec scenario)', () => {
55
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
56
+ const violations = resolveBuildCommandPaths(
57
+ 'cd ../../packages/registry-server && bun install',
58
+ { moduleSourceDir: scratch, buildDir: scratch },
59
+ );
60
+ expect(violations).toHaveLength(1);
61
+ expect(violations[0].rawPath).toBe('../../packages/registry-server');
62
+ });
63
+
64
+ test('accepts $CELILO_MODULE_SOURCE_DIR/server when it exists (npm-cache-node shape)', () => {
65
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
66
+ const moduleSourceDir = join(scratch, 'npm-cache-node');
67
+ cpSync(
68
+ join(REPO_ROOT, 'modules', 'npm-cache-node', 'server'),
69
+ join(moduleSourceDir, 'server'),
70
+ {
71
+ recursive: true,
72
+ },
73
+ );
74
+ const violations = resolveBuildCommandPaths(buildCommand('npm-cache-node'), {
75
+ moduleSourceDir,
76
+ buildDir: moduleSourceDir,
77
+ });
78
+ expect(violations).toEqual([]);
79
+ });
80
+
81
+ test('accepts every module build command at publish time in the monorepo', () => {
82
+ // The regression surface for the five healthy modules. celilo-registry
83
+ // passes here too, and that is correct: at publish, inside the monorepo,
84
+ // its `cd` target exists (the binaries shipped on 2026-08-20 prove the
85
+ // build ran). The gate refuses the script only where it genuinely cannot
86
+ // resolve — the first test above.
87
+ for (const moduleId of BUILD_MODULES) {
88
+ const moduleSourceDir = join(REPO_ROOT, 'modules', moduleId);
89
+ const violations = resolveBuildCommandPaths(buildCommand(moduleId), {
90
+ moduleSourceDir,
91
+ buildDir: moduleSourceDir,
92
+ });
93
+ expect(violations, moduleId).toEqual([]);
94
+ }
95
+ });
96
+
97
+ test('skips a cd target it cannot resolve rather than guessing (forgejo shape)', () => {
98
+ // `cd "$D"` where D was assigned earlier in the same command: the gate
99
+ // does not interpret shell, so an unresolvable target is skipped, not
100
+ // failed. A guard that rejects working modules is worse than none.
101
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
102
+ const violations = resolveBuildCommandPaths('D=files && mkdir -p "$D" && cd "$D" && ls', {
103
+ moduleSourceDir: scratch,
104
+ buildDir: scratch,
105
+ });
106
+ expect(violations).toEqual([]);
107
+ });
108
+
109
+ test('tracks the working directory across chained cds (celilo-web-console shape)', () => {
110
+ // `cd $SOURCE/../../apps/console` then `cd ../console-server`: the second
111
+ // target resolves against the FIRST cd's destination, not the build dir.
112
+ const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-'));
113
+ const moduleSourceDir = join(scratch, 'web');
114
+ const monorepo = join(scratch, 'mono');
115
+ cpSync(join(REPO_ROOT, 'modules', 'celilo-web-console'), moduleSourceDir, { recursive: true });
116
+ // A monorepo-shaped sibling tree, minimal but real.
117
+ const { mkdirSync } = require('node:fs') as typeof import('node:fs');
118
+ mkdirSync(join(monorepo, 'apps', 'console'), { recursive: true });
119
+ mkdirSync(join(monorepo, 'apps', 'console-server'), { recursive: true });
120
+ cpSync(moduleSourceDir, join(monorepo, 'modules', 'celilo-web-console'), { recursive: true });
121
+ const violations = resolveBuildCommandPaths(buildCommand('celilo-web-console'), {
122
+ moduleSourceDir: join(monorepo, 'modules', 'celilo-web-console'),
123
+ buildDir: join(monorepo, 'modules', 'celilo-web-console'),
124
+ });
125
+ expect(violations).toEqual([]);
126
+ });
127
+ });
@@ -0,0 +1,175 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Publish-time gate on a module build command's filesystem references.
6
+ *
7
+ * A `build:` block runs when the module is packaged (publish/package/import),
8
+ * inside a staged copy of the module, with `CELILO_MODULE_SOURCE_DIR` pointing
9
+ * back at the authored source tree. A `cd` in that command is the build's
10
+ * claim about where its inputs live; a target that does not resolve there
11
+ * means the module ships inputs it does not carry, which is how celilo-registry
12
+ * was unbuildable on every real install for four months without anyone finding
13
+ * out (celilo#1307). The packager refuses up front and names the path.
14
+ *
15
+ * The gate does not interpret shell. It finds `cd` commands lexically, knows
16
+ * the two variables the packager supplies, and probes what those resolve to.
17
+ * A target it cannot resolve (an untracked shell variable, a command
18
+ * substitution, a glob) is SKIPPED, not failed: guessing would reject working
19
+ * modules, and a guard that rejects working modules is worse than none.
20
+ */
21
+
22
+ export interface BuildPathViolation {
23
+ /** The path exactly as the build command wrote it, before substitution. */
24
+ rawPath: string;
25
+ /** The absolute path the packaging runtime would resolve it to. */
26
+ resolvedPath: string;
27
+ }
28
+
29
+ export interface ResolveBuildCommandPathsOptions {
30
+ /** What `$CELILO_MODULE_SOURCE_DIR` resolves to when the command runs. */
31
+ moduleSourceDir: string;
32
+ /** The staged tree the command runs in (its initial cwd, and `$STAGE`). */
33
+ buildDir: string;
34
+ /** Injectable existence probe; defaults to existsSync. */
35
+ exists?: (path: string) => boolean;
36
+ }
37
+
38
+ /**
39
+ * Extract the argument of every unquoted `cd` command in the string, with the
40
+ * quoting preserved so later substitution can strip it.
41
+ *
42
+ * A `cd` counts only at a command position: string start, or after `&&`, `||`,
43
+ * `;`, `|`, `(`, or a newline, ignoring whitespace. Quoted spans (single and
44
+ * double) are skipped whole, so a `cd` inside a string is not mistaken for a
45
+ * command.
46
+ */
47
+ export function findCdTargets(command: string): string[] {
48
+ const targets: string[] = [];
49
+ let atCommandStart = true;
50
+ let i = 0;
51
+ while (i < command.length) {
52
+ const ch = command[i];
53
+ if (ch === "'") {
54
+ const end = command.indexOf("'", i + 1);
55
+ i = end === -1 ? command.length : end + 1;
56
+ atCommandStart = false;
57
+ continue;
58
+ }
59
+ if (ch === '"') {
60
+ const end = command.indexOf('"', i + 1);
61
+ i = end === -1 ? command.length : end + 1;
62
+ atCommandStart = false;
63
+ continue;
64
+ }
65
+ if (ch === '\\' && i + 1 < command.length) {
66
+ i += 2;
67
+ atCommandStart = false;
68
+ continue;
69
+ }
70
+ if (ch === '&' || ch === '|' || ch === ';' || ch === '\n' || ch === '(') {
71
+ atCommandStart = true;
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (/\s/.test(ch)) {
76
+ i += 1;
77
+ continue;
78
+ }
79
+ if (atCommandStart && command.startsWith('cd', i) && /\s/.test(command[i + 2] ?? '')) {
80
+ const { target, next } = readCdTarget(command, i + 2);
81
+ if (target.length > 0) targets.push(target);
82
+ i = next;
83
+ atCommandStart = false;
84
+ continue;
85
+ }
86
+ atCommandStart = false;
87
+ i += 1;
88
+ }
89
+ return targets;
90
+ }
91
+
92
+ /**
93
+ * Read one `cd` argument starting just after the `cd` keyword. Stops at an
94
+ * unquoted command terminator. Backslash escapes are carried through so the
95
+ * caller sees exactly what the shell would.
96
+ */
97
+ function readCdTarget(command: string, start: number): { target: string; next: number } {
98
+ let i = start;
99
+ while (i < command.length && /\s/.test(command[i])) i += 1;
100
+ const begin = i;
101
+ let end = i;
102
+ while (i < command.length) {
103
+ const ch = command[i];
104
+ if (ch === "'") {
105
+ const close = command.indexOf("'", i + 1);
106
+ i = close === -1 ? command.length : close + 1;
107
+ end = i;
108
+ continue;
109
+ }
110
+ if (ch === '"') {
111
+ const close = command.indexOf('"', i + 1);
112
+ i = close === -1 ? command.length : close + 1;
113
+ end = i;
114
+ continue;
115
+ }
116
+ if (ch === '\\' && i + 1 < command.length) {
117
+ i += 2;
118
+ end = i;
119
+ continue;
120
+ }
121
+ if (ch === '&' || ch === '|' || ch === ';' || ch === '\n') break;
122
+ i += 1;
123
+ if (!/\s/.test(ch) || command.slice(begin, i).trim().length > 0) end = i;
124
+ }
125
+ return { target: command.slice(begin, end).trim(), next: i };
126
+ }
127
+
128
+ /**
129
+ * Substitute the packager-supplied variables, strip quoting, and drop the
130
+ * target to `null` when anything unresolvable remains. `null` means "this
131
+ * gate cannot answer whether this path exists", never "it does not exist".
132
+ */
133
+ function resolveTarget(target: string, options: ResolveBuildCommandPathsOptions): string | null {
134
+ const substituted = target
135
+ .replaceAll('$CELILO_MODULE_SOURCE_DIR', options.moduleSourceDir)
136
+ .replaceAll('$(pwd)', options.buildDir)
137
+ .replaceAll('$STAGE', options.buildDir)
138
+ .replaceAll('"', '')
139
+ .replaceAll("'", '');
140
+ if (/[$`*?~[(]/.test(substituted)) return null;
141
+ if (substituted.startsWith('-')) return null; // `cd -` / flags, not a path
142
+ return substituted;
143
+ }
144
+
145
+ export function resolveBuildCommandPaths(
146
+ command: string,
147
+ options: ResolveBuildCommandPathsOptions,
148
+ ): BuildPathViolation[] {
149
+ const exists = options.exists ?? existsSync;
150
+ const violations: BuildPathViolation[] = [];
151
+ // The simulated working directory of the build. `null` means unknown: the
152
+ // previous cd could not be resolved, so a later relative cd cannot be either.
153
+ let cwd: string | null = options.buildDir;
154
+ for (const rawTarget of findCdTargets(command)) {
155
+ const target = resolveTarget(rawTarget, options);
156
+ if (target === null) {
157
+ cwd = null;
158
+ continue;
159
+ }
160
+ const resolved: string | null = target.startsWith('/')
161
+ ? target
162
+ : cwd === null
163
+ ? null
164
+ : resolve(cwd, target);
165
+ if (resolved === null) {
166
+ cwd = null;
167
+ continue;
168
+ }
169
+ if (!exists(resolved)) {
170
+ violations.push({ rawPath: rawTarget, resolvedPath: resolved });
171
+ }
172
+ cwd = resolved;
173
+ }
174
+ return violations;
175
+ }
@@ -7,10 +7,11 @@
7
7
  */
8
8
 
9
9
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
10
+ import { execFileSync } from 'node:child_process';
10
11
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
12
  import { tmpdir } from 'node:os';
12
13
  import { join } from 'node:path';
13
- import { computeChecksums } from './build';
14
+ import { buildModule, computeChecksums } from './build';
14
15
 
15
16
  describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
16
17
  let dir: string;
@@ -73,3 +74,72 @@ describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () =>
73
74
  expect(paths.some((p) => p.startsWith('e2e/'))).toBe(false);
74
75
  });
75
76
  });
77
+
78
+ /**
79
+ * The pack-staging branch must not eat the hook runtime (celilo#1310).
80
+ *
81
+ * `buildModule` stages through `bun pm pack` whenever a module has a root
82
+ * package.json, and npm-pack semantics drop every `node_modules` — a `files`
83
+ * entry naming the path does not override it. That silently contradicted
84
+ * `includeNodeModulesPath`, which exists to ship `scripts/node_modules` in full
85
+ * so hooks resolve their deps on a target with no reachable registry.
86
+ *
87
+ * Nothing in `modules/` can reach this branch: every module there has only
88
+ * `scripts/package.json` and takes the `cpSync` path. So the regression is only
89
+ * visible against a fixture shaped like a module that is ALSO a bun project.
90
+ */
91
+ describe('buildModule — a root package.json must not strip the hook runtime', () => {
92
+ let source: string;
93
+ let out: string;
94
+
95
+ function write(rel: string, content: string): void {
96
+ const full = join(source, rel);
97
+ mkdirSync(join(full, '..'), { recursive: true });
98
+ writeFileSync(full, content, 'utf-8');
99
+ }
100
+
101
+ beforeEach(() => {
102
+ source = mkdtempSync(join(tmpdir(), 'celilo-pack-root-pkg-'));
103
+ out = join(mkdtempSync(join(tmpdir(), 'celilo-pack-out-')), 'm.netapp');
104
+
105
+ write('manifest.yml', 'celilo_contract: "1.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
106
+ // The shape that triggers it: a module that is also a bun project. `files`
107
+ // deliberately names scripts/ — proving the loss is npm-pack semantics and
108
+ // not a missing entry.
109
+ write(
110
+ 'package.json',
111
+ JSON.stringify({
112
+ name: 'root-pkg-module',
113
+ version: '0.1.0',
114
+ private: true,
115
+ files: ['manifest.yml', 'scripts/'],
116
+ }),
117
+ );
118
+ write('scripts/on_install.ts', "import '@celilo/capabilities';\n");
119
+ write(
120
+ 'scripts/package.json',
121
+ JSON.stringify({ name: 'm-hooks', dependencies: { tldts: '^7.0.0' } }),
122
+ );
123
+ write('scripts/node_modules/tldts/index.js', 'module.exports = {};\n');
124
+ write(
125
+ 'scripts/node_modules/tldts/package.json',
126
+ JSON.stringify({ name: 'tldts', version: '7.4.12' }),
127
+ );
128
+ write('scripts/node_modules/.bin/tldts', '#!/bin/sh\n');
129
+ });
130
+
131
+ afterEach(() => {
132
+ rmSync(source, { recursive: true, force: true });
133
+ rmSync(join(out, '..'), { recursive: true, force: true });
134
+ });
135
+
136
+ it('ships scripts/node_modules even though bun pm pack dropped it', async () => {
137
+ const result = await buildModule({ sourceDir: source, outputPath: out });
138
+ expect(result.success).toBe(true);
139
+
140
+ const listed = execFileSync('tar', ['tzf', out], { encoding: 'utf-8' }).split('\n');
141
+ expect(listed).toContain('scripts/node_modules/tldts/index.js');
142
+ // The one thing the runtime closure still excludes.
143
+ expect(listed).not.toContain('scripts/node_modules/.bin/tldts');
144
+ });
145
+ });