@apifuse/provider-sdk 2.1.0-beta.1 → 2.1.0-beta.3

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/AUTHORING.md CHANGED
@@ -74,11 +74,38 @@ External contributors are expected to submit standalone Provider source plus:
74
74
  - Health coverage table for every Operation.
75
75
  - `bun run check` output.
76
76
  - `bun run test` output.
77
+ - `bun run submit-check` score/verdict and generated `submission-report.md`.
77
78
  - Fixture evidence and known upstream constraints.
78
79
 
79
80
  Maintainers own monorepo import under `providers/<id>/`, registry generation,
80
81
  deployment projection checks, and release workflows.
81
82
 
83
+ ### Public local debugging checklist
84
+
85
+ - Operation smoke requests use the provider server envelope:
86
+ `{"requestId":"req_local_<operation>","input":{...},"headers":{}}`.
87
+ Omit `connection` for public/no-auth operations; do not send `connection: null`.
88
+ - Credential-backed smoke requests pass local-only credential material in
89
+ `connection.secrets`. Keep real values in shell env or `.env`, never in source
90
+ or fixtures.
91
+ - Auth-flow debugging starts with `/auth/start`, continues with
92
+ `/auth/continue`, and carries returned `contextPatch` values into the next
93
+ request's `context`.
94
+ - TLS/browser providers may require local runtime setup outside Provider code:
95
+ use `bun pm untrusted`/`bun pm trust koffi` for blocked native TLS dependency
96
+ scripts, `browser.engine: "playwright-stealth"` for TypeScript browser
97
+ Providers (`nodriver` is Python-runtime only), `bunx playwright install
98
+ chromium` for local Playwright browser assets, or
99
+ `CDP_POOL_URL`/`APIFUSE_CDP_POOL_URL` for remote browser debugging.
100
+
101
+ ### Running the pre-submission report
102
+
103
+ ```bash
104
+ bun run submit-check
105
+ ```
106
+
107
+ The report scores review readiness across definition metadata, operation/schema quality, fixtures/tests, health coverage, local smoke evidence, auth safety, secret hygiene, and submission docs. It is not a payout guarantee; any blocker must be fixed before review. For the complete public-only submission checklist, see `SUBMISSION.md` in the SDK package.
108
+
82
109
  ### Running the lint locally
83
110
 
84
111
  ```bash
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.1.0-beta.3
4
+
5
+ - Add the public `apifuse submit-check` / `apifuse bounty-check` CLI for score-based pre-submission provider quality checks.
6
+ - Ship `SUBMISSION.md` in the npm package so bounty contributors can follow the checklist without access to the private monorepo.
7
+ - Include submit-check in generated provider validation scripts and packed-artifact smoke coverage.
8
+ - Warn, instead of hard-block, generated OAuth starters that have not yet declared persisted credential keys.
9
+
10
+ ## 2.1.0-beta.2
11
+
12
+ - Harden public bounty contributor DX with server-contract accurate README and generated Provider smoke examples.
13
+ - Add packed-artifact regression checks so stale `connection: null` or missing `requestId` examples cannot ship again.
14
+ - Extend clean-room packed SDK smoke coverage to boot the generated dev server and call `/health` plus `POST /v1/ping`.
15
+ - Document credential, auth-flow, TLS, browser, and Bun trusted-dependency troubleshooting for SDK-only local development.
16
+
3
17
  ## 2.1.0-beta.1
4
18
 
5
19
  - Fix public `apifuse create` runtime packaging by publishing `@clack/prompts` as a production dependency.
package/README.md CHANGED
@@ -58,6 +58,7 @@ Removed legacy runtime paths are not supported:
58
58
  cd my-provider
59
59
  bun run check
60
60
  bun run test
61
+ bun run submit-check
61
62
  bun run dev
62
63
  ```
63
64
 
@@ -67,9 +68,73 @@ Smoke the generated local server:
67
68
  curl -s http://localhost:3900/health
68
69
  curl -s -X POST http://localhost:3900/v1/ping \
69
70
  -H 'Content-Type: application/json' \
70
- -d '{"input":{"value":"hello"},"headers":{},"connection":null}'
71
+ -d '{"requestId":"req_local_ping","input":{"value":"hello"},"headers":{}}'
71
72
  ```
72
73
 
74
+ The operation request body is the same envelope used by the ApiFuse gateway:
75
+
76
+ | Field | Required | Notes |
77
+ |---|---:|---|
78
+ | `requestId` | yes | Any unique string is fine for local debugging; it is echoed in structured errors. |
79
+ | `input` | yes | The operation input after schema validation. |
80
+ | `headers` | no | Extra caller headers to expose through `ctx.request.headers`. |
81
+ | `connection` | no | Omit for no-auth/public operations. For credential debugging, pass an object with `id`, `mode`, `secrets`, `metadata`, and `externalRef`. Do not pass `null`. |
82
+
83
+ Credential-bearing local smoke example:
84
+
85
+ ```bash
86
+ curl -s -X POST http://localhost:3900/v1/me \
87
+ -H 'Content-Type: application/json' \
88
+ -d '{
89
+ "requestId":"req_local_me",
90
+ "input":{},
91
+ "connection":{
92
+ "id":"conn_local_debug",
93
+ "mode":"credentials",
94
+ "secrets":{"apiKey":"dev-only-secret"},
95
+ "scopes":[],
96
+ "metadata":{},
97
+ "externalRef":"local-debug"
98
+ }
99
+ }'
100
+ ```
101
+
102
+ Auth-flow endpoints use the same `requestId` convention:
103
+
104
+ ```bash
105
+ curl -s -X POST http://localhost:3900/auth/start \
106
+ -H 'Content-Type: application/json' \
107
+ -d '{"requestId":"req_auth_start","flowId":"flow_local_1","providerId":"my-provider","context":{}}'
108
+ ```
109
+
110
+ If a local smoke returns `{"error":...}`, inspect the JSON error body and the
111
+ `apifuse dev` server log. Validation failures include a `details` array with
112
+ the bad request path; provider/runtime failures include `code`, `message`, and
113
+ `requestId`.
114
+
115
+ ### Local debugging checklist
116
+
117
+ - **`invalid_request` on `/v1/{operation}`**: confirm the request body includes
118
+ `requestId` and `input`. Omit `connection` for public/no-auth operations;
119
+ never send `connection: null`.
120
+ - **Credential-backed operations**: declare `credential.keys`, then pass matching
121
+ local-only values through `connection.secrets`. Read them in handlers with
122
+ `ctx.credential.get("key")` or `ctx.credential.getAccessToken()`.
123
+ - **Provider env secrets**: declare `secrets[]`, set values in your shell or
124
+ `.env`, and read only those names through `ctx.env.get("NAME")`.
125
+ - **Auth flows**: call `/auth/start`, then `/auth/continue` with the same
126
+ `flowId`; preserve any returned `contextPatch` in the next local request's
127
+ `context` object.
128
+ - **TLS-sensitive providers**: if Bun reports blocked lifecycle scripts after
129
+ install, run `bun pm untrusted`; when it lists trusted SDK native dependencies
130
+ such as `koffi`, run `bun pm trust koffi` (or `bun pm trust`) before debugging
131
+ `ctx.tls` failures.
132
+ - **Browser providers**: for TypeScript Providers use `runtime: "browser"` plus
133
+ `browser.engine: "playwright-stealth"`; `nodriver` is a Python-runtime path.
134
+ Install local browser assets with `bunx playwright install chromium` when
135
+ using the Playwright runtime, or set `CDP_POOL_URL`/`APIFUSE_CDP_POOL_URL`
136
+ when debugging against a remote browser pool.
137
+
73
138
  ## Authoring ergonomics
74
139
 
75
140
  `defineProvider()` infers each operation handler input from the operation `input` schema. For larger providers, factor operations with `defineOperation()` and compose them later:
@@ -131,9 +196,26 @@ apifuse dev [path]
131
196
  apifuse check [path]
132
197
  apifuse record [path] --operation <operation> --params '{"value":"hello"}'
133
198
  apifuse test [path]
199
+ apifuse submit-check [path] --tier bronze --markdown submission-report.md
200
+ apifuse bounty-check [path]
134
201
  apifuse perf <path> --operation <operation>
135
202
  ```
136
203
 
204
+ `apifuse record` is for real upstream-backed operations that declare
205
+ `upstream.baseUrl` and call the upstream through `ctx.http` or `ctx.tls`. The
206
+ generated local-only `ping` operation intentionally has no upstream and should
207
+ be replaced before recording fixtures.
208
+
209
+ ## Bounty submission readiness
210
+
211
+ Standalone providers include a pre-submission script:
212
+
213
+ ```bash
214
+ bun run submit-check
215
+ ```
216
+
217
+ This runs the public review-readiness evaluator and writes `submission-report.md`. The report contains provider metadata, a 100-point readiness score, hard blockers, warnings, checklist evidence, and remediation. Blockers override the score; fix them before posting bounty evidence. The command is offline-safe by default and does not execute arbitrary upstream calls. Add local smoke notes to your bounty issue after testing `/health` and `POST /v1/{operation}`. See [`SUBMISSION.md`](./SUBMISSION.md) for the full public-only bounty submission checklist shipped in the npm package.
218
+
137
219
  ## Scope boundary
138
220
 
139
221
  Generator v1 scaffolds **TypeScript providers only** for this redesign. Python generation remains future work.
package/SUBMISSION.md ADDED
@@ -0,0 +1,86 @@
1
+ # Provider bounty submission guide
2
+
3
+ This guide is for public bounty contributors who only have access to the published `@apifuse/provider-sdk` package. You do **not** need the private ApiFuse monorepo to build or pre-check a Provider.
4
+
5
+ ## Public-only workflow
6
+
7
+ ```bash
8
+ bunx @apifuse/provider-sdk@beta create my-provider --yes
9
+ cd my-provider
10
+ bun run check
11
+ bun run test
12
+ bun run submit-check
13
+ bun run dev
14
+ ```
15
+
16
+ `bun run submit-check` runs `apifuse submit-check . --markdown submission-report.md` in generated Providers.
17
+
18
+ ## What submit-check scores
19
+
20
+ `apifuse submit-check` produces a 100-point review-readiness score and a verdict:
21
+
22
+ - `ready` — no blockers or warnings and score is at least 90.
23
+ - `reviewable_with_warnings` — no blockers, but reviewers should inspect the warnings.
24
+ - `blocked` — at least one hard blocker must be fixed before review.
25
+
26
+ The score is a triage aid, not a payout guarantee. Maintainers still review correctness, upstream behavior, policy fit, and bounty scope.
27
+
28
+ | Category | Points | Examples |
29
+ |---|---:|---|
30
+ | Definition & metadata | 15 | `defineProvider`, package, Dockerfile, SDK structural checks |
31
+ | Operations & schemas | 15 | strong descriptions, annotations, input/output schemas |
32
+ | Fixtures & tests | 15 | bidirectional fixtures that parse against schemas |
33
+ | Health coverage | 15 | real `healthCheck` or specific `healthCheckUnsupported.reason` |
34
+ | Runtime/local smoke | 10 | `/health` and at least one `POST /v1/{operation}` note |
35
+ | Auth/credential safety | 10 | auth mode and credential declarations are consistent |
36
+ | Security hygiene | 10 | no high-confidence secrets in shareable files |
37
+ | Docs/submission evidence | 10 | README Parameters, Response, Example, submit-check guidance |
38
+
39
+ ## Hard blockers
40
+
41
+ Fix all blockers before submitting:
42
+
43
+ - `bun run check` failures.
44
+ - Provider cannot be imported from `index.ts`.
45
+ - Missing handler/input/output for any Operation.
46
+ - Missing fixture request or response.
47
+ - Fixture data does not parse against schemas.
48
+ - Missing `healthCheck` or `healthCheckUnsupported` on any Operation.
49
+ - Credential-backed auth mode without declared credential keys.
50
+ - High-confidence secret or token material in source, README, package metadata, or fixtures.
51
+
52
+ Warnings do not fail the command, but they should be addressed when practical. For example, the generated starter `ping` operation warns because it is not a real upstream-backed bounty Operation.
53
+
54
+ ## Safe local smoke evidence
55
+
56
+ `submit-check` does not call arbitrary live upstream APIs by default. After replacing starter logic, run the Provider locally and record a short evidence note:
57
+
58
+ ```bash
59
+ bun run dev
60
+ curl -s http://localhost:3900/health
61
+ curl -s -X POST http://localhost:3900/v1/<operation> \
62
+ -H 'Content-Type: application/json' \
63
+ -d '{"requestId":"req_local_smoke","input":{...},"headers":{}}'
64
+
65
+ bun run submit-check -- --smoke-note "GET /health and POST /v1/<operation> passed locally with redacted input."
66
+ ```
67
+
68
+ Never paste real credentials, personal data, account numbers, access tokens, cookies, or unredacted upstream responses into issue comments or reports.
69
+
70
+ ## Submission evidence checklist
71
+
72
+ Include the following when you submit a Provider for review:
73
+
74
+ - Provider SDK version, for example `bun pm ls @apifuse/provider-sdk` or package.json dependency.
75
+ - Provider id, version, runtime, and auth mode.
76
+ - Operation list with input/output summaries.
77
+ - Health coverage table: one row per Operation with `healthCheck` or `healthCheckUnsupported` and rationale.
78
+ - `bun run check` output.
79
+ - `bun run test` output.
80
+ - `submission-report.md` generated by `bun run submit-check`.
81
+ - Local smoke evidence for `/health` and at least one `POST /v1/{operation}` call.
82
+ - Known upstream constraints: rate limits, paid/destructive calls, auth limits, flaky endpoints, or unsupported probes.
83
+
84
+ ## Maintainer-owned follow-up
85
+
86
+ After public evidence is submitted, ApiFuse maintainers import accepted standalone Provider work into the private monorepo and run internal registry, generated artifact, deployment projection, and CI checks. Public contributors are not expected to run those private checks.
@@ -7,13 +7,14 @@ import { pathToFileURL } from "node:url";
7
7
  import { z } from "zod";
8
8
 
9
9
  import type { ProviderDefinition } from "../src";
10
+ import { lintProvider } from "../src/lint";
10
11
  import { safeParseSchemaSync } from "../src/schema";
11
12
 
12
13
  const HELP_TEXT = `Usage: apifuse check [path]
13
14
  Example: apifuse check providers/airkorea
14
15
  Default: apifuse check .`;
15
16
 
16
- type CheckResult = {
17
+ export type CheckResult = {
17
18
  message: string;
18
19
  passed: boolean;
19
20
  details?: string[];
@@ -103,7 +104,7 @@ function resolveFromParents(inputPath: string): string {
103
104
  }
104
105
  }
105
106
 
106
- async function runChecks(providerRoot: string): Promise<CheckResult[]> {
107
+ export async function runChecks(providerRoot: string): Promise<CheckResult[]> {
107
108
  const indexPath = resolve(providerRoot, "index.ts");
108
109
  const dockerfilePath = resolve(providerRoot, "Dockerfile");
109
110
  const packageJsonPath = resolve(providerRoot, "package.json");
@@ -118,6 +119,7 @@ async function runChecks(providerRoot: string): Promise<CheckResult[]> {
118
119
  checkOperations(provider),
119
120
  checkFixtures(provider),
120
121
  checkSchemas(provider),
122
+ checkAuthoringLint(provider),
121
123
  checkProviderMetadata(provider),
122
124
  checkDockerfile(dockerfilePath),
123
125
  checkPackageJson(packageJsonPath),
@@ -254,6 +256,32 @@ function checkSchemas(provider: ProviderDefinition | undefined): CheckResult {
254
256
  };
255
257
  }
256
258
 
259
+ function checkAuthoringLint(
260
+ provider: ProviderDefinition | undefined,
261
+ ): CheckResult {
262
+ if (!provider) {
263
+ return {
264
+ message: "Provider authoring lint has no error-level diagnostics",
265
+ passed: false,
266
+ };
267
+ }
268
+
269
+ const diagnostics = lintProvider(provider);
270
+ const errors = diagnostics.filter(
271
+ (diagnostic) => diagnostic.level === "error",
272
+ );
273
+ const details = diagnostics.map((diagnostic) => {
274
+ const field = diagnostic.field ? `${diagnostic.field}: ` : "";
275
+ return `${diagnostic.level.toUpperCase()} ${diagnostic.rule} ${field}${diagnostic.message}`;
276
+ });
277
+
278
+ return {
279
+ message: "Provider authoring lint has no error-level diagnostics",
280
+ passed: errors.length === 0,
281
+ details,
282
+ };
283
+ }
284
+
257
285
  function checkProviderMetadata(
258
286
  provider: ProviderDefinition | undefined,
259
287
  ): CheckResult {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { existsSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, relative, resolve } from "node:path";
5
5
 
6
6
  import type { ProviderDefinition } from "../src";
7
7
  import {
@@ -51,10 +51,24 @@ export async function main() {
51
51
  console.log(` POST http://localhost:${port}/auth/poll`);
52
52
  console.log(` POST http://localhost:${port}/auth/disconnect`);
53
53
 
54
+ const firstOperation = Object.keys(provider.operations)[0];
55
+ if (firstOperation) {
56
+ const sampleInput =
57
+ provider.operations[firstOperation]?.fixtures?.request ?? {};
58
+ const sampleBody = JSON.stringify({
59
+ requestId: `req_local_${firstOperation}`,
60
+ input: sampleInput,
61
+ headers: {},
62
+ });
63
+ console.log("\nSmoke:");
64
+ console.log(` curl -s http://localhost:${port}/health`);
65
+ console.log(
66
+ ` curl -s -X POST http://localhost:${port}/v1/${firstOperation} -H 'Content-Type: application/json' -d ${shellSingleQuote(sampleBody)}`,
67
+ );
68
+ }
69
+
54
70
  console.log("\nHot reload:");
55
- console.log(
56
- ` bun --hot ${resolveImportPath("apifuse-dev.ts")} ${args[0] ?? "."}`,
57
- );
71
+ console.log(` ${renderHotReloadCommand(providerPath, port)}`);
58
72
  }
59
73
 
60
74
  export function createProviderContext(provider: ProviderDefinition): {
@@ -111,8 +125,18 @@ function resolveFromParents(inputPath: string): string {
111
125
  }
112
126
  }
113
127
 
114
- function resolveImportPath(fileName: string): string {
115
- return resolve(process.cwd(), "bin", fileName);
128
+ function renderHotReloadCommand(providerPath: string, port: number): string {
129
+ const devEntry = resolve(providerPath, "dev.ts");
130
+ if (existsSync(devEntry)) {
131
+ const relativeDevEntry = relative(process.cwd(), devEntry) || "dev.ts";
132
+ const portPrefix = process.env.PORT ? `PORT=${port} ` : "";
133
+ return `${portPrefix}bun --hot ${relativeDevEntry}`;
134
+ }
135
+ return "rerun `apifuse dev` after edits (no dev.ts entrypoint found)";
136
+ }
137
+
138
+ function shellSingleQuote(value: string): string {
139
+ return `'${value.replaceAll("'", "'\\''")}'`;
116
140
  }
117
141
 
118
142
  function createUnsupportedBrowserStub(): BrowserClient {
@@ -33,6 +33,8 @@ const requiredPaths = [
33
33
  "bin/apifuse.ts",
34
34
  "bin/apifuse-create.ts",
35
35
  "bin/apifuse-pack-smoke.ts",
36
+ "bin/apifuse-submit-check.ts",
37
+ "SUBMISSION.md",
36
38
  "src/cli/create.ts",
37
39
  "src/cli/templates/provider/index.ts.tpl",
38
40
  "src/cli/templates/provider/README.md.tpl",
@@ -81,7 +83,63 @@ if (packageJson.devDependencies?.["@clack/prompts"]) {
81
83
  );
82
84
  }
83
85
 
86
+ assertPublicSmokeDocs("README.md", readFileSync("README.md", "utf8"));
87
+ assertPublicSmokeDocs(
88
+ "src/cli/templates/provider/README.md.tpl",
89
+ readFileSync("src/cli/templates/provider/README.md.tpl", "utf8"),
90
+ );
91
+
84
92
  console.log(`Packed artifact OK: ${first.filename}`);
85
93
  for (const filePath of filePaths) {
86
94
  console.log(` - ${filePath}`);
87
95
  }
96
+
97
+ function assertPublicSmokeDocs(label: string, content: string): void {
98
+ if (!content.includes('"requestId":"req_local_ping"')) {
99
+ throw new Error(
100
+ `${label} must document the current provider server request envelope with requestId.`,
101
+ );
102
+ }
103
+
104
+ if (content.includes('"connection":null')) {
105
+ throw new Error(
106
+ `${label} must not tell public users to send connection:null; omit connection for no-auth operations.`,
107
+ );
108
+ }
109
+
110
+ if (!content.includes("bunx playwright install chromium")) {
111
+ throw new Error(
112
+ `${label} must include browser runtime troubleshooting for public SDK-only debugging.`,
113
+ );
114
+ }
115
+
116
+ if (!content.includes("bun pm untrusted")) {
117
+ throw new Error(
118
+ `${label} must include Bun trusted-dependency troubleshooting for TLS/browser bounties.`,
119
+ );
120
+ }
121
+
122
+ if (!content.includes("submit-check")) {
123
+ throw new Error(
124
+ `${label} must document the submit-check pre-submission workflow.`,
125
+ );
126
+ }
127
+
128
+ if (
129
+ !content.includes('browser.engine: "playwright-stealth"') ||
130
+ !content.includes("nodriver")
131
+ ) {
132
+ throw new Error(
133
+ `${label} must clarify that TypeScript browser providers use playwright-stealth and nodriver is not the TypeScript happy path.`,
134
+ );
135
+ }
136
+
137
+ if (
138
+ label.includes("templates/provider/README.md.tpl") &&
139
+ !content.includes("bun run record -- --operation <operation>")
140
+ ) {
141
+ throw new Error(
142
+ `${label} must document fixture recording through a generated package script, not a shell-global apifuse command.`,
143
+ );
144
+ }
145
+ }
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { execFileSync, spawnSync } from "node:child_process";
3
+ import {
4
+ type ChildProcess,
5
+ execFileSync,
6
+ spawn,
7
+ spawnSync,
8
+ } from "node:child_process";
4
9
  import {
5
10
  existsSync,
6
11
  mkdirSync,
7
12
  mkdtempSync,
13
+ readFileSync,
8
14
  rmSync,
9
15
  writeFileSync,
10
16
  } from "node:fs";
17
+ import { createServer } from "node:net";
11
18
  import { tmpdir } from "node:os";
12
19
  import { join, resolve } from "node:path";
13
20
  import { z } from "zod";
@@ -17,6 +24,20 @@ const PACK_RESULT_SCHEMA = z.array(
17
24
  filename: z.string(),
18
25
  }),
19
26
  );
27
+ const HEALTH_RESPONSE_SCHEMA = z.object({
28
+ status: z.string(),
29
+ provider: z.string(),
30
+ version: z.string().optional(),
31
+ });
32
+ const PING_RESPONSE_SCHEMA = z.object({
33
+ data: z
34
+ .object({
35
+ ok: z.boolean(),
36
+ message: z.string(),
37
+ })
38
+ .optional(),
39
+ error: z.unknown().optional(),
40
+ });
20
41
 
21
42
  const KEEP_TEMP = process.env.APIFUSE_PACK_SMOKE_KEEP_TEMP === "1";
22
43
 
@@ -72,7 +93,10 @@ try {
72
93
 
73
94
  const generatedProviderDir = join(consumerDir, "dx-smoke");
74
95
  run("bun", ["run", "check"], generatedProviderDir);
96
+ run("bun", ["run", "submit-check"], generatedProviderDir);
75
97
  run("bun", ["run", "test"], generatedProviderDir);
98
+ assertGeneratedReadme(generatedProviderDir);
99
+ await smokeGeneratedDevServer(generatedProviderDir);
76
100
 
77
101
  console.log(
78
102
  `Provider SDK packed-artifact smoke passed: ${tarballPath} -> ${generatedProviderDir}`,
@@ -120,3 +144,159 @@ function run(command: string, args: string[], cwd: string): void {
120
144
  );
121
145
  }
122
146
  }
147
+
148
+ function assertGeneratedReadme(providerDir: string): void {
149
+ const readme = readFileSync(join(providerDir, "README.md"), "utf8");
150
+ if (!readme.includes('"requestId":"req_local_ping"')) {
151
+ throw new Error(
152
+ "Generated README is missing requestId in local smoke docs.",
153
+ );
154
+ }
155
+ if (readme.includes('"connection":null')) {
156
+ throw new Error(
157
+ "Generated README must not document connection:null for no-auth local smoke.",
158
+ );
159
+ }
160
+ if (!readme.includes("bunx playwright install chromium")) {
161
+ throw new Error(
162
+ "Generated README is missing browser runtime troubleshooting guidance.",
163
+ );
164
+ }
165
+ if (!readme.includes("bun pm untrusted")) {
166
+ throw new Error(
167
+ "Generated README is missing Bun trusted-dependency troubleshooting guidance.",
168
+ );
169
+ }
170
+ if (!readme.includes("bun run submit-check")) {
171
+ throw new Error(
172
+ "Generated README must document the submit-check pre-submission workflow.",
173
+ );
174
+ }
175
+ if (!readme.includes("bun run record -- --operation <operation>")) {
176
+ throw new Error(
177
+ "Generated README must document fixture recording through the generated record script.",
178
+ );
179
+ }
180
+ }
181
+
182
+ async function smokeGeneratedDevServer(providerDir: string): Promise<void> {
183
+ const port = await getAvailablePort();
184
+ const server = spawn("bun", ["run", "dev"], {
185
+ cwd: providerDir,
186
+ env: { ...process.env, PORT: String(port) },
187
+ stdio: ["ignore", "pipe", "pipe"],
188
+ });
189
+ let output = "";
190
+ server.stdout?.on("data", (chunk) => {
191
+ output += chunk.toString();
192
+ });
193
+ server.stderr?.on("data", (chunk) => {
194
+ output += chunk.toString();
195
+ });
196
+
197
+ try {
198
+ const baseUrl = `http://127.0.0.1:${port}`;
199
+ await waitForHttp(`${baseUrl}/health`, server, () => output);
200
+
201
+ const health = await fetchJson(`${baseUrl}/health`, HEALTH_RESPONSE_SCHEMA);
202
+ if (health.status !== "ok" || health.provider !== "dx-smoke") {
203
+ throw new Error(`Unexpected /health payload: ${JSON.stringify(health)}`);
204
+ }
205
+
206
+ const response = await fetch(`${baseUrl}/v1/ping`, {
207
+ method: "POST",
208
+ headers: { "content-type": "application/json" },
209
+ body: JSON.stringify({
210
+ requestId: "req_pack_smoke_ping",
211
+ input: { value: "hello" },
212
+ headers: {},
213
+ }),
214
+ });
215
+ const payload = PING_RESPONSE_SCHEMA.parse(await response.json());
216
+
217
+ if (!response.ok || payload.data?.ok !== true) {
218
+ throw new Error(
219
+ `Unexpected /v1/ping response (${response.status}): ${JSON.stringify(payload)}`,
220
+ );
221
+ }
222
+ } finally {
223
+ await stopServer(server);
224
+ }
225
+ }
226
+
227
+ async function getAvailablePort(): Promise<number> {
228
+ return await new Promise((resolvePromise, rejectPromise) => {
229
+ const server = createServer();
230
+ server.once("error", rejectPromise);
231
+ server.listen(0, "127.0.0.1", () => {
232
+ const address = server.address();
233
+ server.close((error) => {
234
+ if (error) {
235
+ rejectPromise(error);
236
+ return;
237
+ }
238
+ if (!address || typeof address === "string") {
239
+ rejectPromise(new Error("Could not allocate a local TCP port."));
240
+ return;
241
+ }
242
+ resolvePromise(address.port);
243
+ });
244
+ });
245
+ });
246
+ }
247
+
248
+ async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
249
+ const response = await fetch(url);
250
+ if (!response.ok) {
251
+ throw new Error(`${url} returned ${response.status}`);
252
+ }
253
+ return schema.parse(await response.json());
254
+ }
255
+
256
+ async function waitForHttp(
257
+ url: string,
258
+ server: ChildProcess,
259
+ getOutput: () => string,
260
+ ): Promise<void> {
261
+ const deadline = Date.now() + 10_000;
262
+ let lastError: unknown;
263
+
264
+ while (Date.now() < deadline) {
265
+ if (server.exitCode !== null) {
266
+ throw new Error(
267
+ `Dev server exited early with code ${server.exitCode}\n${getOutput()}`,
268
+ );
269
+ }
270
+
271
+ try {
272
+ await fetchJson(url, HEALTH_RESPONSE_SCHEMA);
273
+ return;
274
+ } catch (error) {
275
+ lastError = error;
276
+ await new Promise((resolve) => setTimeout(resolve, 200));
277
+ }
278
+ }
279
+
280
+ throw new Error(
281
+ `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${getOutput()}`,
282
+ );
283
+ }
284
+
285
+ async function stopServer(server: ChildProcess): Promise<void> {
286
+ if (server.exitCode !== null) {
287
+ return;
288
+ }
289
+ server.kill("SIGTERM");
290
+ await new Promise<void>((resolvePromise) => {
291
+ const timeout = setTimeout(() => {
292
+ if (server.exitCode === null) {
293
+ server.kill("SIGKILL");
294
+ }
295
+ resolvePromise();
296
+ }, 2_000);
297
+ server.once("exit", () => {
298
+ clearTimeout(timeout);
299
+ resolvePromise();
300
+ });
301
+ });
302
+ }