@celilo/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>;