padrone 1.4.0 → 1.6.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 +115 -0
- package/README.md +108 -283
- package/dist/args-Cnq0nwSM.mjs +272 -0
- package/dist/args-Cnq0nwSM.mjs.map +1 -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/commands-B_gufyR9.mjs +514 -0
- package/dist/commands-B_gufyR9.mjs.map +1 -0
- package/dist/{completion.mjs → completion-BEuflbDO.mjs} +86 -108
- package/dist/completion-BEuflbDO.mjs.map +1 -0
- package/dist/docs/index.d.mts +22 -2
- package/dist/docs/index.d.mts.map +1 -1
- package/dist/docs/index.mjs +92 -7
- package/dist/docs/index.mjs.map +1 -1
- package/dist/errors-CL63UOzt.mjs +137 -0
- package/dist/errors-CL63UOzt.mjs.map +1 -0
- package/dist/{formatter-ClUK5hcQ.d.mts → formatter-DrvhDMrq.d.mts} +35 -6
- package/dist/formatter-DrvhDMrq.d.mts.map +1 -0
- package/dist/help-B5Kk83of.mjs +849 -0
- package/dist/help-B5Kk83of.mjs.map +1 -0
- package/dist/index-BaU3X6dY.d.mts +1178 -0
- package/dist/index-BaU3X6dY.d.mts.map +1 -0
- package/dist/index.d.mts +763 -36
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3608 -1534
- package/dist/index.mjs.map +1 -1
- package/dist/mcp-BM-d0nZi.mjs +377 -0
- package/dist/mcp-BM-d0nZi.mjs.map +1 -0
- package/dist/serve-Bk0JUlCj.mjs +402 -0
- package/dist/serve-Bk0JUlCj.mjs.map +1 -0
- package/dist/stream-DC4H8YTx.mjs +77 -0
- package/dist/stream-DC4H8YTx.mjs.map +1 -0
- package/dist/test.d.mts +5 -8
- package/dist/test.d.mts.map +1 -1
- package/dist/test.mjs +5 -27
- package/dist/test.mjs.map +1 -1
- package/dist/{update-check-EbNDkzyV.mjs → update-check-CZ2VqjnV.mjs} +16 -17
- package/dist/update-check-CZ2VqjnV.mjs.map +1 -0
- 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 +20 -9
- package/src/cli/completions.ts +14 -11
- package/src/cli/docs.ts +13 -16
- package/src/cli/doctor.ts +213 -24
- package/src/cli/index.ts +28 -82
- package/src/cli/init.ts +12 -10
- package/src/cli/link.ts +22 -18
- package/src/cli/wrap.ts +14 -11
- 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/core/args.ts +296 -0
- package/src/core/commands.ts +373 -0
- package/src/core/create.ts +268 -0
- package/src/{runtime.ts → core/default-runtime.ts} +70 -135
- package/src/{errors.ts → core/errors.ts} +22 -0
- package/src/core/exec.ts +259 -0
- package/src/core/interceptors.ts +302 -0
- package/src/{parse.ts → core/parse.ts} +36 -89
- package/src/core/program-methods.ts +301 -0
- package/src/core/results.ts +229 -0
- package/src/core/runtime.ts +246 -0
- package/src/core/validate.ts +247 -0
- package/src/docs/index.ts +124 -11
- package/src/extension/auto-output.ts +95 -0
- package/src/extension/color.ts +38 -0
- package/src/extension/completion.ts +49 -0
- package/src/extension/config.ts +262 -0
- package/src/extension/env.ts +101 -0
- package/src/extension/help.ts +192 -0
- package/src/extension/index.ts +43 -0
- package/src/extension/ink.ts +93 -0
- package/src/extension/interactive.ts +106 -0
- package/src/extension/logger.ts +214 -0
- package/src/extension/man.ts +51 -0
- package/src/extension/mcp.ts +52 -0
- package/src/extension/progress-renderer.ts +338 -0
- package/src/extension/progress.ts +299 -0
- package/src/extension/repl.ts +94 -0
- package/src/extension/serve.ts +48 -0
- package/src/extension/signal.ts +87 -0
- package/src/extension/stdin.ts +62 -0
- package/src/extension/suggestions.ts +114 -0
- package/src/extension/timing.ts +81 -0
- package/src/extension/tracing.ts +175 -0
- package/src/extension/update-check.ts +77 -0
- package/src/extension/utils.ts +51 -0
- package/src/extension/version.ts +63 -0
- package/src/{completion.ts → feature/completion.ts} +130 -57
- package/src/{interactive.ts → feature/interactive.ts} +47 -6
- package/src/feature/mcp.ts +387 -0
- package/src/{repl-loop.ts → feature/repl-loop.ts} +26 -16
- package/src/feature/serve.ts +438 -0
- package/src/feature/test.ts +262 -0
- package/src/{update-check.ts → feature/update-check.ts} +16 -16
- package/src/{wrap.ts → feature/wrap.ts} +27 -27
- package/src/index.ts +120 -11
- package/src/output/colorizer.ts +154 -0
- package/src/{formatter.ts → output/formatter.ts} +281 -135
- package/src/{help.ts → output/help.ts} +62 -15
- package/src/{zod.d.ts → schema/zod.d.ts} +1 -1
- package/src/schema/zod.ts +50 -0
- package/src/test.ts +2 -285
- package/src/types/args-meta.ts +151 -0
- package/src/types/builder.ts +697 -0
- package/src/types/command.ts +157 -0
- package/src/types/index.ts +59 -0
- package/src/types/interceptor.ts +296 -0
- package/src/types/preferences.ts +83 -0
- package/src/types/result.ts +71 -0
- package/src/types/schema.ts +19 -0
- package/src/util/dotenv.ts +244 -0
- package/src/{shell-utils.ts → util/shell-utils.ts} +26 -9
- package/src/util/stream.ts +101 -0
- package/src/{type-helpers.ts → util/type-helpers.ts} +23 -16
- package/src/{type-utils.ts → util/type-utils.ts} +99 -37
- package/src/util/utils.ts +51 -0
- package/src/zod.ts +1 -0
- package/dist/args-CVDbyyzG.mjs +0 -199
- package/dist/args-CVDbyyzG.mjs.map +0 -1
- package/dist/chunk-y_GBKt04.mjs +0 -5
- package/dist/completion.d.mts +0 -64
- package/dist/completion.d.mts.map +0 -1
- package/dist/completion.mjs.map +0 -1
- 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 +0 -1059
- package/dist/types-DjIdJN5G.d.mts.map +0 -1
- package/dist/update-check-EbNDkzyV.mjs.map +0 -1
- package/src/args.ts +0 -461
- package/src/colorizer.ts +0 -41
- package/src/command-utils.ts +0 -532
- package/src/create.ts +0 -1477
- package/src/types.ts +0 -1109
- package/src/utils.ts +0 -140
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { buildInputSchema, type CollectedEndpoint, collectEndpoints, serializeArgsToFlags } from '../core/commands.ts';
|
|
2
|
+
import { RoutingError, ValidationError } from '../core/errors.ts';
|
|
3
|
+
import { generateHelp } from '../output/help.ts';
|
|
4
|
+
import type { AnyPadroneCommand, AnyPadroneProgram } from '../types/index.ts';
|
|
5
|
+
import { readStreamAsText } from '../util/stream.ts';
|
|
6
|
+
|
|
7
|
+
export type PadroneServePreferences = {
|
|
8
|
+
/** Port to listen on. Default: 3000 */
|
|
9
|
+
port?: number;
|
|
10
|
+
/** Host to bind to. Default: '127.0.0.1' */
|
|
11
|
+
host?: string;
|
|
12
|
+
/** Base path prefix for all routes. Default: '/' */
|
|
13
|
+
basePath?: string;
|
|
14
|
+
/** CORS allowed origin. Default: '*'. Set to `false` to disable CORS headers. */
|
|
15
|
+
cors?: string | false;
|
|
16
|
+
/** Control built-in utility endpoints. All enabled by default. */
|
|
17
|
+
builtins?: {
|
|
18
|
+
/** GET /_health — returns 200 OK. */
|
|
19
|
+
health?: boolean;
|
|
20
|
+
/** GET /_help and GET /_help/:command — returns help text. */
|
|
21
|
+
help?: boolean;
|
|
22
|
+
/** GET /_schema and GET /_schema/:command — returns JSON Schema. */
|
|
23
|
+
schema?: boolean;
|
|
24
|
+
/** GET /_docs — Scalar OpenAPI docs viewer. */
|
|
25
|
+
docs?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Hook to run before each request. Return a Response to short-circuit. */
|
|
28
|
+
onRequest?: (req: Request) => Response | void | Promise<Response | void>;
|
|
29
|
+
/** Transform errors into responses. */
|
|
30
|
+
onError?: (error: unknown, req: Request) => Response;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Convert an endpoint dot-path to a URL path segment. */
|
|
34
|
+
function toUrlPath(name: string): string {
|
|
35
|
+
return name.replace(/\./g, '/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert a URL path segment back to a command path (slash → space). */
|
|
39
|
+
function toCommandPath(urlPath: string): string {
|
|
40
|
+
return urlPath.replace(/\//g, ' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function jsonResponse(body: unknown, status = 200, headers?: Record<string, string>): Response {
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status,
|
|
46
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorToStatus(error: unknown): number {
|
|
51
|
+
if (error instanceof RoutingError) return 404;
|
|
52
|
+
if (error instanceof ValidationError) return 400;
|
|
53
|
+
return 500;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorToResponse(error: unknown): Response {
|
|
57
|
+
const status = errorToStatus(error);
|
|
58
|
+
if (error instanceof ValidationError) {
|
|
59
|
+
return jsonResponse(
|
|
60
|
+
{
|
|
61
|
+
ok: false,
|
|
62
|
+
error: 'validation',
|
|
63
|
+
message: error.message,
|
|
64
|
+
issues: error.issues.map((i) => ({ path: i.path?.map(String), message: i.message })),
|
|
65
|
+
},
|
|
66
|
+
status,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (error instanceof RoutingError) {
|
|
70
|
+
return jsonResponse({ ok: false, error: 'not_found', message: error.message, suggestions: error.suggestions }, status);
|
|
71
|
+
}
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return jsonResponse({ ok: false, error: 'action_error', message }, status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Generate an OpenAPI 3.1.0 spec from the command tree. */
|
|
77
|
+
function buildOpenApiSpec(existingCommand: AnyPadroneCommand, endpoints: CollectedEndpoint[], basePath: string): Record<string, unknown> {
|
|
78
|
+
const paths: Record<string, unknown> = {};
|
|
79
|
+
|
|
80
|
+
const responseSchema = {
|
|
81
|
+
'200': {
|
|
82
|
+
description: 'Successful response',
|
|
83
|
+
content: { 'application/json': { schema: { type: 'object', properties: { ok: { type: 'boolean', const: true }, result: {} } } } },
|
|
84
|
+
},
|
|
85
|
+
'400': {
|
|
86
|
+
description: 'Validation error',
|
|
87
|
+
content: {
|
|
88
|
+
'application/json': {
|
|
89
|
+
schema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
ok: { type: 'boolean', const: false },
|
|
93
|
+
error: { type: 'string', const: 'validation' },
|
|
94
|
+
message: { type: 'string' },
|
|
95
|
+
issues: { type: 'array', items: { type: 'object', properties: { path: { type: 'array' }, message: { type: 'string' } } } },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
'404': {
|
|
102
|
+
description: 'Command not found',
|
|
103
|
+
content: {
|
|
104
|
+
'application/json': {
|
|
105
|
+
schema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
ok: { type: 'boolean', const: false },
|
|
109
|
+
error: { type: 'string', const: 'not_found' },
|
|
110
|
+
message: { type: 'string' },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
'500': {
|
|
117
|
+
description: 'Action error',
|
|
118
|
+
content: {
|
|
119
|
+
'application/json': {
|
|
120
|
+
schema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
ok: { type: 'boolean', const: false },
|
|
124
|
+
error: { type: 'string', const: 'action_error' },
|
|
125
|
+
message: { type: 'string' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const { name, command: cmd } of endpoints) {
|
|
134
|
+
const urlPath = `${basePath}${toUrlPath(name)}`;
|
|
135
|
+
const inputSchema = buildInputSchema(cmd);
|
|
136
|
+
const description = cmd.description || cmd.title || `Run the "${name}" command`;
|
|
137
|
+
const pathItem: Record<string, unknown> = {};
|
|
138
|
+
|
|
139
|
+
const postOp = {
|
|
140
|
+
summary: cmd.title || name,
|
|
141
|
+
description,
|
|
142
|
+
operationId: `post_${name.replace(/\./g, '_')}`,
|
|
143
|
+
requestBody: { content: { 'application/json': { schema: inputSchema } } },
|
|
144
|
+
responses: responseSchema,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (cmd.mutation) {
|
|
148
|
+
pathItem.post = postOp;
|
|
149
|
+
} else {
|
|
150
|
+
// GET: args as query parameters
|
|
151
|
+
const properties = (inputSchema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
152
|
+
const queryParams = Object.entries(properties).map(([key, schema]) => ({
|
|
153
|
+
name: key,
|
|
154
|
+
in: 'query',
|
|
155
|
+
schema,
|
|
156
|
+
required: (inputSchema.required as string[] | undefined)?.includes(key) ?? false,
|
|
157
|
+
}));
|
|
158
|
+
pathItem.get = {
|
|
159
|
+
summary: cmd.title || name,
|
|
160
|
+
description,
|
|
161
|
+
operationId: `get_${name.replace(/\./g, '_')}`,
|
|
162
|
+
parameters: queryParams,
|
|
163
|
+
responses: responseSchema,
|
|
164
|
+
};
|
|
165
|
+
pathItem.post = postOp;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
paths[urlPath] = pathItem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
openapi: '3.1.0',
|
|
173
|
+
info: {
|
|
174
|
+
title: existingCommand.title || existingCommand.name,
|
|
175
|
+
description: existingCommand.description,
|
|
176
|
+
version: existingCommand.version ?? '0.0.0',
|
|
177
|
+
},
|
|
178
|
+
paths,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scalarDocsHtml(openapiUrl: string, title: string): string {
|
|
183
|
+
return `<!doctype html>
|
|
184
|
+
<html>
|
|
185
|
+
<head>
|
|
186
|
+
<title>${title} — API Docs</title>
|
|
187
|
+
<meta charset="utf-8" />
|
|
188
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<script id="api-reference" data-url="${openapiUrl}"></script>
|
|
192
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
193
|
+
</body>
|
|
194
|
+
</html>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Create the serve request handler. */
|
|
198
|
+
export function createServeHandler(
|
|
199
|
+
existingCommand: AnyPadroneCommand,
|
|
200
|
+
evalCommand: AnyPadroneProgram['eval'],
|
|
201
|
+
prefs?: PadroneServePreferences,
|
|
202
|
+
): (req: Request) => Promise<Response> {
|
|
203
|
+
const basePath = (prefs?.basePath ?? '/').replace(/\/$/, '/');
|
|
204
|
+
const corsOrigin = prefs?.cors !== false ? (prefs?.cors ?? '*') : undefined;
|
|
205
|
+
const builtins = { health: true, help: true, schema: true, docs: true, ...prefs?.builtins };
|
|
206
|
+
|
|
207
|
+
const endpoints = collectEndpoints(existingCommand.commands, '');
|
|
208
|
+
if (existingCommand.action || existingCommand.argsSchema) {
|
|
209
|
+
endpoints.unshift({ name: '', command: existingCommand });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const routeMap = new Map<string, CollectedEndpoint>();
|
|
213
|
+
for (const ep of endpoints) {
|
|
214
|
+
routeMap.set(toUrlPath(ep.name), ep);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let cachedOpenApiSpec: Record<string, unknown> | undefined;
|
|
218
|
+
const getOpenApiSpec = () => (cachedOpenApiSpec ??= buildOpenApiSpec(existingCommand, endpoints, basePath));
|
|
219
|
+
|
|
220
|
+
function addCorsHeaders(res: Response): Response {
|
|
221
|
+
if (!corsOrigin) return res;
|
|
222
|
+
const headers = new Headers(res.headers);
|
|
223
|
+
headers.set('Access-Control-Allow-Origin', corsOrigin);
|
|
224
|
+
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
225
|
+
headers.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
226
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function evalAndRespond(commandString: string, request: Request): Promise<Response> {
|
|
230
|
+
const output: string[] = [];
|
|
231
|
+
const errors: string[] = [];
|
|
232
|
+
const result = await evalCommand(commandString || (undefined as any), {
|
|
233
|
+
caller: 'serve',
|
|
234
|
+
runtime: {
|
|
235
|
+
output: (...args: unknown[]) => output.push(args.map(String).join(' ')),
|
|
236
|
+
error: (text: string) => errors.push(text),
|
|
237
|
+
interactive: 'unsupported',
|
|
238
|
+
format: 'json',
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (result.error) {
|
|
243
|
+
return prefs?.onError ? prefs.onError(result.error, request) : errorToResponse(result.error);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (result.argsResult?.issues) {
|
|
247
|
+
const issues = (result.argsResult.issues as { path?: PropertyKey[]; message: string }[]).map((i) => ({
|
|
248
|
+
path: i.path?.map(String),
|
|
249
|
+
message: i.message,
|
|
250
|
+
}));
|
|
251
|
+
return jsonResponse({ ok: false, error: 'validation', issues }, 400);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return jsonResponse({ ok: true, result: result.result ?? null });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return async function handleRequest(req: Request): Promise<Response> {
|
|
258
|
+
// CORS preflight
|
|
259
|
+
if (req.method === 'OPTIONS') {
|
|
260
|
+
return addCorsHeaders(new Response(null, { status: corsOrigin ? 204 : 405 }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// onRequest hook
|
|
264
|
+
if (prefs?.onRequest) {
|
|
265
|
+
const hookResponse = await prefs.onRequest(req);
|
|
266
|
+
if (hookResponse) return addCorsHeaders(hookResponse);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const url = new URL(req.url, 'http://localhost');
|
|
270
|
+
let pathname = url.pathname;
|
|
271
|
+
|
|
272
|
+
// Strip basePath prefix
|
|
273
|
+
if (basePath !== '/' && pathname.startsWith(basePath)) {
|
|
274
|
+
pathname = pathname.slice(basePath.length - 1);
|
|
275
|
+
}
|
|
276
|
+
// Remove leading slash for route matching
|
|
277
|
+
const routePath = pathname.replace(/^\//, '');
|
|
278
|
+
|
|
279
|
+
// Built-in endpoints
|
|
280
|
+
if (req.method === 'GET') {
|
|
281
|
+
if (builtins.health && routePath === '_health') {
|
|
282
|
+
return addCorsHeaders(jsonResponse({ status: 'ok' }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (builtins.schema && routePath === '_schema') {
|
|
286
|
+
const schemaMap: Record<string, unknown> = {};
|
|
287
|
+
for (const ep of endpoints) {
|
|
288
|
+
schemaMap[toUrlPath(ep.name) || '/'] = buildInputSchema(ep.command);
|
|
289
|
+
}
|
|
290
|
+
return addCorsHeaders(jsonResponse(schemaMap));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (builtins.schema && routePath.startsWith('_schema/')) {
|
|
294
|
+
const cmdPath = routePath.slice('_schema/'.length);
|
|
295
|
+
const ep = routeMap.get(cmdPath);
|
|
296
|
+
if (!ep) return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${cmdPath}` }, 404));
|
|
297
|
+
return addCorsHeaders(jsonResponse(buildInputSchema(ep.command)));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (builtins.help && routePath === '_help') {
|
|
301
|
+
const accept = req.headers.get('accept') ?? '';
|
|
302
|
+
const format = accept.includes('application/json') ? 'json' : 'markdown';
|
|
303
|
+
const helpText = generateHelp(existingCommand, existingCommand, { format, detail: 'full' });
|
|
304
|
+
if (format === 'json') return addCorsHeaders(jsonResponse(JSON.parse(helpText)));
|
|
305
|
+
return addCorsHeaders(new Response(helpText, { status: 200, headers: { 'Content-Type': 'text/markdown' } }));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (builtins.help && routePath.startsWith('_help/')) {
|
|
309
|
+
const cmdPath = routePath.slice('_help/'.length);
|
|
310
|
+
const ep = routeMap.get(cmdPath);
|
|
311
|
+
if (!ep) return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${cmdPath}` }, 404));
|
|
312
|
+
const accept = req.headers.get('accept') ?? '';
|
|
313
|
+
const format = accept.includes('application/json') ? 'json' : 'markdown';
|
|
314
|
+
const helpText = generateHelp(existingCommand, ep.command, { format, detail: 'full' });
|
|
315
|
+
if (format === 'json') return addCorsHeaders(jsonResponse(JSON.parse(helpText)));
|
|
316
|
+
return addCorsHeaders(new Response(helpText, { status: 200, headers: { 'Content-Type': 'text/markdown' } }));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (builtins.docs && routePath === '_openapi') {
|
|
320
|
+
return addCorsHeaders(jsonResponse(getOpenApiSpec()));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (builtins.docs && routePath === '_docs') {
|
|
324
|
+
const openapiUrl = `${basePath}_openapi`;
|
|
325
|
+
const title = existingCommand.title || existingCommand.name;
|
|
326
|
+
const html = scalarDocsHtml(openapiUrl, title);
|
|
327
|
+
return addCorsHeaders(new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Route to command
|
|
332
|
+
const endpoint = routeMap.get(routePath);
|
|
333
|
+
if (!endpoint) {
|
|
334
|
+
return addCorsHeaders(jsonResponse({ ok: false, error: 'not_found', message: `Command not found: ${routePath || '/'}` }, 404));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Enforce method based on mutation flag
|
|
338
|
+
if (endpoint.command.mutation && req.method === 'GET') {
|
|
339
|
+
return addCorsHeaders(
|
|
340
|
+
new Response(JSON.stringify({ ok: false, error: 'method_not_allowed', message: 'Mutation commands only accept POST' }), {
|
|
341
|
+
status: 405,
|
|
342
|
+
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
348
|
+
return addCorsHeaders(new Response(null, { status: 405, headers: { Allow: endpoint.command.mutation ? 'POST' : 'GET, POST' } }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Build command string from request
|
|
352
|
+
const commandPath = toCommandPath(routePath);
|
|
353
|
+
let argParts: string[];
|
|
354
|
+
|
|
355
|
+
if (req.method === 'POST') {
|
|
356
|
+
try {
|
|
357
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
358
|
+
argParts = serializeArgsToFlags(body);
|
|
359
|
+
} catch {
|
|
360
|
+
return addCorsHeaders(jsonResponse({ ok: false, error: 'bad_request', message: 'Invalid JSON body' }, 400));
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
// GET: query string → flags
|
|
364
|
+
argParts = [];
|
|
365
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
366
|
+
if (key === '_') {
|
|
367
|
+
// Positional args
|
|
368
|
+
argParts.push(value);
|
|
369
|
+
} else {
|
|
370
|
+
argParts.push(value === '' ? `--${key}` : `--${key}=${value}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const commandString = [commandPath, ...argParts].filter(Boolean).join(' ');
|
|
376
|
+
const response = await evalAndRespond(commandString, req);
|
|
377
|
+
return addCorsHeaders(response);
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Start the serve HTTP server. */
|
|
382
|
+
export async function startServeServer(
|
|
383
|
+
_program: AnyPadroneProgram,
|
|
384
|
+
existingCommand: AnyPadroneCommand,
|
|
385
|
+
evalCommand: AnyPadroneProgram['eval'],
|
|
386
|
+
prefs?: PadroneServePreferences,
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
const handler = createServeHandler(existingCommand, evalCommand, prefs);
|
|
389
|
+
const http = await import('node:http');
|
|
390
|
+
|
|
391
|
+
const port = prefs?.port ?? 3000;
|
|
392
|
+
const host = prefs?.host ?? '127.0.0.1';
|
|
393
|
+
const basePath = (prefs?.basePath ?? '/').replace(/\/$/, '/');
|
|
394
|
+
|
|
395
|
+
const server = http.createServer(async (req, res) => {
|
|
396
|
+
const url = `http://${host}:${port}${req.url}`;
|
|
397
|
+
const headers = new Headers();
|
|
398
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
399
|
+
if (value) headers.set(key, Array.isArray(value) ? value.join(', ') : value);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const fetchReq = new Request(url, {
|
|
403
|
+
method: req.method,
|
|
404
|
+
headers,
|
|
405
|
+
body: req.method !== 'GET' && req.method !== 'HEAD' ? await readBody(req) : undefined,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const response = await handler(fetchReq);
|
|
409
|
+
const resHeaders: Record<string, string> = {};
|
|
410
|
+
response.headers.forEach((v, k) => {
|
|
411
|
+
resHeaders[k] = v;
|
|
412
|
+
});
|
|
413
|
+
res.writeHead(response.status, resHeaders);
|
|
414
|
+
const body = await response.text();
|
|
415
|
+
res.end(body);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const { getCommandRuntime } = await import('../core/commands.ts');
|
|
419
|
+
const runtime = getCommandRuntime(existingCommand);
|
|
420
|
+
|
|
421
|
+
return new Promise<void>((resolve, reject) => {
|
|
422
|
+
server.listen(port, host, () => {
|
|
423
|
+
runtime.error(`REST server listening on http://${host}:${port}${basePath}`);
|
|
424
|
+
const builtins = { health: true, help: true, schema: true, docs: true, ...prefs?.builtins };
|
|
425
|
+
if (builtins.docs) runtime.error(`API docs: http://${host}:${port}${basePath}_docs`);
|
|
426
|
+
});
|
|
427
|
+
server.on('error', reject);
|
|
428
|
+
const unsubscribe = runtime.onSignal?.(() => {
|
|
429
|
+
server.close(() => resolve());
|
|
430
|
+
});
|
|
431
|
+
server.on('close', () => unsubscribe?.());
|
|
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
|
+
return readStreamAsText(req as AsyncIterable<Uint8Array>);
|
|
438
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { InteractivePromptConfig, PadroneRuntime } from '../core/runtime.ts';
|
|
2
|
+
import type { AnyPadroneCommand, PadroneCommandResult } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Result from a single command execution in test mode.
|
|
6
|
+
* Extends the standard PadroneCommandResult with captured I/O.
|
|
7
|
+
*/
|
|
8
|
+
export type TestCliResult = {
|
|
9
|
+
/** The matched command. */
|
|
10
|
+
command: AnyPadroneCommand;
|
|
11
|
+
/** Validated arguments (undefined if validation failed). */
|
|
12
|
+
args: unknown;
|
|
13
|
+
/** Action handler return value (undefined if validation failed or no action). */
|
|
14
|
+
result: unknown;
|
|
15
|
+
/** Validation issues, if any. */
|
|
16
|
+
issues: { message: string; path?: PropertyKey[] }[] | undefined;
|
|
17
|
+
/** All values passed to `runtime.output()`. */
|
|
18
|
+
stdout: unknown[];
|
|
19
|
+
/** All strings passed to `runtime.error()`. */
|
|
20
|
+
stderr: string[];
|
|
21
|
+
/** The thrown error, if the command threw (routing error, action error, etc.). */
|
|
22
|
+
error?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Result from a REPL test session.
|
|
27
|
+
*/
|
|
28
|
+
export type TestReplResult = {
|
|
29
|
+
/** One entry per successfully executed command (validation errors are captured in stderr, not here). */
|
|
30
|
+
results: Omit<TestCliResult, 'stdout' | 'stderr'>[];
|
|
31
|
+
/** All output from the entire REPL session. */
|
|
32
|
+
stdout: unknown[];
|
|
33
|
+
/** All errors from the entire REPL session. */
|
|
34
|
+
stderr: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fluent builder for setting up CLI test scenarios.
|
|
39
|
+
*/
|
|
40
|
+
export type TestCliBuilder = {
|
|
41
|
+
/** Set the CLI input string (e.g. `'deploy --env production'`). */
|
|
42
|
+
args(input: string): TestCliBuilder;
|
|
43
|
+
/** Set environment variables visible to the command. */
|
|
44
|
+
env(vars: Record<string, string | undefined>): TestCliBuilder;
|
|
45
|
+
/** Provide mock answers for interactive prompts. Keys are field names. */
|
|
46
|
+
prompt(answers: Record<string, unknown>): TestCliBuilder;
|
|
47
|
+
/** Provide mock stdin data (simulates piped input). */
|
|
48
|
+
stdin(data: string): TestCliBuilder;
|
|
49
|
+
/**
|
|
50
|
+
* Execute a single command via `eval()` and return the result with captured I/O.
|
|
51
|
+
* @param input - Optional CLI input string. Overrides `.args()` if provided.
|
|
52
|
+
*/
|
|
53
|
+
run(input?: string): Promise<TestCliResult>;
|
|
54
|
+
/**
|
|
55
|
+
* Run a REPL session with the given sequence of inputs.
|
|
56
|
+
* Each string in the array is fed as one line of input.
|
|
57
|
+
* The session ends after all inputs are consumed (EOF).
|
|
58
|
+
*/
|
|
59
|
+
repl(inputs: string[]): Promise<TestReplResult>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a fluent test builder for a Padrone program.
|
|
64
|
+
* Captures all I/O and provides a clean interface for assertions.
|
|
65
|
+
*
|
|
66
|
+
* Works with any test framework (bun:test, vitest, jest, node:test, etc.).
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { testCli } from 'padrone/test'
|
|
71
|
+
*
|
|
72
|
+
* const result = await testCli(myProgram)
|
|
73
|
+
* .args('deploy --env production')
|
|
74
|
+
* .env({ API_KEY: 'xxx' })
|
|
75
|
+
* .run()
|
|
76
|
+
*
|
|
77
|
+
* expect(result.result).toBe('Deployed')
|
|
78
|
+
* expect(result.stdout).toContain('Deploying...')
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* // Shorthand: pass input directly to run()
|
|
84
|
+
* const result = await testCli(myProgram).run('deploy --env production')
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* // Test interactive prompts
|
|
90
|
+
* const result = await testCli(myProgram)
|
|
91
|
+
* .args('init')
|
|
92
|
+
* .prompt({ name: 'myapp', template: 'react' })
|
|
93
|
+
* .run()
|
|
94
|
+
*
|
|
95
|
+
* expect(result.args).toEqual({ name: 'myapp', template: 'react' })
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```ts
|
|
100
|
+
* // Test REPL sessions
|
|
101
|
+
* const { results } = await testCli(myProgram)
|
|
102
|
+
* .repl(['greet World', 'add --a=2 --b=3'])
|
|
103
|
+
*
|
|
104
|
+
* expect(results[0].result).toBe('Hello, World!')
|
|
105
|
+
* expect(results[1].result).toBe(5)
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Any program-like object that has `eval`, `runtime`, and `repl` methods.
|
|
110
|
+
* Avoids strict variance issues with `AnyPadroneProgram`.
|
|
111
|
+
*/
|
|
112
|
+
type TestableProgram = {
|
|
113
|
+
eval: (input: string, prefs?: Record<string, unknown>) => any;
|
|
114
|
+
runtime: (runtime: PadroneRuntime) => TestableProgram;
|
|
115
|
+
repl: (options?: { greeting?: false; hint?: false }) => AsyncIterable<any>;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function testCli(program: TestableProgram): TestCliBuilder {
|
|
119
|
+
let input: string | undefined;
|
|
120
|
+
let envVars: Record<string, string | undefined> | undefined;
|
|
121
|
+
let promptAnswers: Record<string, unknown> | undefined;
|
|
122
|
+
let stdinData: string | undefined;
|
|
123
|
+
|
|
124
|
+
const builder: TestCliBuilder = {
|
|
125
|
+
args(args: string) {
|
|
126
|
+
input = args;
|
|
127
|
+
return builder;
|
|
128
|
+
},
|
|
129
|
+
env(vars) {
|
|
130
|
+
envVars = vars;
|
|
131
|
+
return builder;
|
|
132
|
+
},
|
|
133
|
+
prompt(answers) {
|
|
134
|
+
promptAnswers = answers;
|
|
135
|
+
return builder;
|
|
136
|
+
},
|
|
137
|
+
stdin(data: string) {
|
|
138
|
+
stdinData = data;
|
|
139
|
+
return builder;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async run(runInput?: string) {
|
|
143
|
+
const stdout: unknown[] = [];
|
|
144
|
+
const stderr: string[] = [];
|
|
145
|
+
|
|
146
|
+
const runtime = buildRuntime(stdout, stderr, { envVars, promptAnswers, stdinData });
|
|
147
|
+
const testProgram = program.runtime(runtime);
|
|
148
|
+
|
|
149
|
+
const evalResult = await testProgram.eval(runInput ?? input ?? '', {});
|
|
150
|
+
if (evalResult.error) {
|
|
151
|
+
stderr.push(evalResult.error instanceof Error ? evalResult.error.message : String(evalResult.error));
|
|
152
|
+
}
|
|
153
|
+
return toTestResult(evalResult, stdout, stderr);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async repl(inputs: string[]) {
|
|
157
|
+
const stdout: unknown[] = [];
|
|
158
|
+
const stderr: string[] = [];
|
|
159
|
+
|
|
160
|
+
const runtime = buildRuntime(stdout, stderr, {
|
|
161
|
+
envVars,
|
|
162
|
+
promptAnswers,
|
|
163
|
+
readLine: createMockReadLine(inputs),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const testProgram = program.runtime(runtime);
|
|
167
|
+
const results: Omit<TestCliResult, 'stdout' | 'stderr'>[] = [];
|
|
168
|
+
|
|
169
|
+
for await (const r of testProgram.repl({ greeting: false, hint: false })) {
|
|
170
|
+
results.push({
|
|
171
|
+
command: r.command!,
|
|
172
|
+
args: r.args,
|
|
173
|
+
result: r.result,
|
|
174
|
+
issues: r.argsResult?.issues as TestCliResult['issues'],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { results, stdout, stderr };
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return builder;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function toTestResult(evalResult: PadroneCommandResult, stdout: unknown[], stderr: string[]): TestCliResult {
|
|
186
|
+
return {
|
|
187
|
+
command: evalResult.command!,
|
|
188
|
+
args: evalResult.args,
|
|
189
|
+
result: evalResult.result,
|
|
190
|
+
error: evalResult.error,
|
|
191
|
+
issues: evalResult.argsResult?.issues as TestCliResult['issues'],
|
|
192
|
+
stdout,
|
|
193
|
+
stderr,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildRuntime(
|
|
198
|
+
stdout: unknown[],
|
|
199
|
+
stderr: string[],
|
|
200
|
+
opts: {
|
|
201
|
+
envVars?: Record<string, string | undefined>;
|
|
202
|
+
promptAnswers?: Record<string, unknown>;
|
|
203
|
+
readLine?: (prompt: string) => Promise<string | null>;
|
|
204
|
+
stdinData?: string;
|
|
205
|
+
},
|
|
206
|
+
): PadroneRuntime {
|
|
207
|
+
const runtime: PadroneRuntime = {
|
|
208
|
+
output: (...args: unknown[]) => stdout.push(...args),
|
|
209
|
+
error: (text: string) => stderr.push(text),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (opts.envVars) {
|
|
213
|
+
runtime.env = () => opts.envVars!;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (opts.promptAnswers) {
|
|
217
|
+
runtime.interactive = 'supported';
|
|
218
|
+
runtime.prompt = async (config: InteractivePromptConfig) => opts.promptAnswers![config.name];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (opts.readLine) {
|
|
222
|
+
runtime.readLine = opts.readLine;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (opts.stdinData !== undefined) {
|
|
226
|
+
runtime.stdin = {
|
|
227
|
+
isTTY: false,
|
|
228
|
+
async text() {
|
|
229
|
+
return opts.stdinData!;
|
|
230
|
+
},
|
|
231
|
+
async *lines() {
|
|
232
|
+
const lines = opts.stdinData!.split('\n');
|
|
233
|
+
// Remove trailing empty line from final newline (matches readline behavior)
|
|
234
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
yield line;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
} else {
|
|
241
|
+
// No stdin data: simulate a TTY (no piped input) to avoid reading from process.stdin
|
|
242
|
+
runtime.stdin = {
|
|
243
|
+
isTTY: true,
|
|
244
|
+
async text() {
|
|
245
|
+
return '';
|
|
246
|
+
},
|
|
247
|
+
async *lines() {
|
|
248
|
+
// no lines
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return runtime;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function createMockReadLine(inputs: string[]): (prompt: string) => Promise<string | null> {
|
|
257
|
+
let index = 0;
|
|
258
|
+
return async (_prompt: string): Promise<string | null> => {
|
|
259
|
+
if (index >= inputs.length) return null;
|
|
260
|
+
return inputs[index++] ?? null;
|
|
261
|
+
};
|
|
262
|
+
}
|