@specbook/cli 0.3.0 → 0.4.0
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/agent-guide.md +170 -0
- package/dist/bin.js +249 -94
- package/dist/standalone.js +8 -4
- package/package.json +6 -5
package/agent-guide.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Specbook
|
|
2
|
+
|
|
3
|
+
> Specbook turns an OpenAPI spec into typed requests and flows that run as your API docs, as tests in CI, and as a monitor against production. Requests and flows are TypeScript files in the project's repo. The same files run in the terminal (`specbook test`), as one bundled `.mjs` file (`specbook bundle`), and in the browser (the docs UI, the QA portal and the "Try it" panel).
|
|
4
|
+
|
|
5
|
+
Use this file when you are asked to add or change requests, flows, checks or setup in a project that uses Specbook (`@specbook/cli`, `@specbook/core`). Needs Node 24.
|
|
6
|
+
|
|
7
|
+
Words used here: an **operation** is one endpoint of the spec. A **request** is a `.req.ts` file that calls one operation. A **flow** is a `.seq.ts` file that runs requests in order.
|
|
8
|
+
|
|
9
|
+
## Set up a project
|
|
10
|
+
|
|
11
|
+
1. `npm install --save-dev @specbook/cli @specbook/core`
|
|
12
|
+
2. `npx specbook init` creates `.specbook/specbook.config.ts`, `.specbook/environments/` and the `#api/*` import in `package.json`.
|
|
13
|
+
3. `npx specbook add <service> --spec <path or URL>` registers a spec and generates its types. A URL is downloaded to `openapi/` and recorded in `openapi/sources.json`. Set the service's address in `vars` (for example `petstoreUrl`) in the config; each environment can override it in `.specbook/environments/<env>.env`.
|
|
14
|
+
4. `npx specbook check` validates specs, imports and environment files. `npx specbook generate` regenerates types; `test`, `dev` and `build` do it when they need to.
|
|
15
|
+
|
|
16
|
+
Never edit `.generated/`. Never put a secret in a file: name it in the config (`secrets: { API_KEY: {} }`) and read it as `{{secrets.API_KEY}}`; its value comes from an environment variable or a gitignored `<env>.secrets.env`.
|
|
17
|
+
|
|
18
|
+
## Add a request
|
|
19
|
+
|
|
20
|
+
Put it at `requests/<service>/<name>.req.ts`. Import the operation from `#api/<service>` and export the request as the default:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { FindPetsByStatus } from '#api/petstore'
|
|
24
|
+
|
|
25
|
+
export default FindPetsByStatus.request({
|
|
26
|
+
name: 'Find pets by status',
|
|
27
|
+
tags: ['smoke', 'monitor'],
|
|
28
|
+
query: { status: '{{status}}' },
|
|
29
|
+
init: { status: 'available' },
|
|
30
|
+
capture: { pets: (res) => (res.status === 200 ? res.data : []) },
|
|
31
|
+
assert: (res, { assert }) => {
|
|
32
|
+
assert.is(res.status, 200, 'ok')
|
|
33
|
+
if (res.status === 200) assert.ok(res.data.length > 0, 'returns at least one pet')
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- Find the operation first. Its export name is the spec's `operationId` in PascalCase (`getPetById` becomes `GetPetById`). Read
|
|
39
|
+
`.generated/<service>/index.ts` (read only) or the spec to see them all; your editor completes `import { ... } from '#api/<service>'`.
|
|
40
|
+
- Fields: `name`, `tags`, `init` (default values), `path`, `query`, `headers`, `data` (the body), `capture`, `assert`, `safe`, `timeoutMs`.
|
|
41
|
+
- Give every `{{name}}` the request uses a default in `init`. Then the request also runs on its own, and its `vars` are typed.
|
|
42
|
+
- `{{name}}` is filled from `init`, a flow's inputs, values captured by earlier steps, and the config's `vars`.
|
|
43
|
+
- Path, query and body are typed from the spec. Let the compiler tell you what is missing.
|
|
44
|
+
- Add `tags: ['monitor']` to what should run against production.
|
|
45
|
+
- `safe: true` marks a POST that only reads (a search, a GraphQL query) so it is allowed in a `--safe` run.
|
|
46
|
+
|
|
47
|
+
## Asserts
|
|
48
|
+
|
|
49
|
+
- A request with no `assert` passes when the response is 2xx (the check is listed as `status is 2xx`). An `assert` replaces that check, so state the status you expect, even a 404.
|
|
50
|
+
- The helper is uvu's `assert` (`ok`, `is`, `equal`, `match`, `type`, `instance`, `throws`, with `not.` versions). Give every call a short label as its last argument: `assert.is(res.status, 200, 'order placed')`. The label is what the report shows.
|
|
51
|
+
- Check `res.status` before reading `res.data`: `if (res.status === 200) { ... }`. Then `res.data` has the type the spec gives that response.
|
|
52
|
+
- The second argument also gives `vars` (everything in scope plus this step's captures) and `req` (the request as sent).
|
|
53
|
+
- Every check runs and is reported. A failed one shows expected against received.
|
|
54
|
+
|
|
55
|
+
## Capture
|
|
56
|
+
|
|
57
|
+
`capture: { orderId: (res, vars) => (res.status === 200 ? res.data.id : undefined) }` keeps a value from the response for later steps. Return plain, JSON-serializable data. In a flow the value is `ctx.captured.orderId` and `{{orderId}}` in a later request.
|
|
58
|
+
|
|
59
|
+
## Pass whole values, not just ids
|
|
60
|
+
|
|
61
|
+
A placeholder that is the whole string gives the value itself, with its type: an object, an array, a number, a boolean. Capture the thing
|
|
62
|
+
and send it on:
|
|
63
|
+
|
|
64
|
+
- `capture: { category: (res) => (res.status === 200 ? res.data.category : undefined) }`, then `data: { category: '{{category}}' }` sends
|
|
65
|
+
the object. An array of objects works the same way (`tags: '{{tags}}'`), and the whole body can be one placeholder: `data: '{{order}}'`.
|
|
66
|
+
- Inside a longer string the value becomes text, and an object or array becomes JSON: `name: 'copy of {{category.name}}'`.
|
|
67
|
+
- Reach into a value with dots and array positions: `{{pet.category.name}}`, `{{tags[0].id}}` (`{{tags.0.id}}` is the same), `{{grid[1][0]}}`.
|
|
68
|
+
`{{tags.length}}` is the count. A name or position that is not there, such as `{{tags[5].id}}`, fails the step with `Missing variable`,
|
|
69
|
+
and only a value's own fields are read (not `toString` or `constructor`). There are no expressions or filters: for anything computed,
|
|
70
|
+
do it in `capture`, which is a function, and capture the shaped result.
|
|
71
|
+
- Whole objects belong in the JSON or form body. Path, query and header values are text: an array in a query repeats the key, but an
|
|
72
|
+
object there is sent as "[object Object]". Use one of its fields (`{{pet.category.name}}`) or build the string in `capture`.
|
|
73
|
+
- A number stays a number. To send one as a string, capture `String(id)`.
|
|
74
|
+
- A name that does not exist fails the step with `Missing variable "{{name}}"`.
|
|
75
|
+
|
|
76
|
+
## Build a flow
|
|
77
|
+
|
|
78
|
+
Put it at `sequences/<name>.seq.ts`, importing the requests it uses:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { c, sequence } from '@specbook/core'
|
|
82
|
+
import findPets from '../requests/petstore/find-pets.req'
|
|
83
|
+
import placeOrder from '../requests/petstore/place-order.req'
|
|
84
|
+
|
|
85
|
+
export default sequence({
|
|
86
|
+
id: 'adopt-pet',
|
|
87
|
+
title: 'Adopt a pet',
|
|
88
|
+
description: 'Find available pets, pick one, order it.',
|
|
89
|
+
tags: ['petstore', 'smoke'],
|
|
90
|
+
inputs: {
|
|
91
|
+
quantity: c.number({ default: 1, min: 1, description: 'How many to order.' }),
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.step('Find available pets', findPets) // captures `pets`
|
|
95
|
+
.input('Choose a pet', (ctx) => ({
|
|
96
|
+
petId: c.select(ctx.captured.pets, {
|
|
97
|
+
label: (p) => `${p.name} (#${p.id})`,
|
|
98
|
+
value: (p) => p.id as number,
|
|
99
|
+
default: 0,
|
|
100
|
+
}),
|
|
101
|
+
}))
|
|
102
|
+
.step('Place order', placeOrder) // sends { petId, quantity }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `.step(name, request)`; the second argument may be `(ctx) => request`, where `ctx` has `inputs`, `captured`, `vars` and `env`.
|
|
106
|
+
- `request.with({ ... })` changes a request for one step (for example its `assert`).
|
|
107
|
+
- Also: `.input(name, ctx => controls)` asks a person for values, `.each(name, items, make)` repeats a request per item, `.check(name, fn)` asserts on the flow's state, `.use(otherFlow)` includes another flow.
|
|
108
|
+
- Controls are `c.number`, `c.text`, `c.select`, `c.multiselect`, `c.boolean`, `c.date`, `c.object`, `c.array`. A flow also runs unattended in CI and as a monitor, so every input and every `.input()` control needs a default that makes sense with nobody to ask.
|
|
109
|
+
- For generated data: `import { faker } from '@specbook/core/faker'` and use it in a default, for example `c.text({ default: () => faker.person.fullName() })`.
|
|
110
|
+
- Each step is also a request you can run alone.
|
|
111
|
+
|
|
112
|
+
### Recipe: list, check, pick, act
|
|
113
|
+
|
|
114
|
+
For "fetch the products, assert there is at least one, pick one, add it to the cart with a quantity from the inputs":
|
|
115
|
+
|
|
116
|
+
1. `requests/shop/list-products.req.ts`: the list operation, `assert` that `res.data.length > 0`, and capture the list:
|
|
117
|
+
`capture: { products: (res) => (res.status === 200 ? res.data : []) }`.
|
|
118
|
+
2. `requests/shop/add-to-cart.req.ts`: the add operation with `init: { productId: 1, quantity: 1 }` and
|
|
119
|
+
`data: { productId: '{{productId}}', quantity: '{{quantity}}' }`.
|
|
120
|
+
3. `sequences/add-to-cart.seq.ts`: `inputs: { quantity: c.number({ default: 1, min: 1 }) }`, then
|
|
121
|
+
`.step('List products', listProducts)`, `.input('Choose a product', (ctx) => ({ productId: c.select(ctx.captured.products, { label: (p) => p.name, value: (p) => p.id, default: 0 }) }))`,
|
|
122
|
+
then `.step('Add to cart', addToCart)`.
|
|
123
|
+
4. Run it: `npx specbook test --grep "Add to cart"`, and `--input quantity=3` to try another value.
|
|
124
|
+
|
|
125
|
+
Capture is declared on the request that makes the response, and the flow reads it as `ctx.captured.<name>`. Pick the way to choose:
|
|
126
|
+
- To take the first item with nobody asked, capture it in the list request (`capture: { productId: (res) => (res.status === 200 ? res.data[0]?.id : undefined) }`)
|
|
127
|
+
and leave out `.input()`. The next request's `{{productId}}` is filled in.
|
|
128
|
+
- To let a person choose in the portal, use `.input()` with `c.select`, as above. Automation is not asked and takes the default, the first item.
|
|
129
|
+
|
|
130
|
+
## Mocks
|
|
131
|
+
|
|
132
|
+
The `mock` environment (the default for `specbook test` and the UI) answers from the spec's examples with the request's values filled in, and needs no server. When a check needs data the example lacks, wrap the request in `withMock` from `@specbook/core`; the file's one export stays the default:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
export default withMock(GetOrderById.request({ /* ... */ }), (req, res) => {
|
|
136
|
+
if (res.status === 200) res.data.status = 'placed'
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Run it
|
|
141
|
+
|
|
142
|
+
- `npx specbook test` runs everything against mock data. `--env staging` runs against a real environment. `--tag monitor`, `--grep <re>`, `--list`, `--input name=value`, `--retries 1` (a pass on retry is reported as flaky), `--timeout <ms>`.
|
|
143
|
+
- Against production always pass `--safe`: a request that is not a GET, HEAD or OPTIONS is refused.
|
|
144
|
+
- Exit codes: 0 passed, 1 a flow failed or errored, 2 it could not run (missing secrets, a missing spec), 3 usage.
|
|
145
|
+
- `npx specbook dev` opens the UI; `npx specbook build --minimal` publishes a small portal of only the project's requests and flows for QA; `npx specbook bundle` writes one `.mjs` that runs with just node, for CI and ops.
|
|
146
|
+
|
|
147
|
+
## Write isomorphic code
|
|
148
|
+
|
|
149
|
+
Requests and flows run in three places: node (`specbook test`), the single-file bundle (`specbook bundle`, run with just node) and the
|
|
150
|
+
browser (the docs UI, the QA portal and "Try it"). Everything they import must work in all three. A build and a green `specbook test`
|
|
151
|
+
do not prove it: a request that needs Node builds cleanly and passes in node, and only fails when someone opens it in the portal.
|
|
152
|
+
|
|
153
|
+
- Use what is already there: `fetch` (global), `@specbook/core` (`assert`, `faker` from `@specbook/core/faker`, the `c` controls) and
|
|
154
|
+
the language itself (`Date`, `Intl`, `URL`, `btoa`, `crypto.randomUUID()`). Prefer no new dependencies. A library such as lodash does
|
|
155
|
+
work in the browser, but it adds a lot of weight to the portal and the bundle for something a few lines of code do.
|
|
156
|
+
- Do not use `Buffer`: in the browser it is "Buffer is not defined". Use `btoa`, `Uint8Array`, `TextEncoder` and `TextDecoder`.
|
|
157
|
+
- Do not use Node built-ins (`fs`, `path`, `os`, `net`, `child_process`): in the browser the first call throws "externalized for browser
|
|
158
|
+
compatibility". For a file a request uploads, use the request's file `data`, which is embedded in the portal and the bundle.
|
|
159
|
+
- Do not read `process.env`. In the browser it is empty and silently `undefined`, so a request that reads a secret from it sends nothing.
|
|
160
|
+
Use `{{secrets.NAME}}` for secrets and the config's `vars` (`{{name}}`) for everything else.
|
|
161
|
+
- Do not use native addons, packages that `require()` a computed path, or packages that read their own files at run time.
|
|
162
|
+
- If a new package is unavoidable, it must be plain ESM JavaScript with no install scripts and no Node built-ins. Prove it three ways:
|
|
163
|
+
`npx specbook test`, `npx specbook bundle`, and `npx specbook build --minimal` followed by running the request in the portal.
|
|
164
|
+
- `specbook bundle --external <package>` is not a way around this. It leaves the import for node to resolve and a browser cannot, so a
|
|
165
|
+
request that needs it will not run in the portal. Do not use it for requests or flows. There is no node-only exception: if a request
|
|
166
|
+
cannot be written with `fetch` and plain code, tell the user instead of adding a Node-only dependency.
|
|
167
|
+
|
|
168
|
+
## Notes beside the code (optional)
|
|
169
|
+
|
|
170
|
+
Markdown next to what it describes is shown in the UI: `requests/<service>/<Operation>/README.md` for an operation, `<name>.req.md` beside a request, `<name>.seq.md` beside a flow. A heading made of one code span (`` ## `petId` ``) attaches its text to that field.
|
package/dist/bin.js
CHANGED
|
@@ -2143,8 +2143,10 @@ function installFaker(mod, config = {}, opts = {}) {
|
|
|
2143
2143
|
}
|
|
2144
2144
|
|
|
2145
2145
|
// ../core/src/template.ts
|
|
2146
|
-
var
|
|
2147
|
-
var
|
|
2146
|
+
var NAME = String.raw`[\w.$\[\]-]+`;
|
|
2147
|
+
var WHOLE = new RegExp(String.raw`^\s*\{\{\s*(${NAME})\s*\}\}\s*$`);
|
|
2148
|
+
var EMBED = new RegExp(String.raw`\{\{\s*(${NAME})\s*\}\}`, "g");
|
|
2149
|
+
var rootName = (name) => name.split(/[.[]/, 1)[0];
|
|
2148
2150
|
var DEFERRED_NAMESPACES = ["secrets", "session"];
|
|
2149
2151
|
var TemplateError = class extends Error {
|
|
2150
2152
|
constructor(variable) {
|
|
@@ -2169,11 +2171,12 @@ function fromFaker(path) {
|
|
|
2169
2171
|
}
|
|
2170
2172
|
return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
|
|
2171
2173
|
}
|
|
2174
|
+
var steps = (path) => path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
2172
2175
|
function lookup(scope, path) {
|
|
2173
2176
|
if (isFakerName(path)) return fromFaker(path);
|
|
2174
2177
|
let cur = scope;
|
|
2175
|
-
for (const part of path
|
|
2176
|
-
if (cur !== null && typeof cur === "object" && part
|
|
2178
|
+
for (const part of steps(path)) {
|
|
2179
|
+
if (cur !== null && typeof cur === "object" && Object.hasOwn(cur, part)) {
|
|
2177
2180
|
cur = cur[part];
|
|
2178
2181
|
} else {
|
|
2179
2182
|
return { found: false };
|
|
@@ -2862,7 +2865,7 @@ function createColors(enabled) {
|
|
|
2862
2865
|
// ../core/src/reporters/list.ts
|
|
2863
2866
|
var MARKS = { pass: "\u2713", fail: "\u2717", error: "!", skipped: "\u2013" };
|
|
2864
2867
|
var formatDuration = (ms) => ms < 1e3 ? `${Math.round(ms)}ms` : ms < 6e4 ? `${(ms / 1e3).toFixed(2)}s` : `${Math.floor(ms / 6e4)}m ${Math.round(ms % 6e4 / 1e3)}s`;
|
|
2865
|
-
var leaves = (
|
|
2868
|
+
var leaves = (steps3) => steps3.flatMap((s) => s.children?.length ? leaves(s.children) : [s]);
|
|
2866
2869
|
function paint(status, c, text) {
|
|
2867
2870
|
return status === "pass" ? c.green(text) : status === "skipped" ? c.dim(text) : status === "error" ? c.yellow(text) : c.red(text);
|
|
2868
2871
|
}
|
|
@@ -2941,21 +2944,21 @@ function formatSummary(results, o = {}) {
|
|
|
2941
2944
|
const c = o.colors ?? createColors(false);
|
|
2942
2945
|
const t = reportTotals(results);
|
|
2943
2946
|
const part = (n, word, paintFn) => n ? paintFn(`${n} ${word}`) : "";
|
|
2944
|
-
const
|
|
2947
|
+
const join19 = (...parts) => parts.filter(Boolean).join(c.dim(" | ")) || c.dim("none");
|
|
2945
2948
|
const lines2 = [
|
|
2946
2949
|
"",
|
|
2947
|
-
`${c.bold(" Flows ")} ${
|
|
2950
|
+
`${c.bold(" Flows ")} ${join19(
|
|
2948
2951
|
part(t.flows.failed, "failed", c.red),
|
|
2949
2952
|
part(t.flows.errored, "errored", c.yellow),
|
|
2950
2953
|
part(t.flows.flaky, "flaky", c.yellow),
|
|
2951
2954
|
part(t.flows.passed, "passed", c.green)
|
|
2952
2955
|
)} ${c.dim(`(${results.length})`)}`,
|
|
2953
|
-
`${c.bold(" Steps ")} ${
|
|
2956
|
+
`${c.bold(" Steps ")} ${join19(
|
|
2954
2957
|
part(t.steps.failed, "failed", c.red),
|
|
2955
2958
|
part(t.steps.skipped, "skipped", c.dim),
|
|
2956
2959
|
part(t.steps.passed, "passed", c.green)
|
|
2957
2960
|
)}`,
|
|
2958
|
-
`${c.bold(" Assertions ")} ${
|
|
2961
|
+
`${c.bold(" Assertions ")} ${join19(
|
|
2959
2962
|
part(t.assertions.failed, "failed", c.red),
|
|
2960
2963
|
part(t.assertions.passed, "passed", c.green)
|
|
2961
2964
|
)}`
|
|
@@ -3030,7 +3033,7 @@ var esc = (s) => String(s).replace(
|
|
|
3030
3033
|
/[<>&"']|[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,
|
|
3031
3034
|
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] ?? ""
|
|
3032
3035
|
);
|
|
3033
|
-
var leafSteps = (
|
|
3036
|
+
var leafSteps = (steps3) => steps3.flatMap((s) => s.children?.length ? leafSteps(s.children) : [s]);
|
|
3034
3037
|
function toJUnit(results, o = {}) {
|
|
3035
3038
|
const suites = results.map((r) => {
|
|
3036
3039
|
const flat = leafSteps(r.steps);
|
|
@@ -3225,6 +3228,31 @@ function buildUrl(base, req) {
|
|
|
3225
3228
|
return `${base.replace(/\/+$/, "")}${req.url}${q2 ? `?${q2}` : ""}`;
|
|
3226
3229
|
}
|
|
3227
3230
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
3231
|
+
var WHY = {
|
|
3232
|
+
ECONNREFUSED: "connection refused",
|
|
3233
|
+
ENOTFOUND: "host not found",
|
|
3234
|
+
EAI_AGAIN: "host not found",
|
|
3235
|
+
ECONNRESET: "connection reset",
|
|
3236
|
+
ETIMEDOUT: "connection timed out",
|
|
3237
|
+
UND_ERR_CONNECT_TIMEOUT: "connection timed out",
|
|
3238
|
+
EHOSTUNREACH: "host unreachable",
|
|
3239
|
+
ENETUNREACH: "network unreachable"
|
|
3240
|
+
};
|
|
3241
|
+
function unreachable2(e, url) {
|
|
3242
|
+
const cause = e?.cause;
|
|
3243
|
+
if (!(e instanceof TypeError) || !cause) return e;
|
|
3244
|
+
const code = cause.code ?? cause.errors?.find((x2) => x2.code)?.code;
|
|
3245
|
+
const tls = code && /^CERT_|^ERR_TLS|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code) ? "certificate problem" : "";
|
|
3246
|
+
const own = typeof cause.message === "string" && cause.message.length <= 120 ? cause.message : "";
|
|
3247
|
+
const why = code && WHY[code] || tls || own;
|
|
3248
|
+
if (!why) return e;
|
|
3249
|
+
let host = url;
|
|
3250
|
+
try {
|
|
3251
|
+
host = new URL(url).host;
|
|
3252
|
+
} catch {
|
|
3253
|
+
}
|
|
3254
|
+
return new Error(`Could not reach ${host}: ${why}.`, { cause: e });
|
|
3255
|
+
}
|
|
3228
3256
|
function createFetchTransport(opts) {
|
|
3229
3257
|
return async (req, signal) => {
|
|
3230
3258
|
if (signal?.aborted) throw new Error("Stopped before the API answered.");
|
|
@@ -3243,8 +3271,9 @@ function createFetchTransport(opts) {
|
|
|
3243
3271
|
ctl.abort();
|
|
3244
3272
|
}, req.timeoutMs) : void 0;
|
|
3245
3273
|
signal?.addEventListener("abort", () => ctl.abort());
|
|
3274
|
+
const url = buildUrl(opts.baseUrl(req.service), req);
|
|
3246
3275
|
try {
|
|
3247
|
-
const res = await f2(
|
|
3276
|
+
const res = await f2(url, {
|
|
3248
3277
|
method: req.method,
|
|
3249
3278
|
headers,
|
|
3250
3279
|
body: body2,
|
|
@@ -3268,7 +3297,7 @@ function createFetchTransport(opts) {
|
|
|
3268
3297
|
} catch (e) {
|
|
3269
3298
|
if (timedOut) throw new Error(`Timed out after ${req.timeoutMs}ms: no answer from the API.`);
|
|
3270
3299
|
if (signal?.aborted) throw new Error("Stopped before the API answered.");
|
|
3271
|
-
throw e;
|
|
3300
|
+
throw unreachable2(e, url);
|
|
3272
3301
|
} finally {
|
|
3273
3302
|
if (timer) clearTimeout(timer);
|
|
3274
3303
|
}
|
|
@@ -3318,7 +3347,7 @@ function usesOf(names, layers, from) {
|
|
|
3318
3347
|
const out = [];
|
|
3319
3348
|
for (const name of names) {
|
|
3320
3349
|
if (isDeferred(name) || isFakerName(name)) continue;
|
|
3321
|
-
const key = name
|
|
3350
|
+
const key = rootName(name);
|
|
3322
3351
|
if (seen.has(key)) continue;
|
|
3323
3352
|
seen.add(key);
|
|
3324
3353
|
for (let i = layers.length - 1; i >= 0; i--) {
|
|
@@ -3587,7 +3616,7 @@ async function execute(rt2, def, name, flow, kind = "request", noAuth = false) {
|
|
|
3587
3616
|
emit(rt2, { type: "step:end", name, status: result.status });
|
|
3588
3617
|
return { result, response, authResults };
|
|
3589
3618
|
}
|
|
3590
|
-
function summarize(
|
|
3619
|
+
function summarize(steps3) {
|
|
3591
3620
|
const t = { steps: 0, passed: 0, failed: 0, skipped: 0, assertions: { passed: 0, failed: 0 } };
|
|
3592
3621
|
const visit = (s) => {
|
|
3593
3622
|
if (s.children?.length) {
|
|
@@ -3600,21 +3629,21 @@ function summarize(steps) {
|
|
|
3600
3629
|
else t.failed++;
|
|
3601
3630
|
for (const a3 of s.assertions) a3.pass ? t.assertions.passed++ : t.assertions.failed++;
|
|
3602
3631
|
};
|
|
3603
|
-
|
|
3632
|
+
steps3.forEach(visit);
|
|
3604
3633
|
return t;
|
|
3605
3634
|
}
|
|
3606
|
-
function overall(
|
|
3635
|
+
function overall(steps3) {
|
|
3607
3636
|
const all = [];
|
|
3608
3637
|
const walk2 = (s) => {
|
|
3609
3638
|
all.push(s);
|
|
3610
3639
|
s.children?.forEach(walk2);
|
|
3611
3640
|
};
|
|
3612
|
-
|
|
3641
|
+
steps3.forEach(walk2);
|
|
3613
3642
|
if (all.some((s) => s.status === "error")) return "error";
|
|
3614
3643
|
if (all.some((s) => s.status === "fail")) return "fail";
|
|
3615
3644
|
return "pass";
|
|
3616
3645
|
}
|
|
3617
|
-
function finish(rt2, kind, meta,
|
|
3646
|
+
function finish(rt2, kind, meta, steps3, startedAt) {
|
|
3618
3647
|
const result = {
|
|
3619
3648
|
schemaVersion: 1,
|
|
3620
3649
|
runId: rid(),
|
|
@@ -3626,11 +3655,11 @@ function finish(rt2, kind, meta, steps, startedAt) {
|
|
|
3626
3655
|
seed: rt2.o.seed,
|
|
3627
3656
|
usedDrafts: rt2.o.usedDrafts ?? false,
|
|
3628
3657
|
tags: meta.tags ?? [],
|
|
3629
|
-
status: overall(
|
|
3658
|
+
status: overall(steps3),
|
|
3630
3659
|
startedAt: new Date(startedAt).toISOString(),
|
|
3631
3660
|
durationMs: (rt2.o.now?.() ?? now0()) - startedAt,
|
|
3632
|
-
totals: summarize(
|
|
3633
|
-
steps
|
|
3661
|
+
totals: summarize(steps3),
|
|
3662
|
+
steps: steps3
|
|
3634
3663
|
};
|
|
3635
3664
|
const masked = maskDeep(result, rt2.mask);
|
|
3636
3665
|
emit(rt2, { type: "done", result: masked });
|
|
@@ -3718,7 +3747,7 @@ async function runSequence(seq, o) {
|
|
|
3718
3747
|
kind: "sequence",
|
|
3719
3748
|
tags: seq.meta.tags
|
|
3720
3749
|
});
|
|
3721
|
-
const
|
|
3750
|
+
const steps3 = [];
|
|
3722
3751
|
const captured = {};
|
|
3723
3752
|
const from = {};
|
|
3724
3753
|
const remember = (values, step) => {
|
|
@@ -3729,7 +3758,7 @@ async function runSequence(seq, o) {
|
|
|
3729
3758
|
try {
|
|
3730
3759
|
inputs = resolveControls(seq.meta.inputs ?? {}, o.inputs);
|
|
3731
3760
|
} catch (e) {
|
|
3732
|
-
|
|
3761
|
+
steps3.push({
|
|
3733
3762
|
name: "inputs",
|
|
3734
3763
|
kind: "input",
|
|
3735
3764
|
status: "error",
|
|
@@ -3738,7 +3767,7 @@ async function runSequence(seq, o) {
|
|
|
3738
3767
|
error: e.message,
|
|
3739
3768
|
durationMs: 0
|
|
3740
3769
|
});
|
|
3741
|
-
return finish(rt2, "sequence", seq.meta,
|
|
3770
|
+
return finish(rt2, "sequence", seq.meta, steps3, t0);
|
|
3742
3771
|
}
|
|
3743
3772
|
const vars = o.vars ?? {};
|
|
3744
3773
|
const ctx = () => ({
|
|
@@ -3748,7 +3777,7 @@ async function runSequence(seq, o) {
|
|
|
3748
3777
|
env: o.env
|
|
3749
3778
|
});
|
|
3750
3779
|
let failed = false;
|
|
3751
|
-
const skip = (name, kind) =>
|
|
3780
|
+
const skip = (name, kind) => steps3.push({ name, kind, status: "skipped", assertions: [], captured: {}, durationMs: 0 });
|
|
3752
3781
|
for (const node of seq.nodes) {
|
|
3753
3782
|
if (failed && !seq.meta.continueOnFailure) {
|
|
3754
3783
|
skip(node.name, node.kind === "step" ? "request" : node.kind);
|
|
@@ -3767,7 +3796,7 @@ async function runSequence(seq, o) {
|
|
|
3767
3796
|
captured,
|
|
3768
3797
|
from
|
|
3769
3798
|
});
|
|
3770
|
-
|
|
3799
|
+
steps3.push(...authResults, result);
|
|
3771
3800
|
remember(result.captured, node.name);
|
|
3772
3801
|
if (result.status !== "pass") failed = true;
|
|
3773
3802
|
} else if (node.kind === "each") {
|
|
@@ -3803,7 +3832,7 @@ async function runSequence(seq, o) {
|
|
|
3803
3832
|
parent.captured = collected;
|
|
3804
3833
|
parent.durationMs = (o.now?.() ?? now0()) - p0;
|
|
3805
3834
|
remember(collected, node.name);
|
|
3806
|
-
|
|
3835
|
+
steps3.push(parent);
|
|
3807
3836
|
if (parent.status !== "pass") failed = true;
|
|
3808
3837
|
} else if (node.kind === "input") {
|
|
3809
3838
|
const controls = node.controls(ctx());
|
|
@@ -3812,7 +3841,7 @@ async function runSequence(seq, o) {
|
|
|
3812
3841
|
const given = o.onInput ? await o.onInput({ name: node.name, controls: described }) : o.inputs ?? {};
|
|
3813
3842
|
const values = resolveControls(controls, given);
|
|
3814
3843
|
inputs = { ...inputs, ...values };
|
|
3815
|
-
|
|
3844
|
+
steps3.push({
|
|
3816
3845
|
name: node.name,
|
|
3817
3846
|
kind: "input",
|
|
3818
3847
|
status: "pass",
|
|
@@ -3842,12 +3871,12 @@ async function runSequence(seq, o) {
|
|
|
3842
3871
|
s.assertions = a3.results;
|
|
3843
3872
|
if (s.status === "pass" && a3.results.some((r) => !r.pass)) s.status = "fail";
|
|
3844
3873
|
emit(rt2, { type: "step:end", name: node.name, status: s.status });
|
|
3845
|
-
|
|
3874
|
+
steps3.push(s);
|
|
3846
3875
|
if (s.status !== "pass") failed = true;
|
|
3847
3876
|
}
|
|
3848
3877
|
} catch (e) {
|
|
3849
3878
|
const isControl = e instanceof ControlError;
|
|
3850
|
-
|
|
3879
|
+
steps3.push({
|
|
3851
3880
|
name: node.name,
|
|
3852
3881
|
kind: node.kind === "step" ? "request" : node.kind,
|
|
3853
3882
|
status: "error",
|
|
@@ -3859,7 +3888,7 @@ async function runSequence(seq, o) {
|
|
|
3859
3888
|
failed = true;
|
|
3860
3889
|
}
|
|
3861
3890
|
}
|
|
3862
|
-
return finish(rt2, "sequence", seq.meta,
|
|
3891
|
+
return finish(rt2, "sequence", seq.meta, steps3, t0);
|
|
3863
3892
|
}
|
|
3864
3893
|
|
|
3865
3894
|
// ../core/src/runner/suite.ts
|
|
@@ -3919,7 +3948,7 @@ function headerLine(h2, colors2) {
|
|
|
3919
3948
|
var retryLine = (colors2, title, attempt, allowed) => ` ${colors2.yellow("\u21BB")} retrying ${title} (attempt ${attempt} of ${allowed})
|
|
3920
3949
|
`;
|
|
3921
3950
|
function withoutLocations(results) {
|
|
3922
|
-
const strip = (
|
|
3951
|
+
const strip = (steps3) => steps3.map((s) => ({
|
|
3923
3952
|
...s,
|
|
3924
3953
|
assertions: s.assertions.map((a3) => ({ ...a3, location: void 0 })),
|
|
3925
3954
|
...s.children ? { children: strip(s.children) } : {}
|
|
@@ -5472,8 +5501,9 @@ var faker2 = new Proxy({}, {
|
|
|
5472
5501
|
return current[key];
|
|
5473
5502
|
}
|
|
5474
5503
|
});
|
|
5475
|
-
var
|
|
5476
|
-
var
|
|
5504
|
+
var NAME2 = String.raw`[\w.$\[\]-]+`;
|
|
5505
|
+
var WHOLE2 = new RegExp(String.raw`^\s*\{\{\s*(${NAME2})\s*\}\}\s*$`);
|
|
5506
|
+
var EMBED2 = new RegExp(String.raw`\{\{\s*(${NAME2})\s*\}\}`, "g");
|
|
5477
5507
|
var DEFERRED_NAMESPACES2 = ["secrets", "session"];
|
|
5478
5508
|
var TemplateError2 = class extends Error {
|
|
5479
5509
|
constructor(variable) {
|
|
@@ -5498,11 +5528,12 @@ function fromFaker2(path) {
|
|
|
5498
5528
|
}
|
|
5499
5529
|
return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
|
|
5500
5530
|
}
|
|
5531
|
+
var steps2 = (path) => path.replace(/\[(\d+)\]/g, ".$1").split(".");
|
|
5501
5532
|
function lookup2(scope, path) {
|
|
5502
5533
|
if (isFakerName2(path)) return fromFaker2(path);
|
|
5503
5534
|
let cur = scope;
|
|
5504
|
-
for (const part of path
|
|
5505
|
-
if (cur !== null && typeof cur === "object" && part
|
|
5535
|
+
for (const part of steps2(path)) {
|
|
5536
|
+
if (cur !== null && typeof cur === "object" && Object.hasOwn(cur, part)) {
|
|
5506
5537
|
cur = cur[part];
|
|
5507
5538
|
} else {
|
|
5508
5539
|
return { found: false };
|
|
@@ -6143,7 +6174,7 @@ async function writeEnvKey(file, key, value) {
|
|
|
6143
6174
|
await writeFile3(file, setDotenvKey(current, key, value));
|
|
6144
6175
|
}
|
|
6145
6176
|
var IGNORE = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
|
|
6146
|
-
var
|
|
6177
|
+
var NAME22 = /\.example\.json$/;
|
|
6147
6178
|
async function scanExamples(root, outDir, operations) {
|
|
6148
6179
|
const examples = {};
|
|
6149
6180
|
const assets = {};
|
|
@@ -6161,7 +6192,7 @@ async function scanExamples(root, outDir, operations) {
|
|
|
6161
6192
|
const [svc, , id, file] = relative22(gen, abs).split(sep22);
|
|
6162
6193
|
if (!operations.has(`${svc}/${id}`)) continue;
|
|
6163
6194
|
add(`${svc}/${id}`, {
|
|
6164
|
-
name: file.replace(
|
|
6195
|
+
name: file.replace(NAME22, ""),
|
|
6165
6196
|
abs,
|
|
6166
6197
|
source: "generated",
|
|
6167
6198
|
dir: `${svc}/${id}@generated`
|
|
@@ -6175,7 +6206,7 @@ async function scanExamples(root, outDir, operations) {
|
|
|
6175
6206
|
const key = `${svc}/${id}`;
|
|
6176
6207
|
if (!svc || !id || !operations.has(key)) continue;
|
|
6177
6208
|
add(key, {
|
|
6178
|
-
name: file.replace(
|
|
6209
|
+
name: file.replace(NAME22, ""),
|
|
6179
6210
|
abs,
|
|
6180
6211
|
source: "project",
|
|
6181
6212
|
dir: `${svc}/${id}@project`
|
|
@@ -7757,13 +7788,16 @@ function transportFor(ctx, env, vars) {
|
|
|
7757
7788
|
}
|
|
7758
7789
|
});
|
|
7759
7790
|
}
|
|
7760
|
-
function originsFor(config, env, vars) {
|
|
7791
|
+
function originsFor(config, env, vars, basePath = false) {
|
|
7761
7792
|
if (env === "mock") return {};
|
|
7762
7793
|
const out = {};
|
|
7763
7794
|
for (const service of Object.keys(config.services)) {
|
|
7764
7795
|
try {
|
|
7765
7796
|
const url = baseUrlFor(config, service, env, vars);
|
|
7766
|
-
if (url)
|
|
7797
|
+
if (url) {
|
|
7798
|
+
const u = new URL(url);
|
|
7799
|
+
out[service] = basePath ? `${u.origin}${u.pathname.replace(/\/+$/, "")}` : u.origin;
|
|
7800
|
+
}
|
|
7767
7801
|
} catch {
|
|
7768
7802
|
}
|
|
7769
7803
|
}
|
|
@@ -7844,6 +7878,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
7844
7878
|
const vars = environmentScope(config, o.env, { ...fileVars, ...given });
|
|
7845
7879
|
const transport = transportFor(ctx, o.env, vars);
|
|
7846
7880
|
const origins = originsFor(config, o.env, vars);
|
|
7881
|
+
const addresses = originsFor(config, o.env, vars, true);
|
|
7847
7882
|
const faker3 = await ctx.loadFaker();
|
|
7848
7883
|
const git2 = { ...ctx.git ?? gitInfo(ctx.root) };
|
|
7849
7884
|
const ci = detectCi(process.env);
|
|
@@ -7862,7 +7897,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
7862
7897
|
colors: colors2,
|
|
7863
7898
|
quiet: o.quiet,
|
|
7864
7899
|
trace: o.trace,
|
|
7865
|
-
origins,
|
|
7900
|
+
origins: addresses,
|
|
7866
7901
|
cwd: ctx.root,
|
|
7867
7902
|
readSource,
|
|
7868
7903
|
rerun: (r) => `${ctx.command}${r.file && ctx.command === "specbook test" ? ` ${r.file}` : ""} --env ${o.env} --grep ${quote3(escapeRegExp(r.title))}${o.safe ? " --safe" : ""}`
|
|
@@ -7971,7 +8006,7 @@ async function execute2(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
7971
8006
|
toJUnit(clean, {
|
|
7972
8007
|
timestamp: meta.startedAt,
|
|
7973
8008
|
hostname: hostname(),
|
|
7974
|
-
origins,
|
|
8009
|
+
origins: addresses,
|
|
7975
8010
|
properties: { env: o.env, sha: git2.sha, branch: git2.branch, specbook: meta.specbook }
|
|
7976
8011
|
})
|
|
7977
8012
|
);
|
|
@@ -8279,7 +8314,7 @@ async function bundleCommand(project, loader, o) {
|
|
|
8279
8314
|
${e instanceof Error ? e.message : e}`
|
|
8280
8315
|
);
|
|
8281
8316
|
console.error(
|
|
8282
|
-
"A package that cannot be compiled in (a native addon, a computed require) can stay an import: --external <package>."
|
|
8317
|
+
"A package that cannot be compiled in (a native addon, a computed require) can stay an import: --external <package>. A browser cannot load it, so replace it with fetch or plain code if the request must also run in the portal."
|
|
8283
8318
|
);
|
|
8284
8319
|
return 2;
|
|
8285
8320
|
}
|
|
@@ -8432,9 +8467,9 @@ async function checkCommand(project, loader) {
|
|
|
8432
8467
|
...def.dataPatches
|
|
8433
8468
|
]);
|
|
8434
8469
|
const init2 = new Set(Object.keys(def.input.init ?? {}));
|
|
8435
|
-
const roots = new Set([...used2].map(
|
|
8470
|
+
const roots = new Set([...used2].map(rootName));
|
|
8436
8471
|
for (const name of used2) {
|
|
8437
|
-
const root = name
|
|
8472
|
+
const root = rootName(name);
|
|
8438
8473
|
if (name.startsWith("secrets.")) {
|
|
8439
8474
|
if (!declared.has(name.slice(8)))
|
|
8440
8475
|
warnings.push(`[${rel2}] secret "${name.slice(8)}" is not declared in config.secrets`);
|
|
@@ -8468,15 +8503,124 @@ async function generateCommand(project, loader, only) {
|
|
|
8468
8503
|
}
|
|
8469
8504
|
|
|
8470
8505
|
// src/commands/init.ts
|
|
8471
|
-
import { existsSync as
|
|
8506
|
+
import { existsSync as existsSync10 } from "fs";
|
|
8507
|
+
import { mkdir as mkdir8, readFile as readFile16, writeFile as writeFile9 } from "fs/promises";
|
|
8508
|
+
import { join as join15 } from "path";
|
|
8509
|
+
|
|
8510
|
+
// src/commands/agents.ts
|
|
8511
|
+
import { existsSync as existsSync9, realpathSync } from "fs";
|
|
8472
8512
|
import { mkdir as mkdir7, readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
|
|
8473
8513
|
import { join as join14 } from "path";
|
|
8474
|
-
|
|
8514
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8515
|
+
var FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
8516
|
+
var START = "<!-- specbook:start";
|
|
8517
|
+
var END = "<!-- specbook:end -->";
|
|
8518
|
+
function agentBlock(eol = "\n") {
|
|
8519
|
+
return [
|
|
8520
|
+
`${START} (written by specbook init; \`specbook init --agents\` refreshes it) -->`,
|
|
8521
|
+
"## Specbook",
|
|
8522
|
+
"",
|
|
8523
|
+
"This project uses Specbook: requests (`requests/**/*.req.ts`) and flows (`sequences/**/*.seq.ts`) built from OpenAPI specs run as",
|
|
8524
|
+
"the docs, as tests and as monitors. Before adding or changing one, read `.specbook/agent-guide.md`. In short:",
|
|
8525
|
+
"",
|
|
8526
|
+
"- Import operations from `#api/<service>`. Never edit `.generated/`.",
|
|
8527
|
+
"- Label every `assert`, and use `capture` to pass values between steps.",
|
|
8528
|
+
"- Write isomorphic code: requests and flows also run in the browser, so no Node built-ins, `Buffer` or `process.env`.",
|
|
8529
|
+
"- Run `npx specbook check` and `npx specbook test` before you finish.",
|
|
8530
|
+
END
|
|
8531
|
+
].join(eol);
|
|
8532
|
+
}
|
|
8533
|
+
function withBlock(text) {
|
|
8534
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
8535
|
+
const block = agentBlock(eol);
|
|
8536
|
+
const start = text.indexOf(START);
|
|
8537
|
+
if (start >= 0) {
|
|
8538
|
+
const end = text.indexOf(END, start);
|
|
8539
|
+
if (end < 0) return { text, status: "unterminated" };
|
|
8540
|
+
const next = text.slice(0, start) + block + text.slice(end + END.length);
|
|
8541
|
+
return { text: next, status: next === text ? "unchanged" : "updated" };
|
|
8542
|
+
}
|
|
8543
|
+
if (!text.trim()) return { text: `${block}${eol}`, status: "added" };
|
|
8544
|
+
const lead = text.endsWith("\n") ? "" : eol;
|
|
8545
|
+
return { text: `${text}${lead}${eol}${block}${eol}`, status: "added" };
|
|
8546
|
+
}
|
|
8547
|
+
var importsAgents = (text) => /^@AGENTS\.md\b/m.test(text);
|
|
8548
|
+
async function readGuide() {
|
|
8549
|
+
for (const rel2 of ["../agent-guide.md", "../../agent-guide.md"])
|
|
8550
|
+
try {
|
|
8551
|
+
return await readFile15(fileURLToPath2(new URL(rel2, import.meta.url)), "utf8");
|
|
8552
|
+
} catch {
|
|
8553
|
+
}
|
|
8554
|
+
throw new Error("agent-guide.md is missing from the @specbook/cli package");
|
|
8555
|
+
}
|
|
8556
|
+
async function agentsStep(cwd, agents) {
|
|
8557
|
+
if (agents === false) return;
|
|
8475
8558
|
const dir = join14(cwd, ".specbook");
|
|
8476
8559
|
await mkdir7(dir, { recursive: true });
|
|
8477
|
-
const
|
|
8478
|
-
|
|
8560
|
+
const guide = await readGuide();
|
|
8561
|
+
await writeFile8(
|
|
8562
|
+
join14(dir, "agent-guide.md"),
|
|
8563
|
+
`<!-- Written by specbook init v${cliVersion()}. Re-run \`specbook init\` to refresh it, and put project rules in AGENTS.md. -->
|
|
8564
|
+
|
|
8565
|
+
${guide}`
|
|
8566
|
+
);
|
|
8567
|
+
console.log("\u2713 wrote .specbook/agent-guide.md");
|
|
8568
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8569
|
+
const found = [];
|
|
8570
|
+
for (const f2 of FILES) {
|
|
8571
|
+
if (!existsSync9(join14(cwd, f2))) continue;
|
|
8572
|
+
const real = realpathSync(join14(cwd, f2));
|
|
8573
|
+
if (seen.has(real)) continue;
|
|
8574
|
+
seen.add(real);
|
|
8575
|
+
found.push(f2);
|
|
8576
|
+
}
|
|
8577
|
+
if (found.length === 0) {
|
|
8479
8578
|
await writeFile8(
|
|
8579
|
+
join14(cwd, "AGENTS.md"),
|
|
8580
|
+
`# AGENTS.md
|
|
8581
|
+
|
|
8582
|
+
Instructions for AI coding agents working in this repository.
|
|
8583
|
+
|
|
8584
|
+
${agentBlock()}
|
|
8585
|
+
`
|
|
8586
|
+
);
|
|
8587
|
+
console.log("\u2713 created AGENTS.md (read by Claude Code, Codex and other coding agents)");
|
|
8588
|
+
return;
|
|
8589
|
+
}
|
|
8590
|
+
if (!agents) {
|
|
8591
|
+
console.log(
|
|
8592
|
+
`${found.join(" and ")} already ${found.length > 1 ? "exist" : "exists"} and was not changed. To add Specbook's section to ${found.length > 1 ? "them" : "it"}: specbook init --agents`
|
|
8593
|
+
);
|
|
8594
|
+
return;
|
|
8595
|
+
}
|
|
8596
|
+
for (const file of found) {
|
|
8597
|
+
const path = join14(cwd, file);
|
|
8598
|
+
const text = await readFile15(path, "utf8");
|
|
8599
|
+
if (file === "CLAUDE.md" && found.includes("AGENTS.md") && importsAgents(text)) {
|
|
8600
|
+
console.log("CLAUDE.md imports AGENTS.md: its section covers Claude Code too");
|
|
8601
|
+
continue;
|
|
8602
|
+
}
|
|
8603
|
+
const r = withBlock(text);
|
|
8604
|
+
if (r.status === "unterminated") {
|
|
8605
|
+
console.log(`! ${file} has a "${START}" marker with no end marker; left unchanged`);
|
|
8606
|
+
continue;
|
|
8607
|
+
}
|
|
8608
|
+
if (r.status === "unchanged") {
|
|
8609
|
+
console.log(`${file} already has Specbook's section`);
|
|
8610
|
+
continue;
|
|
8611
|
+
}
|
|
8612
|
+
await writeFile8(path, r.text);
|
|
8613
|
+
console.log(`\u2713 ${r.status === "added" ? "added" : "updated"} Specbook's section in ${file}`);
|
|
8614
|
+
}
|
|
8615
|
+
}
|
|
8616
|
+
|
|
8617
|
+
// src/commands/init.ts
|
|
8618
|
+
async function initCommand(cwd, opts = {}) {
|
|
8619
|
+
const dir = join15(cwd, ".specbook");
|
|
8620
|
+
await mkdir8(dir, { recursive: true });
|
|
8621
|
+
const cfg = join15(dir, "specbook.config.ts");
|
|
8622
|
+
if (!existsSync10(cfg)) {
|
|
8623
|
+
await writeFile9(
|
|
8480
8624
|
cfg,
|
|
8481
8625
|
`import { defineConfig } from '@specbook/core'
|
|
8482
8626
|
|
|
@@ -8495,55 +8639,56 @@ export default defineConfig({
|
|
|
8495
8639
|
console.log("\u2713 created .specbook/specbook.config.ts");
|
|
8496
8640
|
}
|
|
8497
8641
|
const envDir = await ensureEnvironmentsDir(cwd);
|
|
8498
|
-
const example =
|
|
8499
|
-
if (!
|
|
8500
|
-
await
|
|
8642
|
+
const example = join15(envDir, "staging.secrets.env.example");
|
|
8643
|
+
if (!existsSync10(example)) {
|
|
8644
|
+
await writeFile9(
|
|
8501
8645
|
example,
|
|
8502
8646
|
"# Copy to staging.secrets.env (gitignored) and fill in. One KEY=value per line.\n# YOUR_API_KEY=\n"
|
|
8503
8647
|
);
|
|
8504
|
-
await
|
|
8505
|
-
|
|
8648
|
+
await writeFile9(
|
|
8649
|
+
join15(envDir, "staging.env"),
|
|
8506
8650
|
'# Non-secret variables for the "staging" environment (committed).\n# region=eu-west-1\n'
|
|
8507
8651
|
);
|
|
8508
8652
|
console.log("\u2713 created .specbook/environments/ (staging.env, staging.secrets.env.example)");
|
|
8509
8653
|
}
|
|
8510
|
-
const pkgPath =
|
|
8511
|
-
const pkg =
|
|
8654
|
+
const pkgPath = join15(cwd, "package.json");
|
|
8655
|
+
const pkg = existsSync10(pkgPath) ? JSON.parse(await readFile16(pkgPath, "utf8")) : { name: "my-apis", private: true, type: "module" };
|
|
8512
8656
|
pkg.imports = { ...pkg.imports ?? {}, "#api/*": "./.generated/*/index.ts" };
|
|
8513
8657
|
pkg.scripts = { ...pkg.scripts ?? {} };
|
|
8514
8658
|
if (!pkg.scripts.prepare) pkg.scripts.prepare = "specbook generate";
|
|
8515
|
-
await
|
|
8659
|
+
await writeFile9(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
8516
8660
|
`);
|
|
8517
8661
|
console.log('\u2713 added the "#api/*" subpath import and a "prepare" script to package.json');
|
|
8518
|
-
const gi =
|
|
8519
|
-
const current =
|
|
8662
|
+
const gi = join15(cwd, ".gitignore");
|
|
8663
|
+
const current = existsSync10(gi) ? await readFile16(gi, "utf8") : "";
|
|
8520
8664
|
const missing = [".generated", ".specbook/secrets.local.env"].filter(
|
|
8521
8665
|
(entry) => !current.split("\n").some((l) => l.trim() === entry)
|
|
8522
8666
|
);
|
|
8523
8667
|
if (missing.length) {
|
|
8524
|
-
await
|
|
8668
|
+
await writeFile9(
|
|
8525
8669
|
gi,
|
|
8526
8670
|
`${current}${current.endsWith("\n") || !current ? "" : "\n"}${missing.join("\n")}
|
|
8527
8671
|
`
|
|
8528
8672
|
);
|
|
8529
8673
|
console.log(`\u2713 gitignored ${missing.join(", ")}`);
|
|
8530
8674
|
}
|
|
8675
|
+
await agentsStep(cwd, opts.agents);
|
|
8531
8676
|
}
|
|
8532
8677
|
|
|
8533
8678
|
// src/commands/preview.ts
|
|
8534
8679
|
import { spawn } from "child_process";
|
|
8535
|
-
import { existsSync as
|
|
8536
|
-
import { mkdir as
|
|
8680
|
+
import { existsSync as existsSync11, readFileSync as readFileSync2 } from "fs";
|
|
8681
|
+
import { mkdir as mkdir9, mkdtemp as mkdtemp2, rm as rm4, symlink, writeFile as writeFile11 } from "fs/promises";
|
|
8537
8682
|
import { createRequire as createRequire6 } from "module";
|
|
8538
8683
|
import { tmpdir as tmpdir2 } from "os";
|
|
8539
|
-
import { dirname as dirname8, extname, join as
|
|
8684
|
+
import { dirname as dirname8, extname, join as join17, resolve as resolve12 } from "path";
|
|
8540
8685
|
import SwaggerParser4 from "@apidevtools/swagger-parser";
|
|
8541
8686
|
|
|
8542
8687
|
// src/commands/ui.ts
|
|
8543
|
-
import { copyFile, cp, readFile as
|
|
8688
|
+
import { copyFile, cp, readFile as readFile17, stat, writeFile as writeFile10 } from "fs/promises";
|
|
8544
8689
|
import { createRequire as createRequire5 } from "module";
|
|
8545
|
-
import { basename as basename5, dirname as dirname7, join as
|
|
8546
|
-
import { fileURLToPath as
|
|
8690
|
+
import { basename as basename5, dirname as dirname7, join as join16, resolve as resolve11, sep as sep8 } from "path";
|
|
8691
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
8547
8692
|
import { build, createServer } from "vite";
|
|
8548
8693
|
function uiRoot() {
|
|
8549
8694
|
const require2 = createRequire5(import.meta.url);
|
|
@@ -8562,10 +8707,10 @@ function enclosingNodeModules(file) {
|
|
|
8562
8707
|
}
|
|
8563
8708
|
function dependencyFolders(root) {
|
|
8564
8709
|
const found = /* @__PURE__ */ new Set([
|
|
8565
|
-
...enclosingNodeModules(
|
|
8566
|
-
...enclosingNodeModules(
|
|
8710
|
+
...enclosingNodeModules(fileURLToPath3(import.meta.url)),
|
|
8711
|
+
...enclosingNodeModules(join16(root, "package.json"))
|
|
8567
8712
|
]);
|
|
8568
|
-
for (const from of [
|
|
8713
|
+
for (const from of [join16(root, "package.json"), fileURLToPath3(import.meta.url)]) {
|
|
8569
8714
|
const require2 = createRequire5(from);
|
|
8570
8715
|
for (const name of ["vite", "prism-code-editor", "preact", "@preact/signals"]) {
|
|
8571
8716
|
try {
|
|
@@ -8623,7 +8768,7 @@ async function devCommand(project, port = 5173) {
|
|
|
8623
8768
|
}
|
|
8624
8769
|
async function buildCommand(project, outDir, options = {}) {
|
|
8625
8770
|
const out = resolve11(project.root, outDir);
|
|
8626
|
-
const generated =
|
|
8771
|
+
const generated = join16(project.root, project.config.codegen?.outDir ?? ".generated");
|
|
8627
8772
|
const operations = options.minimal ? await usedOperationKeys(project, generated) : void 0;
|
|
8628
8773
|
if (operations && operations.size === 0)
|
|
8629
8774
|
throw new Error("Nothing to publish: no request or flow imports an operation (see --minimal)");
|
|
@@ -8645,25 +8790,25 @@ async function buildCommand(project, outDir, options = {}) {
|
|
|
8645
8790
|
}
|
|
8646
8791
|
}
|
|
8647
8792
|
});
|
|
8648
|
-
await copyMetadata(
|
|
8793
|
+
await copyMetadata(join16(generated, "specbook.json"), join16(out, "specbook.json"), operations);
|
|
8649
8794
|
}
|
|
8650
8795
|
async function copyMetadata(from, to, operations) {
|
|
8651
8796
|
try {
|
|
8652
8797
|
if (!operations) return await copyFile(from, to);
|
|
8653
|
-
const meta = JSON.parse(await
|
|
8798
|
+
const meta = JSON.parse(await readFile17(from, "utf8"));
|
|
8654
8799
|
for (const [svc, s] of Object.entries(meta.services)) {
|
|
8655
8800
|
s.operations = Object.fromEntries(
|
|
8656
8801
|
Object.entries(s.operations).filter(([id]) => operations.has(`${svc}/${id}`))
|
|
8657
8802
|
);
|
|
8658
8803
|
if (Object.keys(s.operations).length === 0) delete meta.services[svc];
|
|
8659
8804
|
}
|
|
8660
|
-
await
|
|
8805
|
+
await writeFile10(to, `${JSON.stringify(meta)}
|
|
8661
8806
|
`);
|
|
8662
8807
|
} catch {
|
|
8663
8808
|
}
|
|
8664
8809
|
}
|
|
8665
8810
|
async function copyViewer(project, outDir, viewerDir = resolve11(uiRoot(), "dist-viewer")) {
|
|
8666
|
-
const has2 = await stat(
|
|
8811
|
+
const has2 = await stat(join16(viewerDir, "index.html")).then(
|
|
8667
8812
|
() => true,
|
|
8668
8813
|
() => false
|
|
8669
8814
|
);
|
|
@@ -8682,10 +8827,10 @@ var slug3 = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/
|
|
|
8682
8827
|
async function link(root, name) {
|
|
8683
8828
|
try {
|
|
8684
8829
|
let dir = dirname8(createRequire6(import.meta.url).resolve(name));
|
|
8685
|
-
while (!
|
|
8830
|
+
while (!existsSync11(join17(dir, "package.json")) || JSON.parse(readFileSync2(join17(dir, "package.json"), "utf8")).name !== name)
|
|
8686
8831
|
dir = dirname8(dir);
|
|
8687
|
-
const dest =
|
|
8688
|
-
await
|
|
8832
|
+
const dest = join17(root, "node_modules", ...name.split("/"));
|
|
8833
|
+
await mkdir9(dirname8(dest), { recursive: true });
|
|
8689
8834
|
await symlink(dir, dest, "dir");
|
|
8690
8835
|
} catch {
|
|
8691
8836
|
}
|
|
@@ -8705,7 +8850,7 @@ async function previewCommand(source, o = {}) {
|
|
|
8705
8850
|
const { servers } = openApiServers(api, source);
|
|
8706
8851
|
const where = servers.length === 0 ? "" : servers.length === 1 && servers[0]?.name === "live" ? `, live calls go to ${servers[0].url}` : `
|
|
8707
8852
|
Environments: ${servers.map((s) => `${s.name} \u2192 ${s.url}`).join(", ")}`;
|
|
8708
|
-
const root = await mkdtemp2(
|
|
8853
|
+
const root = await mkdtemp2(join17(tmpdir2(), "specbook-preview-"));
|
|
8709
8854
|
const cleanup = () => rm4(root, { recursive: true, force: true }).catch(() => {
|
|
8710
8855
|
});
|
|
8711
8856
|
process.once("SIGINT", () => cleanup().then(() => process.exit(0)));
|
|
@@ -8715,9 +8860,9 @@ async function previewCommand(source, o = {}) {
|
|
|
8715
8860
|
const res = await fetch(source);
|
|
8716
8861
|
if (!res.ok) throw new Error(`Could not fetch ${source} (${res.status})`);
|
|
8717
8862
|
const ext = /ya?ml/.test(extname(new URL(source).pathname)) ? ".yaml" : ".json";
|
|
8718
|
-
specFile =
|
|
8719
|
-
await
|
|
8720
|
-
await
|
|
8863
|
+
specFile = join17(root, `openapi/api${ext}`);
|
|
8864
|
+
await mkdir9(dirname8(specFile), { recursive: true });
|
|
8865
|
+
await writeFile11(specFile, await res.text());
|
|
8721
8866
|
}
|
|
8722
8867
|
const {
|
|
8723
8868
|
auth,
|
|
@@ -8750,14 +8895,14 @@ async function previewCommand(source, o = {}) {
|
|
|
8750
8895
|
}
|
|
8751
8896
|
}
|
|
8752
8897
|
};
|
|
8753
|
-
const configFile =
|
|
8754
|
-
await
|
|
8755
|
-
await
|
|
8898
|
+
const configFile = join17(root, ".specbook/specbook.config.ts");
|
|
8899
|
+
await mkdir9(dirname8(configFile), { recursive: true });
|
|
8900
|
+
await writeFile11(configFile, `export default ${JSON.stringify(config, null, 2)}
|
|
8756
8901
|
`);
|
|
8757
8902
|
await link(root, "@specbook/core");
|
|
8758
8903
|
await link(root, "@faker-js/faker");
|
|
8759
8904
|
console.log(`Generating "${title}" \u2026`);
|
|
8760
|
-
const out = await generateToDisk({ spec: specFile, service, outDir:
|
|
8905
|
+
const out = await generateToDisk({ spec: specFile, service, outDir: join17(root, ".generated") });
|
|
8761
8906
|
console.log(`\u2713 ${out.operations.length} operations${where}`);
|
|
8762
8907
|
const project = { root, configFile, config };
|
|
8763
8908
|
const server = await devCommand(project, o.port ?? 5173);
|
|
@@ -8826,8 +8971,8 @@ import { resolve as resolve15 } from "path";
|
|
|
8826
8971
|
|
|
8827
8972
|
// src/examples.ts
|
|
8828
8973
|
import { File } from "buffer";
|
|
8829
|
-
import { readFile as
|
|
8830
|
-
import { basename as basename6, dirname as dirname9, join as
|
|
8974
|
+
import { readFile as readFile18 } from "fs/promises";
|
|
8975
|
+
import { basename as basename6, dirname as dirname9, join as join18, resolve as resolve14 } from "path";
|
|
8831
8976
|
import fg9 from "fast-glob";
|
|
8832
8977
|
var IGNORE3 = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
|
|
8833
8978
|
function exampleLoadersFor(project) {
|
|
@@ -8835,9 +8980,9 @@ function exampleLoadersFor(project) {
|
|
|
8835
8980
|
const example = async (op, name) => {
|
|
8836
8981
|
const rel2 = `${op.service}/${op.id}/${name}.example.json`;
|
|
8837
8982
|
const [custom] = await fg9(`**/${rel2}`, { cwd: project.root, absolute: true, ignore: IGNORE3 });
|
|
8838
|
-
const file = custom ??
|
|
8983
|
+
const file = custom ?? join18(outDir, op.service, "examples", op.id, `${name}.example.json`);
|
|
8839
8984
|
try {
|
|
8840
|
-
const doc = JSON.parse(await
|
|
8985
|
+
const doc = JSON.parse(await readFile18(file, "utf8"));
|
|
8841
8986
|
return { ...doc, source: custom ? "project" : "generated", dir: dirname9(file) };
|
|
8842
8987
|
} catch {
|
|
8843
8988
|
return void 0;
|
|
@@ -8845,7 +8990,7 @@ function exampleLoadersFor(project) {
|
|
|
8845
8990
|
};
|
|
8846
8991
|
const read = async (path, dir) => {
|
|
8847
8992
|
const abs = resolve14(dir ?? project.root, path);
|
|
8848
|
-
return new File([await
|
|
8993
|
+
return new File([await readFile18(abs)], basename6(abs));
|
|
8849
8994
|
};
|
|
8850
8995
|
return { example, readFile: read };
|
|
8851
8996
|
}
|
|
@@ -9012,7 +9157,9 @@ function testOptions(values, patterns) {
|
|
|
9012
9157
|
// src/bin.ts
|
|
9013
9158
|
var HELP = `specbook <command>
|
|
9014
9159
|
|
|
9015
|
-
init scaffold config, #api/* import
|
|
9160
|
+
init scaffold config, #api/* import, the environments folder and an agent guide
|
|
9161
|
+
--agents add a Specbook section to an existing AGENTS.md or CLAUDE.md (never edited otherwise)
|
|
9162
|
+
--no-agents skip the agent guide and AGENTS.md
|
|
9016
9163
|
add <service> --spec <p|url> register an OpenAPI spec in the config and generate it; an address is downloaded to
|
|
9017
9164
|
openapi/ and recorded in openapi/sources.json
|
|
9018
9165
|
add --refresh [service...] download the recorded addresses again, say what changed and regenerate it
|
|
@@ -9028,7 +9175,7 @@ var HELP = `specbook <command>
|
|
|
9028
9175
|
${TEST_HELP}
|
|
9029
9176
|
bundle [--out <file>] compile the tests into one .mjs that runs with just node: for CI or ops, no install
|
|
9030
9177
|
--tag <t> only the requests and flows with this tag (repeatable)
|
|
9031
|
-
--external <pkg> leave a package as an import (
|
|
9178
|
+
--external <pkg> leave a package as an import (installed where the file runs; a browser cannot load it; repeatable)
|
|
9032
9179
|
check validate specs, generated code drift, imported operations, placeholders and environment files
|
|
9033
9180
|
secrets [--env <name>] show where each declared secret resolves from (never values)
|
|
9034
9181
|
--secrets-file <path> include a dotenv file, as test would
|
|
@@ -9046,6 +9193,8 @@ async function main() {
|
|
|
9046
9193
|
out: { type: "string" },
|
|
9047
9194
|
external: { type: "string", multiple: true },
|
|
9048
9195
|
"no-open": { type: "boolean" },
|
|
9196
|
+
agents: { type: "boolean" },
|
|
9197
|
+
"no-agents": { type: "boolean" },
|
|
9049
9198
|
viewer: { type: "boolean" },
|
|
9050
9199
|
minimal: { type: "boolean" },
|
|
9051
9200
|
help: { type: "boolean", short: "h" }
|
|
@@ -9070,7 +9219,13 @@ async function main() {
|
|
|
9070
9219
|
});
|
|
9071
9220
|
}
|
|
9072
9221
|
if (cmd === "init") {
|
|
9073
|
-
|
|
9222
|
+
if (values.agents && values["no-agents"]) {
|
|
9223
|
+
console.error("init: pass --agents or --no-agents, not both\n");
|
|
9224
|
+
return 3;
|
|
9225
|
+
}
|
|
9226
|
+
await initCommand(cwd, {
|
|
9227
|
+
agents: values["no-agents"] ? false : values.agents ? true : void 0
|
|
9228
|
+
});
|
|
9074
9229
|
return 0;
|
|
9075
9230
|
}
|
|
9076
9231
|
if (!["generate", "test", "bundle", "dev", "build", "add", "check", "secrets"].includes(cmd)) {
|
package/dist/standalone.js
CHANGED
|
@@ -164,13 +164,16 @@ function transportFor(ctx, env, vars) {
|
|
|
164
164
|
}
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
|
-
function originsFor(config, env, vars) {
|
|
167
|
+
function originsFor(config, env, vars, basePath = false) {
|
|
168
168
|
if (env === "mock") return {};
|
|
169
169
|
const out = {};
|
|
170
170
|
for (const service of Object.keys(config.services)) {
|
|
171
171
|
try {
|
|
172
172
|
const url = baseUrlFor(config, service, env, vars);
|
|
173
|
-
if (url)
|
|
173
|
+
if (url) {
|
|
174
|
+
const u = new URL(url);
|
|
175
|
+
out[service] = basePath ? `${u.origin}${u.pathname.replace(/\/+$/, "")}` : u.origin;
|
|
176
|
+
}
|
|
174
177
|
} catch {
|
|
175
178
|
}
|
|
176
179
|
}
|
|
@@ -251,6 +254,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
251
254
|
const vars = environmentScope(config, o.env, { ...fileVars, ...given });
|
|
252
255
|
const transport = transportFor(ctx, o.env, vars);
|
|
253
256
|
const origins = originsFor(config, o.env, vars);
|
|
257
|
+
const addresses = originsFor(config, o.env, vars, true);
|
|
254
258
|
const faker = await ctx.loadFaker();
|
|
255
259
|
const git = { ...ctx.git ?? gitInfo(ctx.root) };
|
|
256
260
|
const ci = detectCi(process.env);
|
|
@@ -269,7 +273,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
269
273
|
colors,
|
|
270
274
|
quiet: o.quiet,
|
|
271
275
|
trace: o.trace,
|
|
272
|
-
origins,
|
|
276
|
+
origins: addresses,
|
|
273
277
|
cwd: ctx.root,
|
|
274
278
|
readSource,
|
|
275
279
|
rerun: (r) => `${ctx.command}${r.file && ctx.command === "specbook test" ? ` ${r.file}` : ""} --env ${o.env} --grep ${quote(escapeRegExp(r.title))}${o.safe ? " --safe" : ""}`
|
|
@@ -378,7 +382,7 @@ async function execute(ctx, o, picked, specs, fileVars, secrets) {
|
|
|
378
382
|
toJUnit(clean, {
|
|
379
383
|
timestamp: meta.startedAt,
|
|
380
384
|
hostname: hostname(),
|
|
381
|
-
origins,
|
|
385
|
+
origins: addresses,
|
|
382
386
|
properties: { env: o.env, sha: git.sha, branch: git.branch, specbook: meta.specbook }
|
|
383
387
|
})
|
|
384
388
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Specbook command line: init, add, generate, dev, build, test, bundle, check, secrets and preview.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"specbook",
|
|
@@ -32,15 +32,16 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
|
+
"agent-guide.md",
|
|
35
36
|
"!**/*.test.*"
|
|
36
37
|
],
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@apidevtools/swagger-parser": "10.1.1",
|
|
39
40
|
"@faker-js/faker": "9.3.0",
|
|
40
|
-
"@specbook/codegen": "0.
|
|
41
|
-
"@specbook/core": "0.
|
|
42
|
-
"@specbook/ui": "0.
|
|
43
|
-
"@specbook/vite": "0.
|
|
41
|
+
"@specbook/codegen": "0.4.0",
|
|
42
|
+
"@specbook/core": "0.4.0",
|
|
43
|
+
"@specbook/ui": "0.4.0",
|
|
44
|
+
"@specbook/vite": "0.4.0",
|
|
44
45
|
"fast-glob": "3.3.2",
|
|
45
46
|
"rolldown": "1.2.13",
|
|
46
47
|
"swagger2openapi": "7.0.8",
|