padrone 1.4.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 +79 -0
  2. package/README.md +105 -284
  3. package/dist/{args-CVDbyyzG.mjs → args-D5PNDyNu.mjs} +41 -18
  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-ClUK5hcQ.d.mts → formatter-DtHzbP22.d.mts} +34 -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 +493 -265
  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-DjIdJN5G.d.mts → types-Ch8Mk6Qb.d.mts} +310 -62
  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 +68 -40
  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 +380 -30
  60. package/src/completion.ts +120 -47
  61. package/src/create.ts +480 -128
  62. package/src/docs/index.ts +122 -8
  63. package/src/formatter.ts +171 -125
  64. package/src/help.ts +45 -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-CVDbyyzG.mjs.map +0 -1
  78. package/dist/chunk-y_GBKt04.mjs +0 -5
  79. package/dist/formatter-ClUK5hcQ.d.mts.map +0 -1
  80. package/dist/help-CcBe91bV.mjs +0 -1254
  81. package/dist/help-CcBe91bV.mjs.map +0 -1
  82. package/dist/types-DjIdJN5G.d.mts.map +0 -1
package/src/runtime.ts CHANGED
@@ -1,6 +1,48 @@
1
+ import type { ColorConfig, ColorTheme } from './colorizer.ts';
1
2
  import type { HelpFormat } from './formatter.ts';
2
3
  import { findConfigFile, loadConfigFile } from './utils.ts';
3
4
 
5
+ /**
6
+ * A progress indicator instance (spinner, progress bar, etc).
7
+ * Created by the runtime's `progress` factory and used to show loading state during command execution.
8
+ */
9
+ export type PadroneProgressIndicator = {
10
+ /** Update the displayed message. */
11
+ update: (message: string) => void;
12
+ /** Mark as succeeded and stop. Pass `null` to stop without rendering a final message. */
13
+ succeed: (message?: string | null, options?: { indicator?: string }) => void;
14
+ /** Mark as failed and stop. Pass `null` to stop without rendering a final message. */
15
+ fail: (message?: string | null, options?: { indicator?: string }) => void;
16
+ /** Stop without success/fail status. */
17
+ stop: () => void;
18
+ /** Temporarily hide the indicator so other output can be written cleanly. */
19
+ pause: () => void;
20
+ /** Redraw the indicator after a `pause()`. */
21
+ resume: () => void;
22
+ };
23
+
24
+ /** Built-in spinner presets. */
25
+ export type PadroneSpinnerPreset = 'dots' | 'line' | 'arc' | 'bounce';
26
+
27
+ /**
28
+ * Spinner configuration for progress indicators.
29
+ * - A preset name (e.g., `'dots'`) to use built-in frames.
30
+ * - An object with custom `frames` and/or `interval`.
31
+ * - `false` to disable the spinner animation (static text only).
32
+ */
33
+ export type PadroneSpinnerConfig = PadroneSpinnerPreset | { frames?: string[]; interval?: number } | false;
34
+
35
+ /**
36
+ * Options passed to the runtime's `progress` factory.
37
+ */
38
+ export type PadroneProgressOptions = {
39
+ spinner?: PadroneSpinnerConfig;
40
+ /** Character/string shown before the success message. Defaults to `'✔'`. */
41
+ successIndicator?: string;
42
+ /** Character/string shown before the error message. Defaults to `'✖'`. */
43
+ errorIndicator?: string;
44
+ };
45
+
4
46
  /**
5
47
  * Controls interactive prompting capability and default behavior at the runtime level.
6
48
  * - `'supported'` — capable; caller decides.
@@ -45,6 +87,8 @@ export type PadroneRuntime = {
45
87
  env?: () => Record<string, string | undefined>;
46
88
  /** Default help output format. */
47
89
  format?: HelpFormat | 'auto';
90
+ /** Color theme for ANSI/console help output. A theme name or partial color config. */
91
+ theme?: ColorTheme | ColorConfig;
48
92
  /** Load and parse a config file by path. Return undefined if not found or unparsable. */
49
93
  loadConfigFile?: (path: string) => Record<string, unknown> | undefined;
50
94
  /** Find the first existing file from a list of candidate names. */
@@ -81,6 +125,12 @@ export type PadroneRuntime = {
81
125
  * When `interactive` is `true` and this is not provided, defaults to an Enquirer-based terminal prompt.
82
126
  */
83
127
  prompt?: (config: InteractivePromptConfig) => Promise<unknown>;
128
+ /**
129
+ * Create a progress indicator (spinner, progress bar, etc).
130
+ * Used by commands that set `progress` in their config, or manually via `ctx.progress()` in actions.
131
+ * When not provided, auto-progress is silently skipped and `ctx.progress()` returns a no-op indicator.
132
+ */
133
+ progress?: (message: string, options?: PadroneProgressOptions) => PadroneProgressIndicator;
84
134
  /**
85
135
  * Read a line of input from the user. Used by `repl()` for custom runtimes
86
136
  * (web UIs, chat interfaces, testing).
@@ -97,8 +147,10 @@ export type PadroneRuntime = {
97
147
  * Internal resolved runtime where all fields are guaranteed to be present.
98
148
  * The `prompt`, `interactive`, and `readLine` fields remain optional since not all runtimes provide them.
99
149
  */
100
- export type ResolvedPadroneRuntime = Required<Omit<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin'>> &
101
- Pick<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin'>;
150
+ export type ResolvedPadroneRuntime = Required<
151
+ Omit<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin' | 'progress' | 'theme'>
152
+ > &
153
+ Pick<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin' | 'progress' | 'theme'>;
102
154
 
103
155
  /**
104
156
  * Default terminal prompt implementation powered by Enquirer.
@@ -255,6 +307,135 @@ function createDefaultStdin(): NonNullable<PadroneRuntime['stdin']> {
255
307
  };
256
308
  }
257
309
 
310
+ const spinnerPresets: Record<PadroneSpinnerPreset, string[]> = {
311
+ dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
312
+ line: ['-', '\\', '|', '/'],
313
+ arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
314
+ bounce: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'],
315
+ };
316
+
317
+ function resolveSpinnerConfig(config?: PadroneSpinnerConfig): { frames: string[]; interval: number; disabled: boolean } {
318
+ if (config === false) return { frames: [], interval: 80, disabled: true };
319
+ if (typeof config === 'string') return { frames: spinnerPresets[config], interval: 80, disabled: false };
320
+ if (typeof config === 'object') {
321
+ return {
322
+ frames: config.frames ?? spinnerPresets.dots,
323
+ interval: config.interval ?? 80,
324
+ disabled: false,
325
+ };
326
+ }
327
+ return { frames: spinnerPresets.dots, interval: 80, disabled: false };
328
+ }
329
+
330
+ /**
331
+ * Creates a built-in terminal spinner. Returns a no-op indicator in non-TTY/CI environments.
332
+ */
333
+ function createTerminalSpinner(message: string, options?: PadroneProgressOptions): PadroneProgressIndicator {
334
+ const { frames, interval, disabled: spinnerDisabled } = resolveSpinnerConfig(options?.spinner);
335
+ const successIcon = options?.successIndicator ?? '✔';
336
+ const errorIcon = options?.errorIndicator ?? '✖';
337
+
338
+ const formatFinal = (icon: string, msg: string) => (icon ? `${icon} ${msg}\n` : `${msg}\n`);
339
+
340
+ if (typeof process === 'undefined' || !process.stderr?.isTTY) {
341
+ // Non-TTY: just log start/end, no animation
342
+ return {
343
+ update() {},
344
+ succeed(msg, opts) {
345
+ if (msg === null) return;
346
+ const icon = opts?.indicator ?? successIcon;
347
+ if (msg || message) process?.stderr?.write?.(formatFinal(icon, msg || message));
348
+ },
349
+ fail(msg, opts) {
350
+ if (msg === null) return;
351
+ const icon = opts?.indicator ?? errorIcon;
352
+ if (msg || message) process?.stderr?.write?.(formatFinal(icon, msg || message));
353
+ },
354
+ stop() {},
355
+ pause() {},
356
+ resume() {},
357
+ };
358
+ }
359
+
360
+ // If spinner is disabled and there's no message, nothing to render
361
+ if (spinnerDisabled && !message) {
362
+ return { update() {}, succeed() {}, fail() {}, stop() {}, pause() {}, resume() {} };
363
+ }
364
+
365
+ let frame = 0;
366
+ let text = message;
367
+ let stopped = false;
368
+ let paused = false;
369
+
370
+ const writeStderr = process.stderr.write.bind(process.stderr);
371
+ const writeStdout = process.stdout.write.bind(process.stdout);
372
+ const clearLine = () => writeStderr('\x1b[2K\r');
373
+
374
+ const render = () => {
375
+ if (paused || stopped) return;
376
+ if (spinnerDisabled) {
377
+ // Static text only, no spinner frames
378
+ if (text) writeStderr(`\x1b[2K\r${text}`);
379
+ } else {
380
+ const prefix = frames[frame] ?? '';
381
+ writeStderr(`\x1b[2K\r${text ? `${prefix} ${text}` : prefix}`);
382
+ }
383
+ };
384
+
385
+ const timer = spinnerDisabled
386
+ ? undefined
387
+ : setInterval(() => {
388
+ frame = (frame + 1) % frames.length;
389
+ render();
390
+ }, interval);
391
+
392
+ render();
393
+
394
+ const clear = () => {
395
+ if (stopped) return;
396
+ stopped = true;
397
+ paused = false;
398
+ if (timer) clearInterval(timer);
399
+ clearLine();
400
+ };
401
+
402
+ return {
403
+ update(msg) {
404
+ if (stopped) return;
405
+ text = msg;
406
+ render();
407
+ },
408
+ succeed(msg, opts) {
409
+ clear();
410
+ if (msg === null) return;
411
+ const finalMsg = msg ?? text;
412
+ const icon = opts?.indicator ?? successIcon;
413
+ if (finalMsg) writeStderr(formatFinal(icon, finalMsg));
414
+ },
415
+ fail(msg, opts) {
416
+ clear();
417
+ if (msg === null) return;
418
+ const finalMsg = msg ?? text;
419
+ const icon = opts?.indicator ?? errorIcon;
420
+ if (finalMsg) writeStderr(formatFinal(icon, finalMsg));
421
+ },
422
+ stop() {
423
+ clear();
424
+ },
425
+ pause() {
426
+ if (stopped || paused) return;
427
+ paused = true;
428
+ clearLine();
429
+ writeStdout('\x1b[2K\r');
430
+ },
431
+ resume() {
432
+ if (stopped || !paused) return;
433
+ paused = false;
434
+ render();
435
+ },
436
+ };
437
+ }
438
+
258
439
  export function createDefaultRuntime(): ResolvedPadroneRuntime {
259
440
  return {
260
441
  output: (...args) => console.log(...args),
@@ -266,6 +447,7 @@ export function createDefaultRuntime(): ResolvedPadroneRuntime {
266
447
  findFile: findConfigFile,
267
448
  prompt: defaultTerminalPrompt,
268
449
  interactive: detectInteractiveMode(),
450
+ progress: createTerminalSpinner,
269
451
  };
270
452
  }
271
453
 
@@ -285,6 +467,15 @@ export function resolveStdin(partial?: PadroneRuntime): NonNullable<PadroneRunti
285
467
  return defaultStdin;
286
468
  }
287
469
 
470
+ /**
471
+ * Like `resolveStdin`, but always returns a stdin source even when it's a TTY.
472
+ * Used for async streams which support interactive (non-piped) input.
473
+ */
474
+ export function resolveStdinAlways(partial?: PadroneRuntime): NonNullable<PadroneRuntime['stdin']> {
475
+ if (partial?.stdin) return partial.stdin;
476
+ return createDefaultStdin();
477
+ }
478
+
288
479
  export function resolveRuntime(partial?: PadroneRuntime): ResolvedPadroneRuntime {
289
480
  const defaults = createDefaultRuntime();
290
481
  if (!partial) return defaults;
@@ -299,6 +490,8 @@ export function resolveRuntime(partial?: PadroneRuntime): ResolvedPadroneRuntime
299
490
  interactive: partial.interactive ?? defaults.interactive,
300
491
  prompt: partial.prompt ?? defaults.prompt,
301
492
  readLine: partial.readLine ?? defaults.readLine,
493
+ progress: partial.progress ?? defaults.progress,
302
494
  stdin: partial.stdin,
495
+ theme: partial.theme,
303
496
  };
304
497
  }
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
+ }