@celilo/cli 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +61 -9
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +7 -7
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-act.ts +1 -1
  11. package/src/cli/commands/alerts-sweep.ts +2 -0
  12. package/src/cli/commands/backup-create.ts +26 -11
  13. package/src/cli/commands/backup-list.test.ts +83 -0
  14. package/src/cli/commands/backup-list.ts +67 -3
  15. package/src/cli/commands/backup-prune.ts +17 -17
  16. package/src/cli/commands/backup-sweep.ts +20 -8
  17. package/src/cli/commands/events.ts +34 -3
  18. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  19. package/src/cli/commands/firewall-interface-list.ts +123 -0
  20. package/src/cli/commands/machine-add.ts +30 -2
  21. package/src/cli/commands/module-config.test.ts +64 -2
  22. package/src/cli/commands/module-config.ts +159 -8
  23. package/src/cli/commands/module-deploy.ts +2 -2
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import.ts +3 -3
  26. package/src/cli/commands/module-list.ts +12 -1
  27. package/src/cli/commands/module-pause.ts +317 -0
  28. package/src/cli/commands/module-remove.ts +78 -40
  29. package/src/cli/commands/module-status.ts +127 -4
  30. package/src/cli/commands/module-update.test.ts +1 -1
  31. package/src/cli/commands/monitor.ts +116 -19
  32. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  33. package/src/cli/commands/status.ts +25 -3
  34. package/src/cli/commands/system-migrate.ts +14 -0
  35. package/src/cli/commands/system-update.ts +4 -1
  36. package/src/cli/completion.ts +39 -9
  37. package/src/cli/fuel-gauge.ts +4 -4
  38. package/src/cli/index.ts +104 -22
  39. package/src/cli/json-output.test.ts +162 -0
  40. package/src/cli/prompts.ts +53 -74
  41. package/src/cli/service-credential.ts +3 -3
  42. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  43. package/src/cli/tui/audit-state.ts +2 -0
  44. package/src/cli/types.ts +7 -2
  45. package/src/db/schema.ts +73 -15
  46. package/src/hooks/capability-loader.ts +130 -4
  47. package/src/hooks/run-named-hook.ts +28 -0
  48. package/src/hooks/types.ts +2 -1
  49. package/src/manifest/contracts/v1.ts +16 -0
  50. package/src/manifest/schema.ts +40 -65
  51. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  52. package/src/services/alerting/cadence-migration.test.ts +155 -0
  53. package/src/services/alerting/cadence-migration.ts +90 -0
  54. package/src/services/alerting/coverage-source.ts +8 -11
  55. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  56. package/src/services/alerting/deploy-hooks.ts +11 -5
  57. package/src/services/alerting/health-cadence.test.ts +58 -0
  58. package/src/services/alerting/health-cadence.ts +128 -0
  59. package/src/services/alerting/health-coverage.ts +18 -8
  60. package/src/services/alerting/monitors.ts +50 -15
  61. package/src/services/alerting/suppression.test.ts +5 -0
  62. package/src/services/alerting/suppression.ts +18 -1
  63. package/src/services/alerting/sweep-runner.test.ts +52 -3
  64. package/src/services/alerting/sweep-runner.ts +41 -8
  65. package/src/services/audit/backup-source.ts +24 -1
  66. package/src/services/audit/backups.test.ts +95 -10
  67. package/src/services/audit/backups.ts +40 -37
  68. package/src/services/audit/interface-classification.test.ts +220 -0
  69. package/src/services/audit/interface-classification.ts +167 -0
  70. package/src/services/audit/types.ts +2 -1
  71. package/src/services/backup-age-agreement.test.ts +118 -0
  72. package/src/services/backup-create.ts +36 -30
  73. package/src/services/backup-metadata.ts +52 -1
  74. package/src/services/backup-retention.test.ts +123 -0
  75. package/src/services/backup-retention.ts +66 -5
  76. package/src/services/backup-schedule.test.ts +166 -0
  77. package/src/services/backup-schedule.ts +105 -15
  78. package/src/services/backup-staging.ts +14 -1
  79. package/src/services/backup-sweep.test.ts +22 -3
  80. package/src/services/backup-sweep.ts +15 -5
  81. package/src/services/bus-interview.ts +2 -2
  82. package/src/services/bus-secret-flow.test.ts +1 -1
  83. package/src/services/cadence.test.ts +97 -0
  84. package/src/services/cadence.ts +165 -0
  85. package/src/services/fleet-checks.ts +48 -0
  86. package/src/services/machine-detector.ts +23 -1
  87. package/src/services/module-config.ts +33 -0
  88. package/src/services/module-deploy.ts +1 -1
  89. package/src/services/module-pause-observability.test.ts +224 -0
  90. package/src/services/module-pause-quiescence.test.ts +163 -0
  91. package/src/services/module-pause.test.ts +573 -0
  92. package/src/services/module-pause.ts +544 -0
  93. package/src/services/remove-guard.test.ts +175 -0
  94. package/src/services/remove-guard.ts +109 -0
  95. package/src/services/storage-providers/s3.test.ts +96 -13
  96. package/src/services/storage-providers/s3.ts +48 -15
  97. package/src/services/terminal-responder.ts +16 -16
  98. package/src/services/update/dep-graph.test.ts +33 -4
  99. package/src/services/update/dep-graph.ts +39 -17
  100. package/src/services/zone-detector.test.ts +34 -3
  101. package/src/services/zone-detector.ts +32 -49
  102. package/src/test-utils/cli.ts +15 -14
  103. package/src/test-utils/integration-guard.ts +26 -0
  104. package/src/test-utils/setup-test-db.ts +13 -23
@@ -80,13 +80,42 @@ describe('buildModuleGraph', () => {
80
80
  expect(g.edges.get('weird')?.size).toBe(0);
81
81
  });
82
82
 
83
- test('first declared provider wins when multiple modules provide the same capability', () => {
83
+ // Previously asserted "first declared provider wins". That was the behaviour,
84
+ // and it was a defect: `capability-loader.ts` resolves a `firewall` consumer
85
+ // through `buildFirewallChain()`, which wires a chain across EVERY provider —
86
+ // the edge device that owns egress plus the downstream layers. First-wins made
87
+ // the others invisible, so `module pause --cascade greenwave` omitted `caddy`
88
+ // even though caddy requires `firewall` and greenwave provides it, and the
89
+ // remove guard (which reads all providers) then refused the removal the
90
+ // cascade was supposed to enable.
91
+ test('a consumer depends on EVERY provider of a capability, not just the first', () => {
84
92
  const g = buildModuleGraph([
85
- makeManifest('a', { provides: ['firewall'] }),
86
- makeManifest('b', { provides: ['firewall'] }),
93
+ makeManifest('iptables', { provides: ['firewall'] }),
94
+ makeManifest('greenwave', { provides: ['firewall'] }),
95
+ makeManifest('caddy', { requires: ['firewall'] }),
96
+ ]);
97
+ expect([...(g.edges.get('caddy') ?? [])].sort()).toEqual(['greenwave', 'iptables']);
98
+ });
99
+
100
+ test('and every provider sees that consumer in its reverse index', () => {
101
+ // This is the half `transitiveConsumers` walks, so it is what decides
102
+ // whether a cascade reaches the module at all.
103
+ const g = buildModuleGraph([
104
+ makeManifest('iptables', { provides: ['firewall'] }),
105
+ makeManifest('greenwave', { provides: ['firewall'] }),
87
106
  makeManifest('caddy', { requires: ['firewall'] }),
88
107
  ]);
89
- expect([...(g.edges.get('caddy') ?? [])]).toEqual(['a']);
108
+ expect([...(g.reverseEdges.get('greenwave') ?? [])]).toEqual(['caddy']);
109
+ expect([...(g.reverseEdges.get('iptables') ?? [])]).toEqual(['caddy']);
110
+ });
111
+
112
+ test('an optional consumer also edges to every provider', () => {
113
+ const g = buildModuleGraph([
114
+ makeManifest('greenwave', { provides: ['dhcp_server'] }),
115
+ makeManifest('router2', { provides: ['dhcp_server'] }),
116
+ makeManifest('technitium', { optional: ['dhcp_server'] }),
117
+ ]);
118
+ expect([...(g.edges.get('technitium') ?? [])].sort()).toEqual(['greenwave', 'router2']);
90
119
  });
91
120
  });
92
121
 
@@ -45,15 +45,36 @@ export interface ModuleGraph {
45
45
  reverseEdges: Map<ModuleId, Set<ModuleId>>;
46
46
  }
47
47
 
48
- /** Internal: build an index of capability name → providing module id. */
49
- function indexProviders(modules: ModuleNode[]): Map<string, ModuleId> {
50
- const index = new Map<string, ModuleId>();
48
+ /**
49
+ * Internal: index capability name → EVERY module providing it.
50
+ *
51
+ * This used to keep only the first provider, on the reasoning that "one edge per
52
+ * capability is enough for graph-walking". It is not, and the discrepancy is
53
+ * not theoretical: `firewall` is provided by BOTH `iptables` and `greenwave`,
54
+ * and `capability-loader.ts` resolves a consumer's `requires: firewall` through
55
+ * `buildFirewallChain()`, which wires a chain across ALL providers — the edge
56
+ * device that owns egress plus the downstream layers. So a consumer genuinely
57
+ * depends on every one of them.
58
+ *
59
+ * With first-wins, whichever provider happened to be inserted first absorbed the
60
+ * edge and the others became invisible to the graph. `module pause --cascade
61
+ * greenwave` therefore omitted `caddy` entirely, even though caddy requires
62
+ * `firewall` and greenwave provides it — so the operator paused an incomplete
63
+ * set and `module remove` then refused, because the remove guard reads the
64
+ * capabilities table (all providers) and disagreed. That disagreement is exactly
65
+ * what openspec/changes/module-pause-lifecycle design D3 forbids.
66
+ *
67
+ * Keeping all providers makes the graph agree with how capabilities actually
68
+ * resolve. For `topologicalOrder` it means a consumer sorts after every provider
69
+ * of what it consumes, which is strictly more correct for `system update`.
70
+ */
71
+ function indexProviders(modules: ModuleNode[]): Map<string, ModuleId[]> {
72
+ const index = new Map<string, ModuleId[]>();
51
73
  for (const m of modules) {
52
74
  for (const cap of m.provides) {
53
- // First provider wins. The capability-loader's chain logic handles
54
- // multi-provider scenarios at deploy time; for graph-walking
55
- // purposes one edge per capability is enough.
56
- if (!index.has(cap)) index.set(cap, m.id);
75
+ const existing = index.get(cap);
76
+ if (existing) existing.push(m.id);
77
+ else index.set(cap, [m.id]);
57
78
  }
58
79
  }
59
80
  return index;
@@ -82,16 +103,17 @@ export function buildModuleGraph(manifests: ModuleManifest[]): ModuleGraph {
82
103
  reverseEdges.set(node.id, reverseEdges.get(node.id) ?? new Set());
83
104
 
84
105
  for (const cap of node.consumes) {
85
- const providerId = providerIndex.get(cap);
86
- // Skip self-edges (a module that consumes its own capability —
87
- // unusual but legal) and missing providers (deploy preflight
88
- // handles those; graph just routes around them).
89
- if (!providerId || providerId === node.id) continue;
90
-
91
- edges.get(node.id)?.add(providerId);
92
- const rev = reverseEdges.get(providerId) ?? new Set();
93
- rev.add(node.id);
94
- reverseEdges.set(providerId, rev);
106
+ for (const providerId of providerIndex.get(cap) ?? []) {
107
+ // Skip self-edges (a module that consumes its own capability —
108
+ // unusual but legal) and missing providers (deploy preflight
109
+ // handles those; graph just routes around them).
110
+ if (providerId === node.id) continue;
111
+
112
+ edges.get(node.id)?.add(providerId);
113
+ const rev = reverseEdges.get(providerId) ?? new Set();
114
+ rev.add(node.id);
115
+ reverseEdges.set(providerId, rev);
116
+ }
95
117
  }
96
118
  }
97
119
 
@@ -70,9 +70,14 @@ describe('zone-detector', () => {
70
70
  expect(zone).toBe('secure');
71
71
  });
72
72
 
73
- it('returns external for non-matching IP', async () => {
73
+ it("returns 'unknown' for a PUBLIC address in no declared subnet", async () => {
74
+ // This asserted `external` before. It is now `'unknown'` because that is
75
+ // the question this function answers: containment. Whether 167.99.123.45
76
+ // is an external EDGE is `isPubliclyRoutable`'s question, and the caller
77
+ // resolves the two — see machine-add. Conflating them is the defect this
78
+ // change exists to remove (design D1).
74
79
  const zone = await detectZoneFromIp('167.99.123.45');
75
- expect(zone).toBe('external');
80
+ expect(zone).toBe('unknown');
76
81
  });
77
82
 
78
83
  it('matches first IP in subnet', async () => {
@@ -87,7 +92,33 @@ describe('zone-detector', () => {
87
92
 
88
93
  it('does not match IP outside subnet', async () => {
89
94
  const zone = await detectZoneFromIp('192.168.1.100');
90
- expect(zone).toBe('external');
95
+ expect(zone).toBe('unknown');
96
+ });
97
+
98
+ it("a PRIVATE address in no declared subnet is 'unknown', never 'external'", async () => {
99
+ // The heart of it. 172.16.5.5 is RFC 1918 — the internet cannot route to
100
+ // it under any circumstances — and it matched no declared subnet. Calling
101
+ // that `external` is the claim that broke `machine add`.
102
+ const zone = await detectZoneFromIp('172.16.5.5');
103
+ expect(zone).toBe('unknown');
104
+ });
105
+
106
+ /**
107
+ * §5.7 — PROOF THE OLD BEHAVIOUR IS GONE.
108
+ *
109
+ * The proposal's opening example, against the real subnet declarations in
110
+ * this fixture. Three RFC1918 gateway legs that no declared subnet contains.
111
+ * Pre-change, `detectZoneFromIp` returned `'external'` for every one of
112
+ * them, so `machine add` printed three private addresses as facing the
113
+ * internet. This test fails against that code and passes against this.
114
+ */
115
+ it('§5.7: three RFC1918 legs in no declared subnet are NOT external', async () => {
116
+ const undeclaredPrivateLegs = ['172.16.5.1', '10.99.0.1', '192.168.77.1'];
117
+ const zones = await Promise.all(undeclaredPrivateLegs.map((ip) => detectZoneFromIp(ip)));
118
+
119
+ expect(zones).toEqual(['unknown', 'unknown', 'unknown']);
120
+ // Stated separately so a failure says WHICH property broke.
121
+ expect(zones.filter((z) => z === 'external')).toEqual([]);
91
122
  });
92
123
  });
93
124
 
@@ -3,47 +3,27 @@
3
3
  * Auto-detects network zone from IP address by matching against system subnets
4
4
  */
5
5
 
6
+ import { subnetContains } from '@celilo/capabilities';
6
7
  import { eq } from 'drizzle-orm';
7
8
  import { getDb } from '../db/client';
8
- import { type NetworkZone, systemConfig } from '../db/schema';
9
+ import { NETWORK_ZONES, type NetworkZone, systemConfig } from '../db/schema';
9
10
 
10
11
  /**
11
- * Parse CIDR notation to get network address and prefix length
12
- */
13
- function parseCIDR(cidr: string): { network: string; prefixLength: number } {
14
- const [ip, prefix] = cidr.split('/');
15
- return {
16
- network: ip,
17
- prefixLength: Number.parseInt(prefix, 10),
18
- };
19
- }
20
-
21
- /**
22
- * Convert IP address string to 32-bit integer
23
- */
24
- function ipToInt(ip: string): number {
25
- const parts = ip.split('.').map((part) => Number.parseInt(part, 10));
26
- return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
27
- }
28
-
29
- /**
30
- * Check if an IP address is in a CIDR subnet
12
+ * The zones a subnet can be declared for every `NetworkZone` except
13
+ * `external`.
14
+ *
15
+ * `external` is deliberately absent and must stay absent: it has no
16
+ * `network.external.subnet` and must never be given one. It is not a segment
17
+ * celilo manages, it is whatever the ISP handed you, so it is the RESIDUAL —
18
+ * decided by `isPubliclyRoutable`, not by containment (design D1, amended).
19
+ *
20
+ * Derived from NETWORK_ZONES rather than listed by hand. The hand-written list
21
+ * that used to be here had already dropped `control-plane-vpn`, which is the
22
+ * third instance of that bug class in this repo.
31
23
  */
32
- function ipInSubnet(ip: string, cidr: string): boolean {
33
- try {
34
- const { network, prefixLength } = parseCIDR(cidr);
35
- const ipInt = ipToInt(ip);
36
- const networkInt = ipToInt(network);
37
-
38
- // Create subnet mask
39
- const mask = ~((1 << (32 - prefixLength)) - 1);
40
-
41
- // Check if IP is in subnet
42
- return (ipInt & mask) === (networkInt & mask);
43
- } catch {
44
- return false;
45
- }
46
- }
24
+ const SUBNET_BACKED_ZONES: readonly NetworkZone[] = NETWORK_ZONES.filter(
25
+ (zone) => zone !== 'external',
26
+ );
47
27
 
48
28
  /**
49
29
  * Get system network configuration for a zone
@@ -65,25 +45,28 @@ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
65
45
  }
66
46
 
67
47
  /**
68
- * Detect network zone from IP address
69
- * Matches IP against system network configuration
48
+ * Which zone an address belongs to, or `'unknown'` when celilo cannot say.
49
+ *
50
+ * **`'unknown'` is the honest answer, and it used to be unreachable.** This
51
+ * returned `'external'` on no-match — so on a firewall with five RFC1918 legs
52
+ * and three declared zones, private gateway addresses were each reported as
53
+ * facing the internet. `NetworkInterface.zone` was already typed
54
+ * `NetworkZone | 'unknown'` and nothing could produce it, because this claimed
55
+ * `external` instead. The slot for the honest answer existed and was
56
+ * unreachable.
70
57
  *
71
- * @param ip - IP address to check
72
- * @returns Detected network zone, or 'external' if no match
58
+ * This answers ONLY the containment question. Whether an unmatched address is
59
+ * an external edge is a different question, answered by `isPubliclyRoutable`
60
+ * see design D1 on why conflating the two was the defect.
73
61
  */
74
- export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
75
- // Check each zone's subnet
76
- const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
77
-
78
- for (const zone of zones) {
62
+ export async function detectZoneFromIp(ip: string): Promise<NetworkZone | 'unknown'> {
63
+ for (const zone of SUBNET_BACKED_ZONES) {
79
64
  const subnet = await getZoneSubnet(zone);
80
- if (subnet && ipInSubnet(ip, subnet)) {
65
+ if (subnet && subnetContains(subnet, ip)) {
81
66
  return zone;
82
67
  }
83
68
  }
84
-
85
- // No match found - must be external
86
- return 'external';
69
+ return 'unknown';
87
70
  }
88
71
 
89
72
  /**
@@ -6,13 +6,17 @@ import { execSync } from 'node:child_process';
6
6
  * Disconnects stdin to ensure CLI cannot prompt for user input.
7
7
  * Any attempt to read from stdin will immediately fail, preventing hanging tests.
8
8
  *
9
- * When command fails, throws error with stderr included in message for test assertions.
9
+ * When command fails, throws an error carrying stderr where the CLI writes
10
+ * its diagnostics. Both helpers used to concatenate stdout and stderr because
11
+ * `@clack/prompts` put error messages on stdout, which left no way to tell a
12
+ * result from a complaint about producing one (celilo#699). The streams are
13
+ * separate now and this reads only the one that carries diagnostics.
10
14
  *
11
15
  * @param cli - CLI command prefix (includes env vars and path)
12
16
  * @param command - Command to execute (e.g., "system config get")
13
17
  * @param options - Additional exec options
14
- * @returns Command output as string
15
- * @throws Error with stderr message when command fails
18
+ * @returns Command stdout as string
19
+ * @throws Error with the stderr message when command fails
16
20
  */
17
21
  export function runCli(cli: string, command: string, options: { encoding?: 'utf-8' } = {}): string {
18
22
  try {
@@ -22,34 +26,31 @@ export function runCli(cli: string, command: string, options: { encoding?: 'utf-
22
26
  timeout: 30000, // 30 second timeout to prevent tests hanging indefinitely
23
27
  });
24
28
  } catch (error: unknown) {
25
- // CLI writes errors to stdout (via @clack/prompts), so capture both
26
- const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
27
29
  const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
28
- // Combine both stdout and stderr (some commands write to both)
29
- const output = [stdout, stderr].filter(Boolean).join('\n') || (error as Error).message;
30
- throw new Error(output);
30
+ throw new Error(stderr.trim() || (error as Error).message);
31
31
  }
32
32
  }
33
33
 
34
34
  /**
35
35
  * Execute CLI command expecting failure
36
- * Returns combined stdout + stderr for assertion
36
+ * Returns stderr for assertion that is where the CLI writes diagnostics.
37
37
  *
38
38
  * Disconnects stdin to ensure CLI cannot prompt for user input.
39
39
  *
40
40
  * @param cli - CLI command prefix
41
41
  * @param command - Command to execute
42
- * @returns combined output as string
42
+ * @returns stderr as string
43
43
  * @throws Error if command succeeds (when it should fail)
44
44
  */
45
45
  export function runCliExpectingFailure(cli: string, command: string): string {
46
46
  try {
47
47
  execSync(`${cli} ${command}`, { stdio: ['ignore', 'pipe', 'pipe'] });
48
- throw new Error('Expected command to fail but it succeeded');
49
48
  } catch (error: unknown) {
50
- // CLI writes errors to stdout (via @clack/prompts), so capture both
51
- const stdout = (error as { stdout?: Buffer }).stdout?.toString() || '';
52
49
  const stderr = (error as { stderr?: Buffer }).stderr?.toString() || '';
53
- return [stdout, stderr].filter(Boolean).join('\n') || (error as Error).message;
50
+ return stderr.trim() || (error as Error).message;
54
51
  }
52
+ // Previously thrown from inside the `try`, where the catch below swallowed it
53
+ // and RETURNED the message — so a command that wrongly succeeded read as a
54
+ // passing assertion about its own failure.
55
+ throw new Error('Expected command to fail but it succeeded');
55
56
  }
@@ -31,3 +31,29 @@ export function skipIntegration(
31
31
  if (req.platform && process.platform !== req.platform) return true;
32
32
  return false;
33
33
  }
34
+
35
+ /**
36
+ * Quarantine a test that FAILS ON THE CI RUNNER for a reason we have not yet
37
+ * explained, while keeping it running for developers locally.
38
+ *
39
+ * This is the `cele2e-ci-unsafe` device (see `--ci-safe` in the cele2e runner)
40
+ * applied to the integration suite, and it is deliberately uncomfortable to
41
+ * use: the caller must pass the tracking issue, and the whole point is that the
42
+ * skip is legible in the log rather than silent.
43
+ *
44
+ * Use this ONLY when the failure is not explained by a missing tool — reach for
45
+ * `skipIntegration({ tools: [...] })` first, because "which tool" is a real
46
+ * diagnosis and "quarantined" is an admission that we do not have one yet.
47
+ *
48
+ * The alternative is worse in both directions: leaving the test in makes a gate
49
+ * permanently red, which trains everyone to ignore it — the same disease as a
50
+ * gate that cannot fail. Deleting it loses the coverage and the evidence.
51
+ *
52
+ * Keyed on `GITHUB_ACTIONS`, which the Forgejo runner sets (it is what makes
53
+ * `bun test` emit `::error` annotations there).
54
+ *
55
+ * Usage: test.skipIf(quarantinedInCi('celilo#713'))('...', ...)
56
+ */
57
+ export function quarantinedInCi(_trackingIssue: string): boolean {
58
+ return process.env.GITHUB_ACTIONS === 'true';
59
+ }
@@ -1,30 +1,20 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
3
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
5
- import { type DbClient, createDbClient } from '../db/client';
4
+ import { type DbClient, createDbClient, findMigrationsFolder } from '../db/client';
6
5
 
7
- /**
8
- * Find migrations folder relative to current working directory
9
- */
10
- function findMigrationsFolder(): string {
11
- // Try common locations
12
- const candidates = [
13
- './drizzle', // Running from backend directory
14
- './backend/drizzle', // Running from celilo directory
15
- join(process.cwd(), 'drizzle'), // Absolute from backend
16
- join(process.cwd(), 'backend', 'drizzle'), // Absolute from celilo
17
- ];
18
-
19
- for (const candidate of candidates) {
20
- const metaPath = join(candidate, 'meta', '_journal.json');
21
- if (existsSync(metaPath)) {
22
- return candidate;
23
- }
24
- }
25
-
26
- throw new Error(`Could not find drizzle migrations folder. Tried: ${candidates.join(', ')}`);
27
- }
6
+ // `findMigrationsFolder` is imported from db/client rather than redefined here.
7
+ // This file used to carry its own copy, and the copy had drifted: it listed
8
+ // only CWD-RELATIVE candidates (`./drizzle`, `process.cwd()/drizzle`, …) and was
9
+ // missing the `join(currentDir, '../../drizzle')` fallback that resolves
10
+ // relative to the source file.
11
+ //
12
+ // The effect was invisible from `apps/celilo` and total from the repo root —
13
+ // which is where CLAUDE.md says to run the gate. Every suite using
14
+ // setupTestDatabase threw here, left `db` unassigned, and then failed again in
15
+ // its own afterEach on `db.$client`, so one missing path candidate presented as
16
+ // hundreds of unrelated-looking assertion failures (celilo: 668 across the
17
+ // repo, 341 in apps/celilo/src alone).
28
18
 
29
19
  /**
30
20
  * Setup test database with real migrations