@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/meta/_journal.json +1 -1
- package/package.json +2 -2
- package/src/capabilities/well-known.test.ts +0 -59
- package/src/capabilities/well-known.ts +0 -9
- package/src/cli/commands/publish/helpers.ts +18 -0
- package/src/cli/commands/publish/types.ts +6 -8
- package/src/cli/commands/publish/workspace.test.ts +44 -7
- package/src/cli/commands/publish/workspace.ts +40 -164
- 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
|
@@ -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>;
|