@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.
@@ -101,4 +101,4 @@
101
101
  "breakpoints": true
102
102
  }
103
103
  ]
104
- }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.9.0",
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
- }
@@ -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
- });
@@ -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 differs
136
- * (D7 — the prior decision was about a different scope; re-prompt).
137
- * - 'no_approval': no row for this version undecided; prompt.
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
- if (existing.scopeHash !== currentHash) return 'scope_changed';
149
- return existing.consented ? 'approved' : 'denied';
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
  },
@@ -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';
@@ -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>;