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.
- package/CHANGELOG.md +79 -0
- package/README.md +105 -284
- package/dist/{args-CVDbyyzG.mjs → args-D5PNDyNu.mjs} +41 -18
- package/dist/args-D5PNDyNu.mjs.map +1 -0
- package/dist/chunk-CjcI7cDX.mjs +15 -0
- package/dist/codegen/index.d.mts +28 -3
- package/dist/codegen/index.d.mts.map +1 -1
- package/dist/codegen/index.mjs +169 -19
- package/dist/codegen/index.mjs.map +1 -1
- package/dist/command-utils-B1D-HqCd.mjs +1117 -0
- package/dist/command-utils-B1D-HqCd.mjs.map +1 -0
- package/dist/completion.d.mts +1 -1
- package/dist/completion.d.mts.map +1 -1
- package/dist/completion.mjs +77 -29
- package/dist/completion.mjs.map +1 -1
- package/dist/docs/index.d.mts +22 -2
- package/dist/docs/index.d.mts.map +1 -1
- package/dist/docs/index.mjs +94 -7
- package/dist/docs/index.mjs.map +1 -1
- package/dist/errors-BiVrBgi6.mjs +114 -0
- package/dist/errors-BiVrBgi6.mjs.map +1 -0
- package/dist/{formatter-ClUK5hcQ.d.mts → formatter-DtHzbP22.d.mts} +34 -5
- package/dist/formatter-DtHzbP22.d.mts.map +1 -0
- package/dist/help-bbmu9-qd.mjs +735 -0
- package/dist/help-bbmu9-qd.mjs.map +1 -0
- package/dist/index.d.mts +32 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +493 -265
- package/dist/index.mjs.map +1 -1
- package/dist/mcp-mLWIdUIu.mjs +379 -0
- package/dist/mcp-mLWIdUIu.mjs.map +1 -0
- package/dist/serve-B0u43DK7.mjs +404 -0
- package/dist/serve-B0u43DK7.mjs.map +1 -0
- package/dist/stream-BcC146Ud.mjs +56 -0
- package/dist/stream-BcC146Ud.mjs.map +1 -0
- package/dist/test.d.mts +1 -1
- package/dist/test.mjs +4 -15
- package/dist/test.mjs.map +1 -1
- package/dist/{types-DjIdJN5G.d.mts → types-Ch8Mk6Qb.d.mts} +310 -62
- package/dist/types-Ch8Mk6Qb.d.mts.map +1 -0
- package/dist/{update-check-EbNDkzyV.mjs → update-check-CFX1FV3v.mjs} +2 -2
- package/dist/{update-check-EbNDkzyV.mjs.map → update-check-CFX1FV3v.mjs.map} +1 -1
- package/dist/zod.d.mts +32 -0
- package/dist/zod.d.mts.map +1 -0
- package/dist/zod.mjs +50 -0
- package/dist/zod.mjs.map +1 -0
- package/package.json +10 -2
- package/src/args.ts +68 -40
- package/src/cli/docs.ts +1 -7
- package/src/cli/doctor.ts +195 -10
- package/src/cli/index.ts +1 -1
- package/src/cli/init.ts +2 -3
- package/src/cli/link.ts +2 -2
- package/src/codegen/discovery.ts +80 -28
- package/src/codegen/index.ts +2 -1
- package/src/codegen/parsers/bash.ts +179 -0
- package/src/codegen/schema-to-code.ts +2 -1
- package/src/colorizer.ts +126 -13
- package/src/command-utils.ts +380 -30
- package/src/completion.ts +120 -47
- package/src/create.ts +480 -128
- package/src/docs/index.ts +122 -8
- package/src/formatter.ts +171 -125
- package/src/help.ts +45 -12
- package/src/index.ts +29 -1
- package/src/interactive.ts +45 -4
- package/src/mcp.ts +390 -0
- package/src/repl-loop.ts +16 -3
- package/src/runtime.ts +195 -2
- package/src/serve.ts +442 -0
- package/src/stream.ts +75 -0
- package/src/test.ts +7 -16
- package/src/type-utils.ts +28 -4
- package/src/types.ts +212 -30
- package/src/wrap.ts +23 -25
- package/src/zod.ts +50 -0
- package/dist/args-CVDbyyzG.mjs.map +0 -1
- package/dist/chunk-y_GBKt04.mjs +0 -5
- package/dist/formatter-ClUK5hcQ.d.mts.map +0 -1
- package/dist/help-CcBe91bV.mjs +0 -1254
- package/dist/help-CcBe91bV.mjs.map +0 -1
- 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<
|
|
101
|
-
|
|
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
|
+
}
|