@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +61 -9
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +7 -7
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/events.ts +34 -3
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +127 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +39 -9
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +104 -22
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +52 -3
- package/src/services/alerting/sweep-runner.ts +41 -8
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +32 -49
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- 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
|
-
|
|
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('
|
|
86
|
-
makeManifest('
|
|
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.
|
|
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
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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('
|
|
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('
|
|
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
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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 &&
|
|
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
|
/**
|
package/src/test-utils/cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|