@celilo/cli 4.1.0 → 5.0.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 (81) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +9 -0
  4. package/drizzle/0034_private_routes.sql +41 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +2 -2
  7. package/src/api/sessions.test.ts +1 -1
  8. package/src/capabilities/registration.test.ts +11 -11
  9. package/src/capabilities/secrets.test.ts +18 -18
  10. package/src/capabilities/validation.test.ts +24 -24
  11. package/src/cli/cli.test.ts +3 -3
  12. package/src/cli/commands/module-changeset.test.ts +1 -1
  13. package/src/cli/commands/module-generate.test.ts +2 -2
  14. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  15. package/src/cli/commands/module-update.test.ts +14 -14
  16. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  17. package/src/cli/commands/module-upgrade.test.ts +3 -3
  18. package/src/cli/commands/module-upgrade.ts +12 -0
  19. package/src/cli/commands/module-version.test.ts +1 -1
  20. package/src/cli/index.ts +22 -2
  21. package/src/cli/restore-migration-failure.test.ts +1 -1
  22. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  23. package/src/db/private-routes.test.ts +159 -0
  24. package/src/db/schema.ts +49 -0
  25. package/src/hooks/capability-loader.ts +179 -12
  26. package/src/hooks/executor.test.ts +13 -13
  27. package/src/hooks/hook-state-dir.test.ts +2 -2
  28. package/src/hooks/mount-set.test.ts +28 -0
  29. package/src/hooks/mount-set.ts +33 -0
  30. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  31. package/src/manifest/contracts/index.ts +28 -7
  32. package/src/manifest/contracts/v2.ts +32 -0
  33. package/src/manifest/icon-schema.test.ts +1 -1
  34. package/src/manifest/schema.ts +27 -2
  35. package/src/manifest/template-validator.test.ts +1 -1
  36. package/src/manifest/validate-privileged.test.ts +1 -1
  37. package/src/manifest/validate.test.ts +68 -29
  38. package/src/manifest/validate.ts +14 -1
  39. package/src/module/import.test.ts +7 -7
  40. package/src/module/packaging/build.test.ts +1 -1
  41. package/src/module/packaging/build.ts +16 -2
  42. package/src/packaging/nfpm-depends.test.ts +42 -0
  43. package/src/policy/capability-shape-baseline.ts +7 -1
  44. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  45. package/src/policy/module-business-baseline.ts +33 -4
  46. package/src/registry/client.test.ts +5 -4
  47. package/src/services/aspect-approvals.test.ts +2 -2
  48. package/src/services/aspect-reconcile.test.ts +2 -2
  49. package/src/services/aspect-runner.test.ts +4 -4
  50. package/src/services/audit/backups.test.ts +1 -1
  51. package/src/services/audit/capability-abi.test.ts +1 -1
  52. package/src/services/audit/module-configs.test.ts +1 -1
  53. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  54. package/src/services/bus-interview-park.test.ts +1 -1
  55. package/src/services/bus-secret-flow.test.ts +1 -1
  56. package/src/services/capability-table-rows.test.ts +20 -2
  57. package/src/services/celilo-events.test.ts +80 -6
  58. package/src/services/celilo-events.ts +115 -12
  59. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  60. package/src/services/cross-module-read.test.ts +2 -2
  61. package/src/services/deploy-validation.test.ts +6 -6
  62. package/src/services/dns-provider-backfill.test.ts +2 -2
  63. package/src/services/dns-registrations.test.ts +1 -1
  64. package/src/services/fleet-checks.test.ts +2 -2
  65. package/src/services/health-runner.test.ts +1 -1
  66. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  67. package/src/services/instance-ops.test.ts +2 -2
  68. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  69. package/src/services/module-pause-quiescence.test.ts +1 -1
  70. package/src/services/module-pause.test.ts +1 -1
  71. package/src/services/module-subscriptions.test.ts +2 -2
  72. package/src/services/module-types-generator.test.ts +1 -1
  73. package/src/services/module-validator/contract-version.test.ts +3 -3
  74. package/src/services/network-ensure.test.ts +1 -1
  75. package/src/services/proxmox-reconcile.test.ts +2 -2
  76. package/src/services/remove-guard.test.ts +1 -1
  77. package/src/services/update/dep-graph.test.ts +1 -1
  78. package/src/services/update/orchestrator.test.ts +1 -1
  79. package/src/variables/capability-self-ref.test.ts +2 -2
  80. package/src/variables/computed/computed-integration.test.ts +1 -1
  81. package/src/variables/declarative-derivation.test.ts +19 -19
@@ -10,7 +10,7 @@ import { validatePrivilegedCapabilities } from './validate';
10
10
 
11
11
  function buildManifest(overrides: Partial<ModuleManifest>): ModuleManifest {
12
12
  return {
13
- celilo_contract: '1.0',
13
+ celilo_contract: '2.0',
14
14
  id: 'some-module',
15
15
  name: 'some module',
16
16
  version: '0.0.1',
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { getSingularSystemSpec } from './schema';
2
+ import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
3
3
  import {
4
4
  validateCapabilityNames,
5
5
  validateCapabilityRequirements,
@@ -12,7 +12,7 @@ import {
12
12
  validateVariableSources,
13
13
  } from './validate';
14
14
 
15
- const CONTRACT_LINE = 'celilo_contract: "1.0"';
15
+ const CONTRACT_LINE = 'celilo_contract: "2.0"';
16
16
 
17
17
  describe('validateManifest', () => {
18
18
  test('should validate minimal valid manifest', () => {
@@ -251,6 +251,45 @@ version: 1.0.0
251
251
  expect(result.success).toBe(false);
252
252
  });
253
253
 
254
+ test('a stored manifest at a retired version still PARSES — only importing one is refused', () => {
255
+ // module-pause.ts, module-remove.ts and provider-arrival.ts re-parse the
256
+ // manifest celilo already stored, and each `continue`s past a failure. If
257
+ // the retirement moved onto the schema, every installed module not yet
258
+ // migrated would silently vanish from the dependent-removal check and the
259
+ // provider backfill — a narrowing with no error, which is the failure mode
260
+ // this whole change exists to remove.
261
+ const stored = ModuleManifestSchema.safeParse({
262
+ celilo_contract: '1.0',
263
+ id: 'legacy-module',
264
+ name: 'Legacy',
265
+ version: '1.0.0',
266
+ });
267
+ expect(stored.success).toBe(true);
268
+
269
+ const imported = validateManifest(
270
+ 'celilo_contract: "1.0"\nid: legacy-module\nname: Legacy\nversion: 1.0.0\n',
271
+ );
272
+ expect(imported.success).toBe(false);
273
+ });
274
+
275
+ test('should reject a retired celilo_contract version, naming the migration', () => {
276
+ // The whole point of retiring 1.0 rather than deleting it: a 1.0 module
277
+ // deploys fine and silently stores nothing, so the refusal has to say why.
278
+ const yaml = `
279
+ celilo_contract: "1.0"
280
+ id: test-module
281
+ name: Test
282
+ version: 1.0.0
283
+ `;
284
+ const result = validateManifest(yaml);
285
+ expect(result.success).toBe(false);
286
+ if (!result.success) {
287
+ const message = result.errors.find((e) => e.path === 'celilo_contract')?.message ?? '';
288
+ expect(message).toContain('retired');
289
+ expect(message).toContain('context.secrets.set');
290
+ }
291
+ });
292
+
254
293
  test('should reject manifest with unknown top-level key', () => {
255
294
  const yaml = `
256
295
  ${CONTRACT_LINE}
@@ -653,7 +692,7 @@ version: 1.0.0
653
692
  describe('validateCapabilityNames', () => {
654
693
  test('accepts known capability names in requires', () => {
655
694
  const manifest = {
656
- celilo_contract: '1.0' as const,
695
+ celilo_contract: '2.0' as const,
657
696
  id: 'lunacycle',
658
697
  name: 'LunaCycle',
659
698
  version: '1.0.0',
@@ -672,7 +711,7 @@ describe('validateCapabilityNames', () => {
672
711
 
673
712
  test('accepts known capability names in optional', () => {
674
713
  const manifest = {
675
- celilo_contract: '1.0' as const,
714
+ celilo_contract: '2.0' as const,
676
715
  id: 'caddy',
677
716
  name: 'Caddy',
678
717
  version: '1.0.0',
@@ -689,7 +728,7 @@ describe('validateCapabilityNames', () => {
689
728
 
690
729
  test('rejects unknown capability name in requires', () => {
691
730
  const manifest = {
692
- celilo_contract: '1.0' as const,
731
+ celilo_contract: '2.0' as const,
693
732
  id: 'broken',
694
733
  name: 'Broken',
695
734
  version: '1.0.0',
@@ -712,7 +751,7 @@ describe('validateCapabilityNames', () => {
712
751
 
713
752
  test('rejects unknown capability name in optional', () => {
714
753
  const manifest = {
715
- celilo_contract: '1.0' as const,
754
+ celilo_contract: '2.0' as const,
716
755
  id: 'broken',
717
756
  name: 'Broken',
718
757
  version: '1.0.0',
@@ -734,7 +773,7 @@ describe('validateCapabilityNames', () => {
734
773
 
735
774
  test('reports both required and optional unknowns in one pass', () => {
736
775
  const manifest = {
737
- celilo_contract: '1.0' as const,
776
+ celilo_contract: '2.0' as const,
738
777
  id: 'broken',
739
778
  name: 'Broken',
740
779
  version: '1.0.0',
@@ -757,7 +796,7 @@ describe('validateCapabilityNames', () => {
757
796
 
758
797
  test('passes when no capabilities declared', () => {
759
798
  const manifest = {
760
- celilo_contract: '1.0' as const,
799
+ celilo_contract: '2.0' as const,
761
800
  id: 'standalone',
762
801
  name: 'Standalone',
763
802
  version: '1.0.0',
@@ -773,7 +812,7 @@ describe('validateCapabilityNames', () => {
773
812
  describe('validateCapabilityRequirements', () => {
774
813
  test('should pass when all required capabilities are available', () => {
775
814
  const manifest = {
776
- celilo_contract: '1.0' as const,
815
+ celilo_contract: '2.0' as const,
777
816
  id: 'caddy',
778
817
  name: 'Caddy',
779
818
  version: '1.0.0',
@@ -791,7 +830,7 @@ describe('validateCapabilityRequirements', () => {
791
830
 
792
831
  test('should fail when required capability is missing', () => {
793
832
  const manifest = {
794
- celilo_contract: '1.0' as const,
833
+ celilo_contract: '2.0' as const,
795
834
  id: 'caddy',
796
835
  name: 'Caddy',
797
836
  version: '1.0.0',
@@ -816,7 +855,7 @@ describe('validateCapabilityRequirements', () => {
816
855
 
817
856
  test('should pass when no capabilities required', () => {
818
857
  const manifest = {
819
- celilo_contract: '1.0' as const,
858
+ celilo_contract: '2.0' as const,
820
859
  id: 'homebridge',
821
860
  name: 'Homebridge',
822
861
  version: '1.0.0',
@@ -834,7 +873,7 @@ describe('validateCapabilityRequirements', () => {
834
873
  describe('validateVariableSources', () => {
835
874
  test('should pass when imported capability variables reference required capabilities', () => {
836
875
  const manifest = {
837
- celilo_contract: '1.0' as const,
876
+ celilo_contract: '2.0' as const,
838
877
  id: 'caddy',
839
878
  name: 'Caddy',
840
879
  version: '1.0.0',
@@ -862,7 +901,7 @@ describe('validateVariableSources', () => {
862
901
 
863
902
  test('should fail when imported capability variable references unrequired capability', () => {
864
903
  const manifest = {
865
- celilo_contract: '1.0' as const,
904
+ celilo_contract: '2.0' as const,
866
905
  id: 'caddy',
867
906
  name: 'Caddy',
868
907
  version: '1.0.0',
@@ -891,7 +930,7 @@ describe('validateVariableSources', () => {
891
930
 
892
931
  test('should pass when imported capability variable references an optional capability', () => {
893
932
  const manifest = {
894
- celilo_contract: '1.0' as const,
933
+ celilo_contract: '2.0' as const,
895
934
  id: 'caddy',
896
935
  name: 'Caddy',
897
936
  version: '1.0.0',
@@ -919,7 +958,7 @@ describe('validateVariableSources', () => {
919
958
 
920
959
  test('should fail when imported capability variable has invalid reference format', () => {
921
960
  const manifest = {
922
- celilo_contract: '1.0' as const,
961
+ celilo_contract: '2.0' as const,
923
962
  id: 'test',
924
963
  name: 'Test',
925
964
  version: '1.0.0',
@@ -941,7 +980,7 @@ describe('validateVariableSources', () => {
941
980
 
942
981
  test('should pass when no variables import capabilities', () => {
943
982
  const manifest = {
944
- celilo_contract: '1.0' as const,
983
+ celilo_contract: '2.0' as const,
945
984
  id: 'homebridge',
946
985
  name: 'Homebridge',
947
986
  version: '1.0.0',
@@ -969,7 +1008,7 @@ describe('validateVariableSources', () => {
969
1008
  describe('validateDeriveFromSources', () => {
970
1009
  test('should pass when capability source uses $capability: prefix', () => {
971
1010
  const manifest = {
972
- celilo_contract: '1.0' as const,
1011
+ celilo_contract: '2.0' as const,
973
1012
  id: 'lunacycle',
974
1013
  name: 'LunaCycle',
975
1014
  version: '1.0.0',
@@ -996,7 +1035,7 @@ describe('validateDeriveFromSources', () => {
996
1035
  test('should reject capability source with $system: prefix in derive_from (regression)', () => {
997
1036
  // This is the original lunacycle bug that motivated D7.
998
1037
  const manifest = {
999
- celilo_contract: '1.0' as const,
1038
+ celilo_contract: '2.0' as const,
1000
1039
  id: 'lunacycle',
1001
1040
  name: 'LunaCycle',
1002
1041
  version: '1.0.0',
@@ -1026,7 +1065,7 @@ describe('validateDeriveFromSources', () => {
1026
1065
 
1027
1066
  test('should pass for variables without derive_from', () => {
1028
1067
  const manifest = {
1029
- celilo_contract: '1.0' as const,
1068
+ celilo_contract: '2.0' as const,
1030
1069
  id: 'test',
1031
1070
  name: 'Test',
1032
1071
  version: '1.0.0',
@@ -1055,7 +1094,7 @@ describe('validateDeriveFromSources', () => {
1055
1094
  // doesn't introduce the cross-context mismatch the validator exists
1056
1095
  // to catch (e.g. authentik's auth_url derives from $self:domain).
1057
1096
  const manifest = {
1058
- celilo_contract: '1.0' as const,
1097
+ celilo_contract: '2.0' as const,
1059
1098
  id: 'authentik',
1060
1099
  name: 'Authentik',
1061
1100
  version: '1.0.0',
@@ -1090,7 +1129,7 @@ describe('validateDeriveFromSources', () => {
1090
1129
  // mismatch error. This guards against accidentally green-lighting
1091
1130
  // "$self:foo and $system:bar" combos.
1092
1131
  const manifest = {
1093
- celilo_contract: '1.0' as const,
1132
+ celilo_contract: '2.0' as const,
1094
1133
  id: 'test',
1095
1134
  name: 'Test',
1096
1135
  version: '1.0.0',
@@ -1122,7 +1161,7 @@ describe('validateDeriveFromSources', () => {
1122
1161
  describe('validateHookContract', () => {
1123
1162
  test('should pass when all declared hooks are in v1 contract', () => {
1124
1163
  const manifest = {
1125
- celilo_contract: '1.0' as const,
1164
+ celilo_contract: '2.0' as const,
1126
1165
  id: 'test',
1127
1166
  name: 'Test',
1128
1167
  version: '1.0.0',
@@ -1141,7 +1180,7 @@ describe('validateHookContract', () => {
1141
1180
 
1142
1181
  test('should pass for manifests with no hooks block', () => {
1143
1182
  const manifest = {
1144
- celilo_contract: '1.0' as const,
1183
+ celilo_contract: '2.0' as const,
1145
1184
  id: 'test',
1146
1185
  name: 'Test',
1147
1186
  version: '1.0.0',
@@ -1158,7 +1197,7 @@ describe('validateHookContract', () => {
1158
1197
  describe('validateProvidesNoCrossCapabilityRefs', () => {
1159
1198
  test('should pass when capability data has no cross-capability references', () => {
1160
1199
  const manifest = {
1161
- celilo_contract: '1.0' as const,
1200
+ celilo_contract: '2.0' as const,
1162
1201
  id: 'authentik',
1163
1202
  name: 'Authentik',
1164
1203
  version: '1.0.0',
@@ -1184,7 +1223,7 @@ describe('validateProvidesNoCrossCapabilityRefs', () => {
1184
1223
 
1185
1224
  test('should reject capability data containing $capability: reference (D9 firm rule)', () => {
1186
1225
  const manifest = {
1187
- celilo_contract: '1.0' as const,
1226
+ celilo_contract: '2.0' as const,
1188
1227
  id: 'authentik',
1189
1228
  name: 'Authentik',
1190
1229
  version: '1.0.0',
@@ -1536,7 +1575,7 @@ base_module_aspect:
1536
1575
 
1537
1576
  describe('submodules (openspec/changes/submodules D1, D9)', () => {
1538
1577
  const parent = (over: Record<string, unknown> = {}) => ({
1539
- celilo_contract: '1.0' as const,
1578
+ celilo_contract: '2.0' as const,
1540
1579
  id: 'forgejo',
1541
1580
  name: 'Forgejo',
1542
1581
  version: '1.0.0',
@@ -1547,7 +1586,7 @@ describe('submodules (openspec/changes/submodules D1, D9)', () => {
1547
1586
  });
1548
1587
 
1549
1588
  const submodule = (over: Record<string, unknown> = {}) => ({
1550
- celilo_contract: '1.0' as const,
1589
+ celilo_contract: '2.0' as const,
1551
1590
  id: 'runner',
1552
1591
  name: 'Forgejo Runner',
1553
1592
  version: '1.0.0',
@@ -1642,7 +1681,7 @@ describe('submodules (openspec/changes/submodules D1, D9)', () => {
1642
1681
  describe('schema', () => {
1643
1682
  test('parses a manifest declaring submodules', () => {
1644
1683
  const yaml = `
1645
- celilo_contract: "1.0"
1684
+ celilo_contract: "2.0"
1646
1685
  id: forgejo
1647
1686
  name: Forgejo
1648
1687
  version: 1.0.0
@@ -1658,7 +1697,7 @@ requires:
1658
1697
 
1659
1698
  test('rejects a non-kebab-case submodule name', () => {
1660
1699
  const yaml = `
1661
- celilo_contract: "1.0"
1700
+ celilo_contract: "2.0"
1662
1701
  id: forgejo
1663
1702
  name: Forgejo
1664
1703
  version: 1.0.0
@@ -3,7 +3,7 @@ import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { SUBMODULES_DIR } from '../module/packaging/package-rules';
5
5
  import { validateModuleZoneRequirements } from '../services/zone-policy';
6
- import { resolveContract, supportedContractVersions } from './contracts';
6
+ import { RETIRED_CONTRACT_VERSIONS, resolveContract, supportedContractVersions } from './contracts';
7
7
  import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
8
8
  import type { ModuleManifest } from './schema';
9
9
 
@@ -90,6 +90,19 @@ export function validateManifest(yamlContent: string): ValidationResult {
90
90
  return formatZodErrors(result.error);
91
91
  }
92
92
 
93
+ const retired = RETIRED_CONTRACT_VERSIONS[result.data.celilo_contract];
94
+ if (retired) {
95
+ return {
96
+ success: false,
97
+ errors: [
98
+ {
99
+ path: 'celilo_contract',
100
+ message: `celilo_contract '${result.data.celilo_contract}' is retired: ${retired}`,
101
+ },
102
+ ],
103
+ };
104
+ }
105
+
93
106
  return {
94
107
  success: true,
95
108
  data: result.data,
@@ -120,7 +120,7 @@ describe('Module Import', () => {
120
120
  await writeFile(
121
121
  join(moduleDir, 'manifest.yml'),
122
122
  `
123
- celilo_contract: "1.0"
123
+ celilo_contract: "2.0"
124
124
  id: homebridge
125
125
  name: Homebridge
126
126
  version: 1.0.0
@@ -173,7 +173,7 @@ version: 1.0.0
173
173
 
174
174
  describe('submodules (openspec/changes/submodules D1)', () => {
175
175
  const PARENT = `
176
- celilo_contract: "1.0"
176
+ celilo_contract: "2.0"
177
177
  id: forgejo
178
178
  name: Forgejo
179
179
  version: 1.0.0
@@ -181,7 +181,7 @@ submodules:
181
181
  - runner
182
182
  `;
183
183
  const SUBMODULE = `
184
- celilo_contract: "1.0"
184
+ celilo_contract: "2.0"
185
185
  id: runner
186
186
  name: Forgejo Runner
187
187
  version: 1.0.0
@@ -250,7 +250,7 @@ provides:
250
250
  const dir = await writeParent(
251
251
  'plain-module',
252
252
  `
253
- celilo_contract: "1.0"
253
+ celilo_contract: "2.0"
254
254
  id: plain
255
255
  name: Plain
256
256
  version: 1.0.0
@@ -322,7 +322,7 @@ version: 1.0.0
322
322
  describe('insertModuleToDb', () => {
323
323
  test('should insert module record into database', async () => {
324
324
  const manifest: ModuleManifest = {
325
- celilo_contract: '1.0',
325
+ celilo_contract: '2.0',
326
326
  id: 'test-module',
327
327
  name: 'Test Module',
328
328
  version: '1.0.0',
@@ -375,7 +375,7 @@ version: 1.0.0
375
375
  await writeFile(
376
376
  join(moduleDir, 'manifest.yml'),
377
377
  `
378
- celilo_contract: "1.0"
378
+ celilo_contract: "2.0"
379
379
  id: homebridge
380
380
  name: Homebridge
381
381
  version: 1.0.0
@@ -453,7 +453,7 @@ version: 1.0.0
453
453
  await mkdir(moduleDir, { recursive: true });
454
454
  await writeFile(
455
455
  join(moduleDir, 'manifest.yml'),
456
- 'celilo_contract: "1.0"\nid: existing\nname: Existing\nversion: 2.0.0',
456
+ 'celilo_contract: "2.0"\nid: existing\nname: Existing\nversion: 2.0.0',
457
457
  );
458
458
 
459
459
  const result = await importModule({
@@ -102,7 +102,7 @@ describe('buildModule — a root package.json must not strip the hook runtime',
102
102
  source = mkdtempSync(join(tmpdir(), 'celilo-pack-root-pkg-'));
103
103
  out = join(mkdtempSync(join(tmpdir(), 'celilo-pack-out-')), 'm.netapp');
104
104
 
105
- write('manifest.yml', 'celilo_contract: "1.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
105
+ write('manifest.yml', 'celilo_contract: "2.0"\nid: root-pkg-module\nname: M\nversion: 0.1.0\n');
106
106
  // The shape that triggers it: a module that is also a bun project. `files`
107
107
  // deliberately names scripts/ — proving the loss is npm-pack semantics and
108
108
  // not a missing entry.
@@ -348,12 +348,19 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
348
348
  // variable expansion would strip shell-only bash variables like
349
349
  // $STAGE before bash ever sees them.
350
350
  const buildEnv = { ...process.env, CELILO_MODULE_SOURCE_DIR: sourceDir };
351
+ // celilo#1252: the budget is the MODULE's, not a constant three layers
352
+ // away. forgejo's fetch declares `curl --retry 10 --max-time 900` and was
353
+ // killed at 300s having emitted five of its ten retries, reporting only
354
+ // "spawnSync bash ETIMEDOUT" — which names neither the cap, the elapsed
355
+ // time, nor the module.
356
+ const timeoutMs = (manifest.build.timeout_seconds ?? 300) * 1000;
357
+ const startedAt = Date.now();
351
358
  try {
352
359
  if (manifest.build.command) {
353
360
  execFileSync('bash', ['-c', manifest.build.command], {
354
361
  cwd: buildDir,
355
362
  stdio: 'inherit',
356
- timeout: 300_000,
363
+ timeout: timeoutMs,
357
364
  env: buildEnv,
358
365
  });
359
366
  } else {
@@ -362,12 +369,19 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
362
369
  execFileSync(cmd, [script], {
363
370
  cwd: buildDir,
364
371
  stdio: 'inherit',
365
- timeout: 300_000,
372
+ timeout: timeoutMs,
366
373
  env: buildEnv,
367
374
  });
368
375
  }
369
376
  } catch (buildError) {
370
377
  const msg = buildError instanceof Error ? buildError.message : String(buildError);
378
+ if (msg.includes('ETIMEDOUT')) {
379
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
380
+ return {
381
+ success: false,
382
+ error: `Auto-build failed: ${moduleId}'s build exceeded its ${timeoutMs / 1000}s timeout (killed after ${elapsed}s). Raise build.timeout_seconds in manifest.yml if the build legitimately needs longer, or shrink what it fetches.`,
383
+ };
384
+ }
371
385
  return { success: false, error: `Auto-build failed: Build exited with code ${msg}` };
372
386
  }
373
387
 
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'yaml';
5
+
6
+ // structural
7
+ //
8
+ // bubblewrap is the hook jail's Linux backend, and it must NOT be in the .deb's
9
+ // `depends` while the module conversions of
10
+ // openspec/changes/module-orchestrator-primitives are in flight (design.md D9).
11
+ // Shipping the backend before the modules survive it means a jail that arrives
12
+ // with enforcement in the same apt upgrade — it would break deploys rather than
13
+ // fail open. The jail keeps being exercised meanwhile because the e2e management
14
+ // image installs bubblewrap in its own Dockerfile
15
+ // (packages/e2e/docker/Dockerfile.management).
16
+ //
17
+ // REVERSAL CONDITION: slice 8 of module-orchestrator-primitives — the last
18
+ // commit of that change — puts `bubblewrap` back in `depends`. Delete this test
19
+ // in that same commit. Do not re-add the line for any other reason; the old
20
+ // nfpm comment argued for it from the AppArmor profile alone and predates the
21
+ // migration.
22
+ const NFPM_PATH = join(import.meta.dir, '../../../../packaging/celilo/nfpm.yaml');
23
+
24
+ function nfpmDepends(): string[] {
25
+ const parsed = parse(readFileSync(NFPM_PATH, 'utf8')) as { depends?: string[] };
26
+ return parsed.depends ?? [];
27
+ }
28
+
29
+ describe('nfpm depends (structural)', () => {
30
+ test('parses a non-empty depends list, so an absence assertion means something', () => {
31
+ // Guards the reach of the assertion below: a typo'd path or a renamed key
32
+ // would otherwise give an empty list, in which nothing is ever found.
33
+ const depends = nfpmDepends();
34
+ expect(depends.length).toBeGreaterThan(0);
35
+ expect(depends).toContain('ansible');
36
+ });
37
+
38
+ test('does not depend on bubblewrap', () => {
39
+ const offenders = nfpmDepends().filter((d) => d.split(/[\s(]/)[0] === 'bubblewrap');
40
+ expect(offenders).toEqual([]);
41
+ });
42
+ });
@@ -115,7 +115,7 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
115
115
  },
116
116
  private_web: {
117
117
  version: '2.0.0',
118
- hash: 'fb301f5e55ca14fdb0ede0d428d2ac72eabed77e277de8c795eaa0b955695483',
118
+ hash: 'd1d1ef31d51f853d5e02460387f18a86c2833bd97e45122e8e2703ff44397a44',
119
119
  requests:
120
120
  '[{kind:"alias",members:[{name:"_self",optional:false,type:"Record<string, string | number | boolean>"}],name:"ClientConfig"},{kind:"interface",members:[{name:"defaultHostname",optional:false,type:"string"},{name:"getCaCertificate",optional:false,type:"Promise<string>"},{name:"publishStaticSite",optional:false,type:"Promise<PublishStaticSiteResult>"},{name:"registerReverseProxy",optional:false,type:"Promise<RegisterReverseProxyResult>"}],name:"PrivateWebCapability"},{kind:"interface",members:[{name:"clientConfig",optional:true,type:"ClientConfig"},{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"spaFallback",optional:true,type:"boolean"}],name:"PublishStaticSiteRequest"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"filesUploaded",optional:false,type:"number"},{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"PublishStaticSiteResult"},{kind:"interface",members:[{name:"hostname",optional:true,type:"string"},{name:"path",optional:false,type:"string"},{name:"targetHost",optional:false,type:"string"},{name:"targetPort",optional:false,type:"number"},{name:"websocket",optional:true,type:"boolean"}],name:"RegisterReverseProxyRequest"},{kind:"interface",members:[{name:"path",optional:false,type:"string"},{name:"success",optional:false,type:"boolean"}],name:"RegisterReverseProxyResult"}]',
121
121
  },
@@ -149,4 +149,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
149
149
  requests:
150
150
  '[{kind:"alias",members:[{name:"_self",optional:false,type:"{\\n sourceDir: string;\\n overlayDir?: string;\\n} | {\\n unavailable: string;\\n}"}],name:"ConsumerSiteResolution"},{kind:"interface",members:[{name:"content",optional:false,type:"string"},{name:"path",optional:false,type:"string"}],name:"ProviderConfigFile"},{kind:"interface",members:[{name:"converge",optional:false,type:"Promise<{\\n success: boolean;\\n error?: string;\\n unresolved: string[];\\n}>"},{name:"resolveConsumerSite",optional:false,type:"Promise<ConsumerSiteResolution>"}],name:"ProviderConvergeView"},{kind:"interface",members:[{name:"contentHash",optional:false,type:"string"},{name:"hostnames",optional:false,type:"string[]"},{name:"moduleId",optional:false,type:"string"},{name:"overlayDir",optional:true,type:"string"},{name:"slug",optional:false,type:"string"},{name:"sourceDir",optional:false,type:"string"}],name:"ProviderSiteRequest"}]',
151
151
  },
152
+ private_routes: {
153
+ version: '1.0.0',
154
+ hash: '6b15e73c0aeca909a1d9250177e4f8c26cb414481b67e9923888af18f902b963',
155
+ requests:
156
+ '[{kind:"interface",members:[{name:"all",optional:false,type:"Promise<PrivateWebRoute[]>"},{name:"replaceAll",optional:false,type:"Promise<void>"}],name:"PrivateRouteView"},{kind:"interface",members:[{name:"hostname",optional:false,type:"string"},{name:"module_id",optional:false,type:"string"},{name:"path",optional:false,type:"string"},{name:"slug",optional:false,type:"string"},{name:"target_host",optional:true,type:"string"},{name:"target_port",optional:true,type:"number"},{name:"type",optional:false,type:"\'static\' | \'reverse_proxy\'"},{name:"websocket",optional:true,type:"boolean"}],name:"PrivateWebRoute"}]',
157
+ },
152
158
  };
@@ -187,7 +187,7 @@ describe('fixture capability coverage', () => {
187
187
  }
188
188
 
189
189
  const providerManifest = [
190
- 'celilo_contract: "1.0"',
190
+ 'celilo_contract: "2.0"',
191
191
  'id: marker-provider',
192
192
  'name: Marker Provider',
193
193
  'version: 1.0.0',
@@ -208,7 +208,7 @@ describe('fixture capability coverage', () => {
208
208
  'marker-provider': { manifest: providerManifest },
209
209
  'marker-consumer': {
210
210
  manifest: [
211
- 'celilo_contract: "1.0"',
211
+ 'celilo_contract: "2.0"',
212
212
  'id: marker-consumer',
213
213
  'name: Marker Consumer',
214
214
  'version: 1.0.0',
@@ -235,7 +235,7 @@ describe('fixture capability coverage', () => {
235
235
  'marker-provider': { manifest: providerManifest },
236
236
  'marker-consumer': {
237
237
  manifest: [
238
- 'celilo_contract: "1.0"',
238
+ 'celilo_contract: "2.0"',
239
239
  'id: marker-consumer',
240
240
  'name: Marker Consumer',
241
241
  'version: 1.0.0',
@@ -270,7 +270,7 @@ describe('fixture capability coverage', () => {
270
270
  mirrorTree(dir, {
271
271
  'marker-consumer': {
272
272
  manifest: [
273
- 'celilo_contract: "1.0"',
273
+ 'celilo_contract: "2.0"',
274
274
  'id: marker-consumer',
275
275
  'name: Marker Consumer',
276
276
  'version: 1.0.0',
@@ -297,7 +297,7 @@ describe('fixture capability coverage', () => {
297
297
  'marker-provider': { manifest: providerManifest },
298
298
  'marker-consumer': {
299
299
  manifest: [
300
- 'celilo_contract: "1.0"',
300
+ 'celilo_contract: "2.0"',
301
301
  'id: marker-consumer',
302
302
  'name: Marker Consumer',
303
303
  'version: 1.0.0',
@@ -102,7 +102,7 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
102
102
  {
103
103
  file: 'apps/celilo/src/hooks/capability-loader.ts',
104
104
  capability: 'provider_converge',
105
- count: 2,
105
+ count: 4,
106
106
  why: 'PERMANENT — a provider VIEW, not a module-provided capability. Core injects the generic converge every provider renders through; naming it is framework plumbing, not core branching on a provider (providers-converge-declared-state)',
107
107
  },
108
108
  {
@@ -240,13 +240,13 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
240
240
  {
241
241
  file: 'apps/celilo/src/hooks/capability-loader.ts',
242
242
  capability: 'private_web',
243
- count: 1,
243
+ count: 6,
244
244
  why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
245
245
  },
246
246
  {
247
247
  file: 'apps/celilo/src/hooks/capability-loader.ts',
248
248
  capability: 'public_web',
249
- count: 8,
249
+ count: 11,
250
250
  why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
251
251
  },
252
252
  {
@@ -465,8 +465,37 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
465
465
  count: 3,
466
466
  why: 'X8 — shared helpers that name the capability they serve; moves with X8 in Phase 5 (#940)',
467
467
  },
468
+ {
469
+ file: 'apps/celilo/src/hooks/capability-loader.ts',
470
+ capability: 'private_routes',
471
+ count: 2,
472
+ why: "PERMANENT — a provider VIEW, not a module-provided capability, and the private-ingress sibling of the 'web_routes' row above. The name was registered so it stops being invisible to audits; these references are framework plumbing (providers-converge-declared-state slice 2)",
473
+ },
474
+ {
475
+ file: 'apps/celilo/src/db/schema.ts',
476
+ capability: 'private_routes',
477
+ count: 1,
478
+ why: "PERMANENT — the TABLE named private_routes, exactly as the 'web_routes' row above; the collision with the view name is nominal",
479
+ },
480
+ {
481
+ file: 'packages/capabilities/src/private-web.ts',
482
+ capability: 'private_routes',
483
+ count: 1,
484
+ why: "PERMANENT — the private_web implementation naming its own table; the capability package doing its job, as the public-web.ts 'web_routes' row above",
485
+ },
486
+ {
487
+ file: 'packages/capabilities/src/declared-tables.ts',
488
+ capability: 'private_web',
489
+ count: 1,
490
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same justification as the public_web row above: naming the capability IS the mapping (openspec/changes/capability-owned-tables D2)',
491
+ },
492
+ {
493
+ file: 'packages/capabilities/src/capability-contract.ts',
494
+ capability: 'private_routes',
495
+ count: 1,
496
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
497
+ },
468
498
  ];
469
-
470
499
  export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
471
500
  {
472
501
  file: 'apps/celilo/src/services/alerting/notification-responder.ts',
@@ -513,10 +513,11 @@ describe('RegistryClient.download integrity', () => {
513
513
  });
514
514
 
515
515
  test('a non-digest cksum sentinel skips verification instead of failing', async () => {
516
- // The registry's bootstrap path publishes `cksum: 'bootstrap'`
517
- // (packages/registry-server/src/bootstrap.ts:114) because those modules are
518
- // packaged on demand and have no stable digest. Treating a sentinel as a
519
- // digest rejects a perfectly good 2.7MB package on every import.
516
+ // `bootstrap` is the sentinel an index entry carries when it has no
517
+ // integrity data at all. The registry's bootstrap path no longer emits it
518
+ // — it hashes the .netapp it packages (celilo#1262) — but the sentinel
519
+ // branch stays, because treating one as a digest would reject a perfectly
520
+ // good 2.7MB package on every import.
520
521
  fetchSpy.mockImplementation(() => new Response(body, { status: 200 }));
521
522
 
522
523
  const client = new RegistryClient('https://reg.example.com');
@@ -28,7 +28,7 @@ function insertModule(id: string, version: string) {
28
28
  id,
29
29
  name: id,
30
30
  version,
31
- manifestData: { id, name: id, version, celilo_contract: '1.0' },
31
+ manifestData: { id, name: id, version, celilo_contract: '2.0' },
32
32
  sourcePath: `/tmp/${id}`,
33
33
  })
34
34
  .run();
@@ -160,7 +160,7 @@ describe('aspect-approvals', () => {
160
160
  id: 'knot-unbound-internal-v2',
161
161
  name: 'knot',
162
162
  version: '2.0.0',
163
- manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '1.0' },
163
+ manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '2.0' },
164
164
  sourcePath: '/tmp/knot',
165
165
  })
166
166
  .run();