@celilo/cli 2.2.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 (115) hide show
  1. package/package.json +2 -2
  2. package/schemas/system_config.json +5 -0
  3. package/src/ansible/inventory.test.ts +2 -1
  4. package/src/api/sessions.test.ts +2 -1
  5. package/src/cli/backup-rename.test.ts +2 -1
  6. package/src/cli/cli.test.ts +2 -1
  7. package/src/cli/commands/console-get-chain.test.ts +2 -1
  8. package/src/cli/commands/firewall-interface-list.test.ts +2 -1
  9. package/src/cli/commands/module-config.test.ts +2 -1
  10. package/src/cli/commands/module-deploy.ts +8 -2
  11. package/src/cli/commands/module-generate.test.ts +53 -0
  12. package/src/cli/commands/module-generate.ts +31 -26
  13. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  14. package/src/cli/commands/module-import-registry.test.ts +2 -1
  15. package/src/cli/commands/module-operations.test.ts +2 -1
  16. package/src/cli/commands/module-publish.test.ts +5 -12
  17. package/src/cli/commands/module-update.test.ts +6 -5
  18. package/src/cli/commands/module-upgrade.test.ts +15 -0
  19. package/src/cli/commands/module-upgrade.ts +54 -2
  20. package/src/cli/commands/module-verify.test.ts +2 -3
  21. package/src/cli/commands/module-verify.ts +0 -1
  22. package/src/cli/commands/notify-config.test.ts +5 -3
  23. package/src/cli/commands/registry-owner.test.ts +2 -1
  24. package/src/cli/commands/registry-token.test.ts +2 -1
  25. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  26. package/src/cli/commands/system-config.test.ts +148 -0
  27. package/src/cli/commands/system-config.ts +26 -1
  28. package/src/cli/commands/system-doctor.test.ts +71 -0
  29. package/src/cli/commands/system-doctor.ts +110 -24
  30. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  31. package/src/cli/commands/system-migrate.test.ts +2 -1
  32. package/src/cli/index.ts +2 -1
  33. package/src/cli/restore-command.test.ts +2 -1
  34. package/src/cli/restore-migration-failure.test.ts +2 -1
  35. package/src/config/paths.test.ts +3 -3
  36. package/src/db/migrate.test.ts +2 -1
  37. package/src/db/migrate.ts +16 -16
  38. package/src/hooks/capability-loader.test.ts +78 -0
  39. package/src/hooks/capability-loader.ts +37 -2
  40. package/src/hooks/executor.test.ts +87 -1
  41. package/src/hooks/executor.ts +57 -21
  42. package/src/hooks/hook-store.test.ts +6 -0
  43. package/src/hooks/hook-trespass.test.ts +17 -7
  44. package/src/hooks/jail.test.ts +61 -2
  45. package/src/hooks/jail.ts +53 -1
  46. package/src/module/packaging/audit.ts +9 -26
  47. package/src/module/packaging/build-paths.test.ts +127 -0
  48. package/src/module/packaging/build-paths.ts +175 -0
  49. package/src/module/packaging/build.test.ts +71 -1
  50. package/src/module/packaging/build.ts +60 -0
  51. package/src/module/packaging/extract.ts +1 -5
  52. package/src/policy/module-script-scan.ts +0 -40
  53. package/src/policy/no-swallowed-refusal.test.ts +9 -10
  54. package/src/services/alerting/ack.test.ts +2 -1
  55. package/src/services/alerting/cadence-migration.test.ts +3 -2
  56. package/src/services/alerting/coverage-source.test.ts +2 -1
  57. package/src/services/alerting/deferral.test.ts +2 -1
  58. package/src/services/alerting/delivery-loop.test.ts +2 -1
  59. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  60. package/src/services/alerting/inbound-poller.test.ts +2 -1
  61. package/src/services/alerting/inbound.test.ts +2 -1
  62. package/src/services/alerting/notification-responder.test.ts +2 -1
  63. package/src/services/alerting/run-monitor.test.ts +2 -1
  64. package/src/services/alerting/store.test.ts +2 -1
  65. package/src/services/alerting/sweep-runner.test.ts +2 -1
  66. package/src/services/alerting/tokens.test.ts +2 -1
  67. package/src/services/aspect-approvals.test.ts +2 -1
  68. package/src/services/aspect-reconcile.test.ts +4 -3
  69. package/src/services/aspect-runner.test.ts +2 -1
  70. package/src/services/audit/module-integrity.test.ts +0 -21
  71. package/src/services/audit/module-integrity.ts +0 -14
  72. package/src/services/backup-age-agreement.test.ts +2 -1
  73. package/src/services/backup-envelope-roundtrip.test.ts +2 -1
  74. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  75. package/src/services/bus-ensure-flow.test.ts +2 -1
  76. package/src/services/bus-interview-park.test.ts +2 -1
  77. package/src/services/bus-secret-flow.test.ts +2 -1
  78. package/src/services/capability-table-rows.test.ts +2 -1
  79. package/src/services/consumer-cleanup.test.ts +3 -2
  80. package/src/services/container-service.test.ts +2 -1
  81. package/src/services/control-plane-bootstrap.test.ts +2 -1
  82. package/src/services/cross-module-read.test.ts +2 -1
  83. package/src/services/deploy-validation.test.ts +3 -2
  84. package/src/services/dns-internal-records.test.ts +3 -2
  85. package/src/services/dns-provider-backfill.test.ts +2 -1
  86. package/src/services/dns-registrations.test.ts +2 -1
  87. package/src/services/ensure-interview.test.ts +3 -2
  88. package/src/services/fleet-checks.test.ts +3 -2
  89. package/src/services/fleet-key.test.ts +3 -2
  90. package/src/services/infrastructure-selector.test.ts +2 -1
  91. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  92. package/src/services/machine-pool.test.ts +2 -1
  93. package/src/services/module-config.test.ts +2 -1
  94. package/src/services/module-deploy.dns-repoint.test.ts +2 -1
  95. package/src/services/module-deploy.ts +36 -20
  96. package/src/services/module-operations.test.ts +2 -1
  97. package/src/services/module-subscriptions.test.ts +2 -1
  98. package/src/services/port-forwards.test.ts +2 -1
  99. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  100. package/src/services/proxmox-reconcile.test.ts +2 -1
  101. package/src/services/restore-from-file.test.ts +3 -2
  102. package/src/services/restore-preflight.test.ts +2 -1
  103. package/src/services/secret-schema-loader.test.ts +2 -1
  104. package/src/services/ssh-key-manager.test.ts +2 -1
  105. package/src/services/static-content-converge.test.ts +5 -4
  106. package/src/services/static-content-converge.ts +33 -10
  107. package/src/services/system-state-stage.test.ts +2 -1
  108. package/src/services/trusted-sources.test.ts +3 -2
  109. package/src/services/zone-detector.test.ts +2 -1
  110. package/src/test-utils/bus-responder.ts +5 -3
  111. package/src/test-utils/db-path.ts +25 -0
  112. package/src/test-utils/integration.ts +7 -0
  113. package/src/test-utils/module-fixtures.ts +5 -6
  114. package/src/module/packaging/generated-plane.test.ts +0 -79
  115. package/src/module/packaging/generated-plane.ts +0 -134
package/src/hooks/jail.ts CHANGED
@@ -418,16 +418,68 @@ function unavailableReason(error: unknown): string {
418
418
  * `CELILO_HOOK_JAIL=requried` that silently meant `auto` would read as the
419
419
  * jail being enforced when it is not, which is the one mistake this variable
420
420
  * exists to prevent.
421
+ *
422
+ * The unset/empty default is `off` (peba's ruling on ce-rez7): jailing must
423
+ * begin because someone set a switch, not because a package upgrade installed
424
+ * a backend. Turning the jail ON is a deliberate act.
421
425
  */
422
426
  export function jailPolicy(): JailPolicy {
423
427
  const raw = process.env.CELILO_HOOK_JAIL;
424
- if (raw === undefined || raw === '') return 'auto';
428
+ if (raw === undefined || raw === '') return 'off';
425
429
  if (raw === 'auto' || raw === 'off' || raw === 'required') return raw;
426
430
  throw new Error(
427
431
  `CELILO_HOOK_JAIL='${raw}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
428
432
  );
429
433
  }
430
434
 
435
+ /** Where the effective jail policy came from. Rendered by `system doctor` (D4). */
436
+ export type JailPolicySource = 'env' | 'config' | 'default';
437
+
438
+ /**
439
+ * Apply D2's precedence to the two sources: env wins, then stored config,
440
+ * then the default, which is `off` (peba's ruling on ce-rez7; see
441
+ * openspec/changes/hook-jail-config-surface/design.md for the reversal
442
+ * trigger).
443
+ *
444
+ * Pure, so both consumers (executor.ts and system-doctor.ts) can fetch the
445
+ * stored row themselves and stay testable without a database.
446
+ *
447
+ * The empty-string env value is treated as unset, matching `jailPolicy()`'s
448
+ * own handling — one rule for "what counts as set", not a second one here.
449
+ *
450
+ * The config path is deliberately stricter than the env path: a stored
451
+ * empty string THROWS (peba's ruling on ce-8832). The set-time pattern
452
+ * `^(auto|off|required)$` rejects `''`, so a stored one can only arrive via
453
+ * a restore from file or a hand edit to the DB — exactly the untrusted
454
+ * paths D3 exists to catch. Resolving it to a silent `auto` would let
455
+ * foreign state pick the jailing default with nobody told, and failing
456
+ * closed is the whole point. An empty CELILO_HOOK_JAIL stays "not set",
457
+ * because `jailPolicy()` relies on that and slice 1's safety rests on the
458
+ * two resolvers agreeing.
459
+ */
460
+ export function resolveJailPolicy(
461
+ envValue: string | undefined,
462
+ configValue: string | undefined,
463
+ ): { policy: JailPolicy; source: JailPolicySource } {
464
+ if (envValue !== undefined && envValue !== '') {
465
+ if (envValue === 'auto' || envValue === 'off' || envValue === 'required') {
466
+ return { policy: envValue, source: 'env' };
467
+ }
468
+ throw new Error(
469
+ `CELILO_HOOK_JAIL='${envValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
470
+ );
471
+ }
472
+ if (configValue !== undefined) {
473
+ if (configValue === 'auto' || configValue === 'off' || configValue === 'required') {
474
+ return { policy: configValue, source: 'config' };
475
+ }
476
+ throw new Error(
477
+ `hooks.jail_policy='${configValue}' is not a hook jail policy. Use 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off'.`,
478
+ );
479
+ }
480
+ return { policy: 'off', source: 'default' };
481
+ }
482
+
431
483
  /**
432
484
  * Resolve every path in a mount-set request through the filesystem (task 4.2k).
433
485
  *
@@ -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
+ });
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
9
9
  import { log } from '../../cli/prompts';
10
10
  import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan';
11
11
  import { validateModuleDirectory } from '../import';
12
+ import { resolveBuildCommandPaths } from './build-paths';
12
13
  import { computeFileChecksum } from './checksum';
13
14
  import { classifyModulePath, includeNodeModulesPath } from './package-rules';
14
15
  import { signChecksums } from './signature';
@@ -23,6 +24,37 @@ export interface ChecksumsData {
23
24
  files: Record<string, string>; // filepath -> xxhash
24
25
  }
25
26
 
27
+ /**
28
+ * Put `scripts/node_modules` back after a `bun pm pack` staging.
29
+ *
30
+ * `bun pm pack` drops every `node_modules` unconditionally — that is npm-pack
31
+ * semantics, and a `files` entry naming the path does not override it. But the
32
+ * packager is explicit downstream (`includeNodeModulesPath`) that
33
+ * `scripts/node_modules` is the module's HOOK RUNTIME and ships in full, so
34
+ * hooks resolve their third-party deps on a target with no reachable registry.
35
+ *
36
+ * Those two rules disagreed, and which one won was decided by whether a module
37
+ * happened to have a root `package.json`. Every module in `modules/` has only
38
+ * `scripts/package.json`, so all of them take the `cpSync` branch below and
39
+ * keep their runtime. A module that is ALSO a bun project — a build script, a
40
+ * data pipeline, its own tests — takes this branch instead and shipped without
41
+ * one. Nothing caught it: package, publish, import and deploy all succeed, and
42
+ * the first symptom is the hook dying on the target with
43
+ * `Cannot find package '@celilo/capabilities'` (celilo#1310).
44
+ *
45
+ * The hook runtime is not build input, so `files` has no business filtering it.
46
+ * Symlinks are materialised for the same reason the `cpSync` branch does it: a
47
+ * `file:` workspace link would point at a path the target does not have.
48
+ */
49
+ function stageHookRuntime(sourceDir: string, buildDir: string): void {
50
+ const from = join(sourceDir, 'scripts', 'node_modules');
51
+ if (!existsSync(from)) return;
52
+ cpSync(from, join(buildDir, 'scripts', 'node_modules'), {
53
+ recursive: true,
54
+ dereference: true,
55
+ });
56
+ }
57
+
26
58
  /**
27
59
  * Module build options
28
60
  */
@@ -215,6 +247,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
215
247
  timeout: 60_000,
216
248
  });
217
249
  rmSync(tarballs[0], { force: true });
250
+ stageHookRuntime(sourceDir, buildDir);
218
251
  } catch (err) {
219
252
  const errMsg = err instanceof Error ? err.message : String(err);
220
253
  return {
@@ -258,6 +291,33 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
258
291
  if (manifest.build?.command || manifest.build?.script) {
259
292
  log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
260
293
 
294
+ // Publish-time gate on the build command's paths (D3 of
295
+ // control-plane-stops-building-modules). A `cd` whose target does not
296
+ // resolve where the command runs means the module depends on inputs it
297
+ // does not carry. celilo-registry did exactly this for four months
298
+ // (`cd ../../packages/registry-server`) and the failure surfaced on a
299
+ // production control plane, months after publish, as a /bin/sh error.
300
+ // Refuse here, at the only point where a human is watching, and name the
301
+ // path. Targets the gate cannot resolve (untracked shell variables,
302
+ // command substitution) are skipped, never guessed at.
303
+ if (manifest.build.command) {
304
+ const pathViolations = resolveBuildCommandPaths(manifest.build.command, {
305
+ moduleSourceDir: sourceDir,
306
+ buildDir,
307
+ });
308
+ if (pathViolations.length > 0) {
309
+ return {
310
+ success: false,
311
+ error: [
312
+ `Refusing to build ${moduleId}: the build command references a path that does not exist where the command runs.`,
313
+ ...pathViolations.map((v) => ` ${v.rawPath}\n resolves to: ${v.resolvedPath}`),
314
+ '',
315
+ 'A build input that does not resolve at publish time makes the module unbuildable wherever it is installed. Fix the path in manifest.yml (keep it module-local, or reach the monorepo through $CELILO_MODULE_SOURCE_DIR) and re-publish.',
316
+ ].join('\n'),
317
+ };
318
+ }
319
+ }
320
+
261
321
  // Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
262
322
  // buildDir has no workspace root, so a sibling monorepo package pulled in
263
323
  // via `workspace:^` won't resolve during the build's `bun install`.
@@ -19,12 +19,8 @@ export interface IntegrityViolation {
19
19
  * being old rather than by the files having changed. Only `auditModule`
20
20
  * produces it; package verification compares a package to its own manifest
21
21
  * and cannot be stale in this sense.
22
- *
23
- * `stale-generated` is the second plane (D3): a verbatim role asset in
24
- * `generated/` whose bytes are not the installed module's. That is what
25
- * celilo#925 was, and what would ship on the next deploy.
26
22
  */
27
- type: 'missing' | 'modified' | 'extra' | 'stale-baseline' | 'stale-generated';
23
+ type: 'missing' | 'modified' | 'extra' | 'stale-baseline';
28
24
  /**
29
25
  * What the baseline says the file should hash to, and what it actually
30
26
  * hashes to. Both optional because not every violation is about a digest —
@@ -344,12 +344,6 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
344
344
  reason:
345
345
  'hook-owned-state 5.5 / survey class G: machine list becomes a machine-pool read capability',
346
346
  },
347
- {
348
- module: 'caddy-internal',
349
- file: 'scripts/private-web-functions.ts',
350
- matches: 1,
351
- reason: 'hook-owned-state 5.5 (routes, capability-owned-tables Group B1)',
352
- },
353
347
  {
354
348
  module: 'dnsmasq-dhcp',
355
349
  file: 'scripts/dhcp-server-functions.ts',
@@ -389,34 +383,6 @@ export const JAILED_CLI_SPAWN_DEBT: readonly PolicyDebtEntry[] = [
389
383
  reason:
390
384
  'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
391
385
  },
392
- {
393
- module: 'wireguard',
394
- file: 'scripts/control-plane-vpn-functions.ts',
395
- matches: 1,
396
- reason:
397
- 'hook-owned-state 5.5 shape (self-config write); not in its 10-site table — reconcile there',
398
- },
399
- {
400
- module: 'wireguard',
401
- file: 'scripts/health-check.ts',
402
- matches: 1,
403
- reason:
404
- 'survey class C: system config get becomes an injected read; not in hook-owned-state 5.5 — reconcile there',
405
- },
406
- {
407
- module: 'wireguard',
408
- file: 'scripts/on_install.ts',
409
- matches: 1,
410
- reason:
411
- 'hook-owned-state 5.5 (registered_peers, Group B2); THE five-suite blocker per the wave-0 census',
412
- },
413
- {
414
- module: 'wireguard-manager',
415
- file: 'scripts/setup.ts',
416
- matches: 3,
417
- reason:
418
- 'hook-owned-state 5.5 (vpn_endpoint/vpn_server_public_key/client_pool, category 3; secret set)',
419
- },
420
386
  ];
421
387
 
422
388
  /**
@@ -439,12 +405,6 @@ export const SWALLOWED_REFUSAL_DEBT: readonly PolicyDebtEntry[] = [
439
405
  reason:
440
406
  'hook-owned-state 5.5 / survey class G: machine list becomes a typed-absent machine-pool read',
441
407
  },
442
- {
443
- module: 'wireguard',
444
- file: 'scripts/health-check.ts',
445
- matches: 1,
446
- reason: 'survey class C: system config get becomes an injected typed-absent read',
447
- },
448
408
  ];
449
409
 
450
410
  /**