@celilo/cli 0.9.0 → 0.9.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.
- package/drizzle/meta/_journal.json +1 -1
- package/package.json +2 -8
- package/src/capabilities/well-known.test.ts +0 -59
- package/src/capabilities/well-known.ts +0 -9
- package/src/cli/completion.ts +0 -34
- package/src/cli/validators.test.ts +1 -206
- package/src/cli/validators.ts +0 -168
- package/src/services/aspect-approvals.test.ts +52 -0
- package/src/services/aspect-approvals.ts +41 -8
- package/src/services/programmatic-responder.aspect.test.ts +157 -0
- package/src/services/programmatic-responder.ts +51 -0
- package/src/utils/shell.test.ts +1 -163
- package/src/utils/shell.ts +0 -100
- package/src/validation/schemas.ts +0 -5
- package/src/config/env.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,13 +16,7 @@
|
|
|
16
16
|
"CELILO_SUBSYSTEMS.md",
|
|
17
17
|
"CELILO_CORE_MODULES.md"
|
|
18
18
|
],
|
|
19
|
-
"keywords": [
|
|
20
|
-
"celilo",
|
|
21
|
-
"homelab",
|
|
22
|
-
"orchestration",
|
|
23
|
-
"ansible",
|
|
24
|
-
"terraform"
|
|
25
|
-
],
|
|
19
|
+
"keywords": ["celilo", "homelab", "orchestration", "ansible", "terraform"],
|
|
26
20
|
"license": "MIT",
|
|
27
21
|
"repository": {
|
|
28
22
|
"type": "git",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
3
|
WELL_KNOWN_CAPABILITIES,
|
|
4
|
-
getCapabilityDataSchema,
|
|
5
4
|
getSupportedCapabilities,
|
|
6
5
|
getWellKnownCapability,
|
|
7
6
|
isWellKnown,
|
|
@@ -166,64 +165,6 @@ describe('Well-Known Capabilities Registry', () => {
|
|
|
166
165
|
});
|
|
167
166
|
});
|
|
168
167
|
|
|
169
|
-
describe('getCapabilityDataSchema', () => {
|
|
170
|
-
test('should return data schema for public_web', () => {
|
|
171
|
-
const schema = getCapabilityDataSchema('public_web');
|
|
172
|
-
|
|
173
|
-
expect(schema).toEqual({
|
|
174
|
-
server: {
|
|
175
|
-
ip: {
|
|
176
|
-
primary: '$self:target_ip',
|
|
177
|
-
},
|
|
178
|
-
port: 443,
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test('should return data schema for dns_registrar', () => {
|
|
184
|
-
const schema = getCapabilityDataSchema('dns_registrar');
|
|
185
|
-
|
|
186
|
-
expect(schema).toEqual({
|
|
187
|
-
provider: 'namecheap',
|
|
188
|
-
domains: '$self:domains',
|
|
189
|
-
supports: ['dynamic_dns_a_record'],
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('should return data schema for auth', () => {
|
|
194
|
-
const schema = getCapabilityDataSchema('auth');
|
|
195
|
-
|
|
196
|
-
// Note: oidc.issuer_url was removed in MANIFEST_V2 D9 — auth providers
|
|
197
|
-
// (e.g. authentik) now derive their issuer URL in their own manifest
|
|
198
|
-
// and expose it through provides.capabilities[].data.
|
|
199
|
-
expect(schema).toEqual({
|
|
200
|
-
server: {
|
|
201
|
-
ip: {
|
|
202
|
-
primary: '$self:target_ip',
|
|
203
|
-
},
|
|
204
|
-
port: 9000,
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('should return cloned schema (not reference)', () => {
|
|
210
|
-
const schema1 = getCapabilityDataSchema('public_web');
|
|
211
|
-
const schema2 = getCapabilityDataSchema('public_web');
|
|
212
|
-
|
|
213
|
-
// Modify one schema to test cloning
|
|
214
|
-
// biome-ignore lint/suspicious/noExplicitAny: intentionally mutating to verify independent copies
|
|
215
|
-
(schema1 as any).server.port = 8080;
|
|
216
|
-
|
|
217
|
-
// Other schema should be unchanged
|
|
218
|
-
// biome-ignore lint/suspicious/noExplicitAny: accessing mutated property for comparison
|
|
219
|
-
expect((schema2 as any).server.port).toBe(443);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test('should throw error for unknown capability', () => {
|
|
223
|
-
expect(() => getCapabilityDataSchema('unknown')).toThrow('Unknown capability: unknown');
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
168
|
describe('Security zone requirements', () => {
|
|
228
169
|
test('home lab public-facing services must be in DMZ', () => {
|
|
229
170
|
expect(WELL_KNOWN_CAPABILITIES.public_web.required_zone).toBe('dmz');
|
|
@@ -227,12 +227,3 @@ export function validateZoneRequirement(
|
|
|
227
227
|
|
|
228
228
|
return { valid: true };
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Get capability data schema with variables resolved
|
|
233
|
-
* Note: This returns the template schema - actual variable resolution happens during generation
|
|
234
|
-
*/
|
|
235
|
-
export function getCapabilityDataSchema(capabilityName: string): Record<string, unknown> {
|
|
236
|
-
const capability = getWellKnownCapability(capabilityName);
|
|
237
|
-
return structuredClone(capability.data_schema);
|
|
238
|
-
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -533,40 +533,6 @@ complete -F _celilo_completion celilo
|
|
|
533
533
|
`;
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
-
/**
|
|
537
|
-
* Generate zsh completion script
|
|
538
|
-
*/
|
|
539
|
-
export function generateZshCompletion(): string {
|
|
540
|
-
return `#compdef celilo
|
|
541
|
-
|
|
542
|
-
_celilo() {
|
|
543
|
-
local -a completions
|
|
544
|
-
local -a words_array
|
|
545
|
-
local current_word
|
|
546
|
-
|
|
547
|
-
# Get current word index (CURRENT is 1-based)
|
|
548
|
-
current_word=\${CURRENT}
|
|
549
|
-
|
|
550
|
-
# Get words from command line
|
|
551
|
-
words_array=("\${words[@]}")
|
|
552
|
-
|
|
553
|
-
# Call celilo to get completions (returns newline-separated list)
|
|
554
|
-
local result
|
|
555
|
-
result=$(celilo --get-completions "\${words_array[@]}" "$current_word" 2>/dev/null)
|
|
556
|
-
|
|
557
|
-
# Split result into array by newlines
|
|
558
|
-
completions=("\${(@f)result}")
|
|
559
|
-
|
|
560
|
-
# Provide completions
|
|
561
|
-
if (( \${#completions} > 0 )); then
|
|
562
|
-
compadd -a completions
|
|
563
|
-
fi
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
compdef _celilo celilo
|
|
567
|
-
`;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
536
|
/**
|
|
571
537
|
* Generate fish completion script.
|
|
572
538
|
*
|
|
@@ -3,149 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
|
-
import {
|
|
7
|
-
validateDomain,
|
|
8
|
-
validateEmail,
|
|
9
|
-
validateIpAddress,
|
|
10
|
-
validateIpAddressList,
|
|
11
|
-
validateProxmoxUrl,
|
|
12
|
-
validateRequired,
|
|
13
|
-
validateRequiredNoDefault,
|
|
14
|
-
validateSubnet,
|
|
15
|
-
} from './validators';
|
|
16
|
-
|
|
17
|
-
describe('validateProxmoxUrl', () => {
|
|
18
|
-
test('accepts valid HTTPS URL with port', () => {
|
|
19
|
-
expect(validateProxmoxUrl('https://proxmox.example.com:8006')).toBeUndefined();
|
|
20
|
-
expect(validateProxmoxUrl('https://192.168.1.100:8006')).toBeUndefined();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('rejects non-HTTPS URLs', () => {
|
|
24
|
-
expect(validateProxmoxUrl('http://proxmox.example.com:8006')).toBe(
|
|
25
|
-
'URL must start with https://',
|
|
26
|
-
);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('rejects URLs without port', () => {
|
|
30
|
-
expect(validateProxmoxUrl('https://proxmox.example.com')).toBe(
|
|
31
|
-
'URL must include port (e.g., :8006)',
|
|
32
|
-
);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('allows undefined (for default values)', () => {
|
|
36
|
-
expect(validateProxmoxUrl(undefined)).toBeUndefined();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('rejects empty input', () => {
|
|
40
|
-
expect(validateProxmoxUrl('')).toBe('URL is required');
|
|
41
|
-
expect(validateProxmoxUrl(' ')).toBe('URL is required');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('rejects invalid URLs', () => {
|
|
45
|
-
expect(validateProxmoxUrl('https://:8006')).toBe('Invalid URL format');
|
|
46
|
-
expect(validateProxmoxUrl('not-a-url')).toBe('URL must start with https://');
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('validateSubnet', () => {
|
|
51
|
-
test('accepts valid CIDR notation', () => {
|
|
52
|
-
expect(validateSubnet('10.0.10.0/24')).toBeUndefined();
|
|
53
|
-
expect(validateSubnet('192.168.1.0/16')).toBeUndefined();
|
|
54
|
-
expect(validateSubnet('172.16.0.0/12')).toBeUndefined();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('rejects invalid CIDR format', () => {
|
|
58
|
-
expect(validateSubnet('10.0.10.0')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
|
|
59
|
-
expect(validateSubnet('10.0.10/24')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('rejects invalid IP octets', () => {
|
|
63
|
-
expect(validateSubnet('256.0.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
|
|
64
|
-
expect(validateSubnet('10.300.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('rejects invalid prefix', () => {
|
|
68
|
-
expect(validateSubnet('10.0.10.0/33')).toBe('Invalid prefix length (must be 0-32)');
|
|
69
|
-
expect(validateSubnet('10.0.10.0/-1')).toBe('Invalid prefix length (must be 0-32)');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('allows undefined (for default values)', () => {
|
|
73
|
-
expect(validateSubnet(undefined)).toBeUndefined();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('rejects empty input', () => {
|
|
77
|
-
expect(validateSubnet('')).toBe('Subnet is required');
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('validateDomain', () => {
|
|
82
|
-
test('accepts valid domains', () => {
|
|
83
|
-
expect(validateDomain('homelab.local')).toBeUndefined();
|
|
84
|
-
expect(validateDomain('example.com')).toBeUndefined();
|
|
85
|
-
expect(validateDomain('sub.domain.co.uk')).toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('rejects invalid domain format', () => {
|
|
89
|
-
expect(validateDomain('not-a-domain')).toBe('Invalid domain format (e.g., homelab.local)');
|
|
90
|
-
expect(validateDomain('.example.com')).toBe('Invalid domain format (e.g., homelab.local)');
|
|
91
|
-
expect(validateDomain('example..com')).toBe('Invalid domain format (e.g., homelab.local)');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('allows undefined (for default values)', () => {
|
|
95
|
-
expect(validateDomain(undefined)).toBeUndefined();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('rejects empty input', () => {
|
|
99
|
-
expect(validateDomain('')).toBe('Domain is required');
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('validateEmail', () => {
|
|
104
|
-
test('accepts valid emails', () => {
|
|
105
|
-
expect(validateEmail('admin@example.com')).toBeUndefined();
|
|
106
|
-
expect(validateEmail('user.name@domain.co.uk')).toBeUndefined();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('rejects invalid email format', () => {
|
|
110
|
-
expect(validateEmail('not-an-email')).toBe('Invalid email format');
|
|
111
|
-
expect(validateEmail('@example.com')).toBe('Invalid email format');
|
|
112
|
-
expect(validateEmail('user@')).toBe('Invalid email format');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('allows undefined (for default values)', () => {
|
|
116
|
-
expect(validateEmail(undefined)).toBeUndefined();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('rejects empty input', () => {
|
|
120
|
-
expect(validateEmail('')).toBe('Email is required');
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('validateIpAddress', () => {
|
|
125
|
-
test('accepts valid IP addresses', () => {
|
|
126
|
-
expect(validateIpAddress('1.1.1.1')).toBeUndefined();
|
|
127
|
-
expect(validateIpAddress('192.168.1.100')).toBeUndefined();
|
|
128
|
-
expect(validateIpAddress('10.0.0.1')).toBeUndefined();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('rejects invalid IP format', () => {
|
|
132
|
-
expect(validateIpAddress('not-an-ip')).toBe('Invalid IP format (e.g., 1.1.1.1)');
|
|
133
|
-
expect(validateIpAddress('192.168.1')).toBe('Invalid IP format (e.g., 1.1.1.1)');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test('rejects invalid octets', () => {
|
|
137
|
-
expect(validateIpAddress('256.1.1.1')).toBe('Invalid IP address (octets must be 0-255)');
|
|
138
|
-
expect(validateIpAddress('192.168.1.300')).toBe('Invalid IP address (octets must be 0-255)');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('allows undefined (for default values)', () => {
|
|
142
|
-
expect(validateIpAddress(undefined)).toBeUndefined();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('rejects empty input', () => {
|
|
146
|
-
expect(validateIpAddress('')).toBe('IP address is required');
|
|
147
|
-
});
|
|
148
|
-
});
|
|
6
|
+
import { validateRequired } from './validators';
|
|
149
7
|
|
|
150
8
|
describe('validateRequired', () => {
|
|
151
9
|
test('accepts non-empty strings', () => {
|
|
@@ -170,66 +28,3 @@ describe('validateRequired', () => {
|
|
|
170
28
|
expect(validator('')).toBe('Custom field is required');
|
|
171
29
|
});
|
|
172
30
|
});
|
|
173
|
-
|
|
174
|
-
describe('validateRequiredNoDefault', () => {
|
|
175
|
-
test('accepts non-empty strings', () => {
|
|
176
|
-
const validator = validateRequiredNoDefault('Password');
|
|
177
|
-
expect(validator('value')).toBeUndefined();
|
|
178
|
-
expect(validator(' text ')).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('rejects undefined (no default values)', () => {
|
|
182
|
-
const validator = validateRequiredNoDefault('Password');
|
|
183
|
-
expect(validator(undefined)).toBe('Password is required');
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('rejects empty strings', () => {
|
|
187
|
-
const validator = validateRequiredNoDefault('Password');
|
|
188
|
-
expect(validator('')).toBe('Password is required');
|
|
189
|
-
expect(validator(' ')).toBe('Password is required');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test('uses custom field name in error message', () => {
|
|
193
|
-
const validator = validateRequiredNoDefault('Custom password');
|
|
194
|
-
expect(validator('')).toBe('Custom password is required');
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe('validateIpAddressList', () => {
|
|
199
|
-
test('accepts single IP address', () => {
|
|
200
|
-
expect(validateIpAddressList('1.1.1.1')).toBeUndefined();
|
|
201
|
-
expect(validateIpAddressList('192.168.1.100')).toBeUndefined();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test('accepts space-separated IP addresses', () => {
|
|
205
|
-
expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
|
|
206
|
-
expect(validateIpAddressList('192.168.0.1 10.0.0.1 1.1.1.1')).toBeUndefined();
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('accepts multiple spaces between IPs', () => {
|
|
210
|
-
expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
|
|
211
|
-
expect(validateIpAddressList('1.1.1.1 8.8.8.8 1.0.0.1')).toBeUndefined();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('rejects comma-separated IPs', () => {
|
|
215
|
-
expect(validateIpAddressList('1.1.1.1,8.8.8.8')).toContain('Invalid IP address');
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('rejects invalid IP in list', () => {
|
|
219
|
-
expect(validateIpAddressList('8.8.8.8 999.999.999.999')).toContain(
|
|
220
|
-
"Invalid IP address '999.999.999.999'",
|
|
221
|
-
);
|
|
222
|
-
expect(validateIpAddressList('192.168.0.1 not-an-ip')).toContain(
|
|
223
|
-
"Invalid IP address 'not-an-ip'",
|
|
224
|
-
);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test('allows undefined (for default values)', () => {
|
|
228
|
-
expect(validateIpAddressList(undefined)).toBeUndefined();
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test('rejects empty input', () => {
|
|
232
|
-
expect(validateIpAddressList('')).toBe('At least one IP address is required');
|
|
233
|
-
expect(validateIpAddressList(' ')).toBe('At least one IP address is required');
|
|
234
|
-
});
|
|
235
|
-
});
|
package/src/cli/validators.ts
CHANGED
|
@@ -3,137 +3,6 @@
|
|
|
3
3
|
* Validation functions for interactive prompts
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Validate URL format (must start with https:// and include port)
|
|
8
|
-
*/
|
|
9
|
-
export function validateProxmoxUrl(value: string | undefined): string | Error | undefined {
|
|
10
|
-
// Allow undefined (default will be used)
|
|
11
|
-
if (value === undefined) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (!value.trim()) {
|
|
16
|
-
return 'URL is required';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (!value.startsWith('https://')) {
|
|
20
|
-
return 'URL must start with https://';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (!value.includes(':') || !value.match(/:\d+/)) {
|
|
24
|
-
return 'URL must include port (e.g., :8006)';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Basic URL validation
|
|
28
|
-
try {
|
|
29
|
-
new URL(value);
|
|
30
|
-
} catch {
|
|
31
|
-
return 'Invalid URL format';
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Validate subnet CIDR notation (e.g., 10.0.10.0/24)
|
|
37
|
-
*/
|
|
38
|
-
export function validateSubnet(value: string | undefined): string | Error | undefined {
|
|
39
|
-
// Allow undefined (default will be used)
|
|
40
|
-
if (value === undefined) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (!value.trim()) {
|
|
45
|
-
return 'Subnet is required';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Check CIDR format: IP/prefix
|
|
49
|
-
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/(.+)$/;
|
|
50
|
-
if (!cidrPattern.test(value)) {
|
|
51
|
-
return 'Subnet must be in CIDR format (e.g., 10.0.10.0/24)';
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Validate IP octets (0-255)
|
|
55
|
-
const [ip, prefix] = value.split('/');
|
|
56
|
-
const octets = ip.split('.').map(Number);
|
|
57
|
-
|
|
58
|
-
for (const octet of octets) {
|
|
59
|
-
if (octet < 0 || octet > 255) {
|
|
60
|
-
return 'Invalid IP address (octets must be 0-255)';
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Validate prefix (0-32)
|
|
65
|
-
const prefixNum = Number(prefix);
|
|
66
|
-
if (prefixNum < 0 || prefixNum > 32) {
|
|
67
|
-
return 'Invalid prefix length (must be 0-32)';
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Validate domain name format
|
|
73
|
-
*/
|
|
74
|
-
export function validateDomain(value: string | undefined): string | Error | undefined {
|
|
75
|
-
// Allow undefined (default will be used)
|
|
76
|
-
if (value === undefined) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!value.trim()) {
|
|
81
|
-
return 'Domain is required';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Basic domain format: lowercase, alphanumeric, hyphens, dots
|
|
85
|
-
const domainPattern = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
|
|
86
|
-
if (!domainPattern.test(value)) {
|
|
87
|
-
return 'Invalid domain format (e.g., homelab.local)';
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Validate email format
|
|
93
|
-
*/
|
|
94
|
-
export function validateEmail(value: string | undefined): string | Error | undefined {
|
|
95
|
-
// Allow undefined (default will be used)
|
|
96
|
-
if (value === undefined) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!value.trim()) {
|
|
101
|
-
return 'Email is required';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
105
|
-
if (!emailPattern.test(value)) {
|
|
106
|
-
return 'Invalid email format';
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Validate IP address format
|
|
112
|
-
*/
|
|
113
|
-
export function validateIpAddress(value: string | undefined): string | Error | undefined {
|
|
114
|
-
// Allow undefined (default will be used)
|
|
115
|
-
if (value === undefined) {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!value.trim()) {
|
|
120
|
-
return 'IP address is required';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
124
|
-
if (!ipPattern.test(value)) {
|
|
125
|
-
return 'Invalid IP format (e.g., 1.1.1.1)';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Validate octets (0-255)
|
|
129
|
-
const octets = value.split('.').map(Number);
|
|
130
|
-
for (const octet of octets) {
|
|
131
|
-
if (octet < 0 || octet > 255) {
|
|
132
|
-
return 'Invalid IP address (octets must be 0-255)';
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
6
|
/**
|
|
138
7
|
* Validate non-empty string (allows undefined for prompts with default values)
|
|
139
8
|
*/
|
|
@@ -149,40 +18,3 @@ export function validateRequired(fieldName: string) {
|
|
|
149
18
|
}
|
|
150
19
|
};
|
|
151
20
|
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Validate required field with no defaults (for password fields)
|
|
155
|
-
*/
|
|
156
|
-
export function validateRequiredNoDefault(fieldName: string) {
|
|
157
|
-
return (value: string | undefined): string | Error | undefined => {
|
|
158
|
-
// Reject undefined - no default value for this field
|
|
159
|
-
if (value === undefined || !value.trim()) {
|
|
160
|
-
return `${fieldName} is required`;
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Validate space-separated list of IP addresses
|
|
167
|
-
*/
|
|
168
|
-
export function validateIpAddressList(value: string | undefined): string | Error | undefined {
|
|
169
|
-
// Allow undefined (default will be used)
|
|
170
|
-
if (value === undefined) {
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!value.trim()) {
|
|
175
|
-
return 'At least one IP address is required';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Split by spaces (any amount of whitespace)
|
|
179
|
-
const ips = value.trim().split(/\s+/);
|
|
180
|
-
|
|
181
|
-
// Validate each IP
|
|
182
|
-
for (const ip of ips) {
|
|
183
|
-
const error = validateIpAddress(ip);
|
|
184
|
-
if (error) {
|
|
185
|
-
return `Invalid IP address '${ip}': ${error}`;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
@@ -247,5 +247,57 @@ describe('aspect-approvals', () => {
|
|
|
247
247
|
const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db);
|
|
248
248
|
expect(status).toBe('scope_changed');
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
// #262 — scope-keyed carry-forward across versions. The ISS-0156 cutover
|
|
252
|
+
// hung because consent was recorded at the imported version, then a
|
|
253
|
+
// `module update` bumped the version and the deploy re-prompted (no row for
|
|
254
|
+
// the new version) — and nothing answered the prompt. Per D7, only a SCOPE
|
|
255
|
+
// change requires re-consent; a pure version bump must not.
|
|
256
|
+
it('carries approval forward to a bumped version with the SAME scope (#262 — no re-prompt)', () => {
|
|
257
|
+
insertModule('technitium', '1.0.2+4');
|
|
258
|
+
const db = getDb();
|
|
259
|
+
recordAspectApproval({
|
|
260
|
+
moduleId: 'technitium',
|
|
261
|
+
version: '1.0.2+4',
|
|
262
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
263
|
+
approver: null,
|
|
264
|
+
db,
|
|
265
|
+
});
|
|
266
|
+
// `module update` bumped +4 → +6; aspect scope unchanged.
|
|
267
|
+
expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('approved');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('carries a DENIAL forward to a bumped version with the same scope', () => {
|
|
271
|
+
insertModule('technitium', '1.0.2+4');
|
|
272
|
+
const db = getDb();
|
|
273
|
+
recordAspectConsent({
|
|
274
|
+
moduleId: 'technitium',
|
|
275
|
+
version: '1.0.2+4',
|
|
276
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
277
|
+
approver: null,
|
|
278
|
+
consented: false,
|
|
279
|
+
db,
|
|
280
|
+
});
|
|
281
|
+
expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('denied');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('does NOT carry forward to a bumped version when the scope changed', () => {
|
|
285
|
+
insertModule('technitium', '1.0.2+4');
|
|
286
|
+
const db = getDb();
|
|
287
|
+
recordAspectApproval({
|
|
288
|
+
moduleId: 'technitium',
|
|
289
|
+
version: '1.0.2+4',
|
|
290
|
+
scopeHash: computeAspectScopeHash(baseAspect),
|
|
291
|
+
approver: null,
|
|
292
|
+
db,
|
|
293
|
+
});
|
|
294
|
+
const narrowed: BaseModuleAspect = {
|
|
295
|
+
ansible_role: 'dns-client-config',
|
|
296
|
+
applicable_zones: ['dmz'],
|
|
297
|
+
triggers: ['on_install'],
|
|
298
|
+
};
|
|
299
|
+
// Bumped version AND narrowed scope → no exact row, no scope match → re-prompt.
|
|
300
|
+
expect(checkAspectApproval('technitium', '1.0.2+6', narrowed, db)).toBe('no_approval');
|
|
301
|
+
});
|
|
250
302
|
});
|
|
251
303
|
});
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
-
import { and, eq, sql } from 'drizzle-orm';
|
|
24
|
+
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
25
25
|
import type { getDb } from '../db/client';
|
|
26
26
|
import { aspectApprovals } from '../db/schema';
|
|
27
27
|
import type { BaseModuleAspect } from '../manifest/schema';
|
|
@@ -65,6 +65,26 @@ export function findAspectApproval(
|
|
|
65
65
|
.get();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Find the most recent consent row for a (moduleId, scopeHash) pair across ALL
|
|
70
|
+
* versions. Used to carry an operator's decision forward when a module version
|
|
71
|
+
* bump leaves the aspect scope (applicable_zones + triggers) unchanged — so a
|
|
72
|
+
* `module update` auto-revision doesn't force re-consent (#262). The latest
|
|
73
|
+
* decision (by approvedAt) for that scope wins.
|
|
74
|
+
*/
|
|
75
|
+
export function findAspectApprovalByScope(
|
|
76
|
+
moduleId: string,
|
|
77
|
+
scopeHash: string,
|
|
78
|
+
db: DbClient,
|
|
79
|
+
): typeof aspectApprovals.$inferSelect | undefined {
|
|
80
|
+
return db
|
|
81
|
+
.select()
|
|
82
|
+
.from(aspectApprovals)
|
|
83
|
+
.where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.scopeHash, scopeHash)))
|
|
84
|
+
.orderBy(desc(aspectApprovals.approvedAt))
|
|
85
|
+
.get();
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
/**
|
|
69
89
|
* Record the operator's consent DECISION for a module version's
|
|
70
90
|
* base-module aspect — `consented: true` (approve) or `false`
|
|
@@ -132,9 +152,17 @@ export function recordAspectApproval(args: {
|
|
|
132
152
|
* - 'approved': a matching-scope row with `consented: true` — run.
|
|
133
153
|
* - 'denied': a matching-scope row with `consented: false` — the
|
|
134
154
|
* operator explicitly refused (ISS-0027). Skip; do NOT re-prompt.
|
|
135
|
-
* - 'scope_changed': a row exists but the manifest's scope
|
|
136
|
-
* (D7 — the prior decision was about a different scope; re-prompt).
|
|
137
|
-
* - 'no_approval': no row for this version
|
|
155
|
+
* - 'scope_changed': a row exists for THIS version but the manifest's scope
|
|
156
|
+
* differs (D7 — the prior decision was about a different scope; re-prompt).
|
|
157
|
+
* - 'no_approval': no row for this version AND no prior decision for this
|
|
158
|
+
* scope at any version — undecided; prompt.
|
|
159
|
+
*
|
|
160
|
+
* #262: when there's no row for the exact version, the operator's decision is
|
|
161
|
+
* carried forward by SCOPE. A pure version bump (e.g. a `module update`
|
|
162
|
+
* auto-revision) that leaves applicable_zones + triggers unchanged must NOT
|
|
163
|
+
* re-prompt — that re-prompt, unanswerable headlessly, is what hung the
|
|
164
|
+
* ISS-0156 cutover. A genuine scope change has a different scopeHash, so it
|
|
165
|
+
* finds no match and correctly falls through to 'no_approval'.
|
|
138
166
|
*/
|
|
139
167
|
export function checkAspectApproval(
|
|
140
168
|
moduleId: string,
|
|
@@ -142,9 +170,14 @@ export function checkAspectApproval(
|
|
|
142
170
|
aspect: BaseModuleAspect,
|
|
143
171
|
db: DbClient,
|
|
144
172
|
): 'approved' | 'denied' | 'scope_changed' | 'no_approval' {
|
|
145
|
-
const existing = findAspectApproval(moduleId, version, db);
|
|
146
|
-
if (!existing) return 'no_approval';
|
|
147
173
|
const currentHash = computeAspectScopeHash(aspect);
|
|
148
|
-
|
|
149
|
-
|
|
174
|
+
const exact = findAspectApproval(moduleId, version, db);
|
|
175
|
+
if (exact) {
|
|
176
|
+
if (exact.scopeHash !== currentHash) return 'scope_changed';
|
|
177
|
+
return exact.consented ? 'approved' : 'denied';
|
|
178
|
+
}
|
|
179
|
+
// No row for this exact version — carry the decision forward by scope (#262).
|
|
180
|
+
const byScope = findAspectApprovalByScope(moduleId, currentHash, db);
|
|
181
|
+
if (byScope) return byScope.consented ? 'approved' : 'denied';
|
|
182
|
+
return 'no_approval';
|
|
150
183
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: the programmatic responder (`celilo events respond`) must handle
|
|
3
|
+
* `aspect.required.*` so a HEADLESS deploy of a module with a base_module_aspect
|
|
4
|
+
* can be approved without a TTY.
|
|
5
|
+
*
|
|
6
|
+
* Before the fix it watched only config/secret/ensure/interview — never aspect —
|
|
7
|
+
* so a headless deploy whose aspect consent wasn't pre-recorded emitted
|
|
8
|
+
* `aspect.required.<m>.<role>` and hung forever (busInterview uses timeoutMs:0;
|
|
9
|
+
* no responder ever replied). This reproduced the ISS-0156 cutover hang and is
|
|
10
|
+
* the core of #262.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
18
|
+
import { closeDb, getDb } from '../db/client';
|
|
19
|
+
import { runMigrations } from '../db/migrate';
|
|
20
|
+
import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview';
|
|
21
|
+
import { startProgrammaticResponder } from './programmatic-responder';
|
|
22
|
+
|
|
23
|
+
const NO_SCHEMAS = defineEvents({});
|
|
24
|
+
|
|
25
|
+
const ASPECT_PAYLOAD: AspectRequiredPayload = {
|
|
26
|
+
module: 'technitium',
|
|
27
|
+
role: 'dns-client-config',
|
|
28
|
+
zones: ['dmz', 'app', 'secure', 'internal'],
|
|
29
|
+
triggers: ['on_install'],
|
|
30
|
+
trigger: 'on_install',
|
|
31
|
+
reason: 'no_approval',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Emit the aspect-consent query and return the responder's decision, or null. */
|
|
35
|
+
async function askAspectConsent(
|
|
36
|
+
busPath: string,
|
|
37
|
+
module: string,
|
|
38
|
+
role: string,
|
|
39
|
+
payload: AspectRequiredPayload,
|
|
40
|
+
): Promise<boolean | null> {
|
|
41
|
+
const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
|
|
42
|
+
try {
|
|
43
|
+
const replies = await bus.query(
|
|
44
|
+
EVENT_TYPES.aspectRequired(module, role) as never,
|
|
45
|
+
payload as never,
|
|
46
|
+
{
|
|
47
|
+
timeoutMs: 3000,
|
|
48
|
+
pollIntervalMs: 100,
|
|
49
|
+
expect: 'first',
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
if (replies.length === 0) return null;
|
|
53
|
+
return (replies[0].payload as { consented?: boolean }).consented ?? null;
|
|
54
|
+
} finally {
|
|
55
|
+
bus.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('programmatic responder — aspect.required consent (#262)', () => {
|
|
60
|
+
let dir: string;
|
|
61
|
+
let busPath: string;
|
|
62
|
+
let db: ReturnType<typeof getDb>;
|
|
63
|
+
|
|
64
|
+
beforeEach(async () => {
|
|
65
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-resp-aspect-'));
|
|
66
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
67
|
+
busPath = join(dir, 'bus.db');
|
|
68
|
+
await runMigrations(process.env.CELILO_DB_PATH);
|
|
69
|
+
db = getDb();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
closeDb();
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('approves (consented=true) when the policy approves the module', async () => {
|
|
79
|
+
const handle = startProgrammaticResponder({
|
|
80
|
+
busDbPath: busPath,
|
|
81
|
+
db,
|
|
82
|
+
onMissing: 'skip',
|
|
83
|
+
values: { aspects: { technitium: true } },
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const decision = await askAspectConsent(
|
|
87
|
+
busPath,
|
|
88
|
+
'technitium',
|
|
89
|
+
'dns-client-config',
|
|
90
|
+
ASPECT_PAYLOAD,
|
|
91
|
+
);
|
|
92
|
+
expect(decision).toBe(true);
|
|
93
|
+
} finally {
|
|
94
|
+
handle.close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("approves via the '*' wildcard policy", async () => {
|
|
99
|
+
const handle = startProgrammaticResponder({
|
|
100
|
+
busDbPath: busPath,
|
|
101
|
+
db,
|
|
102
|
+
onMissing: 'skip',
|
|
103
|
+
values: { aspects: { '*': true } },
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const decision = await askAspectConsent(
|
|
107
|
+
busPath,
|
|
108
|
+
'technitium',
|
|
109
|
+
'dns-client-config',
|
|
110
|
+
ASPECT_PAYLOAD,
|
|
111
|
+
);
|
|
112
|
+
expect(decision).toBe(true);
|
|
113
|
+
} finally {
|
|
114
|
+
handle.close();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('refuses (consented=false) when the policy denies the module', async () => {
|
|
119
|
+
const handle = startProgrammaticResponder({
|
|
120
|
+
busDbPath: busPath,
|
|
121
|
+
db,
|
|
122
|
+
onMissing: 'skip',
|
|
123
|
+
values: { aspects: { technitium: false } },
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const decision = await askAspectConsent(
|
|
127
|
+
busPath,
|
|
128
|
+
'technitium',
|
|
129
|
+
'dns-client-config',
|
|
130
|
+
ASPECT_PAYLOAD,
|
|
131
|
+
);
|
|
132
|
+
expect(decision).toBe(false);
|
|
133
|
+
} finally {
|
|
134
|
+
handle.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not reply when no aspect policy is provided (onMissing: skip)', async () => {
|
|
139
|
+
const handle = startProgrammaticResponder({
|
|
140
|
+
busDbPath: busPath,
|
|
141
|
+
db,
|
|
142
|
+
onMissing: 'skip',
|
|
143
|
+
values: {},
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const decision = await askAspectConsent(
|
|
147
|
+
busPath,
|
|
148
|
+
'technitium',
|
|
149
|
+
'dns-client-config',
|
|
150
|
+
ASPECT_PAYLOAD,
|
|
151
|
+
);
|
|
152
|
+
expect(decision).toBeNull();
|
|
153
|
+
} finally {
|
|
154
|
+
handle.close();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -21,6 +21,7 @@ import type { DbClient } from '../db/client';
|
|
|
21
21
|
import { generateSecret } from '../secrets/generators';
|
|
22
22
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
23
23
|
import type {
|
|
24
|
+
AspectRequiredPayload,
|
|
24
25
|
ConfigRequiredPayload,
|
|
25
26
|
EnsureRequiredPayload,
|
|
26
27
|
InterviewRequiredPayload,
|
|
@@ -66,6 +67,17 @@ export interface ResponderValues {
|
|
|
66
67
|
* (string for text/select, string[] for multiselect, boolean for confirm).
|
|
67
68
|
*/
|
|
68
69
|
interview?: Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* Aspect-consent decisions for a module's `base_module_aspect`
|
|
72
|
+
* (ISS-0027 / #262). When a HEADLESS deploy emits
|
|
73
|
+
* `aspect.required.<module>.<role>`, the responder replies
|
|
74
|
+
* `{ consented }` so the fan-out is approved/denied without a TTY —
|
|
75
|
+
* the gap that hung the ISS-0156 cutover. Lookup precedence:
|
|
76
|
+
* `<module>.<role>`, then `<module>`, then the `'*'` wildcard.
|
|
77
|
+
* Absent → the responder skips (onMissing), exactly like an unmapped
|
|
78
|
+
* config value — it never silently approves an un-policied aspect.
|
|
79
|
+
*/
|
|
80
|
+
aspects?: Record<string, boolean>;
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
export interface ProgrammaticResponderOptions {
|
|
@@ -117,6 +129,7 @@ export interface ProgrammaticResponderHandle {
|
|
|
117
129
|
seenSecretPayloads(): SecretRequiredPayload[];
|
|
118
130
|
seenEnsurePayloads(): EnsureRequiredPayload[];
|
|
119
131
|
seenInterviewPayloads(): InterviewRequiredPayload[];
|
|
132
|
+
seenAspectPayloads(): AspectRequiredPayload[];
|
|
120
133
|
/** Stop watching. Caller still owns the db client. */
|
|
121
134
|
close(): void;
|
|
122
135
|
}
|
|
@@ -136,6 +149,7 @@ export function startProgrammaticResponder(
|
|
|
136
149
|
const seenSecret: SecretRequiredPayload[] = [];
|
|
137
150
|
const seenEnsure: EnsureRequiredPayload[] = [];
|
|
138
151
|
const seenInterview: InterviewRequiredPayload[] = [];
|
|
152
|
+
const seenAspect: AspectRequiredPayload[] = [];
|
|
139
153
|
let lastActivityAt = Date.now();
|
|
140
154
|
|
|
141
155
|
const me = opts.emittedBy ?? 'programmatic';
|
|
@@ -308,6 +322,41 @@ export function startProgrammaticResponder(
|
|
|
308
322
|
answered.push({ type: event.type, key: lookupKey });
|
|
309
323
|
});
|
|
310
324
|
|
|
325
|
+
// Aspect consent (ISS-0027 / #262): a headless deploy about to fan out a
|
|
326
|
+
// module's base_module_aspect emits `aspect.required.<module>.<role>` and
|
|
327
|
+
// waits (busInterview, timeoutMs:0). Without this watch the responder never
|
|
328
|
+
// replied → the deploy hung forever (the ISS-0156 cutover failure). We reply
|
|
329
|
+
// per the `aspects` policy; an un-policied aspect is skipped, never approved.
|
|
330
|
+
const aspectWatch = bus.watch('aspect.required.*.*', async (event) => {
|
|
331
|
+
if (event.replyFor !== null) return;
|
|
332
|
+
lastActivityAt = Date.now();
|
|
333
|
+
|
|
334
|
+
const payload = event.payload as AspectRequiredPayload;
|
|
335
|
+
if (!payload || typeof payload.module !== 'string' || typeof payload.role !== 'string') {
|
|
336
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
seenAspect.push(payload);
|
|
340
|
+
|
|
341
|
+
// Precedence: exact "<module>.<role>", then "<module>", then "*" wildcard.
|
|
342
|
+
const lookupKey = `${payload.module}.${payload.role}`;
|
|
343
|
+
const decision =
|
|
344
|
+
opts.values.aspects?.[lookupKey] ??
|
|
345
|
+
opts.values.aspects?.[payload.module] ??
|
|
346
|
+
opts.values.aspects?.['*'];
|
|
347
|
+
if (decision === undefined) {
|
|
348
|
+
handleMissing(event.type, lookupKey, `no aspect decision for "${lookupKey}"`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
bus.emitRaw(
|
|
353
|
+
`${event.type}.reply`,
|
|
354
|
+
{ consented: decision },
|
|
355
|
+
{ replyFor: event.id, emittedBy: me },
|
|
356
|
+
);
|
|
357
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
358
|
+
});
|
|
359
|
+
|
|
311
360
|
// Liveness probe: a non-interactive caller (e.g. `module generate`
|
|
312
361
|
// with no TTY) emits `responder.probe` to detect whether any
|
|
313
362
|
// responder is listening before calling busInterview (which waits
|
|
@@ -331,11 +380,13 @@ export function startProgrammaticResponder(
|
|
|
331
380
|
seenSecretPayloads: () => [...seenSecret],
|
|
332
381
|
seenEnsurePayloads: () => [...seenEnsure],
|
|
333
382
|
seenInterviewPayloads: () => [...seenInterview],
|
|
383
|
+
seenAspectPayloads: () => [...seenAspect],
|
|
334
384
|
close: () => {
|
|
335
385
|
configWatch.close();
|
|
336
386
|
secretWatch.close();
|
|
337
387
|
ensureWatch.close();
|
|
338
388
|
interviewWatch.close();
|
|
389
|
+
aspectWatch.close();
|
|
339
390
|
probeWatch.close();
|
|
340
391
|
bus.close();
|
|
341
392
|
},
|
package/src/utils/shell.test.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
needsEscaping,
|
|
4
|
-
safeShellEscape,
|
|
5
|
-
shellEscape,
|
|
6
|
-
shellEscapeArray,
|
|
7
|
-
validatePath,
|
|
8
|
-
} from './shell';
|
|
2
|
+
import { shellEscape } from './shell';
|
|
9
3
|
|
|
10
4
|
describe('shellEscape', () => {
|
|
11
5
|
describe('simple paths', () => {
|
|
@@ -176,162 +170,6 @@ describe('shellEscape', () => {
|
|
|
176
170
|
});
|
|
177
171
|
});
|
|
178
172
|
|
|
179
|
-
describe('shellEscapeArray', () => {
|
|
180
|
-
test('escapes array of simple paths', () => {
|
|
181
|
-
const paths = ['/tmp/test1', '/tmp/test2', '/tmp/test3'];
|
|
182
|
-
expect(shellEscapeArray(paths)).toEqual(["'/tmp/test1'", "'/tmp/test2'", "'/tmp/test3'"]);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('escapes array of paths with spaces', () => {
|
|
186
|
-
const paths = ['/tmp/test one', '/tmp/test two'];
|
|
187
|
-
expect(shellEscapeArray(paths)).toEqual(["'/tmp/test one'", "'/tmp/test two'"]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('escapes empty array', () => {
|
|
191
|
-
expect(shellEscapeArray([])).toEqual([]);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test('escapes array with mixed path types', () => {
|
|
195
|
-
const paths = ['/tmp/simple', '/tmp/with spaces', "/tmp/with'quote", '/tmp/$special'];
|
|
196
|
-
expect(shellEscapeArray(paths)).toEqual([
|
|
197
|
-
"'/tmp/simple'",
|
|
198
|
-
"'/tmp/with spaces'",
|
|
199
|
-
"'/tmp/with'\\''quote'",
|
|
200
|
-
"'/tmp/$special'",
|
|
201
|
-
]);
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe('needsEscaping', () => {
|
|
206
|
-
test('returns false for simple path', () => {
|
|
207
|
-
expect(needsEscaping('/tmp/test')).toBe(false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('returns false for path with only alphanumeric and slashes', () => {
|
|
211
|
-
expect(needsEscaping('/usr/local/bin/test123')).toBe(false);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test('returns false for path with hyphens and underscores', () => {
|
|
215
|
-
expect(needsEscaping('/tmp/test-module_v1')).toBe(false);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('returns false for path with dots', () => {
|
|
219
|
-
expect(needsEscaping('./relative/path.txt')).toBe(false);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test('returns true for path with space', () => {
|
|
223
|
-
expect(needsEscaping('/tmp/test module')).toBe(true);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test('returns true for path with single quote', () => {
|
|
227
|
-
expect(needsEscaping("/tmp/Bob's Files")).toBe(true);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test('returns true for path with double quote', () => {
|
|
231
|
-
expect(needsEscaping('/tmp/"test"')).toBe(true);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('returns true for path with dollar sign', () => {
|
|
235
|
-
expect(needsEscaping('/tmp/$VAR')).toBe(true);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test('returns true for path with backtick', () => {
|
|
239
|
-
expect(needsEscaping('/tmp/`cmd`')).toBe(true);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test('returns true for path with special shell characters', () => {
|
|
243
|
-
const specialChars = [
|
|
244
|
-
'!',
|
|
245
|
-
'&',
|
|
246
|
-
'|',
|
|
247
|
-
';',
|
|
248
|
-
'<',
|
|
249
|
-
'>',
|
|
250
|
-
'(',
|
|
251
|
-
')',
|
|
252
|
-
'[',
|
|
253
|
-
']',
|
|
254
|
-
'{',
|
|
255
|
-
'}',
|
|
256
|
-
'*',
|
|
257
|
-
'?',
|
|
258
|
-
'~',
|
|
259
|
-
'#',
|
|
260
|
-
'\\',
|
|
261
|
-
];
|
|
262
|
-
for (const char of specialChars) {
|
|
263
|
-
expect(needsEscaping(`/tmp/test${char}`)).toBe(true);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe('validatePath', () => {
|
|
269
|
-
test('accepts valid simple path', () => {
|
|
270
|
-
expect(() => validatePath('/tmp/test')).not.toThrow();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test('accepts path with spaces', () => {
|
|
274
|
-
expect(() => validatePath('/tmp/test module')).not.toThrow();
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('accepts path with special characters', () => {
|
|
278
|
-
expect(() => validatePath('/tmp/$VAR/test')).not.toThrow();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test('accepts relative path', () => {
|
|
282
|
-
expect(() => validatePath('./modules/homebridge')).not.toThrow();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test('accepts path traversal (..)', () => {
|
|
286
|
-
expect(() => validatePath('../../etc/passwd')).not.toThrow();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test('throws on empty string', () => {
|
|
290
|
-
expect(() => validatePath('')).toThrow('Path cannot be empty');
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test('throws on whitespace-only string', () => {
|
|
294
|
-
expect(() => validatePath(' ')).toThrow('Path cannot be empty');
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test('throws on null byte', () => {
|
|
298
|
-
expect(() => validatePath('/tmp/test\0file')).toThrow('Path cannot contain null bytes');
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test('throws on extremely long path', () => {
|
|
302
|
-
const longPath = `/tmp/${'a'.repeat(5000)}`;
|
|
303
|
-
expect(() => validatePath(longPath)).toThrow('Path exceeds maximum length');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test('accepts path at maximum length', () => {
|
|
307
|
-
const maxPath = `/tmp/${'a'.repeat(4090)}`; // Total ~4096
|
|
308
|
-
expect(() => validatePath(maxPath)).not.toThrow();
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
describe('safeShellEscape', () => {
|
|
313
|
-
test('validates and escapes valid path', () => {
|
|
314
|
-
expect(safeShellEscape('/tmp/test')).toBe("'/tmp/test'");
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test('validates and escapes path with spaces', () => {
|
|
318
|
-
expect(safeShellEscape('/tmp/test module')).toBe("'/tmp/test module'");
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
test('throws on empty path', () => {
|
|
322
|
-
expect(() => safeShellEscape('')).toThrow('Path cannot be empty');
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
test('throws on null byte', () => {
|
|
326
|
-
expect(() => safeShellEscape('/tmp/test\0')).toThrow('Path cannot contain null bytes');
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test('throws on extremely long path', () => {
|
|
330
|
-
const longPath = `/tmp/${'a'.repeat(5000)}`;
|
|
331
|
-
expect(() => safeShellEscape(longPath)).toThrow('Path exceeds maximum length');
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
173
|
describe('usage examples', () => {
|
|
336
174
|
test('example: cd command with spaces', () => {
|
|
337
175
|
const modulePath = '/Users/user/Library/Application Support/celilo';
|
package/src/utils/shell.ts
CHANGED
|
@@ -57,103 +57,3 @@ export function shellEscape(path: string): string {
|
|
|
57
57
|
|
|
58
58
|
return `'${escaped}'`;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Escapes an array of paths for shell usage.
|
|
63
|
-
*
|
|
64
|
-
* @param paths - Array of paths to escape
|
|
65
|
-
* @returns Array of shell-escaped strings
|
|
66
|
-
*
|
|
67
|
-
* @example
|
|
68
|
-
* ```typescript
|
|
69
|
-
* const paths = ['/tmp/test', '/Users/user/My Files'];
|
|
70
|
-
* const escaped = shellEscapeArray(paths);
|
|
71
|
-
* // Returns: ["'/tmp/test'", "'/Users/user/My Files'"]
|
|
72
|
-
*
|
|
73
|
-
* // Use in command
|
|
74
|
-
* execSync(`cp ${escaped.join(' ')} /dest/`);
|
|
75
|
-
* ```
|
|
76
|
-
*/
|
|
77
|
-
export function shellEscapeArray(paths: string[]): string[] {
|
|
78
|
-
return paths.map((p) => shellEscape(p));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Checks if a path contains characters that require escaping.
|
|
83
|
-
*
|
|
84
|
-
* This is primarily for logging/debugging - you should ALWAYS escape paths
|
|
85
|
-
* regardless of this check for security and reliability.
|
|
86
|
-
*
|
|
87
|
-
* @param path - Path to check
|
|
88
|
-
* @returns True if path contains special characters
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* ```typescript
|
|
92
|
-
* needsEscaping('/tmp/test') // false
|
|
93
|
-
* needsEscaping('/tmp/test module') // true (space)
|
|
94
|
-
* needsEscaping('/tmp/Bob\'s Files') // true (quote)
|
|
95
|
-
* needsEscaping('/tmp/test$var') // true (special char)
|
|
96
|
-
* ```
|
|
97
|
-
*/
|
|
98
|
-
export function needsEscaping(path: string): boolean {
|
|
99
|
-
// Characters that require escaping in shell:
|
|
100
|
-
// - Spaces
|
|
101
|
-
// - Quotes (single and double)
|
|
102
|
-
// - Shell special characters: $ ` ! & | ; < > ( ) [ ] { } * ? ~ #
|
|
103
|
-
// - Backslash
|
|
104
|
-
const specialChars = /[ '"$`!&|;<>()[\]{}*?~#\\]/;
|
|
105
|
-
|
|
106
|
-
return specialChars.test(path);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Validates a path before escaping (throws on clearly invalid inputs).
|
|
111
|
-
*
|
|
112
|
-
* Note: This does NOT validate that the path exists or is accessible,
|
|
113
|
-
* only that it's not obviously malicious or invalid.
|
|
114
|
-
*
|
|
115
|
-
* @param path - Path to validate
|
|
116
|
-
* @throws {Error} If path is clearly invalid or suspicious
|
|
117
|
-
*
|
|
118
|
-
* @example
|
|
119
|
-
* ```typescript
|
|
120
|
-
* validatePath('/tmp/test') // OK
|
|
121
|
-
* validatePath('') // throws: empty path
|
|
122
|
-
* validatePath('../../../etc/passwd') // OK (relative paths allowed)
|
|
123
|
-
* ```
|
|
124
|
-
*/
|
|
125
|
-
export function validatePath(path: string): void {
|
|
126
|
-
if (!path || path.trim().length === 0) {
|
|
127
|
-
throw new Error('Path cannot be empty or whitespace-only');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Check for null bytes (security risk)
|
|
131
|
-
if (path.includes('\0')) {
|
|
132
|
-
throw new Error('Path cannot contain null bytes');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Check for extremely long paths (likely an error)
|
|
136
|
-
if (path.length > 4096) {
|
|
137
|
-
throw new Error('Path exceeds maximum length (4096 characters)');
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Safe shell escape with validation.
|
|
143
|
-
*
|
|
144
|
-
* Convenience function that validates then escapes a path.
|
|
145
|
-
*
|
|
146
|
-
* @param path - Path to validate and escape
|
|
147
|
-
* @returns Shell-escaped string
|
|
148
|
-
* @throws {Error} If path is invalid
|
|
149
|
-
*
|
|
150
|
-
* @example
|
|
151
|
-
* ```typescript
|
|
152
|
-
* safeShellEscape('/tmp/My Files') // Returns: '/tmp/My Files'
|
|
153
|
-
* safeShellEscape('') // Throws: Path cannot be empty
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
export function safeShellEscape(path: string): string {
|
|
157
|
-
validatePath(path);
|
|
158
|
-
return shellEscape(path);
|
|
159
|
-
}
|
|
@@ -126,11 +126,6 @@ export const CLIServerResponseSchema = z.object({
|
|
|
126
126
|
|
|
127
127
|
export type CLIServerResponse = z.infer<typeof CLIServerResponseSchema>;
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
* Array of strings (for inventory groups, etc.)
|
|
131
|
-
*/
|
|
132
|
-
export const StringArraySchema = z.array(z.string());
|
|
133
|
-
|
|
134
129
|
/**
|
|
135
130
|
* Helper: Parse JSON with Zod validation
|
|
136
131
|
* Wraps JSON.parse() with schema validation and user-friendly error messages
|
package/src/config/env.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Environment Configuration
|
|
3
|
-
*
|
|
4
|
-
* Validates and exports typed environment variables using Zod.
|
|
5
|
-
* Fails fast on startup if required environment variables are missing or invalid.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { z } from 'zod';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Environment variable schema
|
|
12
|
-
*/
|
|
13
|
-
const envSchema = z.object({
|
|
14
|
-
// Database configuration
|
|
15
|
-
CELILO_DB_PATH: z.string().default('./celilo.db'),
|
|
16
|
-
|
|
17
|
-
// Security
|
|
18
|
-
CELILO_MASTER_KEY_PATH: z.string().default('./master.key'),
|
|
19
|
-
|
|
20
|
-
// Data directory
|
|
21
|
-
CELILO_DATA_DIR: z.string().default('./data'),
|
|
22
|
-
|
|
23
|
-
// Environment
|
|
24
|
-
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
25
|
-
|
|
26
|
-
// Optional: Server port (for future API server)
|
|
27
|
-
PORT: z.string().regex(/^\d+$/).transform(Number).default('3000'),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Validated environment variables
|
|
32
|
-
*
|
|
33
|
-
* This will throw a detailed error on startup if validation fails,
|
|
34
|
-
* preventing the app from running with invalid configuration.
|
|
35
|
-
*/
|
|
36
|
-
export const env = envSchema.parse(process.env);
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Type-safe environment variable access
|
|
40
|
-
*/
|
|
41
|
-
export type Env = z.infer<typeof envSchema>;
|