argsbarg 6.1.4 → 6.1.6

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.
@@ -0,0 +1,39 @@
1
+ /*
2
+ HTTP path prefix helpers and framework route guards.
3
+ */
4
+
5
+ import type { CliProgram } from "~/core/types.ts";
6
+
7
+ /** Top-level segments reserved for framework routes when `pathPrefix` is empty. */
8
+ export const HTTP_RESERVED_TOP_LEVEL_SEGMENTS = new Set(["health", "openapi.json", "swagger", "tools"]);
9
+
10
+ /** Resolved path prefix for user HTTP routes (`""` by default, or e.g. `"/api"`). */
11
+ export function resolveHttpPathPrefix(program: CliProgram): string {
12
+ const raw = program.httpServer?.pathPrefix;
13
+ if (raw === undefined || raw === "") {
14
+ return "";
15
+ }
16
+ return raw;
17
+ }
18
+
19
+ /** OpenAPI / route path for a user command from URL segments. */
20
+ export function buildHttpUserPath(prefix: string, urlSegments: string[]): string {
21
+ const tail = urlSegments.map((s) => (s.startsWith(":") ? `{${s.slice(1)}}` : s)).join("/");
22
+ if (!prefix) {
23
+ return tail ? `/${tail}` : "/";
24
+ }
25
+ return tail ? `${prefix}/${tail}` : prefix;
26
+ }
27
+
28
+ /** Regex-safe path prefix for route matching. */
29
+ export function httpUserPathRegexPrefix(prefix: string): string {
30
+ if (!prefix) {
31
+ return "";
32
+ }
33
+ return prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
+ }
35
+
36
+ /** Wildcard label for docs (e.g. `/api/*` or `/*`). */
37
+ export function httpUserPathGlob(prefix: string): string {
38
+ return prefix ? `${prefix}/*` : "/*";
39
+ }
@@ -1,5 +1,5 @@
1
1
  /*
2
- HTTP/MCP readiness checks for GET /health/ready (orchestrator probes only).
2
+ HTTP/MCP readiness checks for GET /health/readiness (orchestrator probes only).
3
3
  */
4
4
 
5
5
  import type { AnyAppConfigSnapshot } from "~/config/context.ts";
@@ -14,13 +14,14 @@ import {
14
14
  } from "~/core/types.ts";
15
15
  import { formatMcpOptionValue } from "~/mcp/tools.ts";
16
16
  import { isHttpDisabled, isHttpHidden } from "~/runtime/exposure.ts";
17
+ import { buildHttpUserPath, httpUserPathRegexPrefix, resolveHttpPathPrefix } from "./paths.ts";
17
18
 
18
19
  const VERB_KEYS = new Set(["get", "post", "put", "patch", "delete"]);
19
20
 
20
21
  /** One HTTP route derived from a user leaf command. */
21
22
  export interface HttpRouteDef {
22
23
  method: CliHttpMethod;
23
- /** OpenAPI-style path e.g. `/api/workspaces/{id}`. */
24
+ /** OpenAPI-style path e.g. `/workspaces/{id}` or `/api/workspaces/{id}`. */
24
25
  openApiPath: string;
25
26
  /** Regex matching pathname (no query). */
26
27
  pathPattern: RegExp;
@@ -67,7 +68,7 @@ type WalkState = {
67
68
  paramNames: string[];
68
69
  };
69
70
 
70
- function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): void {
71
+ function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState, pathPrefix: string): void {
71
72
  const urlSegments = [...state.urlSegments];
72
73
  const commandPath = [...state.commandPath];
73
74
  if (!isVerbLeaf(leaf)) {
@@ -79,9 +80,11 @@ function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): voi
79
80
  commandPath.push(leaf.key);
80
81
  }
81
82
  }
82
- const openApiPath = `/api/${urlSegments.map((s) => (s.startsWith(":") ? `{${s.slice(1)}}` : s)).join("/")}`;
83
+ const openApiPath = buildHttpUserPath(pathPrefix, urlSegments);
83
84
  const patternParts = urlSegments.map((s) => (s.startsWith(":") ? "([^/]+)" : escapeRegex(s)));
84
- const pathPattern = new RegExp(`^/api/${patternParts.join("/")}/?$`);
85
+ const regexPrefix = httpUserPathRegexPrefix(pathPrefix);
86
+ const tail = patternParts.length > 0 ? `/${patternParts.join("/")}` : "";
87
+ const pathPattern = new RegExp(`^${regexPrefix}${tail}/?$`);
85
88
  routes.push({
86
89
  method: inferHttpMethod(leaf),
87
90
  openApiPath,
@@ -92,14 +95,14 @@ function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): voi
92
95
  });
93
96
  }
94
97
 
95
- function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
98
+ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[], pathPrefix: string): void {
96
99
  if (isHttpDisabled(node) || isHttpHidden(node)) {
97
100
  return;
98
101
  }
99
102
 
100
103
  if (isCliLeaf(node)) {
101
104
  if (leafHttpExposed(node)) {
102
- pushRoute(routes, node, state);
105
+ pushRoute(routes, node, state, pathPrefix);
103
106
  }
104
107
  return;
105
108
  }
@@ -115,6 +118,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
115
118
  paramNames: [...state.paramNames, paramName],
116
119
  },
117
120
  routes,
121
+ pathPrefix,
118
122
  );
119
123
  continue;
120
124
  }
@@ -127,6 +131,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
127
131
  paramNames: state.paramNames,
128
132
  },
129
133
  routes,
134
+ pathPrefix,
130
135
  );
131
136
  continue;
132
137
  }
@@ -139,6 +144,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
139
144
  paramNames: state.paramNames,
140
145
  },
141
146
  routes,
147
+ pathPrefix,
142
148
  );
143
149
  }
144
150
  }
@@ -154,8 +160,10 @@ export function collectHttpRoutes(program: CliProgram): HttpRouteDef[] {
154
160
  return routes;
155
161
  }
156
162
 
163
+ const pathPrefix = resolveHttpPathPrefix(program);
164
+
157
165
  if (isCliLeaf(program)) {
158
- walk(program, { urlSegments: [], commandPath: [], paramNames: [] }, routes);
166
+ walk(program, { urlSegments: [], commandPath: [], paramNames: [] }, routes, pathPrefix);
159
167
  return routes;
160
168
  }
161
169
 
@@ -171,9 +179,14 @@ export function collectHttpRoutes(program: CliProgram): HttpRouteDef[] {
171
179
  continue;
172
180
  }
173
181
  if (isCliLeaf(child)) {
174
- walk(child, { urlSegments: [], commandPath: [child.key], paramNames: [] }, routes);
182
+ walk(child, { urlSegments: [], commandPath: [child.key], paramNames: [] }, routes, pathPrefix);
175
183
  } else {
176
- walk(child, { urlSegments: [segmentForNode(child)], commandPath: [child.key], paramNames: [] }, routes);
184
+ walk(
185
+ child,
186
+ { urlSegments: [segmentForNode(child)], commandPath: [child.key], paramNames: [] },
187
+ routes,
188
+ pathPrefix,
189
+ );
177
190
  }
178
191
  }
179
192
 
@@ -112,11 +112,11 @@ export async function handleApiRequest(
112
112
  const root = cli.program;
113
113
  const path = url.pathname;
114
114
 
115
- if (request.method === "GET" && (path === "/health" || path === "/health/live")) {
115
+ if (request.method === "GET" && path === "/health/liveness") {
116
116
  return finish(jsonResponse(200, { ok: true }));
117
117
  }
118
118
 
119
- if (request.method === "GET" && path === "/health/ready") {
119
+ if (request.method === "GET" && path === "/health/readiness") {
120
120
  const runtime = cli.server?.runtime;
121
121
  if (!runtime) {
122
122
  return finish(jsonResponse(200, { ok: true }));
@@ -141,12 +141,12 @@ export async function handleApiRequest(
141
141
  );
142
142
  }
143
143
 
144
- if (path.startsWith("/api")) {
145
- const match = matchHttpRoute(root, request.method, path);
146
- if (!match.ok) {
147
- return finish(apiErrorResponse(404, { error: "Not found" }));
148
- }
144
+ if (path.startsWith("/tools")) {
145
+ return finish(apiErrorResponse(404, { error: "Not found" }));
146
+ }
149
147
 
148
+ const match = matchHttpRoute(root, request.method, path);
149
+ if (match.ok) {
150
150
  let body: Record<string, unknown> = {};
151
151
  if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
152
152
  const rawBody = await request.text();
@@ -181,10 +181,6 @@ export async function handleApiRequest(
181
181
  return finish(headlessFailureToHttpResponse(result, obscure), result.invokeResult?.failureKind, result.message);
182
182
  }
183
183
 
184
- if (path.startsWith("/tools")) {
185
- return finish(apiErrorResponse(404, { error: "Not found" }));
186
- }
187
-
188
184
  return finish(apiErrorResponse(404, { error: "Not found" }));
189
185
  }
190
186
 
@@ -178,20 +178,40 @@ describe("httpServer validation", () => {
178
178
  } as unknown as import("~/core/types.ts").CliProgram;
179
179
  expect(() => cliValidateProgram(root)).toThrow(/httpServer is only supported on the program root/);
180
180
  });
181
+
182
+ test("rejects reserved top-level command when pathPrefix is empty", () => {
183
+ const root = testProgram({
184
+ key: "app",
185
+ description: "",
186
+ httpServer: { enabled: true },
187
+ commands: [{ key: "health", description: "user", handler: () => {} }],
188
+ });
189
+ expect(() => cliValidateProgram(root)).toThrow(/Reserved HTTP command name/);
190
+ });
191
+
192
+ test("rejects invalid pathPrefix", () => {
193
+ const root = testProgram({
194
+ key: "app",
195
+ description: "",
196
+ httpServer: { enabled: true, pathPrefix: "api" },
197
+ handler: () => {},
198
+ });
199
+ expect(() => cliValidateProgram(root)).toThrow(/pathPrefix must start with \//);
200
+ });
181
201
  });
182
202
 
183
203
  describe("HTTP API routes", () => {
184
204
  const program = nestedApiFixture();
185
205
  cliValidateProgram(program);
186
206
 
187
- test("GET /health/live returns ok", async () => {
188
- const res = await apiRequest(program, new Request("http://127.0.0.1/health/live"));
207
+ test("GET /health/liveness returns ok", async () => {
208
+ const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness"));
189
209
  expect(res.status).toBe(200);
190
210
  expect(await res.json()).toEqual({ ok: true });
191
211
  });
192
212
 
193
- test("GET /health/ready returns ok when healthy", async () => {
194
- const res = await apiRequest(program, new Request("http://127.0.0.1/health/ready"), { withServer: true });
213
+ test("GET /health/readiness returns ok when healthy", async () => {
214
+ const res = await apiRequest(program, new Request("http://127.0.0.1/health/readiness"), { withServer: true });
195
215
  expect(res.status).toBe(200);
196
216
  const body = (await res.json()) as { ok: boolean; checks: Record<string, { ok: boolean }> };
197
217
  expect(body.ok).toBe(true);
@@ -199,7 +219,7 @@ describe("HTTP API routes", () => {
199
219
  expect(body.checks.config_required.ok).toBe(true);
200
220
  });
201
221
 
202
- test("GET /health/ready returns 503 when custom readiness fails", async () => {
222
+ test("GET /health/readiness returns 503 when custom readiness fails", async () => {
203
223
  const failProgram = testProgram({
204
224
  key: "app",
205
225
  description: "Test",
@@ -209,7 +229,7 @@ describe("HTTP API routes", () => {
209
229
  handler: () => ({ ok: true }),
210
230
  });
211
231
  cliValidateProgram(failProgram);
212
- const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/ready"), { withServer: true });
232
+ const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/readiness"), { withServer: true });
213
233
  expect(res.status).toBe(503);
214
234
  const body = (await res.json()) as { ok: boolean; checks: { custom: { ok: boolean } } };
215
235
  expect(body.ok).toBe(false);
@@ -232,10 +252,7 @@ describe("HTTP API routes", () => {
232
252
  ],
233
253
  });
234
254
  cliValidateProgram(throwProgram);
235
- const res = await apiRequest(
236
- throwProgram,
237
- new Request("http://127.0.0.1/api/boom", { method: "POST", body: "{}" }),
238
- );
255
+ const res = await apiRequest(throwProgram, new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" }));
239
256
  expect(res.status).toBe(500);
240
257
  });
241
258
 
@@ -264,7 +281,7 @@ describe("HTTP API routes", () => {
264
281
  };
265
282
  const res = await handleApiRequest(
266
283
  cli,
267
- new Request("http://127.0.0.1/api/boom", { method: "POST", body: "{}" }),
284
+ new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" }),
268
285
  resolved,
269
286
  );
270
287
  expect(res.status).toBe(500);
@@ -272,15 +289,15 @@ describe("HTTP API routes", () => {
272
289
  expect(body.error).toBe("An unexpected error occurred.");
273
290
  });
274
291
 
275
- test("GET /health includes CORS headers", async () => {
276
- const res = await apiRequest(program, new Request("http://127.0.0.1/health"));
292
+ test("GET /health/liveness includes CORS headers", async () => {
293
+ const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness"));
277
294
  expect(res.status).toBe(200);
278
295
  expect(res.headers.get("access-control-allow-origin")).toBe("*");
279
296
  expect(await res.json()).toEqual({ ok: true });
280
297
  });
281
298
 
282
299
  test("OPTIONS returns 204 with CORS headers", async () => {
283
- const res = await apiRequest(program, new Request("http://127.0.0.1/api/stat/owner/lookup", { method: "OPTIONS" }));
300
+ const res = await apiRequest(program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "OPTIONS" }));
284
301
  expect(res.status).toBe(204);
285
302
  expect(res.headers.get("access-control-allow-origin")).toBe("*");
286
303
  expect(res.headers.get("access-control-allow-methods")).toContain("POST");
@@ -290,7 +307,7 @@ describe("HTTP API routes", () => {
290
307
  const readme = join(import.meta.dir, "..", "..", "..", "README.md");
291
308
  const res = await apiRequest(
292
309
  program,
293
- new Request("http://127.0.0.1/api/stat/owner/lookup", {
310
+ new Request("http://127.0.0.1/stat/owner/lookup", {
294
311
  method: "POST",
295
312
  headers: { "content-type": "application/json" },
296
313
  body: JSON.stringify({ "user-name": "alice", path: readme, json: true }),
@@ -305,7 +322,7 @@ describe("HTTP API routes", () => {
305
322
  const readme = join(import.meta.dir, "..", "..", "..", "README.md");
306
323
  const res = await apiRequest(
307
324
  program,
308
- new Request("http://127.0.0.1/api/stat/owner/lookup", {
325
+ new Request("http://127.0.0.1/stat/owner/lookup", {
309
326
  method: "POST",
310
327
  headers: { "content-type": "application/json" },
311
328
  body: JSON.stringify({ "user-name": "alice", path: readme }),
@@ -331,7 +348,7 @@ describe("HTTP API routes", () => {
331
348
  test("POST /api/pdf returns PDF bytes with 201", async () => {
332
349
  const res = await apiRequest(
333
350
  program,
334
- new Request("http://127.0.0.1/api/pdf", {
351
+ new Request("http://127.0.0.1/pdf", {
335
352
  method: "POST",
336
353
  headers: { "content-type": "application/json" },
337
354
  body: "{}",
@@ -346,7 +363,7 @@ describe("HTTP API routes", () => {
346
363
  test("POST /api/html returns HTML with 201", async () => {
347
364
  const res = await apiRequest(
348
365
  program,
349
- new Request("http://127.0.0.1/api/html", {
366
+ new Request("http://127.0.0.1/html", {
350
367
  method: "POST",
351
368
  body: "{}",
352
369
  }),
@@ -359,7 +376,7 @@ describe("HTTP API routes", () => {
359
376
  test("POST /api/silent returns 500 when handler has no response", async () => {
360
377
  const res = await apiRequest(
361
378
  program,
362
- new Request("http://127.0.0.1/api/silent", {
379
+ new Request("http://127.0.0.1/silent", {
363
380
  method: "POST",
364
381
  body: "{}",
365
382
  }),
@@ -372,7 +389,7 @@ describe("HTTP API routes", () => {
372
389
  test("POST /api returns 404 for unknown route", async () => {
373
390
  const res = await apiRequest(
374
391
  program,
375
- new Request("http://127.0.0.1/api/missing_tool", {
392
+ new Request("http://127.0.0.1/missing_tool", {
376
393
  method: "POST",
377
394
  headers: { "content-type": "application/json" },
378
395
  body: "{}",
@@ -384,7 +401,7 @@ describe("HTTP API routes", () => {
384
401
  test("POST /api returns 400 for bad args", async () => {
385
402
  const res = await apiRequest(
386
403
  program,
387
- new Request("http://127.0.0.1/api/stat/owner/lookup", {
404
+ new Request("http://127.0.0.1/stat/owner/lookup", {
388
405
  method: "POST",
389
406
  headers: { "content-type": "application/json" },
390
407
  body: JSON.stringify({ "user-name": "alice" }),
@@ -415,7 +432,7 @@ describe("HTTP API routes", () => {
415
432
  cliValidateProgram(failProgram);
416
433
  const res = await apiRequest(
417
434
  failProgram,
418
- new Request("http://127.0.0.1/api/fail", {
435
+ new Request("http://127.0.0.1/fail", {
419
436
  method: "POST",
420
437
  headers: { "content-type": "application/json" },
421
438
  body: "{}",
@@ -431,10 +448,10 @@ describe("HTTP API routes", () => {
431
448
  expect(res.status).toBe(200);
432
449
  const doc = (await res.json()) as { openapi: string; paths: Record<string, unknown> };
433
450
  expect(doc.openapi).toBe("3.1.0");
434
- expect(doc.paths["/api/stat/owner/lookup"]).toBeDefined();
435
- expect(doc.paths["/health"]).toBeDefined();
436
- expect(doc.paths["/health/live"]).toBeDefined();
437
- expect(doc.paths["/health/ready"]).toBeDefined();
451
+ expect(doc.paths["/stat/owner/lookup"]).toBeDefined();
452
+ expect(doc.paths["/health/liveness"]).toBeDefined();
453
+ expect(doc.paths["/health/readiness"]).toBeDefined();
454
+ expect(doc.paths["/health"]).toBeUndefined();
438
455
  });
439
456
 
440
457
  test("GET /swagger returns Swagger UI HTML", async () => {
@@ -463,17 +480,21 @@ test("generateOpenApi includes health probe paths", () => {
463
480
  {
464
481
  get: {
465
482
  tags: string[];
483
+ summary: string;
466
484
  responses: Record<string, { content: Record<string, { schema: Record<string, unknown> }> }>;
467
485
  };
468
486
  }
469
487
  >;
470
488
  };
471
489
  expect(doc.tags.some((t) => t.name === "health")).toBe(true);
472
- expect(doc.paths["/health"]?.get.tags).toContain("health");
473
- expect(doc.paths["/health/live"]?.get.responses["200"]).toBeDefined();
474
- expect(doc.paths["/health/ready"]?.get.responses["200"]).toBeDefined();
475
- expect(doc.paths["/health/ready"]?.get.responses["503"]).toBeDefined();
476
- const readySchema = doc.paths["/health/ready"]?.get.responses["200"].content["application/json; charset=utf-8"]
490
+ expect(doc.paths["/health"]).toBeUndefined();
491
+ expect(doc.paths["/health/liveness"]?.get.tags).toContain("health");
492
+ expect(doc.paths["/health/liveness"]?.get.summary).toBe("Liveness probe");
493
+ expect(doc.paths["/health/readiness"]?.get.summary).toBe("Readiness probe");
494
+ expect(doc.paths["/health/liveness"]?.get.responses["200"]).toBeDefined();
495
+ expect(doc.paths["/health/readiness"]?.get.responses["200"]).toBeDefined();
496
+ expect(doc.paths["/health/readiness"]?.get.responses["503"]).toBeDefined();
497
+ const readySchema = doc.paths["/health/readiness"]?.get.responses["200"].content["application/json; charset=utf-8"]
477
498
  .schema as { properties?: { checks?: unknown } };
478
499
  expect(readySchema.properties?.checks).toBeDefined();
479
500
  });
@@ -501,9 +522,22 @@ test("generateOpenApi groups routes by top-level command tag", () => {
501
522
  expect(tagNames).toContain("stat");
502
523
  expect(tagNames).toContain("pdf");
503
524
  expect(doc.tags.find((t) => t.name === "stat")?.description).toBe("File metadata.");
504
- expect(doc.paths["/api/stat/owner/lookup"]?.post?.tags).toEqual(["stat"]);
505
- expect(doc.paths["/api/pdf"]?.post?.tags).toEqual(["pdf"]);
506
- expect(doc.paths["/api/read"]?.post?.tags).toEqual(["read"]);
525
+ expect(doc.paths["/stat/owner/lookup"]?.post?.tags).toEqual(["stat"]);
526
+ expect(doc.paths["/pdf"]?.post?.tags).toEqual(["pdf"]);
527
+ expect(doc.paths["/read"]?.post?.tags).toEqual(["read"]);
528
+ });
529
+
530
+ test("generateOpenApi honors httpServer.pathPrefix", () => {
531
+ const program = testProgram({
532
+ key: "app",
533
+ description: "Test app",
534
+ httpServer: { enabled: true, pathPrefix: "/api" },
535
+ commands: [{ key: "echo", description: "Echo.", handler: () => ({ ok: true }) }],
536
+ });
537
+ cliValidateProgram(program);
538
+ const doc = generateOpenApi(program) as { paths: Record<string, unknown> };
539
+ expect(doc.paths["/api/echo"]).toBeDefined();
540
+ expect(doc.paths["/echo"]).toBeUndefined();
507
541
  });
508
542
 
509
543
  test("generateOpenApi maps binary content types", () => {
@@ -511,7 +545,7 @@ test("generateOpenApi maps binary content types", () => {
511
545
  const doc = generateOpenApi(program) as {
512
546
  paths: Record<string, { post: { responses: { "201": { content: Record<string, unknown> } } } }>;
513
547
  };
514
- const pdf = doc.paths["/api/pdf"]?.post.responses["201"].content["application/pdf"] as {
548
+ const pdf = doc.paths["/pdf"]?.post.responses["201"].content["application/pdf"] as {
515
549
  schema: { format: string };
516
550
  };
517
551
  expect(pdf.schema.format).toBe("binary");
@@ -558,7 +592,7 @@ test("generateOpenApi dereferences nested inputSchema definitions", () => {
558
592
  }
559
593
  >;
560
594
  };
561
- const schema = doc.paths["/api/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
595
+ const schema = doc.paths["/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
562
596
  expect(schema.properties.invoice).toEqual({
563
597
  type: "object",
564
598
  properties: { id: { type: "string" } },
@@ -601,7 +635,7 @@ test("generateOpenApi generates requestBody for kind: json leaves", () => {
601
635
  }
602
636
  >;
603
637
  };
604
- const op = doc.paths["/api/render-invoice"]?.post;
638
+ const op = doc.paths["/render-invoice"]?.post;
605
639
  expect(op).toBeDefined();
606
640
  expect(op.requestBody).toBeDefined();
607
641
  expect(op.requestBody.required).toBe(true);