argsbarg 6.1.2 → 6.1.4
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 +74 -1
- package/README.md +17 -19
- package/bin/argsbarg +10 -0
- package/docs/README.md +4 -3
- package/docs/ai-skills.md +4 -2
- package/docs/bundled-docs.md +50 -25
- package/docs/cli-program.md +52 -10
- package/docs/config-schema.md +10 -11
- package/docs/configure.md +2 -0
- package/docs/decisions.md +40 -0
- package/docs/developing.md +43 -5
- package/docs/http-server.md +171 -0
- package/docs/json-schema-subset.md +51 -0
- package/docs/mcp.md +4 -2
- package/docs/output-schema.md +55 -62
- package/examples/formats.ts +6 -6
- package/examples/full-example/Formula/full-example.rb +35 -0
- package/examples/full-example/README.md +20 -21
- package/examples/full-example/docs/README.md +1 -1
- package/examples/full-example/docs/cli-schema.json +1790 -98
- package/examples/full-example/docs/cli.md +1990 -0
- package/examples/full-example/docs/http.md +30 -31
- package/examples/full-example/docs/mcp.md +8 -22
- package/examples/full-example/docs/openapi.json +798 -44
- package/examples/full-example/docs/skill.md +10 -10
- package/examples/full-example/justfile +11 -1
- package/examples/full-example/src/commands/render-json/__generated__/RenderJsonInputSchema.json +15 -0
- package/examples/full-example/src/commands/render-json/__generated__/index.ts +5 -0
- package/examples/full-example/src/commands/render-json/command.test.ts +46 -0
- package/examples/full-example/src/commands/render-json/command.ts +30 -0
- package/examples/full-example/src/commands/render-json/types.ts +9 -0
- package/examples/full-example/src/commands/status/__generated__/StatusJsonOutputSchema.json +15 -0
- package/examples/full-example/src/commands/status/__generated__/index.ts +2 -2
- package/examples/full-example/src/commands/status/command.ts +5 -13
- package/examples/full-example/src/commands/status/types.ts +1 -14
- package/examples/full-example/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +15 -0
- package/examples/full-example/src/commands/workspaces/__generated__/index.ts +5 -0
- package/examples/full-example/src/commands/workspaces/command.test.ts +58 -0
- package/examples/full-example/src/commands/workspaces/command.ts +94 -0
- package/examples/full-example/src/commands/workspaces/types.ts +6 -0
- package/examples/full-example/src/db/index.test.ts +86 -0
- package/examples/full-example/src/db/index.ts +101 -0
- package/examples/full-example/src/db/migrate.test.ts +35 -0
- package/examples/full-example/src/db/migrate.ts +69 -0
- package/examples/full-example/src/db/migrations/001_workspaces.sql +6 -0
- package/examples/full-example/src/db/tables/workspaces.ts +66 -0
- package/examples/full-example/src/program.ts +11 -36
- package/examples/full-example/src/types/argsbarg.d.ts +11 -0
- package/examples/full-example/src/types/md.d.ts +4 -0
- package/examples/full-example/tsconfig.json +5 -2
- package/examples/mcp-test.ts +1 -2
- package/examples/minimal.ts +1 -7
- package/examples/nested.ts +1 -2
- package/examples/option-required.ts +1 -1
- package/examples/servers.ts +4 -5
- package/index.d.ts +431 -136
- package/package.json +19 -2
- package/src/builtins/builtins.test.ts +7 -7
- package/src/builtins/completion-bash.ts +1 -1
- package/src/builtins/completion-fish.ts +1 -1
- package/src/builtins/completion-group.ts +4 -4
- package/src/builtins/completion-simulate-shared.ts +9 -0
- package/src/builtins/completion-zsh.ts +1 -1
- package/src/builtins/config.test.ts +3 -3
- package/src/builtins/config.ts +9 -9
- package/src/builtins/configure-copy.ts +2 -2
- package/src/builtins/configure.ts +4 -4
- package/src/builtins/dispatch.ts +19 -18
- package/src/builtins/export.ts +7 -5
- package/src/builtins/http.ts +68 -0
- package/src/builtins/mcp.ts +28 -4
- package/src/builtins/presentation.ts +6 -6
- package/src/builtins/registry.ts +6 -6
- package/src/builtins/scopes.ts +2 -2
- package/src/builtins/version.ts +1 -1
- package/src/cli-tool/full-example-capabilities.test.ts +10 -15
- package/src/cli-tool/main.ts +1 -1
- package/src/cli-tool/program.ts +3 -2
- package/src/cli-tool/prompt.ts +1 -1
- package/src/cli-tool/run-schemagen.ts +1 -3
- package/src/cli-tool/schemagen/cleanup.ts +6 -7
- package/src/cli-tool/schemagen/discover-schema-roots.ts +66 -120
- package/src/cli-tool/schemagen/index.ts +2 -2
- package/src/cli-tool/schemagen/names.ts +8 -13
- package/src/cli-tool/schemagen/run.ts +21 -28
- package/src/cli-tool/schemagen/schemagen.test.ts +136 -46
- package/src/config/bindings.test.ts +1 -1
- package/src/config/bindings.ts +1 -1
- package/src/config/bootstrap.test.ts +1 -1
- package/src/config/bootstrap.ts +36 -4
- package/src/config/context.test.ts +1 -1
- package/src/config/context.ts +1 -1
- package/src/config/entry.ts +1 -1
- package/src/config/file.test.ts +1 -1
- package/src/config/file.ts +3 -3
- package/src/config/manifest.ts +1 -1
- package/src/config/resolve.test.ts +1 -1
- package/src/config/resolve.ts +1 -1
- package/src/config/schema.ts +1 -1
- package/src/config/validate.ts +1 -1
- package/src/{install → configure/artifacts}/binary-placement.test.ts +1 -1
- package/src/{install → configure/artifacts}/binary-placement.ts +1 -1
- package/src/{install → configure/artifacts}/gh-release-update.ts +1 -1
- package/src/{install → configure/artifacts}/install-validate.test.ts +3 -3
- package/src/{install → configure/artifacts}/mcp-config.ts +1 -1
- package/src/{install → configure/artifacts}/mcp-opencode.test.ts +1 -1
- package/src/{install → configure/artifacts}/mcp-opencode.ts +1 -1
- package/src/{install → configure/artifacts}/paths.ts +5 -5
- package/src/configure/artifacts/plan.ts +24 -0
- package/src/{install → configure/artifacts}/status.test.ts +1 -1
- package/src/{install → configure/artifacts}/status.ts +2 -2
- package/src/{install → configure/artifacts}/target-base.ts +1 -1
- package/src/{install → configure/artifacts}/target-detect.ts +1 -1
- package/src/{install → configure/artifacts}/target-effective.ts +3 -9
- package/src/{install → configure/artifacts}/target-mcp-cli.ts +1 -1
- package/src/{install → configure/artifacts}/target-mcp-json.ts +1 -1
- package/src/{install → configure/artifacts}/target-plan-build.ts +2 -2
- package/src/{install → configure/artifacts}/target-registry.ts +2 -2
- package/src/{install → configure/artifacts}/target-scope.ts +3 -3
- package/src/{install → configure/artifacts}/target-skill.ts +1 -1
- package/src/{install → configure/artifacts}/target-types.ts +2 -2
- package/src/{install → configure/artifacts}/targets/app.ts +5 -5
- package/src/{install → configure/artifacts}/targets/chatgpt-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/claude-code-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/claude-desktop-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/claude-skill.ts +2 -2
- package/src/{install → configure/artifacts}/targets/codex-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/codex-skill.ts +2 -2
- package/src/{install → configure/artifacts}/targets/configure.ts +5 -5
- package/src/{install → configure/artifacts}/targets/cursor-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/cursor-skill.ts +2 -2
- package/src/{install → configure/artifacts}/targets/index.ts +1 -1
- package/src/{install → configure/artifacts}/targets/openclaw-mcp.ts +2 -2
- package/src/{install → configure/artifacts}/targets/openclaw-skill.ts +3 -3
- package/src/{install → configure/artifacts}/targets/opencode-mcp.ts +5 -5
- package/src/{install → configure/artifacts}/targets/opencode-skill.ts +3 -3
- package/src/{install → configure/artifacts}/targets.test.ts +1 -1
- package/src/{install → configure/artifacts}/uninstall.ts +1 -1
- package/src/configure/configure.test.ts +11 -11
- package/src/configure/index.ts +14 -14
- package/src/configure/prompt.ts +2 -2
- package/src/{context.ts → core/context.ts} +26 -20
- package/src/{json-leaf.test.ts → core/json-leaf.test.ts} +4 -4
- package/src/{leaf-inputs.test.ts → core/leaf-inputs.test.ts} +7 -7
- package/src/{leaf-inputs.ts → core/leaf-inputs.ts} +16 -12
- package/src/{parse.test.ts → core/parse.test.ts} +97 -109
- package/src/{parse.ts → core/parse.ts} +129 -31
- package/src/{schema.ts → core/schema.ts} +25 -13
- package/src/{types.ts → core/types.ts} +225 -35
- package/src/{validate.ts → core/validate.ts} +39 -29
- package/src/docs/builtin.ts +8 -19
- package/src/docs/{api-guide.test.ts → cli-guide.test.ts} +21 -21
- package/src/docs/{api-guide.ts → cli-guide.ts} +45 -16
- package/src/docs/docs.test.ts +76 -41
- package/src/docs/http-guide.ts +39 -36
- package/src/docs/mcp-guide.ts +12 -14
- package/src/docs/mcp-resources.test.ts +2 -3
- package/src/docs/mcp-resources.ts +6 -11
- package/src/docs/resolve.ts +22 -30
- package/src/docs/save.ts +3 -3
- package/src/exports/cli.ts +47 -0
- package/src/exports/headless.ts +13 -0
- package/src/exports/http.ts +6 -0
- package/src/exports/mcp.ts +6 -0
- package/src/{headless.test.ts → headless/routing.test.ts} +3 -3
- package/src/{headless.ts → headless/routing.ts} +3 -3
- package/src/headless/tool-call.ts +114 -46
- package/src/help.test.ts +152 -0
- package/src/help.ts +3 -3
- package/src/hooks/builtin.ts +20 -0
- package/src/hooks/run.ts +142 -0
- package/src/http/openapi.ts +290 -0
- package/src/http/readiness.ts +78 -0
- package/src/{api → http}/result.ts +22 -11
- package/src/http/routes.ts +329 -0
- package/src/http/server.ts +225 -0
- package/src/index.ts +36 -25
- package/src/log/ecs.test.ts +43 -0
- package/src/log/ecs.ts +59 -0
- package/src/log/emitter.ts +166 -0
- package/src/mcp/bundle.ts +2 -2
- package/src/mcp/claude.test.ts +1 -1
- package/src/mcp/claude.ts +4 -4
- package/src/{hidden-mcpb.test.ts → mcp/hidden-mcpb.test.ts} +10 -9
- package/src/mcp/result.ts +2 -2
- package/src/mcp/server.ts +54 -6
- package/src/mcp/tools.ts +9 -20
- package/src/{capabilities.ts → runtime/capabilities.ts} +11 -11
- package/src/{cli-errors.ts → runtime/cli-errors.ts} +4 -4
- package/src/{cli.ts → runtime/cli.ts} +159 -49
- package/src/runtime/exposure.ts +102 -0
- package/src/{invoke.test.ts → runtime/invoke.test.ts} +31 -7
- package/src/server/context.ts +25 -0
- package/src/server/overrides.ts +112 -0
- package/src/skill/generate.ts +8 -8
- package/src/skill/hint.ts +1 -1
- package/src/skill/install.ts +2 -2
- package/src/skill/naming.ts +1 -1
- package/src/{test-fixtures.ts → test/fixtures.ts} +3 -2
- package/src/{config.integration.test.ts → test/integration/config.test.ts} +8 -8
- package/src/test/integration/http.test.ts +651 -0
- package/src/{mcp.integration.test.ts → test/integration/mcp.test.ts} +11 -57
- package/docs/api-server.md +0 -141
- package/examples/full-example/docs/api.md +0 -511
- package/examples/full-example/src/commands/status/__generated__/outputSchema.json +0 -28
- package/examples/full-example/src/config/__generated__/configSchema.json +0 -40
- package/examples/full-example/src/config/__generated__/index.ts +0 -5
- package/examples/full-example/src/config/types.ts +0 -24
- package/src/api/openapi.ts +0 -117
- package/src/api/server.ts +0 -120
- package/src/api.integration.test.ts +0 -441
- package/src/builtins/api.ts +0 -38
- package/src/hidden.ts +0 -30
- package/src/install/plan.ts +0 -53
- /package/src/{install → configure/artifacts}/detect-installed.ts +0 -0
- /package/src/{install → configure/artifacts}/gh-release-update.test.ts +0 -0
- /package/src/{install → configure/artifacts}/mcp-codex.test.ts +0 -0
- /package/src/{install → configure/artifacts}/mcp-codex.ts +0 -0
- /package/src/{install → configure/artifacts}/mcp-openclaw.test.ts +0 -0
- /package/src/{install → configure/artifacts}/mcp-openclaw.ts +0 -0
- /package/src/{install → configure/artifacts}/normalize-uninstall.ts +0 -0
- /package/src/{install → configure/artifacts}/normalize.ts +0 -0
- /package/src/{install → configure/artifacts}/opts.ts +0 -0
- /package/src/{install → configure/artifacts}/shell.ts +0 -0
- /package/src/{formats.test.ts → core/formats.test.ts} +0 -0
- /package/src/{formats.ts → core/formats.ts} +0 -0
- /package/src/{respond.ts → core/respond.ts} +0 -0
- /package/src/{types.test.ts → core/types.test.ts} +0 -0
- /package/src/{api → http}/schema-deref.test.ts +0 -0
- /package/src/{api → http}/schema-deref.ts +0 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/*
|
|
2
|
+
HTTP API integration tests: routes, tool invocation, CORS, OpenAPI, and validation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { $ } from "bun";
|
|
8
|
+
import { cliValidateProgram } from "~/core/validate.ts";
|
|
9
|
+
import { generateOpenApi } from "~/http/openapi.ts";
|
|
10
|
+
import { API_CORS_HEADERS } from "~/http/result.ts";
|
|
11
|
+
import { handleApiRequest } from "~/http/server.ts";
|
|
12
|
+
import { Cli, CliContext, type CliContext as CliContextType, CliOptionKind, cliErrWithHelp } from "~/index";
|
|
13
|
+
import { LogEmitter } from "~/log/emitter.ts";
|
|
14
|
+
import { createServerRuntime } from "~/server/context.ts";
|
|
15
|
+
import { resolveHttpServeConfig } from "~/server/overrides.ts";
|
|
16
|
+
import { nestedMcpFixture, testProgram } from "~/test/fixtures.ts";
|
|
17
|
+
|
|
18
|
+
/** Program with HTTP API enabled and handlers that return values. */
|
|
19
|
+
function nestedApiFixture() {
|
|
20
|
+
return testProgram({
|
|
21
|
+
...nestedMcpFixture,
|
|
22
|
+
httpServer: { enabled: true },
|
|
23
|
+
commands: [
|
|
24
|
+
{
|
|
25
|
+
key: "stat",
|
|
26
|
+
description: "File metadata.",
|
|
27
|
+
options: [
|
|
28
|
+
{
|
|
29
|
+
name: "json",
|
|
30
|
+
description: "Emit handler output as JSON.",
|
|
31
|
+
kind: CliOptionKind.Presence,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
commands: [
|
|
35
|
+
{
|
|
36
|
+
key: "owner",
|
|
37
|
+
description: "Ownership helpers.",
|
|
38
|
+
commands: [
|
|
39
|
+
{
|
|
40
|
+
key: "lookup",
|
|
41
|
+
description: "Resolve owner info.",
|
|
42
|
+
options: [
|
|
43
|
+
{
|
|
44
|
+
name: "user-name",
|
|
45
|
+
description: "User to look up.",
|
|
46
|
+
kind: CliOptionKind.String,
|
|
47
|
+
shortName: "u",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
positionals: [
|
|
51
|
+
{
|
|
52
|
+
name: "path",
|
|
53
|
+
description: "File or directory.",
|
|
54
|
+
kind: CliOptionKind.String,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
handler: (ctx: CliContextType) => {
|
|
58
|
+
const user = ctx.stringOpt("user-name") ?? "unknown";
|
|
59
|
+
const path = ctx.positional("path") ?? "";
|
|
60
|
+
if (ctx.hasFlag("json")) {
|
|
61
|
+
return { user, path };
|
|
62
|
+
}
|
|
63
|
+
return `lookup user=${user} path=${path}`;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: "read",
|
|
72
|
+
description: "Print the first line of each file.",
|
|
73
|
+
positionals: [
|
|
74
|
+
{
|
|
75
|
+
name: "files",
|
|
76
|
+
description: "Paths to read.",
|
|
77
|
+
kind: CliOptionKind.String,
|
|
78
|
+
argMax: 0,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
handler: () => ({ lines: [] }),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: "pdf",
|
|
85
|
+
description: "Return a minimal PDF.",
|
|
86
|
+
http: { successContentType: "application/pdf" },
|
|
87
|
+
handler: (ctx: CliContextType) => {
|
|
88
|
+
ctx.respond({
|
|
89
|
+
body: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]),
|
|
90
|
+
contentType: "application/pdf",
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "html",
|
|
96
|
+
description: "Return HTML.",
|
|
97
|
+
http: { successContentType: "text/html; charset=utf-8" },
|
|
98
|
+
handler: (ctx: CliContextType) => {
|
|
99
|
+
ctx.respond({
|
|
100
|
+
body: "<!DOCTYPE html><html><body>hi</body></html>",
|
|
101
|
+
contentType: "text/html; charset=utf-8",
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "silent",
|
|
107
|
+
description: "Returns nothing.",
|
|
108
|
+
handler: () => {},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sends one HTTP request through the in-process API handler. */
|
|
115
|
+
async function apiRequest(
|
|
116
|
+
program: ReturnType<typeof nestedApiFixture>,
|
|
117
|
+
request: Request,
|
|
118
|
+
opts?: { withServer?: boolean },
|
|
119
|
+
) {
|
|
120
|
+
const cli = new Cli(program);
|
|
121
|
+
if (opts?.withServer) {
|
|
122
|
+
const resolved = resolveHttpServeConfig(program);
|
|
123
|
+
cli.server = {
|
|
124
|
+
runtime: createServerRuntime(program, "http"),
|
|
125
|
+
emitter: new LogEmitter({
|
|
126
|
+
program,
|
|
127
|
+
resolved: { ...resolved.log, access: false },
|
|
128
|
+
}),
|
|
129
|
+
http: resolved,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return handleApiRequest(cli, request, cli.server?.http);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("httpServer validation", () => {
|
|
136
|
+
test("rejects empty httpServer", () => {
|
|
137
|
+
const root = testProgram({
|
|
138
|
+
key: "app",
|
|
139
|
+
description: "",
|
|
140
|
+
httpServer: {} as { enabled: boolean },
|
|
141
|
+
handler: () => {},
|
|
142
|
+
});
|
|
143
|
+
expect(() => cliValidateProgram(root)).toThrow(/httpServer requires enabled: true/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rejects top-level command name http when httpServer enabled", () => {
|
|
147
|
+
const root = testProgram({
|
|
148
|
+
key: "app",
|
|
149
|
+
description: "",
|
|
150
|
+
httpServer: { enabled: true },
|
|
151
|
+
commands: [{ key: "http", description: "user", handler: () => {} }],
|
|
152
|
+
});
|
|
153
|
+
expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: http/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("allows top-level command name http without httpServer", () => {
|
|
157
|
+
const root = testProgram({
|
|
158
|
+
key: "app",
|
|
159
|
+
description: "",
|
|
160
|
+
commands: [{ key: "http", description: "user", handler: () => {} }],
|
|
161
|
+
});
|
|
162
|
+
expect(() => cliValidateProgram(root)).not.toThrow();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects httpServer on non-root node", () => {
|
|
166
|
+
const root = {
|
|
167
|
+
key: "app",
|
|
168
|
+
version: "0.0.0",
|
|
169
|
+
description: "",
|
|
170
|
+
commands: [
|
|
171
|
+
{
|
|
172
|
+
key: "x",
|
|
173
|
+
description: "cmd",
|
|
174
|
+
httpServer: { enabled: true },
|
|
175
|
+
handler: () => {},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
} as unknown as import("~/core/types.ts").CliProgram;
|
|
179
|
+
expect(() => cliValidateProgram(root)).toThrow(/httpServer is only supported on the program root/);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("HTTP API routes", () => {
|
|
184
|
+
const program = nestedApiFixture();
|
|
185
|
+
cliValidateProgram(program);
|
|
186
|
+
|
|
187
|
+
test("GET /health/live returns ok", async () => {
|
|
188
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/health/live"));
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
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 });
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
const body = (await res.json()) as { ok: boolean; checks: Record<string, { ok: boolean }> };
|
|
197
|
+
expect(body.ok).toBe(true);
|
|
198
|
+
expect(body.checks.config_file.ok).toBe(true);
|
|
199
|
+
expect(body.checks.config_required.ok).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("GET /health/ready returns 503 when custom readiness fails", async () => {
|
|
203
|
+
const failProgram = testProgram({
|
|
204
|
+
key: "app",
|
|
205
|
+
description: "Test",
|
|
206
|
+
version: "1.0.0",
|
|
207
|
+
httpServer: { enabled: true },
|
|
208
|
+
readiness: () => false,
|
|
209
|
+
handler: () => ({ ok: true }),
|
|
210
|
+
});
|
|
211
|
+
cliValidateProgram(failProgram);
|
|
212
|
+
const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/ready"), { withServer: true });
|
|
213
|
+
expect(res.status).toBe(503);
|
|
214
|
+
const body = (await res.json()) as { ok: boolean; checks: { custom: { ok: boolean } } };
|
|
215
|
+
expect(body.ok).toBe(false);
|
|
216
|
+
expect(body.checks.custom.ok).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("POST /api returns 500 for non-Error throw", async () => {
|
|
220
|
+
const throwProgram = testProgram({
|
|
221
|
+
key: "app",
|
|
222
|
+
description: "Test",
|
|
223
|
+
httpServer: { enabled: true },
|
|
224
|
+
commands: [
|
|
225
|
+
{
|
|
226
|
+
key: "boom",
|
|
227
|
+
description: "Throws non-Error",
|
|
228
|
+
handler: () => {
|
|
229
|
+
throw "unexpected";
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
cliValidateProgram(throwProgram);
|
|
235
|
+
const res = await apiRequest(
|
|
236
|
+
throwProgram,
|
|
237
|
+
new Request("http://127.0.0.1/api/boom", { method: "POST", body: "{}" }),
|
|
238
|
+
);
|
|
239
|
+
expect(res.status).toBe(500);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("POST /api obscures unexpected errors when configured", async () => {
|
|
243
|
+
const throwProgram = testProgram({
|
|
244
|
+
key: "app",
|
|
245
|
+
description: "Test",
|
|
246
|
+
httpServer: { enabled: true, errors: { obscureUnexpected: true } },
|
|
247
|
+
commands: [
|
|
248
|
+
{
|
|
249
|
+
key: "boom",
|
|
250
|
+
description: "Throws non-Error",
|
|
251
|
+
handler: () => {
|
|
252
|
+
throw "secret";
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
cliValidateProgram(throwProgram);
|
|
258
|
+
const cli = new Cli(throwProgram);
|
|
259
|
+
const resolved = resolveHttpServeConfig(throwProgram);
|
|
260
|
+
cli.server = {
|
|
261
|
+
runtime: createServerRuntime(throwProgram, "http"),
|
|
262
|
+
emitter: new LogEmitter({ program: throwProgram, resolved: { ...resolved.log, access: false } }),
|
|
263
|
+
http: resolved,
|
|
264
|
+
};
|
|
265
|
+
const res = await handleApiRequest(
|
|
266
|
+
cli,
|
|
267
|
+
new Request("http://127.0.0.1/api/boom", { method: "POST", body: "{}" }),
|
|
268
|
+
resolved,
|
|
269
|
+
);
|
|
270
|
+
expect(res.status).toBe(500);
|
|
271
|
+
const body = (await res.json()) as { error: string };
|
|
272
|
+
expect(body.error).toBe("An unexpected error occurred.");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("GET /health includes CORS headers", async () => {
|
|
276
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/health"));
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
279
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
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" }));
|
|
284
|
+
expect(res.status).toBe(204);
|
|
285
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
286
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("POST /api/... returns raw JSON body with 201", async () => {
|
|
290
|
+
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
291
|
+
const res = await apiRequest(
|
|
292
|
+
program,
|
|
293
|
+
new Request("http://127.0.0.1/api/stat/owner/lookup", {
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "content-type": "application/json" },
|
|
296
|
+
body: JSON.stringify({ "user-name": "alice", path: readme, json: true }),
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
expect(res.status).toBe(201);
|
|
300
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
301
|
+
expect(await res.json()).toEqual({ user: "alice", path: readme });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("POST /api/... returns raw text body with 201", async () => {
|
|
305
|
+
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
306
|
+
const res = await apiRequest(
|
|
307
|
+
program,
|
|
308
|
+
new Request("http://127.0.0.1/api/stat/owner/lookup", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ "user-name": "alice", path: readme }),
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
expect(res.status).toBe(201);
|
|
315
|
+
const text = await res.text();
|
|
316
|
+
expect(text).toContain("lookup user=alice");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("POST /tools returns 404 (legacy path removed)", async () => {
|
|
320
|
+
const res = await apiRequest(
|
|
321
|
+
program,
|
|
322
|
+
new Request("http://127.0.0.1/tools", {
|
|
323
|
+
method: "POST",
|
|
324
|
+
headers: { "content-type": "application/json" },
|
|
325
|
+
body: "{}",
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
expect(res.status).toBe(404);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("POST /api/pdf returns PDF bytes with 201", async () => {
|
|
332
|
+
const res = await apiRequest(
|
|
333
|
+
program,
|
|
334
|
+
new Request("http://127.0.0.1/api/pdf", {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "content-type": "application/json" },
|
|
337
|
+
body: "{}",
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
expect(res.status).toBe(201);
|
|
341
|
+
expect(res.headers.get("content-type")).toBe("application/pdf");
|
|
342
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
343
|
+
expect(String.fromCharCode(...bytes.slice(0, 4))).toBe("%PDF");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("POST /api/html returns HTML with 201", async () => {
|
|
347
|
+
const res = await apiRequest(
|
|
348
|
+
program,
|
|
349
|
+
new Request("http://127.0.0.1/api/html", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
body: "{}",
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
expect(res.status).toBe(201);
|
|
355
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
356
|
+
expect(await res.text()).toContain("<!DOCTYPE html>");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("POST /api/silent returns 500 when handler has no response", async () => {
|
|
360
|
+
const res = await apiRequest(
|
|
361
|
+
program,
|
|
362
|
+
new Request("http://127.0.0.1/api/silent", {
|
|
363
|
+
method: "POST",
|
|
364
|
+
body: "{}",
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
expect(res.status).toBe(500);
|
|
368
|
+
const body = (await res.json()) as { error: string };
|
|
369
|
+
expect(body.error).toContain("ctx.respond()");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("POST /api returns 404 for unknown route", async () => {
|
|
373
|
+
const res = await apiRequest(
|
|
374
|
+
program,
|
|
375
|
+
new Request("http://127.0.0.1/api/missing_tool", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "content-type": "application/json" },
|
|
378
|
+
body: "{}",
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
expect(res.status).toBe(404);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("POST /api returns 400 for bad args", async () => {
|
|
385
|
+
const res = await apiRequest(
|
|
386
|
+
program,
|
|
387
|
+
new Request("http://127.0.0.1/api/stat/owner/lookup", {
|
|
388
|
+
method: "POST",
|
|
389
|
+
headers: { "content-type": "application/json" },
|
|
390
|
+
body: JSON.stringify({ "user-name": "alice" }),
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
expect(res.status).toBe(400);
|
|
394
|
+
const body = (await res.json()) as { error: string };
|
|
395
|
+
expect(body.error).toContain("Missing argument: path");
|
|
396
|
+
expect(body).not.toHaveProperty("stderr");
|
|
397
|
+
expect(body.error).not.toContain("\u001B[");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("POST /api/... returns plain JSON validation errors", async () => {
|
|
401
|
+
const failProgram = testProgram({
|
|
402
|
+
key: "app",
|
|
403
|
+
description: "Test app",
|
|
404
|
+
httpServer: { enabled: true },
|
|
405
|
+
commands: [
|
|
406
|
+
{
|
|
407
|
+
key: "fail",
|
|
408
|
+
description: "Fails with cliErrWithHelp.",
|
|
409
|
+
handler: (ctx: CliContextType) => {
|
|
410
|
+
cliErrWithHelp(ctx, "bad input");
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
});
|
|
415
|
+
cliValidateProgram(failProgram);
|
|
416
|
+
const res = await apiRequest(
|
|
417
|
+
failProgram,
|
|
418
|
+
new Request("http://127.0.0.1/api/fail", {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "content-type": "application/json" },
|
|
421
|
+
body: "{}",
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
expect(res.status).toBe(400);
|
|
425
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
426
|
+
expect(body).toEqual({ error: "bad input" });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("GET /openapi.json lists REST paths", async () => {
|
|
430
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/openapi.json"));
|
|
431
|
+
expect(res.status).toBe(200);
|
|
432
|
+
const doc = (await res.json()) as { openapi: string; paths: Record<string, unknown> };
|
|
433
|
+
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();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("GET /swagger returns Swagger UI HTML", async () => {
|
|
441
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/swagger"));
|
|
442
|
+
expect(res.status).toBe(200);
|
|
443
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
444
|
+
const html = await res.text();
|
|
445
|
+
expect(html).toContain("swagger-ui-dist");
|
|
446
|
+
expect(html).toContain('url: "/openapi.json"');
|
|
447
|
+
expect(html).toContain('dom_id: "#swagger-ui"');
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("generateOpenApi includes health probe paths", () => {
|
|
452
|
+
const program = testProgram({
|
|
453
|
+
key: "app",
|
|
454
|
+
description: "Test app",
|
|
455
|
+
httpServer: { enabled: true },
|
|
456
|
+
handler: () => ({ ok: true }),
|
|
457
|
+
});
|
|
458
|
+
cliValidateProgram(program);
|
|
459
|
+
const doc = generateOpenApi(program) as {
|
|
460
|
+
tags: { name: string }[];
|
|
461
|
+
paths: Record<
|
|
462
|
+
string,
|
|
463
|
+
{
|
|
464
|
+
get: {
|
|
465
|
+
tags: string[];
|
|
466
|
+
responses: Record<string, { content: Record<string, { schema: Record<string, unknown> }> }>;
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
>;
|
|
470
|
+
};
|
|
471
|
+
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"]
|
|
477
|
+
.schema as { properties?: { checks?: unknown } };
|
|
478
|
+
expect(readySchema.properties?.checks).toBeDefined();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("generateOpenApi omits health paths when httpServer disabled", () => {
|
|
482
|
+
const program = testProgram({
|
|
483
|
+
key: "app",
|
|
484
|
+
description: "Test app",
|
|
485
|
+
handler: () => ({ ok: true }),
|
|
486
|
+
});
|
|
487
|
+
cliValidateProgram(program);
|
|
488
|
+
const doc = generateOpenApi(program) as { paths: Record<string, unknown> };
|
|
489
|
+
expect(doc.paths["/health"]).toBeUndefined();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("generateOpenApi groups routes by top-level command tag", () => {
|
|
493
|
+
const program = nestedApiFixture();
|
|
494
|
+
cliValidateProgram(program);
|
|
495
|
+
const doc = generateOpenApi(program) as {
|
|
496
|
+
tags: { name: string; description?: string }[];
|
|
497
|
+
paths: Record<string, { post?: { tags: string[] }; get?: { tags: string[] } }>;
|
|
498
|
+
};
|
|
499
|
+
const tagNames = doc.tags.map((t) => t.name);
|
|
500
|
+
expect(tagNames).toContain("health");
|
|
501
|
+
expect(tagNames).toContain("stat");
|
|
502
|
+
expect(tagNames).toContain("pdf");
|
|
503
|
+
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"]);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("generateOpenApi maps binary content types", () => {
|
|
510
|
+
const program = nestedApiFixture();
|
|
511
|
+
const doc = generateOpenApi(program) as {
|
|
512
|
+
paths: Record<string, { post: { responses: { "201": { content: Record<string, unknown> } } } }>;
|
|
513
|
+
};
|
|
514
|
+
const pdf = doc.paths["/api/pdf"]?.post.responses["201"].content["application/pdf"] as {
|
|
515
|
+
schema: { format: string };
|
|
516
|
+
};
|
|
517
|
+
expect(pdf.schema.format).toBe("binary");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("generateOpenApi dereferences nested inputSchema definitions", () => {
|
|
521
|
+
const program = testProgram({
|
|
522
|
+
key: "app",
|
|
523
|
+
description: "Test app",
|
|
524
|
+
httpServer: { enabled: true },
|
|
525
|
+
commands: [
|
|
526
|
+
{
|
|
527
|
+
key: "render",
|
|
528
|
+
description: "Render a document.",
|
|
529
|
+
inputSchema: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
invoice: { $ref: "#/definitions/InvoiceData" },
|
|
533
|
+
},
|
|
534
|
+
definitions: {
|
|
535
|
+
InvoiceData: {
|
|
536
|
+
type: "object",
|
|
537
|
+
properties: {
|
|
538
|
+
id: { type: "string" },
|
|
539
|
+
},
|
|
540
|
+
required: ["id"],
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
handler: () => ({ ok: true }),
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
});
|
|
548
|
+
cliValidateProgram(program);
|
|
549
|
+
const doc = generateOpenApi(program) as {
|
|
550
|
+
paths: Record<
|
|
551
|
+
string,
|
|
552
|
+
{
|
|
553
|
+
post: {
|
|
554
|
+
requestBody: {
|
|
555
|
+
content: Record<string, { schema: { properties: { invoice: Record<string, unknown> } } }>;
|
|
556
|
+
};
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
>;
|
|
560
|
+
};
|
|
561
|
+
const schema = doc.paths["/api/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
|
|
562
|
+
expect(schema.properties.invoice).toEqual({
|
|
563
|
+
type: "object",
|
|
564
|
+
properties: { id: { type: "string" } },
|
|
565
|
+
required: ["id"],
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("generateOpenApi generates requestBody for kind: json leaves", () => {
|
|
570
|
+
const program = testProgram({
|
|
571
|
+
key: "app",
|
|
572
|
+
description: "Test app",
|
|
573
|
+
httpServer: { enabled: true },
|
|
574
|
+
commands: [
|
|
575
|
+
{
|
|
576
|
+
key: "render-invoice",
|
|
577
|
+
description: "Render an invoice.",
|
|
578
|
+
kind: "json",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
type: "object",
|
|
581
|
+
properties: {
|
|
582
|
+
id: { type: "string" },
|
|
583
|
+
},
|
|
584
|
+
required: ["id"],
|
|
585
|
+
},
|
|
586
|
+
handler: () => ({ ok: true }),
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
});
|
|
590
|
+
cliValidateProgram(program);
|
|
591
|
+
const doc = generateOpenApi(program) as {
|
|
592
|
+
paths: Record<
|
|
593
|
+
string,
|
|
594
|
+
{
|
|
595
|
+
post: {
|
|
596
|
+
requestBody: {
|
|
597
|
+
required: boolean;
|
|
598
|
+
content: Record<string, { schema: Record<string, unknown> }>;
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
>;
|
|
603
|
+
};
|
|
604
|
+
const op = doc.paths["/api/render-invoice"]?.post;
|
|
605
|
+
expect(op).toBeDefined();
|
|
606
|
+
expect(op.requestBody).toBeDefined();
|
|
607
|
+
expect(op.requestBody.required).toBe(true);
|
|
608
|
+
expect(op.requestBody.content["application/json; charset=utf-8"].schema).toEqual({
|
|
609
|
+
type: "object",
|
|
610
|
+
properties: { id: { type: "string" } },
|
|
611
|
+
required: ["id"],
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("ctx.respond throws when called twice", () => {
|
|
616
|
+
const program = testProgram({
|
|
617
|
+
key: "app",
|
|
618
|
+
description: "",
|
|
619
|
+
handler: () => {},
|
|
620
|
+
});
|
|
621
|
+
const context = new CliContext("app", [], [], {}, program, "http");
|
|
622
|
+
context.respond({ body: { ok: true } });
|
|
623
|
+
expect(() => context.respond({ body: { ok: true } })).toThrow(/already called/);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("API_CORS_HEADERS are wide open", () => {
|
|
627
|
+
expect(API_CORS_HEADERS["access-control-allow-origin"]).toBe("*");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("ctx.invocation is http via Cli.invoke", async () => {
|
|
631
|
+
let seen = "";
|
|
632
|
+
const root = testProgram({
|
|
633
|
+
key: "app",
|
|
634
|
+
description: "",
|
|
635
|
+
handler: (ctx: CliContextType) => {
|
|
636
|
+
seen = ctx.invocation;
|
|
637
|
+
return { invocation: ctx.invocation };
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
cliValidateProgram(root);
|
|
641
|
+
const result = await new Cli(root).invoke([], { invocation: "http" });
|
|
642
|
+
expect(result.kind).toBe("ok");
|
|
643
|
+
expect(seen).toBe("http");
|
|
644
|
+
expect(result.response?.body).toEqual({ invocation: "http" });
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("minimal.ts http without opt-in fails", async () => {
|
|
648
|
+
const { stderr, exitCode } = await $`bun run examples/minimal.ts http`.nothrow().quiet();
|
|
649
|
+
expect(exitCode).toBe(1);
|
|
650
|
+
expect(stderr.toString()).toContain("HTTP API is not available");
|
|
651
|
+
});
|