argsbarg 6.1.3 → 6.1.5

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.
@@ -3,8 +3,8 @@ Hand-built OpenAPI 3.1 document from exposed HTTP REST routes.
3
3
  */
4
4
 
5
5
  import { collectOptionDefs } from "~/core/parse.ts";
6
- import type { CliHttpMethod, CliProgram } from "~/core/types.ts";
7
- import { CliOptionKind, isJsonLeaf } from "~/core/types.ts";
6
+ import type { CliHttpMethod, CliNode, CliProgram } from "~/core/types.ts";
7
+ import { CliOptionKind, isCliLeaf, isJsonLeaf } from "~/core/types.ts";
8
8
  import { collectHttpRoutes, defaultSuccessStatus } from "./routes.ts";
9
9
  import { dereferenceJsonSchema } from "./schema-deref.ts";
10
10
 
@@ -106,15 +106,120 @@ function methodLower(method: CliHttpMethod): string {
106
106
  return method.toLowerCase();
107
107
  }
108
108
 
109
+ const HEALTH_TAG = "health";
110
+
111
+ const livenessResponseSchema = {
112
+ type: "object",
113
+ properties: { ok: { type: "boolean", const: true } },
114
+ required: ["ok"],
115
+ } as const;
116
+
117
+ const readinessCheckSchema = {
118
+ type: "object",
119
+ properties: {
120
+ ok: { type: "boolean" },
121
+ error: { type: "string" },
122
+ missing: { type: "array", items: { type: "string" } },
123
+ },
124
+ required: ["ok"],
125
+ } as const;
126
+
127
+ const readinessResponseSchema = {
128
+ type: "object",
129
+ properties: {
130
+ ok: { type: "boolean" },
131
+ checks: {
132
+ type: "object",
133
+ properties: {
134
+ config_file: readinessCheckSchema,
135
+ config_required: readinessCheckSchema,
136
+ custom: readinessCheckSchema,
137
+ },
138
+ required: ["config_file", "config_required", "custom"],
139
+ },
140
+ },
141
+ required: ["ok", "checks"],
142
+ } as const;
143
+
144
+ function jsonResponseEntry(description: string, schema: Record<string, unknown>): Record<string, unknown> {
145
+ return {
146
+ description,
147
+ content: {
148
+ [JSON_CONTENT_TYPE]: { schema },
149
+ },
150
+ };
151
+ }
152
+
153
+ function livenessGetOp(operationId: string, summary: string): Record<string, unknown> {
154
+ return {
155
+ tags: [HEALTH_TAG],
156
+ operationId,
157
+ summary,
158
+ responses: {
159
+ "200": jsonResponseEntry("Server is listening", livenessResponseSchema),
160
+ },
161
+ };
162
+ }
163
+
164
+ /** Framework health probe paths served alongside user command routes. */
165
+ function buildHealthPaths(): Record<string, unknown> {
166
+ return {
167
+ "/health": {
168
+ get: livenessGetOp("health", "Liveness probe (alias of /health/liveness)"),
169
+ },
170
+ "/health/liveness": {
171
+ get: livenessGetOp("health_liveness", "Liveness probe"),
172
+ },
173
+ "/health/readiness": {
174
+ get: {
175
+ tags: [HEALTH_TAG],
176
+ operationId: "health_readiness",
177
+ summary: "Readiness probe",
178
+ description: "Config file, required app config, and optional program.readiness checks.",
179
+ responses: {
180
+ "200": jsonResponseEntry("Ready to serve traffic", readinessResponseSchema),
181
+ "503": jsonResponseEntry("Not ready", readinessResponseSchema),
182
+ },
183
+ },
184
+ },
185
+ };
186
+ }
187
+
188
+ type HttpRoute = ReturnType<typeof collectHttpRoutes>[number];
189
+
190
+ /** Top-level command key for OpenAPI grouping (first non-`:param` segment). */
191
+ function topLevelCommandKey(route: HttpRoute, program: CliProgram): string {
192
+ const key = route.commandPath.find((k) => !k.startsWith(":"));
193
+ return key ?? program.key;
194
+ }
195
+
196
+ function findTopLevelCommand(program: CliProgram, key: string): CliNode | undefined {
197
+ if (isCliLeaf(program)) {
198
+ return program.key === key ? program : undefined;
199
+ }
200
+ return program.commands.find((c) => c.key === key);
201
+ }
202
+
203
+ /** OpenAPI tags for user command routes, one per top-level command. */
204
+ function collectCommandTags(program: CliProgram, routes: HttpRoute[]): { name: string; description?: string }[] {
205
+ const names = [...new Set(routes.map((route) => topLevelCommandKey(route, program)))].sort();
206
+ return names.map((name) => {
207
+ const node = findTopLevelCommand(program, name);
208
+ return node?.description ? { name, description: node.description } : { name };
209
+ });
210
+ }
211
+
109
212
  /** Generates an OpenAPI 3.1 document for the program's HTTP routes. */
110
213
  export function generateOpenApi(program: CliProgram): Record<string, unknown> {
111
214
  const routes = collectHttpRoutes(program);
112
- const paths: Record<string, unknown> = {};
215
+ const paths: Record<string, unknown> = program.httpServer?.enabled ? buildHealthPaths() : {};
216
+ const commandTags = collectCommandTags(program, routes);
113
217
 
114
218
  for (const route of routes) {
115
219
  const pathKey = route.openApiPath;
116
220
  const existing = (paths[pathKey] as Record<string, unknown> | undefined) ?? {};
117
221
  const op: Record<string, unknown> = {
222
+ tags: [topLevelCommandKey(route, program)],
118
223
  operationId: route.openApiPath.replace(/\//g, "_").replace(/[{}]/g, ""),
119
224
  summary: route.leaf.description ?? route.leaf.key,
120
225
  responses: {
@@ -150,9 +255,9 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
150
255
  description: opt.description,
151
256
  })),
152
257
  ];
153
- } else if (!isJsonLeaf(route.leaf)) {
258
+ } else {
154
259
  op.requestBody = {
155
- required: false,
260
+ required: isJsonLeaf(route.leaf),
156
261
  content: {
157
262
  [JSON_CONTENT_TYPE]: {
158
263
  schema: dereferenceJsonSchema(buildInputSchema(program, route)),
@@ -172,6 +277,9 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
172
277
  version: program.version,
173
278
  description: program.description,
174
279
  },
280
+ ...(program.httpServer?.enabled
281
+ ? { tags: [{ name: HEALTH_TAG, description: "Server health probes" }, ...commandTags] }
282
+ : {}),
175
283
  paths,
176
284
  };
177
285
  }
@@ -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";
@@ -98,7 +98,7 @@ export function apiErrorResponse(status: number, body: ApiToolCallErrorBody): Re
98
98
  });
99
99
  }
100
100
 
101
- /** Scalar API reference HTML served at GET /openapi-browser. */
101
+ /** Swagger UI HTML served at GET /swagger. */
102
102
  export function apiDocsHtml(): string {
103
103
  return `<!doctype html>
104
104
  <html lang="en">
@@ -106,15 +106,15 @@ export function apiDocsHtml(): string {
106
106
  <meta charset="utf-8" />
107
107
  <meta name="viewport" content="width=device-width, initial-scale=1" />
108
108
  <title>API Reference</title>
109
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
109
110
  </head>
110
111
  <body>
111
- <div id="api-reference"></div>
112
- <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
112
+ <div id="swagger-ui"></div>
113
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
113
114
  <script>
114
- Scalar.createApiReference("#api-reference", {
115
+ SwaggerUIBundle({
115
116
  url: "/openapi.json",
116
- orderSchemaPropertiesBy: "preserve",
117
- orderRequiredPropertiesFirst: false,
117
+ dom_id: "#swagger-ui",
118
118
  });
119
119
  </script>
120
120
  </body>
@@ -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" || 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 }));
@@ -129,7 +129,7 @@ export async function handleApiRequest(
129
129
  return finish(jsonResponse(200, generateOpenApi(root)));
130
130
  }
131
131
 
132
- if (request.method === "GET" && path === "/openapi-browser") {
132
+ if (request.method === "GET" && path === "/swagger") {
133
133
  return finish(
134
134
  new Response(apiDocsHtml(), {
135
135
  status: 200,
@@ -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