padrone 1.3.0 → 1.5.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +105 -284
  3. package/dist/{args-DFEI7_G_.mjs → args-D5PNDyNu.mjs} +46 -21
  4. package/dist/args-D5PNDyNu.mjs.map +1 -0
  5. package/dist/chunk-CjcI7cDX.mjs +15 -0
  6. package/dist/codegen/index.d.mts +28 -3
  7. package/dist/codegen/index.d.mts.map +1 -1
  8. package/dist/codegen/index.mjs +169 -19
  9. package/dist/codegen/index.mjs.map +1 -1
  10. package/dist/command-utils-B1D-HqCd.mjs +1117 -0
  11. package/dist/command-utils-B1D-HqCd.mjs.map +1 -0
  12. package/dist/completion.d.mts +1 -1
  13. package/dist/completion.d.mts.map +1 -1
  14. package/dist/completion.mjs +77 -29
  15. package/dist/completion.mjs.map +1 -1
  16. package/dist/docs/index.d.mts +22 -2
  17. package/dist/docs/index.d.mts.map +1 -1
  18. package/dist/docs/index.mjs +94 -7
  19. package/dist/docs/index.mjs.map +1 -1
  20. package/dist/errors-BiVrBgi6.mjs +114 -0
  21. package/dist/errors-BiVrBgi6.mjs.map +1 -0
  22. package/dist/{formatter-XroimS3Q.d.mts → formatter-DtHzbP22.d.mts} +35 -5
  23. package/dist/formatter-DtHzbP22.d.mts.map +1 -0
  24. package/dist/help-bbmu9-qd.mjs +735 -0
  25. package/dist/help-bbmu9-qd.mjs.map +1 -0
  26. package/dist/index.d.mts +32 -3
  27. package/dist/index.d.mts.map +1 -1
  28. package/dist/index.mjs +495 -267
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/mcp-mLWIdUIu.mjs +379 -0
  31. package/dist/mcp-mLWIdUIu.mjs.map +1 -0
  32. package/dist/serve-B0u43DK7.mjs +404 -0
  33. package/dist/serve-B0u43DK7.mjs.map +1 -0
  34. package/dist/stream-BcC146Ud.mjs +56 -0
  35. package/dist/stream-BcC146Ud.mjs.map +1 -0
  36. package/dist/test.d.mts +1 -1
  37. package/dist/test.mjs +4 -15
  38. package/dist/test.mjs.map +1 -1
  39. package/dist/{types-BS7RP5Ls.d.mts → types-Ch8Mk6Qb.d.mts} +311 -63
  40. package/dist/types-Ch8Mk6Qb.d.mts.map +1 -0
  41. package/dist/{update-check-EbNDkzyV.mjs → update-check-CFX1FV3v.mjs} +2 -2
  42. package/dist/{update-check-EbNDkzyV.mjs.map → update-check-CFX1FV3v.mjs.map} +1 -1
  43. package/dist/zod.d.mts +32 -0
  44. package/dist/zod.d.mts.map +1 -0
  45. package/dist/zod.mjs +50 -0
  46. package/dist/zod.mjs.map +1 -0
  47. package/package.json +10 -2
  48. package/src/args.ts +76 -44
  49. package/src/cli/docs.ts +1 -7
  50. package/src/cli/doctor.ts +195 -10
  51. package/src/cli/index.ts +1 -1
  52. package/src/cli/init.ts +2 -3
  53. package/src/cli/link.ts +2 -2
  54. package/src/codegen/discovery.ts +80 -28
  55. package/src/codegen/index.ts +2 -1
  56. package/src/codegen/parsers/bash.ts +179 -0
  57. package/src/codegen/schema-to-code.ts +2 -1
  58. package/src/colorizer.ts +126 -13
  59. package/src/command-utils.ts +401 -23
  60. package/src/completion.ts +120 -47
  61. package/src/create.ts +483 -130
  62. package/src/docs/index.ts +122 -8
  63. package/src/formatter.ts +173 -125
  64. package/src/help.ts +46 -12
  65. package/src/index.ts +29 -1
  66. package/src/interactive.ts +45 -4
  67. package/src/mcp.ts +390 -0
  68. package/src/repl-loop.ts +16 -3
  69. package/src/runtime.ts +195 -2
  70. package/src/serve.ts +442 -0
  71. package/src/stream.ts +75 -0
  72. package/src/test.ts +7 -16
  73. package/src/type-utils.ts +28 -4
  74. package/src/types.ts +212 -30
  75. package/src/wrap.ts +23 -25
  76. package/src/zod.ts +50 -0
  77. package/dist/args-DFEI7_G_.mjs.map +0 -1
  78. package/dist/chunk-y_GBKt04.mjs +0 -5
  79. package/dist/formatter-XroimS3Q.d.mts.map +0 -1
  80. package/dist/help-CgGP7hQU.mjs +0 -1229
  81. package/dist/help-CgGP7hQU.mjs.map +0 -1
  82. package/dist/types-BS7RP5Ls.d.mts.map +0 -1
package/src/serve.ts ADDED
@@ -0,0 +1,442 @@
1
+ import { buildInputSchema, type CollectedEndpoint, collectEndpoints, serializeArgsToFlags } from './command-utils.ts';
2
+ import { RoutingError, ValidationError } from './errors.ts';
3
+ import { generateHelp } from './help.ts';
4
+ import type { AnyPadroneCommand, AnyPadroneProgram } from './types.ts';
5
+
6
+ export type PadroneServePreferences = {
7
+ /** Port to listen on. Default: 3000 */
8
+ port?: number;
9
+ /** Host to bind to. Default: '127.0.0.1' */
10
+ host?: string;
11
+ /** Base path prefix for all routes. Default: '/' */
12
+ basePath?: string;
13
+ /** CORS allowed origin. Default: '*'. Set to `false` to disable CORS headers. */
14
+ cors?: string | false;
15
+ /** Control built-in utility endpoints. All enabled by default. */
16
+ builtins?: {
17
+ /** GET /_health — returns 200 OK. */
18
+ health?: boolean;
19
+ /** GET /_help and GET /_help/:command — returns help text. */
20
+ help?: boolean;
21
+ /** GET /_schema and GET /_schema/:command — returns JSON Schema. */
22
+ schema?: boolean;
23
+ /** GET /_docs — Scalar OpenAPI docs viewer. */
24
+ docs?: boolean;
25
+ };
26
+ /** Hook to run before each request. Return a Response to short-circuit. */
27
+ onRequest?: (req: Request) => Response | void | Promise<Response | void>;
28
+ /** Transform errors into responses. */
29
+ onError?: (error: unknown, req: Request) => Response;
30
+ };
31
+
32
+ /** Convert an endpoint dot-path to a URL path segment. */
33
+ function toUrlPath(name: string): string {
34
+ return name.replace(/\./g, '/');
35
+ }
36
+
37
+ /** Convert a URL path segment back to a command path (slash → space). */
38
+ function toCommandPath(urlPath: string): string {
39
+ return urlPath.replace(/\//g, ' ');
40
+ }
41
+
42
+ function jsonResponse(body: unknown, status = 200, headers?: Record<string, string>): Response {
43
+ return new Response(JSON.stringify(body), {
44
+ status,
45
+ headers: { 'Content-Type': 'application/json', ...headers },
46
+ });
47
+ }
48
+
49
+ function errorToStatus(error: unknown): number {
50
+ if (error instanceof RoutingError) return 404;
51
+ if (error instanceof ValidationError) return 400;
52
+ return 500;
53
+ }
54
+
55
+ function errorToResponse(error: unknown): Response {
56
+ const status = errorToStatus(error);
57
+ if (error instanceof ValidationError) {
58
+ return jsonResponse(
59
+ {
60
+ ok: false,
61
+ error: 'validation',
62
+ message: error.message,
63
+ issues: error.issues.map((i) => ({ path: i.path?.map(String), message: i.message })),
64
+ },
65
+ status,
66
+ );
67
+ }
68
+ if (error instanceof RoutingError) {
69
+ return jsonResponse({ ok: false, error: 'not_found', message: error.message, suggestions: error.suggestions }, status);
70
+ }
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ return jsonResponse({ ok: false, error: 'action_error', message }, status);
73
+ }
74
+
75
+ /** Generate an OpenAPI 3.1.0 spec from the command tree. */
76
+ function buildOpenApiSpec(existingCommand: AnyPadroneCommand, endpoints: CollectedEndpoint[], basePath: string): Record<string, unknown> {
77
+ const paths: Record<string, unknown> = {};
78
+
79
+ const responseSchema = {
80
+ '200': {
81
+ description: 'Successful response',
82
+ content: { 'application/json': { schema: { type: 'object', properties: { ok: { type: 'boolean', const: true }, result: {} } } } },
83
+ },
84
+ '400': {
85
+ description: 'Validation error',
86
+ content: {
87
+ 'application/json': {
88
+ schema: {
89
+ type: 'object',
90
+ properties: {
91
+ ok: { type: 'boolean', const: false },
92
+ error: { type: 'string', const: 'validation' },
93
+ message: { type: 'string' },
94
+ issues: { type: 'array', items: { type: 'object', properties: { path: { type: 'array' }, message: { type: 'string' } } } },
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ '404': {
101
+ description: 'Command not found',
102
+ content: {
103
+ 'application/json': {
104
+ schema: {
105
+ type: 'object',
106
+ properties: {
107
+ ok: { type: 'boolean', const: false },
108
+ error: { type: 'string', const: 'not_found' },
109
+ message: { type: 'string' },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ '500': {
116
+ description: 'Action error',
117
+ content: {
118
+ 'application/json': {
119
+ schema: {
120
+ type: 'object',
121
+ properties: {
122
+ ok: { type: 'boolean', const: false },
123
+ error: { type: 'string', const: 'action_error' },
124
+ message: { type: 'string' },
125
+ },
126
+ },
127
+ },
128
+ },
129
+ },
130
+ };
131
+
132
+ for (const { name, command: cmd } of endpoints) {
133
+ const urlPath = `${basePath}${toUrlPath(name)}`;
134
+ const inputSchema = buildInputSchema(cmd);
135
+ const description = cmd.description || cmd.title || `Run the "${name}" command`;
136
+ const pathItem: Record<string, unknown> = {};
137
+
138
+ const postOp = {
139
+ summary: cmd.title || name,
140
+ description,
141
+ operationId: `post_${name.replace(/\./g, '_')}`,
142
+ requestBody: { content: { 'application/json': { schema: inputSchema } } },
143
+ responses: responseSchema,
144
+ };
145
+
146
+ if (cmd.mutation) {
147
+ pathItem.post = postOp;
148
+ } else {
149
+ // GET: args as query parameters
150
+ const properties = (inputSchema.properties ?? {}) as Record<string, Record<string, unknown>>;
151
+ const queryParams = Object.entries(properties).map(([key, schema]) => ({
152
+ name: key,
153
+ in: 'query',
154
+ schema,
155
+ required: (inputSchema.required as string[] | undefined)?.includes(key) ?? false,
156
+ }));
157
+ pathItem.get = {
158
+ summary: cmd.title || name,
159
+ description,
160
+ operationId: `get_${name.replace(/\./g, '_')}`,
161
+ parameters: queryParams,
162
+ responses: responseSchema,
163
+ };
164
+ pathItem.post = postOp;
165
+ }
166
+
167
+ paths[urlPath] = pathItem;
168
+ }
169
+
170
+ return {
171
+ openapi: '3.1.0',
172
+ info: {
173
+ title: existingCommand.title || existingCommand.name,
174
+ description: existingCommand.description,
175
+ version: existingCommand.version ?? '0.0.0',
176
+ },
177
+ paths,
178
+ };
179
+ }
180
+
181
+ function scalarDocsHtml(openapiUrl: string, title: string): string {
182
+ return `<!doctype html>
183
+ <html>
184
+ <head>
185
+ <title>${title} — API Docs</title>
186
+ <meta charset="utf-8" />
187
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
188
+ </head>
189
+ <body>
190
+ <script id="api-reference" data-url="${openapiUrl}"></script>
191
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
192
+ </body>
193
+ </html>`;
194
+ }
195
+
196
+ /** Create the serve request handler. */
197
+ export function createServeHandler(
198
+ existingCommand: AnyPadroneCommand,
199
+ evalCommand: AnyPadroneProgram['eval'],
200
+ prefs?: PadroneServePreferences,
201
+ ): (req: Request) => Promise<Response> {
202
+ const basePath = (prefs?.basePath ?? '/').replace(/\/$/, '/');
203
+ const corsOrigin = prefs?.cors !== false ? (prefs?.cors ?? '*') : undefined;
204
+ const builtins = { health: true, help: true, schema: true, docs: true, ...prefs?.builtins };
205
+
206
+ const endpoints = collectEndpoints(existingCommand.commands, '');
207
+ if (existingCommand.action || existingCommand.argsSchema) {
208
+ endpoints.unshift({ name: '', command: existingCommand });
209
+ }
210
+
211
+ const routeMap = new Map<string, CollectedEndpoint>();
212
+ for (const ep of endpoints) {
213
+ routeMap.set(toUrlPath(ep.name), ep);
214
+ }
215
+
216
+ let cachedOpenApiSpec: Record<string, unknown> | undefined;
217
+ const getOpenApiSpec = () => (cachedOpenApiSpec ??= buildOpenApiSpec(existingCommand, endpoints, basePath));
218
+
219
+ function addCorsHeaders(res: Response): Response {
220
+ if (!corsOrigin) return res;
221
+ const headers = new Headers(res.headers);
222
+ headers.set('Access-Control-Allow-Origin', corsOrigin);
223
+ headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
224
+ headers.set('Access-Control-Allow-Headers', 'Content-Type');
225
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
226
+ }
227
+
228
+ async function evalAndRespond(commandString: string, request: Request): Promise<Response> {
229
+ const output: string[] = [];
230
+ const errors: string[] = [];
231
+ const result = await evalCommand(commandString || (undefined as any), {
232
+ autoOutput: false,
233
+ runtime: {
234
+ output: (...args: unknown[]) => output.push(args.map(String).join(' ')),
235
+ error: (text: string) => errors.push(text),
236
+ interactive: 'unsupported',
237
+ format: 'json',
238
+ },
239
+ });
240
+
241
+ if (result.error) {
242
+ return prefs?.onError ? prefs.onError(result.error, request) : errorToResponse(result.error);
243
+ }
244
+
245
+ if (result.argsResult?.issues) {
246
+ const issues = (result.argsResult.issues as { path?: PropertyKey[]; message: string }[]).map((i) => ({
247
+ path: i.path?.map(String),
248
+ message: i.message,
249
+ }));
250
+ return jsonResponse({ ok: false, error: 'validation', issues }, 400);
251
+ }
252
+
253
+ return jsonResponse({ ok: true, result: result.result ?? null });
254
+ }
255
+
256
+ return async function handleRequest(req: Request): Promise<Response> {
257
+ // CORS preflight
258
+ if (req.method === 'OPTIONS') {
259
+ return addCorsHeaders(new Response(null, { status: corsOrigin ? 204 : 405 }));
260
+ }
261
+
262
+ // onRequest hook
263
+ if (prefs?.onRequest) {
264
+ const hookResponse = await prefs.onRequest(req);
265
+ if (hookResponse) return addCorsHeaders(hookResponse);
266
+ }
267
+
268
+ const url = new URL(req.url, 'http://localhost');
269
+ let pathname = url.pathname;
270
+
271
+ // Strip basePath prefix
272
+ if (basePath !== '/' && pathname.startsWith(basePath)) {
273
+ pathname = pathname.slice(basePath.length - 1);
274
+ }
275
+ // Remove leading slash for route matching
276
+ const routePath = pathname.replace(/^\//, '');
277
+
278
+ // Built-in endpoints
279
+ if (req.method === 'GET') {
280
+ if (builtins.health && routePath === '_health') {
281
+ return addCorsHeaders(jsonResponse({ status: 'ok' }));
282
+ }
283
+
284
+ if (builtins.schema && routePath === '_schema') {
285
+ const schemaMap: Record<string, unknown> = {};
286
+ for (const ep of endpoints) {
287
+ schemaMap[toUrlPath(ep.name) || '/'] = buildInputSchema(ep.command);
288
+ }
289
+ return addCorsHeaders(jsonResponse(schemaMap));
290
+ }
291
+
292
+ if (builtins.schema && routePath.startsWith('_schema/')) {
293
+ const cmdPath = routePath.slice('_schema/'.length);
294
+ const ep = routeMap.get(cmdPath);
295
+ if (!ep) return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${cmdPath}` }, 404));
296
+ return addCorsHeaders(jsonResponse(buildInputSchema(ep.command)));
297
+ }
298
+
299
+ if (builtins.help && routePath === '_help') {
300
+ const accept = req.headers.get('accept') ?? '';
301
+ const format = accept.includes('application/json') ? 'json' : 'markdown';
302
+ const helpText = generateHelp(existingCommand, existingCommand, { format, detail: 'full' });
303
+ if (format === 'json') return addCorsHeaders(jsonResponse(JSON.parse(helpText)));
304
+ return addCorsHeaders(new Response(helpText, { status: 200, headers: { 'Content-Type': 'text/markdown' } }));
305
+ }
306
+
307
+ if (builtins.help && routePath.startsWith('_help/')) {
308
+ const cmdPath = routePath.slice('_help/'.length);
309
+ const ep = routeMap.get(cmdPath);
310
+ if (!ep) return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${cmdPath}` }, 404));
311
+ const accept = req.headers.get('accept') ?? '';
312
+ const format = accept.includes('application/json') ? 'json' : 'markdown';
313
+ const helpText = generateHelp(existingCommand, ep.command, { format, detail: 'full' });
314
+ if (format === 'json') return addCorsHeaders(jsonResponse(JSON.parse(helpText)));
315
+ return addCorsHeaders(new Response(helpText, { status: 200, headers: { 'Content-Type': 'text/markdown' } }));
316
+ }
317
+
318
+ if (builtins.docs && routePath === '_openapi') {
319
+ return addCorsHeaders(jsonResponse(getOpenApiSpec()));
320
+ }
321
+
322
+ if (builtins.docs && routePath === '_docs') {
323
+ const openapiUrl = `${basePath}_openapi`;
324
+ const title = existingCommand.title || existingCommand.name;
325
+ const html = scalarDocsHtml(openapiUrl, title);
326
+ return addCorsHeaders(new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }));
327
+ }
328
+ }
329
+
330
+ // Route to command
331
+ const endpoint = routeMap.get(routePath);
332
+ if (!endpoint) {
333
+ return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${routePath || '/'}` }, 404));
334
+ }
335
+
336
+ // Enforce method based on mutation flag
337
+ if (endpoint.command.mutation && req.method === 'GET') {
338
+ return addCorsHeaders(
339
+ new Response(JSON.stringify({ ok: false, error: 'method_not_allowed', message: 'Mutation commands only accept POST' }), {
340
+ status: 405,
341
+ headers: { 'Content-Type': 'application/json', Allow: 'POST' },
342
+ }),
343
+ );
344
+ }
345
+
346
+ if (req.method !== 'GET' && req.method !== 'POST') {
347
+ return addCorsHeaders(new Response(null, { status: 405, headers: { Allow: endpoint.command.mutation ? 'POST' : 'GET, POST' } }));
348
+ }
349
+
350
+ // Build command string from request
351
+ const commandPath = toCommandPath(routePath);
352
+ let argParts: string[];
353
+
354
+ if (req.method === 'POST') {
355
+ try {
356
+ const body = (await req.json()) as Record<string, unknown>;
357
+ argParts = serializeArgsToFlags(body);
358
+ } catch {
359
+ return addCorsHeaders(jsonResponse({ ok: false, error: 'bad_request', message: 'Invalid JSON body' }, 400));
360
+ }
361
+ } else {
362
+ // GET: query string → flags
363
+ argParts = [];
364
+ for (const [key, value] of url.searchParams.entries()) {
365
+ if (key === '_') {
366
+ // Positional args
367
+ argParts.push(value);
368
+ } else {
369
+ argParts.push(value === '' ? `--${key}` : `--${key}=${value}`);
370
+ }
371
+ }
372
+ }
373
+
374
+ const commandString = [commandPath, ...argParts].filter(Boolean).join(' ');
375
+ const response = await evalAndRespond(commandString, req);
376
+ return addCorsHeaders(response);
377
+ };
378
+ }
379
+
380
+ /** Start the serve HTTP server. */
381
+ export async function startServeServer(
382
+ _program: AnyPadroneProgram,
383
+ existingCommand: AnyPadroneCommand,
384
+ evalCommand: AnyPadroneProgram['eval'],
385
+ prefs?: PadroneServePreferences,
386
+ ): Promise<void> {
387
+ const handler = createServeHandler(existingCommand, evalCommand, prefs);
388
+ const http = await import('node:http');
389
+
390
+ const port = prefs?.port ?? 3000;
391
+ const host = prefs?.host ?? '127.0.0.1';
392
+ const basePath = (prefs?.basePath ?? '/').replace(/\/$/, '/');
393
+
394
+ const server = http.createServer(async (req, res) => {
395
+ const url = `http://${host}:${port}${req.url}`;
396
+ const headers = new Headers();
397
+ for (const [key, value] of Object.entries(req.headers)) {
398
+ if (value) headers.set(key, Array.isArray(value) ? value.join(', ') : value);
399
+ }
400
+
401
+ const fetchReq = new Request(url, {
402
+ method: req.method,
403
+ headers,
404
+ body: req.method !== 'GET' && req.method !== 'HEAD' ? await readBody(req) : undefined,
405
+ });
406
+
407
+ const response = await handler(fetchReq);
408
+ const resHeaders: Record<string, string> = {};
409
+ response.headers.forEach((v, k) => {
410
+ resHeaders[k] = v;
411
+ });
412
+ res.writeHead(response.status, resHeaders);
413
+ const body = await response.text();
414
+ res.end(body);
415
+ });
416
+
417
+ const { getCommandRuntime } = await import('./command-utils.ts');
418
+ const runtime = getCommandRuntime(existingCommand);
419
+
420
+ return new Promise<void>((resolve, reject) => {
421
+ server.listen(port, host, () => {
422
+ runtime.error(`REST server listening on http://${host}:${port}${basePath}`);
423
+ const builtins = { health: true, help: true, schema: true, docs: true, ...prefs?.builtins };
424
+ if (builtins.docs) runtime.error(`API docs: http://${host}:${port}${basePath}_docs`);
425
+ });
426
+ server.on('error', reject);
427
+ const onSignal = () => {
428
+ server.close(() => resolve());
429
+ };
430
+ process.on('SIGINT', onSignal);
431
+ process.on('SIGTERM', onSignal);
432
+ });
433
+ }
434
+
435
+ /** Read the full body from a Node.js IncomingMessage. */
436
+ async function readBody(req: import('node:http').IncomingMessage): Promise<string> {
437
+ const chunks: Buffer[] = [];
438
+ for await (const chunk of req) {
439
+ chunks.push(chunk as Buffer);
440
+ }
441
+ return Buffer.concat(chunks).toString('utf-8');
442
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { PadroneSchema } from './types.ts';
3
+
4
+ export interface AsyncStreamMeta {
5
+ [x: string]: unknown;
6
+ readonly asyncStream: number;
7
+ readonly itemSchema?: StandardSchemaV1;
8
+ }
9
+
10
+ let asyncStreamIdCounter = 1;
11
+ export const asyncStreamRegistry = new Map<number, AsyncStreamMeta>();
12
+
13
+ /**
14
+ * Returns metadata to mark a schema field as an async stream via `.meta()`.
15
+ *
16
+ * When used with `stdin`, padrone pipes stdin data as an `AsyncIterable` instead of
17
+ * buffering it. Each line is validated against the item schema (if provided) as it arrives.
18
+ *
19
+ * @param itemSchema - Optional item schema for per-item validation.
20
+ * Non-string schemas cause each stdin line to be `JSON.parse`'d before validation.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { asyncStream } from 'padrone';
25
+ *
26
+ * // String lines
27
+ * z.object({ lines: z.custom<AsyncIterable<string>>().meta(asyncStream()) })
28
+ *
29
+ * // Typed items — each line JSON.parse'd and validated
30
+ * z.object({ records: z.custom<AsyncIterable<{ name: string }>>().meta(asyncStream(recordSchema)) })
31
+ * ```
32
+ */
33
+ export function asyncStream<T = string>(itemSchema?: PadroneSchema<T>): AsyncStreamMeta {
34
+ const id = asyncStreamIdCounter++;
35
+ const meta: AsyncStreamMeta = itemSchema ? { asyncStream: id, itemSchema } : { asyncStream: id };
36
+ asyncStreamRegistry.set(id, meta);
37
+ return meta;
38
+ }
39
+
40
+ /** Stdin interface matching PadroneRuntime.stdin */
41
+ interface StdinSource {
42
+ isTTY?: boolean;
43
+ text(): Promise<string>;
44
+ lines(): AsyncIterable<string>;
45
+ }
46
+
47
+ /**
48
+ * Creates an `AsyncIterable` from a stdin source, optionally validating each item.
49
+ * When no stdin is available (TTY / undefined), yields nothing.
50
+ *
51
+ * - No item schema: yields raw string lines
52
+ * - With item schema: `JSON.parse`s each line, validates, then yields
53
+ */
54
+ export function createStdinStream(stdin: StdinSource | undefined, itemSchema?: StandardSchemaV1): AsyncIterable<unknown> {
55
+ if (!stdin) return emptyAsyncIterable;
56
+
57
+ if (!itemSchema) return stdin.lines();
58
+
59
+ return {
60
+ async *[Symbol.asyncIterator]() {
61
+ for await (const line of stdin.lines()) {
62
+ const result = itemSchema['~standard'].validate(line);
63
+ const resolved = result instanceof Promise ? await result : result;
64
+ if ('issues' in resolved && resolved.issues) {
65
+ throw new Error(`Stream item validation failed: ${resolved.issues.map((i) => i.message).join(', ')}`);
66
+ }
67
+ yield (resolved as { value: unknown }).value;
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ const emptyAsyncIterable: AsyncIterable<never> = {
74
+ async *[Symbol.asyncIterator]() {},
75
+ };
package/src/test.ts CHANGED
@@ -153,21 +153,11 @@ export function testCli(program: TestableProgram): TestCliBuilder {
153
153
  const runtime = buildRuntime(stdout, stderr, { envVars, promptAnswers, configFiles, stdinData });
154
154
  const testProgram = program.runtime(runtime);
155
155
 
156
- try {
157
- const evalResult = await testProgram.eval(runInput ?? input ?? '', { autoOutput: false });
158
- return toTestResult(evalResult, stdout, stderr);
159
- } catch (err) {
160
- stderr.push(err instanceof Error ? err.message : String(err));
161
- return {
162
- command: undefined as unknown as AnyPadroneCommand,
163
- args: undefined,
164
- result: undefined,
165
- issues: undefined,
166
- stdout,
167
- stderr,
168
- error: err,
169
- };
156
+ const evalResult = await testProgram.eval(runInput ?? input ?? '', { autoOutput: false });
157
+ if (evalResult.error) {
158
+ stderr.push(evalResult.error instanceof Error ? evalResult.error.message : String(evalResult.error));
170
159
  }
160
+ return toTestResult(evalResult, stdout, stderr);
171
161
  },
172
162
 
173
163
  async repl(inputs: string[]) {
@@ -186,7 +176,7 @@ export function testCli(program: TestableProgram): TestCliBuilder {
186
176
 
187
177
  for await (const r of testProgram.repl({ greeting: false, hint: false })) {
188
178
  results.push({
189
- command: r.command,
179
+ command: r.command!,
190
180
  args: r.args,
191
181
  result: r.result,
192
182
  issues: r.argsResult?.issues as TestCliResult['issues'],
@@ -202,9 +192,10 @@ export function testCli(program: TestableProgram): TestCliBuilder {
202
192
 
203
193
  function toTestResult(evalResult: PadroneCommandResult, stdout: unknown[], stderr: string[]): TestCliResult {
204
194
  return {
205
- command: evalResult.command,
195
+ command: evalResult.command!,
206
196
  args: evalResult.args,
207
197
  result: evalResult.result,
198
+ error: evalResult.error,
208
199
  issues: evalResult.argsResult?.issues as TestCliResult['issues'],
209
200
  stdout,
210
201
  stderr,
package/src/type-utils.ts CHANGED
@@ -36,9 +36,9 @@ export type OrAsync<TExisting extends boolean, TSchema> = TExisting extends true
36
36
  * Detects whether argument meta contains interactive or optionalInteractive configuration.
37
37
  * When either is `true` or a `string[]`, the command requires async execution for prompting.
38
38
  */
39
- export type HasInteractive<TMeta> = TMeta extends { interactive: true | string[] }
39
+ export type HasInteractive<TMeta> = TMeta extends { interactive: true | readonly string[] }
40
40
  ? true
41
- : TMeta extends { optionalInteractive: true | string[] }
41
+ : TMeta extends { optionalInteractive: true | readonly string[] }
42
42
  ? true
43
43
  : false;
44
44
 
@@ -52,14 +52,38 @@ export type OrAsyncMeta<TExisting extends boolean, TMeta> = TExisting extends tr
52
52
  ? true
53
53
  : false;
54
54
 
55
+ /**
56
+ * Unwraps a result type by resolving Promises and collecting iterables into arrays.
57
+ * - `AsyncIterable<U>` → `U[]`
58
+ * - `Iterable<U>` (excluding strings) → `U[]`
59
+ * - `Promise<U>` → `Drained<U>` (recursively unwraps)
60
+ * - `T` → `T`
61
+ */
62
+ export type Drained<T> =
63
+ T extends Promise<infer U>
64
+ ? Drained<U>
65
+ : T extends AsyncIterable<infer U>
66
+ ? U[]
67
+ : T extends string
68
+ ? T
69
+ : T extends Iterable<infer U>
70
+ ? U[]
71
+ : T;
72
+
73
+ /**
74
+ * A sync value augmented with Promise-like methods (.then, .catch, .finally).
75
+ * Unlike a real Promise, properties of T are accessible synchronously.
76
+ */
77
+ export type Thenable<T> = T & PromiseLike<T> & { catch: Promise<T>['catch']; finally: Promise<T>['finally'] };
78
+
55
79
  /**
56
80
  * Conditionally wraps a type in Promise based on the TAsync flag.
57
81
  * - `true` → `Promise<T>`
58
- * - `false` → `T`
82
+ * - `false` → `T & Thenable<T>` (thenable: supports `.then()`, `.catch()`, `.finally()`, and `await`)
59
83
  * - `boolean` (union of true|false) → `Promise<T>` (safe default when async-ness is uncertain)
60
84
  * - `any` → `T` (for generic/any typed commands like AnyPadroneCommand)
61
85
  */
62
- export type MaybePromise<T, TAsync> = IsAny<TAsync> extends true ? T : true extends TAsync ? Promise<T> : T;
86
+ export type MaybePromise<T, TAsync> = IsAny<TAsync> extends true ? T : true extends TAsync ? Promise<T> : Thenable<T>;
63
87
 
64
88
  type SplitString<TName extends string, TSplitBy extends string = ' '> = TName extends `${infer FirstPart}${TSplitBy}${infer RestParts}`
65
89
  ? [FirstPart, ...SplitString<RestParts, TSplitBy>]