@nifrajs/cli 0.1.0-alpha.1

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/check.d.ts +100 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +690 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +208 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/docs-search.d.ts +30 -0
  12. package/dist/docs-search.d.ts.map +1 -0
  13. package/dist/docs-search.js +94 -0
  14. package/dist/docs-search.js.map +1 -0
  15. package/dist/doctor.d.ts +52 -0
  16. package/dist/doctor.d.ts.map +1 -0
  17. package/dist/doctor.js +291 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/examples.d.ts +30 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +70 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/introspect.d.ts +64 -0
  24. package/dist/introspect.d.ts.map +1 -0
  25. package/dist/introspect.js +243 -0
  26. package/dist/introspect.js.map +1 -0
  27. package/dist/load.d.ts +51 -0
  28. package/dist/load.d.ts.map +1 -0
  29. package/dist/load.js +70 -0
  30. package/dist/load.js.map +1 -0
  31. package/dist/mcp-docs-tools.d.ts +13 -0
  32. package/dist/mcp-docs-tools.d.ts.map +1 -0
  33. package/dist/mcp-docs-tools.js +63 -0
  34. package/dist/mcp-docs-tools.js.map +1 -0
  35. package/dist/mcp-http.d.ts +43 -0
  36. package/dist/mcp-http.d.ts.map +1 -0
  37. package/dist/mcp-http.js +136 -0
  38. package/dist/mcp-http.js.map +1 -0
  39. package/dist/mcp-protocol.d.ts +97 -0
  40. package/dist/mcp-protocol.d.ts.map +1 -0
  41. package/dist/mcp-protocol.js +248 -0
  42. package/dist/mcp-protocol.js.map +1 -0
  43. package/dist/mcp-render.d.ts +17 -0
  44. package/dist/mcp-render.d.ts.map +1 -0
  45. package/dist/mcp-render.js +73 -0
  46. package/dist/mcp-render.js.map +1 -0
  47. package/dist/mcp-run.d.ts +24 -0
  48. package/dist/mcp-run.d.ts.map +1 -0
  49. package/dist/mcp-run.js +142 -0
  50. package/dist/mcp-run.js.map +1 -0
  51. package/dist/mcp-ws.d.ts +26 -0
  52. package/dist/mcp-ws.d.ts.map +1 -0
  53. package/dist/mcp-ws.js +258 -0
  54. package/dist/mcp-ws.js.map +1 -0
  55. package/dist/mcp.d.ts +42 -0
  56. package/dist/mcp.d.ts.map +1 -0
  57. package/dist/mcp.js +712 -0
  58. package/dist/mcp.js.map +1 -0
  59. package/dist/openapi-tool.d.ts +6 -0
  60. package/dist/openapi-tool.d.ts.map +1 -0
  61. package/dist/openapi-tool.js +48 -0
  62. package/dist/openapi-tool.js.map +1 -0
  63. package/dist/scaffold.d.ts +35 -0
  64. package/dist/scaffold.d.ts.map +1 -0
  65. package/dist/scaffold.js +140 -0
  66. package/dist/scaffold.js.map +1 -0
  67. package/dist/search-terms.d.ts +17 -0
  68. package/dist/search-terms.d.ts.map +1 -0
  69. package/dist/search-terms.js +82 -0
  70. package/dist/search-terms.js.map +1 -0
  71. package/dist/test-tool.d.ts +29 -0
  72. package/dist/test-tool.d.ts.map +1 -0
  73. package/dist/test-tool.js +150 -0
  74. package/dist/test-tool.js.map +1 -0
  75. package/docs/examples.json +314 -0
  76. package/docs/llms-full.txt +4325 -0
  77. package/package.json +58 -0
  78. package/src/check.ts +790 -0
  79. package/src/cli.ts +237 -0
  80. package/src/docs-search.ts +111 -0
  81. package/src/doctor.ts +349 -0
  82. package/src/examples.ts +89 -0
  83. package/src/introspect.ts +284 -0
  84. package/src/load.ts +129 -0
  85. package/src/mcp-docs-tools.ts +72 -0
  86. package/src/mcp-http.ts +155 -0
  87. package/src/mcp-protocol.ts +326 -0
  88. package/src/mcp-render.ts +78 -0
  89. package/src/mcp-run.ts +153 -0
  90. package/src/mcp-ws.ts +328 -0
  91. package/src/mcp.ts +863 -0
  92. package/src/openapi-tool.ts +51 -0
  93. package/src/scaffold.ts +164 -0
  94. package/src/search-terms.ts +89 -0
  95. package/src/test-tool.ts +181 -0
@@ -0,0 +1,51 @@
1
+ import { basename } from "node:path"
2
+ import { toOpenAPI } from "@nifrajs/schema/openapi"
3
+ import type { LoadedApp } from "./load.ts"
4
+
5
+ export type OpenApiFormat = "json" | "yaml"
6
+
7
+ function hasRoutesMethod(value: unknown): value is Parameters<typeof toOpenAPI>[0] {
8
+ return typeof (value as { routes?: unknown } | undefined)?.routes === "function"
9
+ }
10
+
11
+ function indent(text: string, spaces: number): string {
12
+ const pad = " ".repeat(spaces)
13
+ return text
14
+ .split("\n")
15
+ .map((line) => (line.length === 0 ? line : `${pad}${line}`))
16
+ .join("\n")
17
+ }
18
+
19
+ function toYaml(value: unknown, depth = 0): string {
20
+ if (value === null || typeof value !== "object") return JSON.stringify(value)
21
+ if (Array.isArray(value)) {
22
+ if (value.length === 0) return "[]"
23
+ return value
24
+ .map((item) => {
25
+ if (item !== null && typeof item === "object")
26
+ return `-\n${indent(toYaml(item, depth + 1), 2)}`
27
+ return `- ${toYaml(item, depth + 1)}`
28
+ })
29
+ .join("\n")
30
+ }
31
+ const entries = Object.entries(value as Record<string, unknown>)
32
+ if (entries.length === 0) return "{}"
33
+ return entries
34
+ .map(([key, item]) => {
35
+ const rendered = toYaml(item, depth + 1)
36
+ if (item !== null && typeof item === "object" && rendered !== "{}" && rendered !== "[]") {
37
+ return `${JSON.stringify(key)}:\n${indent(rendered, 2)}`
38
+ }
39
+ return `${JSON.stringify(key)}: ${rendered}`
40
+ })
41
+ .join("\n")
42
+ }
43
+
44
+ /** Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
45
+ * easier for agents and API tooling to handle than a tool error. */
46
+ export function renderOpenApi(app: LoadedApp, format: OpenApiFormat): string {
47
+ const title = `${basename(app.cwd)} API`
48
+ const input = hasRoutesMethod(app.backend) ? app.backend : {}
49
+ const document = toOpenAPI(input, { title, version: "1.0.0" })
50
+ return format === "yaml" ? toYaml(document) : JSON.stringify(document, null, 2)
51
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `nifra_scaffold` — turn a URL path into the correct `routes/` file (the convention an agent most often
3
+ * gets wrong) + a minimal, contract-correct stub. The mapping is the inverse of @nifrajs/web's
4
+ * `filePathToPatterns`: `:id`/`[id]` → `[id]`, `*rest`/`[...rest]` → `[...rest]`, `[[lang]]` optional,
5
+ * `/` → `index`. The framework (→ file extension) comes from the project's `clientModule`.
6
+ *
7
+ * Page stubs are emitted only for the JSX family (react/preact/solid — one shared, verified shape);
8
+ * for vue/svelte/vanilla we return the correct PATH + the route-module contract and point at
9
+ * `nifra_example` for the body, rather than hand-writing an SFC we can't typecheck here.
10
+ */
11
+
12
+ import { mkdir, writeFile } from "node:fs/promises"
13
+ import { dirname, resolve, sep } from "node:path"
14
+
15
+ export type Framework = "react" | "preact" | "solid" | "vue" | "svelte" | "vanilla"
16
+
17
+ const EXT: Record<Framework, string> = {
18
+ react: "tsx",
19
+ preact: "tsx",
20
+ solid: "tsx",
21
+ vue: "vue",
22
+ svelte: "svelte",
23
+ vanilla: "ts",
24
+ }
25
+
26
+ /** Derive the framework from a `clientModule` like `@nifrajs/web-react/client`. Defaults to react. */
27
+ export function frameworkFromClientModule(clientModule: string | undefined): Framework {
28
+ const m = /@nifrajs\/web-(react|preact|solid|vue|svelte|vanilla)\b/.exec(clientModule ?? "")
29
+ return (m?.[1] as Framework | undefined) ?? "react"
30
+ }
31
+
32
+ /** One URL path segment → its `routes/` filename segment. Accepts both URL (`:id`, `*rest`) and
33
+ * file (`[id]`, `[...rest]`) spellings so an agent can pass either. */
34
+ function segmentToFile(seg: string): string {
35
+ if (seg.startsWith("[")) return seg // already file-spelled ([id], [...rest], [[lang]])
36
+ if (seg.startsWith("*")) return `[...${seg.slice(1) || "rest"}]` // *rest / * → catch-all
37
+ if (seg.startsWith(":")) return `[${seg.slice(1)}]` // :id → [id]
38
+ return seg
39
+ }
40
+
41
+ /** Map a URL path to its `routes/` file path (relative to `routes/`, without extension prefix dir).
42
+ * `/` → `index`; `/users/:id` → `users/[id]`; `/blog/*slug` → `blog/[...slug]`. */
43
+ export function routePathToFile(urlPath: string, ext: string): string {
44
+ const segments = urlPath.split("/").filter((s) => s.length > 0)
45
+ if (segments.length === 0) return `routes/index.${ext}`
46
+ const last = segments.length - 1
47
+ // Catch-all must be the final segment (mirrors @nifrajs/web); flag it rather than emit an invalid file.
48
+ for (let i = 0; i < last; i++) {
49
+ if (segments[i]?.startsWith("*") || segments[i]?.startsWith("[..."))
50
+ throw new Error(`catch-all must be the last segment: "${urlPath}"`)
51
+ }
52
+ return `routes/${segments.map(segmentToFile).join("/")}.${ext}`
53
+ }
54
+
55
+ const ROUTE_CONTRACT = `A route module may export:
56
+ - \`export default function Page(props: { data: LoaderData<typeof loader> }) { … }\` — the page component.
57
+ - \`export async function loader({ params, request, api }: LoaderArgs<typeof backend>) { … }\` — server-only; data for SSR. Reach the backend via \`api\` (typed) / DB via the backend, NEVER a top-level server-only import.
58
+ - \`export async function action({ request, api }: ActionArgs<typeof backend>) { … }\` — server-only; handles the form POST.
59
+ - \`export const meta = { title, meta:[…] }\` — head tags.
60
+ Path params are typed on \`params\`.`
61
+
62
+ function jsxStub(file: string, params: string[]): string {
63
+ const paramsNote =
64
+ params.length > 0 ? `params.${params.join(", params.")}` : "no path params on this route"
65
+ const loaderLine =
66
+ params.length > 0
67
+ ? `// export async function loader({ params, api }: LoaderArgs<typeof backend>) { return { /* fetch by ${params[0]} */ } }`
68
+ : `// export async function loader({ api }: LoaderArgs<typeof backend>) { return {} }`
69
+ return `// ${file} — server-only loader/action allowed; never top-level-import server-only code (DB/secrets).
70
+ // Available here: ${paramsNote}. Fetch data in a loader via the typed \`api\`; see nifra_example("loader").
71
+ ${loaderLine}
72
+ export default function Page() {
73
+ return <main>TODO: ${file}</main>
74
+ }
75
+ `
76
+ }
77
+
78
+ /** Param names a route file declares, for the stub's notes. */
79
+ function paramsOf(file: string): string[] {
80
+ const out: string[] = []
81
+ for (const m of file.matchAll(/\[(?:\.\.\.)?([A-Za-z_][A-Za-z0-9_]*)\]/g))
82
+ out.push(m[1] as string)
83
+ return out
84
+ }
85
+
86
+ export interface ScaffoldResult {
87
+ readonly file: string
88
+ readonly content?: string
89
+ readonly note: string
90
+ }
91
+
92
+ export interface ScaffoldWriteResult extends ScaffoldResult {
93
+ readonly written: boolean
94
+ readonly reason?: string
95
+ }
96
+
97
+ /** Scaffold a page route for `urlPath` under the project's `framework`. Returns the correct file path
98
+ * always; a ready-to-write stub for the JSX family, contract guidance otherwise. */
99
+ export function scaffoldRoute(urlPath: string, framework: Framework): ScaffoldResult {
100
+ const ext = EXT[framework]
101
+ const file = routePathToFile(urlPath, ext)
102
+ const params = paramsOf(file)
103
+ if (framework === "react" || framework === "preact" || framework === "solid") {
104
+ return {
105
+ file,
106
+ content: jsxStub(file, params),
107
+ note: `Create ${file}. ${ROUTE_CONTRACT}`,
108
+ }
109
+ }
110
+ return {
111
+ file,
112
+ note: `Create ${file} as a ${framework} route module. ${ROUTE_CONTRACT}\nFor the ${framework} page body, call nifra_example (it ships verified ${framework} snippets) rather than guessing the SFC shape.`,
113
+ }
114
+ }
115
+
116
+ function resolveInsideCwd(cwd: string, relativeFile: string): string {
117
+ const root = resolve(cwd)
118
+ const target = resolve(root, relativeFile)
119
+ if (target !== root && !target.startsWith(`${root}${sep}`)) {
120
+ throw new Error(`refusing to write outside project root: ${relativeFile}`)
121
+ }
122
+ return target
123
+ }
124
+
125
+ /** Write a scaffolded route stub when the framework has a verified ready-to-write body. The write is
126
+ * intentionally conservative: it refuses non-JSX stubs (where we only return contract guidance) and
127
+ * uses `wx`, so an agent cannot overwrite user work by accident. */
128
+ export async function writeScaffoldRoute(
129
+ cwd: string,
130
+ urlPath: string,
131
+ framework: Framework,
132
+ ): Promise<ScaffoldWriteResult> {
133
+ const result = scaffoldRoute(urlPath, framework)
134
+ if (result.content === undefined) {
135
+ return {
136
+ ...result,
137
+ written: false,
138
+ reason: "no verified ready-to-write stub for this framework; use nifra_example for the body",
139
+ }
140
+ }
141
+ const target = resolveInsideCwd(cwd, result.file)
142
+ await mkdir(dirname(target), { recursive: true })
143
+ try {
144
+ await writeFile(target, result.content, { flag: "wx" })
145
+ } catch (err) {
146
+ if (err && typeof err === "object" && (err as { code?: string }).code === "EEXIST") {
147
+ return { ...result, written: false, reason: `file already exists: ${result.file}` }
148
+ }
149
+ throw err
150
+ }
151
+ return { ...result, written: true }
152
+ }
153
+
154
+ /** Render the tool result as markdown — the file path, the stub (if any), and the contract note. */
155
+ export function renderScaffold(urlPath: string, framework: Framework): string {
156
+ let r: ScaffoldResult
157
+ try {
158
+ r = scaffoldRoute(urlPath, framework)
159
+ } catch (err) {
160
+ return `Cannot scaffold ${JSON.stringify(urlPath)}: ${err instanceof Error ? err.message : String(err)}`
161
+ }
162
+ const stub = r.content ? `\n\n\`\`\`${EXT[framework]}\n${r.content}\`\`\`` : ""
163
+ return `# Scaffold route \`${urlPath}\` (${framework})\n\n**File:** \`${r.file}\`\n\n${r.note}${stub}`
164
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tiny deterministic query expansion for the agent-facing docs/example search tools.
3
+ *
4
+ * This is intentionally not "semantic search": the corpora are small, curated, and bundled with the
5
+ * package. A few framework-specific aliases plus safe prefix matching cover the common human/agent
6
+ * wording drift ("authentication" vs "auth", "ws" vs "websocket") without adding dependencies,
7
+ * model calls, or nondeterministic ranking.
8
+ */
9
+
10
+ export interface SearchTermGroup {
11
+ readonly term: string
12
+ readonly variants: readonly string[]
13
+ }
14
+
15
+ const ALIASES: Readonly<Record<string, readonly string[]>> = {
16
+ action: ["actions"],
17
+ actions: ["action"],
18
+ api: ["backend", "server"],
19
+ auth: ["authentication", "login", "session", "sessions"],
20
+ authentication: ["auth", "login", "session", "sessions"],
21
+ backend: ["api", "server"],
22
+ cache: ["caching", "isr"],
23
+ caching: ["cache", "isr"],
24
+ client: ["typed"],
25
+ deploy: ["deployment", "edge", "worker", "workers", "vercel", "deno"],
26
+ deployment: ["deploy", "edge", "worker", "workers", "vercel", "deno"],
27
+ loader: ["loaders"],
28
+ loaders: ["loader"],
29
+ route: ["routes", "routing"],
30
+ routes: ["route", "routing"],
31
+ schema: ["schemas", "validation", "validate"],
32
+ schemas: ["schema", "validation", "validate"],
33
+ server: ["api", "backend"],
34
+ session: ["sessions", "auth", "authentication", "cookie", "cookies"],
35
+ sessions: ["session", "auth", "authentication", "cookie", "cookies"],
36
+ sse: ["eventsource", "stream", "streams"],
37
+ typed: ["client", "type", "types"],
38
+ upload: ["uploads", "file"],
39
+ uploads: ["upload", "file"],
40
+ validate: ["validation", "schema", "schemas"],
41
+ validation: ["validate", "schema", "schemas"],
42
+ websocket: ["websockets", "ws"],
43
+ websockets: ["websocket", "ws"],
44
+ ws: ["websocket", "websockets"],
45
+ }
46
+
47
+ export const tokenize = (s: string): string[] =>
48
+ s
49
+ .toLowerCase()
50
+ .split(/[^a-z0-9]+/)
51
+ .filter((t) => t.length > 1)
52
+
53
+ export function queryTermGroups(query: string): SearchTermGroup[] {
54
+ return [...new Set(tokenize(query))].map((term) => ({
55
+ term,
56
+ variants: [term, ...(ALIASES[term] ?? [])],
57
+ }))
58
+ }
59
+
60
+ function tokenMatches(term: string, token: string): boolean {
61
+ if (term === token) return true
62
+ // Prefix matching catches plural/stem variants without letting very short terms get noisy.
63
+ return term.length >= 4 && token.length >= 4 && (term.startsWith(token) || token.startsWith(term))
64
+ }
65
+
66
+ export function tokenSetHas(tokens: ReadonlySet<string>, group: SearchTermGroup): boolean {
67
+ for (const variant of group.variants) {
68
+ for (const token of tokens) {
69
+ if (tokenMatches(variant, token)) return true
70
+ }
71
+ }
72
+ return false
73
+ }
74
+
75
+ export function countBodyHits(lowerBody: string, group: SearchTermGroup, maxHits: number): number {
76
+ let best = 0
77
+ for (const variant of group.variants) {
78
+ let from = 0
79
+ let hits = 0
80
+ while (hits < maxHits) {
81
+ const at = lowerBody.indexOf(variant, from)
82
+ if (at === -1) break
83
+ hits++
84
+ from = at + variant.length
85
+ }
86
+ if (hits > best) best = hits
87
+ }
88
+ return best
89
+ }
@@ -0,0 +1,181 @@
1
+ export interface TestToolArgs {
2
+ readonly pattern?: unknown
3
+ readonly timeoutMs?: unknown
4
+ }
5
+
6
+ export interface TestSummary {
7
+ readonly passed?: number
8
+ readonly failed?: number
9
+ readonly skipped?: number
10
+ readonly expectations?: number
11
+ readonly files?: number
12
+ }
13
+
14
+ export interface TestToolResult {
15
+ readonly ok: boolean
16
+ readonly command: readonly string[]
17
+ readonly durationMs: number
18
+ readonly exitCode: number | null
19
+ readonly timedOut: boolean
20
+ readonly cancelled?: boolean
21
+ readonly summary: TestSummary
22
+ readonly stdout: string
23
+ readonly stderr: string
24
+ readonly error?: string
25
+ }
26
+
27
+ const DEFAULT_TIMEOUT_MS = 30_000
28
+ const MAX_TIMEOUT_MS = 300_000
29
+ const MAX_OUTPUT_CHARS = 12_000
30
+
31
+ function normalizePattern(value: unknown): string | undefined {
32
+ if (value === undefined || value === null) return undefined
33
+ if (typeof value !== "string") throw new Error("pattern must be a string")
34
+ const pattern = value.trim()
35
+ if (pattern === "") return undefined
36
+ if (pattern.length > 500) throw new Error("pattern is too long")
37
+ if (pattern.includes("\0")) throw new Error("pattern contains a NUL byte")
38
+ // It is passed as argv, not a shell string, but blocking flags keeps the MCP tool's contract simple:
39
+ // `pattern` narrows test files; it is not a remote flag injection surface.
40
+ if (pattern.startsWith("-"))
41
+ throw new Error("pattern must be a file/path pattern, not a CLI flag")
42
+ return pattern
43
+ }
44
+
45
+ function normalizeTimeout(value: unknown): number {
46
+ if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS
47
+ if (typeof value !== "number" || !Number.isFinite(value)) {
48
+ throw new Error("timeoutMs must be a finite number")
49
+ }
50
+ return Math.min(Math.max(Math.trunc(value), 1_000), MAX_TIMEOUT_MS)
51
+ }
52
+
53
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g")
54
+
55
+ function clean(text: string): string {
56
+ const stripped = text.replace(ANSI, "")
57
+ return stripped.length <= MAX_OUTPUT_CHARS
58
+ ? stripped
59
+ : `${stripped.slice(0, 4_000)}\n…(trimmed ${stripped.length - MAX_OUTPUT_CHARS} chars)…\n${stripped.slice(-8_000)}`
60
+ }
61
+
62
+ function firstNumber(pattern: RegExp, text: string): number | undefined {
63
+ const match = pattern.exec(text)
64
+ return match?.[1] === undefined ? undefined : Number(match[1])
65
+ }
66
+
67
+ function parseSummary(output: string): TestSummary {
68
+ const passed = firstNumber(/(\d+)\s+pass(?:ed)?\b/i, output)
69
+ const failed = firstNumber(/(\d+)\s+fail(?:ed)?\b/i, output)
70
+ const skipped = firstNumber(/(\d+)\s+skip(?:ped)?\b/i, output)
71
+ const expectations = firstNumber(/(\d+)\s+expect(?:ation)?s?\b/i, output)
72
+ const files = firstNumber(/(\d+)\s+files?\b/i, output)
73
+ return {
74
+ ...(passed !== undefined ? { passed } : {}),
75
+ ...(failed !== undefined ? { failed } : {}),
76
+ ...(skipped !== undefined ? { skipped } : {}),
77
+ ...(expectations !== undefined ? { expectations } : {}),
78
+ ...(files !== undefined ? { files } : {}),
79
+ }
80
+ }
81
+
82
+ /** Run `bun test` in a project with bounded runtime and bounded output. Arguments are argv entries,
83
+ * never a shell string, so an agent can safely pass a path pattern without command injection risk. */
84
+ export async function collectTestResult(
85
+ cwd: string,
86
+ args: TestToolArgs = {},
87
+ opts: { readonly signal?: AbortSignal } = {},
88
+ ): Promise<TestToolResult> {
89
+ let pattern: string | undefined
90
+ let timeoutMs: number
91
+ try {
92
+ pattern = normalizePattern(args.pattern)
93
+ timeoutMs = normalizeTimeout(args.timeoutMs)
94
+ } catch (err) {
95
+ return {
96
+ ok: false,
97
+ command: ["bun", "test"],
98
+ durationMs: 0,
99
+ exitCode: null,
100
+ timedOut: false,
101
+ summary: {},
102
+ stdout: "",
103
+ stderr: "",
104
+ error: err instanceof Error ? err.message : String(err),
105
+ }
106
+ }
107
+
108
+ const command = ["bun", "test", ...(pattern === undefined ? [] : [pattern])]
109
+ const started = Date.now()
110
+ if (opts.signal?.aborted) {
111
+ return {
112
+ ok: false,
113
+ command,
114
+ durationMs: 0,
115
+ exitCode: null,
116
+ timedOut: false,
117
+ cancelled: true,
118
+ summary: {},
119
+ stdout: "",
120
+ stderr: "",
121
+ error:
122
+ typeof opts.signal.reason === "string" && opts.signal.reason.length > 0
123
+ ? `cancelled: ${opts.signal.reason}`
124
+ : "cancelled",
125
+ }
126
+ }
127
+ const proc = Bun.spawn(command, {
128
+ cwd,
129
+ stdout: "pipe",
130
+ stderr: "pipe",
131
+ })
132
+ let timedOut = false
133
+ let cancelled = false
134
+ const abort = (): void => {
135
+ cancelled = true
136
+ proc.kill()
137
+ }
138
+ opts.signal?.addEventListener("abort", abort, { once: true })
139
+ const timer = setTimeout(() => {
140
+ timedOut = true
141
+ proc.kill()
142
+ }, timeoutMs)
143
+ let stdoutRaw = ""
144
+ let stderrRaw = ""
145
+ let exitCode: number | null = null
146
+ try {
147
+ const result = await Promise.all([
148
+ new Response(proc.stdout).text(),
149
+ new Response(proc.stderr).text(),
150
+ proc.exited,
151
+ ])
152
+ stdoutRaw = result[0]
153
+ stderrRaw = result[1]
154
+ exitCode = result[2]
155
+ } finally {
156
+ clearTimeout(timer)
157
+ opts.signal?.removeEventListener("abort", abort)
158
+ }
159
+ const stdout = clean(stdoutRaw)
160
+ const stderr = clean(stderrRaw)
161
+ const combined = `${stdout}\n${stderr}`
162
+ return {
163
+ ok: exitCode === 0 && !timedOut && !cancelled,
164
+ command,
165
+ durationMs: Date.now() - started,
166
+ exitCode,
167
+ timedOut,
168
+ ...(cancelled ? { cancelled: true } : {}),
169
+ summary: parseSummary(combined),
170
+ stdout,
171
+ stderr,
172
+ ...(cancelled
173
+ ? {
174
+ error:
175
+ typeof opts.signal?.reason === "string" && opts.signal.reason.length > 0
176
+ ? `cancelled: ${opts.signal.reason}`
177
+ : "cancelled",
178
+ }
179
+ : {}),
180
+ }
181
+ }