unfazed 0.6.0 → 0.8.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +299 -78
  3. package/REFERENCE.md +284 -0
  4. package/dist/cli/login.d.ts +129 -0
  5. package/dist/cli/login.js +596 -0
  6. package/dist/cli/login.js.map +1 -0
  7. package/dist/cli/workflow.d.ts +31 -0
  8. package/dist/cli/workflow.js +157 -0
  9. package/dist/cli/workflow.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +173 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.d.ts +26 -5
  14. package/dist/client.js +93 -48
  15. package/dist/client.js.map +1 -1
  16. package/dist/credentials.d.ts +123 -0
  17. package/dist/credentials.js +317 -0
  18. package/dist/credentials.js.map +1 -0
  19. package/dist/errors.d.ts +64 -0
  20. package/dist/errors.js +95 -0
  21. package/dist/errors.js.map +1 -1
  22. package/dist/generated/gql.d.ts +78 -2
  23. package/dist/generated/gql.js +21 -2
  24. package/dist/generated/gql.js.map +1 -1
  25. package/dist/generated/graphql.d.ts +505 -6
  26. package/dist/generated/graphql.js +439 -2
  27. package/dist/generated/graphql.js.map +1 -1
  28. package/dist/index.d.ts +25 -6
  29. package/dist/index.js +23 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/operations.d.ts +116 -5
  32. package/dist/operations.js +379 -7
  33. package/dist/operations.js.map +1 -1
  34. package/dist/reference.d.ts +18 -0
  35. package/dist/reference.generated.d.ts +2 -0
  36. package/dist/reference.generated.js +5 -0
  37. package/dist/reference.generated.js.map +1 -0
  38. package/dist/reference.js +38 -0
  39. package/dist/reference.js.map +1 -0
  40. package/dist/remote/app.js +2 -1
  41. package/dist/remote/app.js.map +1 -1
  42. package/dist/remote/world.d.ts +2 -2
  43. package/dist/remote/world.js +32 -16
  44. package/dist/remote/world.js.map +1 -1
  45. package/dist/unfazed.d.ts +545 -68
  46. package/dist/unfazed.js +688 -64
  47. package/dist/unfazed.js.map +1 -1
  48. package/dist/wait.d.ts +7 -0
  49. package/dist/wait.js.map +1 -1
  50. package/dist/workflow/nitro.js +5 -3
  51. package/dist/workflow/nitro.js.map +1 -1
  52. package/package.json +22 -6
  53. package/schema/SOURCE.json +4 -4
  54. package/schema/schema.graphqls +1628 -30
package/README.md CHANGED
@@ -20,8 +20,11 @@ SDK: one script. Three requests. You read the four lines you cared about.
20
20
  - Zero runtime dependencies. Installing `unfazed` installs exactly one package.
21
21
  (The optional [`unfazed/workflow`](#durable-workflows-unfazedworkflow) subpath
22
22
  uses Workflow SDK packages your app installs; the root entry never loads them.)
23
- - `npx workflow inspect runs --backend unfazed`: Workflow SDK's CLI and web UI
24
- read your app's runs through the platform ([details](#inspect-runs-from-your-terminal)).
23
+ - `npx unfazed login` signs you in from a terminal in the browser; the SDK then
24
+ acts as you, with no token to copy ([details](#sign-in-from-a-terminal-npx-unfazed)).
25
+ - `npx unfazed workflow inspect runs --app <alias>`: Workflow SDK's CLI and web
26
+ UI read your app's runs through the platform, from any directory
27
+ ([details](#inspect-runs-from-your-terminal)).
25
28
  - Strict TypeScript, generated from the platform's own GraphQL schema.
26
29
  - Bun-first, runs unchanged on Node >= 20 and Deno.
27
30
 
@@ -33,27 +36,79 @@ bun add unfazed # or: npm i unfazed
33
36
 
34
37
  ## Auth
35
38
 
36
- Get a token from <https://unfazed.dev/auth.md> (or `POST
37
- https://unfazed.dev/agents/register`). The SDK reads `UNFZ_API_TOKEN`:
39
+ **A person** signs in once from a terminal, in the browser
40
+ ([`npx unfazed login`](#sign-in-from-a-terminal-npx-unfazed)); the SDK then acts
41
+ as them, with their apps, teams and claimed agents:
42
+
43
+ ```sh
44
+ npx unfazed login
45
+ ```
46
+
47
+ **An agent or a CI job** uses a bearer token instead: an agent registers itself
48
+ at <https://unfazed.dev/auth.md> (`POST https://unfazed.dev/agents/register`).
49
+ The SDK reads `UNFZ_API_TOKEN`, which wins over a stored login:
38
50
 
39
51
  ```sh
40
52
  export UNFZ_API_TOKEN="..."
41
53
  ```
42
54
 
43
55
  ```ts
44
- const unfazed = new Unfazed(); // reads UNFZ_API_TOKEN
45
- const unfazed = new Unfazed({ token }); // or pass it explicitly
56
+ const unfazed = new Unfazed(); // UNFZ_API_TOKEN, else your `npx unfazed login`
57
+ const unfazed = new Unfazed({ token }); // or pass a token explicitly
46
58
  ```
47
59
 
48
60
  Both endpoint and token are configurable. `UNFZ_API_URL` overrides the endpoint
49
- (default `https://unfazed.dev/query`); `baseUrl` is a convenience for
50
- self-hosted control planes where only the host differs.
61
+ (default `https://unfazed.dev/query`, or the stored login's); `baseUrl` is a
62
+ convenience for self-hosted control planes where only the host differs.
51
63
 
52
64
  ```ts
53
65
  new Unfazed({ token, baseUrl: "https://staging.example.com" }); // → /query
54
66
  new Unfazed({ token, endpoint: "https://example.com/graphql" }); // exact
55
67
  ```
56
68
 
69
+ ## Sign in from a terminal: `npx unfazed`
70
+
71
+ The package's `unfazed` bin signs a person in, the way `vercel login` does. It
72
+ runs from any directory (`npx` fetches the package when the directory has no
73
+ `unfazed`), on Node >= 20.16 or Bun, with no dependencies.
74
+
75
+ ```sh
76
+ npx unfazed login # opens the browser; you sign in and allow access
77
+ npx unfazed whoami # who the SDK acts as here
78
+ npx unfazed logout # revokes the login where the server offers it, and deletes it
79
+ ```
80
+
81
+ ```
82
+ $ npx unfazed login
83
+ Opening your browser to sign in to https://unfazed.dev.
84
+ …
85
+ Signed in to https://unfazed.dev as ada (user).
86
+ The login is stored in /home/ada/.config/unfazed/credentials.json.
87
+ ```
88
+
89
+ - **How:** OAuth 2.1 with PKCE against the platform's own authorization server,
90
+ the one MCP clients sign in through. The CLI registers itself once as a
91
+ public client (cached in `oauth-clients.json` beside the login), listens on
92
+ `http://127.0.0.1:<random port>/callback` for the answer, and asks for
93
+ `unfz:read unfz:deploy` (`--scope` narrows it).
94
+ - **The login** is `${XDG_CONFIG_HOME:-~/.config}/unfazed/credentials.json`,
95
+ mode 0600: the access token (an hour long), the refresh token (30 days, rotated
96
+ on every refresh), the expiry, the endpoint and who signed in. The SDK uses it
97
+ whenever no token is passed and `UNFZ_API_TOKEN` is unset, refreshes the access
98
+ token before it expires (and once when the platform refuses it), and writes
99
+ the rotated pair back for the next process. It is only ever sent to the
100
+ endpoint it was issued by; another `UNFZ_API_URL` needs its own
101
+ `UNFZ_API_URL=… npx unfazed login`. `new UnfazedClient({ login: false })`
102
+ ignores it.
103
+ - **A browser on another machine** (an SSH session, a box): the URL is printed
104
+ instead of opened. Sign in there, then paste the address the browser ends up
105
+ on (`http://127.0.0.1:…/callback?code=…`, which does not load there) into the
106
+ terminal. `--no-browser` prints the URL on your own machine too.
107
+ - **Another control plane:** `--endpoint https://staging.example.com` (or
108
+ `UNFZ_API_URL`).
109
+ - `whoami --json` prints the same as JSON; nothing the CLI prints ever carries
110
+ a token.
111
+
57
112
  ## Code mode: deploy, wait, tail logs, in one script
58
113
 
59
114
  ```ts
@@ -64,8 +119,8 @@ const unfazed = new Unfazed();
64
119
 
65
120
  const ws = await unfazed.createWorkspace("greeter");
66
121
 
67
- // One request, not three — batched as aliased mutations.
68
- await unfazed.writeFiles(ws.id, {
122
+ // One request, not three — and ONE commit on an app with platform git.
123
+ const written = await unfazed.writeFiles(ws.id, {
69
124
  "server.ts": `
70
125
  export default {
71
126
  fetch(req: Request) {
@@ -76,7 +131,8 @@ await unfazed.writeFiles(ws.id, {
76
131
  `,
77
132
  "unfazed.json": JSON.stringify({ entry: "server.ts" }),
78
133
  "README.md": "# greeter",
79
- });
134
+ }, { commitMessage: "First version" });
135
+ console.log(written.commit?.sha ?? "no platform git");
80
136
 
81
137
  try {
82
138
  // Blocks until the build, VM boot and health probe all pass.
@@ -86,6 +142,8 @@ try {
86
142
  wait: true,
87
143
  });
88
144
  console.log(`live: ${app.url}`);
145
+ // What the platform measured, and what this script waited.
146
+ console.log(app.timings?.platform?.totalMs, app.timings?.client.totalMs);
89
147
 
90
148
  // Verify it for real before trusting the alias.
91
149
  const res = await fetch(`${app.url}?name=agent`);
@@ -109,6 +167,17 @@ try {
109
167
  }
110
168
  ```
111
169
 
170
+ ## Every signature, from a script: `help()`
171
+
172
+ `help()` returns [REFERENCE.md](REFERENCE.md): every method with its
173
+ signature, result and an example, in one page. `help("writeFiles")` is one
174
+ entry, `help("Events")` one section. A Code Mode script (MCP `run_script`) can
175
+ `export default () => help("restoreVersion")` to read it without leaving the
176
+ tool. The platform serves the reference of the version it vendors at
177
+ https://unfazed.dev/sdk.md; a run_script result's `sdk_version` (or
178
+ `VERSION` in the script) names the version a script actually ran, which lags
179
+ for a while after a release (it ships with the platform's worker releases).
180
+
112
181
  ## Two layers
113
182
 
114
183
  ### 1. Typed GraphQL core — `UnfazedClient`
@@ -133,7 +202,8 @@ const data = await client.query<{ workspaces: { id: string }[] }, never>(
133
202
  Errors are typed and specific: `UnfazedAuthError`, `UnfazedHTTPError` (carries
134
203
  `status`/`body`), `UnfazedGraphQLError` (carries `errors` **and any partial
135
204
  `data`**), `UnfazedTimeoutError`, `UnfazedDeployError`, `UnfazedConflictError`,
136
- `UnfazedNotFoundError`. All extend `UnfazedError`.
205
+ `UnfazedNotFoundError`, `UnfazedWriteError` (carries the write's `result` and
206
+ the `failed` paths). All extend `UnfazedError`.
137
207
 
138
208
  ### 2. Workflow layer — `Unfazed`
139
209
 
@@ -151,14 +221,17 @@ The chains an agent actually performs. Reach `client` from it any time:
151
221
  | `createWorkspace(name)` | names are unique per owner, case-insensitively |
152
222
  | `ensureWorkspace(name)` | get-or-create; **use this in re-runnable scripts** |
153
223
  | `listWorkspaces()` | |
154
- | `deleteWorkspace(id, opts?)` | cascades to deployments by default; also destroys a BOX's data disk |
224
+ | `liveApps(id)` | the apps the workspace serves now: what a cascading delete takes offline |
225
+ | `deleteApp({ workspaceId, alias }, opts?)` | deletes ONE app; the workspace and its other apps keep serving |
226
+ | `deleteWorkspace(id, opts?)` | with deployments only via `{ withDeployments: true, confirmAliases }` naming every live app; also deletes a BOX's data disk |
155
227
 
156
228
  **Files**
157
229
  | Method | Notes |
158
230
  | --- | --- |
159
- | `writeFiles(wsId, files, opts?)` | **batched** — many files per request |
160
- | `writeFile(wsId, path, text)` | |
161
- | `writeBinaryFile(wsId, path, bytes)` | base64 under the hood |
231
+ | `writeFiles(wsId, files, opts?)` | **one request, one commit** (MCP `write_workspace_files`); `commitMessage`; answers the `commit` |
232
+ | `writeFile(wsId, path, text, opts?)` | a one-file `writeFiles`; answers the file and its `commit` |
233
+ | `writeBinaryFile(wsId, path, bytes, opts?)` | base64 under the hood |
234
+ | `waitForCommit(wsId)` | resolves once no write waits for its commit (see "Saved, committed, deployed") |
162
235
  | `listFiles(wsId, page?)` | |
163
236
  | `readFile(wsId, path, range?)` | |
164
237
  | `deleteFile(wsId, path)` | |
@@ -166,11 +239,11 @@ The chains an agent actually performs. Reach `client` from it any time:
166
239
  **Deploy**
167
240
  | Method | Notes |
168
241
  | --- | --- |
169
- | `deployService(opts)` | `wait: true` polls to READY |
242
+ | `deployService(opts)` | `wait: true` waits server-side to READY; the result carries `timings` |
170
243
  | `deployStatic(opts)` | `spaMode` for single-page apps |
171
244
  | `deployBox(opts)` | persistent Linux VM; result carries `sshCommand` |
172
245
  | `deploy(input, opts?)` | raw `CreateDeploymentInput` escape hatch |
173
- | `waitForDeployment(id, opts?)` | standalone poller |
246
+ | `waitForDeployment(id, opts?)` | the server-side wait (MCP `wait_for_deployment`); polls on older platforms |
174
247
  | `getDeployment(id)` / `getDeploymentByAlias(alias)` | |
175
248
  | `listDeployments(filter?)` | |
176
249
 
@@ -179,7 +252,8 @@ The chains an agent actually performs. Reach `client` from it any time:
179
252
  | --- | --- |
180
253
  | `getLogs(id, { tailBytes })` | stdout + stderr tail |
181
254
  | `getStatus(id)` | health, in-flight, restarts, `memSizeMiB` |
182
- | `getEvents(id, limit?)` | build/boot/probe/cron lifecycle events |
255
+ | `getEvents(id, limit?)` | lifecycle events; `type` is the dotted name MCP shows (`deployment.build_finished`), `typeEnum` the GraphQL enum |
256
+ | `getDeploymentTimings(id)` | the platform's measured phases, as numbers |
183
257
  | `restartService(id)` / `stopService(id)` | |
184
258
  | `deleteDeployment(id, opts?)` | `ignoreNotFound` for idempotent cleanup |
185
259
 
@@ -198,6 +272,27 @@ The chains an agent actually performs. Reach `client` from it any time:
198
272
  | `listReleases(alias)` | newest first; one entry has `aliasHeld: true` |
199
273
  | `rollback({ alias, to?, expectedCurrentId?, retries? })` | **whole compare-and-swap in one call** |
200
274
 
275
+ **History (platform git)**
276
+ | Method | Notes |
277
+ | --- | --- |
278
+ | `listCommits(wsId, { limit?, before? })` | newest first; `deployed` says what each alias runs (MCP `list_commits`) |
279
+ | `getCommit(wsId, sha)` | the changed paths and a unified diff (MCP `get_commit`) |
280
+ | `restoreVersion({ workspaceId, sha, wait? })` | one new commit with that commit's files; `wait: true` follows its deploy to READY (MCP `restore_version`) |
281
+
282
+ **App errors**
283
+ | Method | Notes |
284
+ | --- | --- |
285
+ | `listErrors({ workspaceId, alias, status? })` | a service app's error groups (MCP `list_errors`) |
286
+ | `getErrorGroup({ workspaceId, alias, groupId })` | the latest occurrence whole, and where it was seen |
287
+ | `resolveErrorGroup` / `muteErrorGroup` / `reopenErrorGroup` | same ref; only on your human's say-so |
288
+
289
+ **Events**
290
+ | Method | Notes |
291
+ | --- | --- |
292
+ | `listEventTypes()` | every type with its filters and payload |
293
+ | `pollEvents({ names?, arguments?, cursor? })` | MCP `poll_events`; the same cursors |
294
+ | `waitForEvents({ …, timeoutMs? })` | blocks server-side until an event arrives (MCP `wait_for_events`) |
295
+
201
296
  **Agent runs**
202
297
  | Method | Notes |
203
298
  | --- | --- |
@@ -214,25 +309,53 @@ The chains an agent actually performs. Reach `client` from it any time:
214
309
 
215
310
  ## The two things worth knowing
216
311
 
217
- ### `writeFiles` batches, because the API is one-file-per-mutation
312
+ ### `writeFiles` is one request and one commit
218
313
 
219
- Writing a 12-file app the obvious way costs 12 round trips. `writeFiles` puts
220
- one **aliased** mutation per file into a single GraphQL document:
314
+ `writeFiles` sends every file in one `writeWorkspaceFiles` mutation, the one
315
+ MCP's `write_workspace_files` uses. Text and binary mix freely. On an app with
316
+ platform git the whole call is ONE commit, made before any file is written,
317
+ with your `commitMessage`; the answer carries it:
221
318
 
222
- ```graphql
223
- mutation SdkWriteFilesBatch($workspaceID: ID!, $p0: String!, $c0: String!, ...) {
224
- f0: writeWorkspaceTextFile(input: { workspaceID: $workspaceID, path: $p0, text: $c0 }) { file { ... } }
225
- f1: writeWorkspaceBase64File(input: { workspaceID: $workspaceID, path: $p1, contentBase64: $c1 }) { file { ... } }
226
- }
319
+ ```ts
320
+ const out = await unfazed.writeFiles(ws.id, { "server.ts": code, "package.json": pkg }, { commitMessage: "Add the API" });
321
+ out.commit; // { sha, branch, message, committed, waiting, waitingCount, deploys, conflicts, writeSHA, paused }
227
322
  ```
228
323
 
229
- Text and binary mix freely in one batch. It splits automatically at 20 files or
230
- 4 MiB per request (`batchSize`, `maxBatchBytes`).
231
-
232
- Batches are **not atomic** — GraphQL resolves each field independently. If one
233
- path fails, the others still commit; the thrown `UnfazedGraphQLError` carries
234
- `data` with the aliases that succeeded and `errors[].path` naming the ones that
235
- didn't.
324
+ A call is **not transactional**, exactly as MCP's: files are written in order,
325
+ each atomically, and one failing does not undo the others. Then `writeFiles`
326
+ throws `UnfazedWriteError`, whose `result` lists what landed and `failed` what
327
+ did not — retry only those, or pass `allowPartial: true` to get the result
328
+ instead. A request carries up to 100 files and 32 MiB (the platform's limits);
329
+ a bigger map is split, one commit per request (`commits`). On a platform older
330
+ than the batch mutation the SDK falls back to one aliased mutation per file in
331
+ one request (one commit each, `commit: null`).
332
+
333
+ ### Saved, committed, deployed
334
+
335
+ - **Saved** — `writeFiles` returned: the app's files have it; the next deploy
336
+ uses it.
337
+ - **Committed** — `commit.committed && !commit.waiting`: it is in the app's
338
+ history (`listCommits`). When the git server is slow the commit comes back
339
+ `waiting` (`sha: null`): saved, landing in order, nothing to retry.
340
+ `writeFiles(…, { waitForCommit: true })` or `waitForCommit(wsId)` resolves
341
+ once it has landed, with the branch head.
342
+ - **Deployed** — a deploy of those files went READY: `deployService({ wait:
343
+ true })`. A write deploys by itself only when `commit.deploys` is true (the
344
+ branch head is imported like a push).
345
+
346
+ ### Waiting is server-side, and you get both clocks
347
+
348
+ `deployService({ wait: true })` and `waitForDeployment` wait on the platform
349
+ (MCP `wait_for_deployment`): each request blocks until the deployment leaves
350
+ BUILDING — woken by the deploy pipeline — or 45 s pass, and answers with the
351
+ full deployment. READY reaches the script one round trip after it happens.
352
+ Before 0.8.0 the SDK polled at 1 s × 1.5 backoff and re-read the deployment in
353
+ full: a deploy READY after 6.8 s was seen at about 8.1 s and returned at 9.0 s.
354
+
355
+ The result carries `timings`: `platform` is what the platform measured
356
+ (`totalMs` from creation to READY, `buildMs`, `promoteMs`, and every phase in
357
+ `phases`), `client` what the script waited (`createMs`, `waitMs`, `totalMs`,
358
+ `waitRequests`, `waitMode`). `timings: false` skips the one extra read.
236
359
 
237
360
  ### `rollback` does the compare-and-swap for you
238
361
 
@@ -306,26 +429,79 @@ what the first one did:
306
429
  | `deployBox({ workspaceId })` | `deployBox({ workspaceId, alias })` | no alias ⇒ a brand-new box each time |
307
430
  | `rollback({ alias })` | `rollback({ alias, to })` or `expectedCurrentId` | bare rollback is a toggle |
308
431
  | `deleteWorkspace(id)` throwing | `deleteWorkspace(id, { ignoreNotFound: true })` | already-deleted is not a failure |
432
+ | `deleteApp({ workspaceId, alias })` throwing | `deleteApp({ workspaceId, alias }, { ignoreNotFound: true })` | same; resolves `null` |
309
433
  | `deleteDeployment(id)` throwing | `deleteDeployment(id, { ignoreNotFound: true })` | same |
310
434
 
311
- `ignoreNotFound` matches the server's two exact sentinels — `workspace was not
312
- found` and `deployment was not found` — and nothing else. A message that merely
313
- *contains* "not found" still throws, so a real failure can't be mistaken for a
314
- successful teardown. (Both sentinels also cover "belongs to another tenant": the
315
- server reports the two identically so ids can't be probed.)
316
-
317
- ### Deleting a workspace cascades by default
318
-
319
- `deleteWorkspace` sends `withDeployments: true` unless you say otherwise. It has
320
- to: the server **refuses** to delete a workspace with any deployment history at
321
- all — including FAILED-only history — without that flag, so the strict form
322
- fails on nearly every workspace that was ever deployed.
435
+ `ignoreNotFound` matches the server's exact refusals — `workspace was not
436
+ found`, `deployment was not found`, and for `deleteApp` `app "<alias>" was not
437
+ found in workspace "…"` naming that alias — and nothing else. A message that
438
+ merely *contains* "not found" still throws, so a real failure can't be mistaken
439
+ for a successful teardown. (The two sentinels also cover "belongs to another
440
+ tenant": the server reports the two identically so ids can't be probed.)
441
+
442
+ ### Deleting names what goes
443
+
444
+ A workspace is a source, and one source can serve several apps: each
445
+ `unfazed.json` `apps[]` entry, or several aliases deployed into it. A delete is
446
+ never wider than what you name:
447
+
448
+ - **`deleteApp({ workspaceId, alias })` deletes one app**: every deployment of
449
+ the alias in that workspace (its VM stops, its URL and custom domains stop
450
+ serving), its App State, its app-scoped env and secrets, and its integration
451
+ bindings. The workspace, its files and defaults, its other apps and a box's
452
+ data disk stay. The result says what went (`deployments`, `domains`,
453
+ `envEntries`) and what still serves (`otherApps`). In a git-linked workspace
454
+ pushes and syncs skip the alias while `unfazed.json` `apps[]` still declares
455
+ it (`skippedOnSync: true`); remove it from `apps[]`, or bring it back with
456
+ the GraphQL `syncRepo` mutation's `restoreApps`. A delete that stops
457
+ part-way throws saying so; deleting again finishes it.
458
+ - **`deleteWorkspace(id, { withDeployments: true, confirmAliases })` deletes the
459
+ workspace with every app it serves**, so `confirmAliases` must name exactly
460
+ those apps, in any order. A missing or extra alias, or an app that went live
461
+ after the check, makes the server delete nothing and refuse with
462
+ `UnfazedUnconfirmedAppsError` (still an `UnfazedGraphQLError`), whose message
463
+ is the server's: it lists every live app with its custom domains. A
464
+ workspace whose deploys all failed serves no app: `confirmAliases: []`.
465
+ - **`deleteWorkspace(id)`** deletes only a workspace with no deployment records
466
+ left; otherwise the server refuses with `still has deployments; delete them
467
+ first`.
468
+
469
+ The SDK never fills `confirmAliases` in for you. `liveApps(id)` is there to look
470
+ the list up deliberately — read it, then name what you mean to take down:
323
471
 
324
472
  ```ts
325
- await unfazed.deleteWorkspace(ws.id); // cascade (default)
326
- await unfazed.deleteWorkspace(ws.id, { withDeployments: false }); // refuse if deployed
473
+ await unfazed.liveApps(ws.id);
474
+ // [{ alias: "api", deploymentID: "…", domains: ["api.example.com"] },
475
+ // { alias: "web", deploymentID: "…", domains: [] }]
476
+
477
+ // Either one app — "web" keeps serving:
478
+ await unfazed.deleteApp({ workspaceId: ws.id, alias: "api" });
479
+
480
+ // or the workspace with every app it serves, each named:
481
+ await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: ["api", "web"] });
482
+
483
+ // Naming fewer (or more) deletes nothing and throws:
484
+ await unfazed.deleteWorkspace(ws.id, { withDeployments: true, confirmAliases: ["web"] });
485
+ // UnfazedUnconfirmedAppsError: unfazed: nothing was deleted: workspace "shop" (…) serves 2 apps:
486
+ // api (api.example.com); web. Deleting it takes every one of them offline. To delete one app,
487
+ // use deleteApp (MCP delete_app). To delete the workspace with all of them, name exactly these
488
+ // aliases in confirmAliases.
327
489
  ```
328
490
 
491
+ **Deleted is kept for 30 days.** A delete takes effect at once: the app,
492
+ release or workspace disappears from your views, its name and alias are free
493
+ for anyone, and it stops serving. The platform keeps what you deleted — its
494
+ files, releases with their env and secrets, App State, box disk, integration
495
+ resources and custom domains — for 30 days. Within that time support (the
496
+ platform operator) can restore it exactly as it was; you cannot restore it
497
+ yourself, so ask support. After 30 days it is erased for good. This holds for
498
+ `deleteApp`, `deleteWorkspace` and `deleteDeployment` alike.
499
+
500
+ **Upgrading from 0.6.** `deleteWorkspace(id)` used to send `withDeployments:
501
+ true` with no apps named; the server now refuses that whenever the workspace
502
+ serves an app. From 0.7 the bare call is the strict delete, and a cascade takes
503
+ `{ withDeployments: true, confirmAliases }`. To remove one app, use `deleteApp`.
504
+
329
505
  ## Env & secrets: three states, not two
330
506
 
331
507
  A name in a given scope is exactly one of **plain**, **secret**, or **masked**,
@@ -446,25 +622,38 @@ to the app as well.
446
622
 
447
623
  ### With Nitro (the recipe)
448
624
 
625
+ The app deploys in Nitro's own layout, as the platform's "Deploying a Nitro
626
+ app" describes, with no re-export file:
627
+
628
+ ```ts
629
+ // server.ts — the app itself; Nitro takes a root server.ts as its server entry
630
+ import { Hono } from "hono";
631
+
632
+ const app = new Hono();
633
+ // … routes that start() workflows
634
+ export default app;
635
+ ```
636
+
449
637
  ```ts
450
638
  // nitro.config.ts
451
639
  import { defineConfig } from "nitro";
452
640
 
453
641
  export default defineConfig({
454
642
  modules: ["workflow/nitro", "unfazed/workflow/nitro"],
455
- routes: { "/**": "./src/app.ts" }, // a Hono app; not named server.ts (Nitro claims that name)
456
643
  });
457
644
  ```
458
645
 
459
646
  ```json
460
- // unfazed.json — package.json has "build": "nitro build"
461
- { "kind": "SERVICE", "runtime": "BUN", "install": "bun install", "build": "bun run build", "entry": "unfazed-entry.ts" }
647
+ // unfazed.json — no "entry"
648
+ { "kind": "SERVICE", "build": "nitro build" }
462
649
  ```
463
650
 
464
- ```ts
465
- // unfazed-entry.ts
466
- export { default } from "./.output/server/index.mjs";
467
- ```
651
+ With no `entry` in unfazed.json and a `standard` Nitro build, unfazed serves
652
+ `.output/server/index.mjs`; the build log says `using Nitro output
653
+ .output/server/index.mjs as the service entry`. Do not set `entry` or add an
654
+ `unfazed-entry.ts` re-export: an explicit entry replaces the Nitro output.
655
+ Install and build commands find the app's `node_modules/.bin` CLIs on PATH, so
656
+ `"build": "bun run build"` over a package.json script works as well.
468
657
 
469
658
  Then bind a Postgres or Neon integration to the app and deploy. In a
470
659
  production build the module:
@@ -481,14 +670,16 @@ production build the module:
481
670
  - mounts the **inspection route** the unfazed dashboard and MCP read runs
482
671
  through (below), with the build's workflow list and Workflow SDK version;
483
672
  - sets `noExternals: true` and builds Nitro's `standard` output (`export
484
- default { fetch }`), which is what unfazed serves, whenever no preset was
673
+ default { fetch }`, recorded as `"preset": "standard"` in
674
+ `.output/nitro.json`), which is what unfazed serves, whenever no preset was
485
675
  chosen, whatever Nitro detected on the build machine: `bun` under Bun (as on
486
676
  the unfazed build VM), `node-server` under Node, `deno-server` under Deno.
487
677
  Their own outputs start a server of their own (under Bun with a 10 s idle
488
678
  timeout that cuts every quiet workflow stream). A preset chosen explicitly
489
679
  (`preset` in nitro.config.ts or .nitrorc, `NITRO_PRESET`, `SERVER_PRESET`)
490
680
  other than `standard` **fails the build** with a message saying so; leave it
491
- out, or set `preset: "standard"`.
681
+ out, or set `preset: "standard"`. A Nitro app without this module sets both
682
+ itself: `defineConfig({ preset: "standard", noExternals: true })`.
492
683
  - serves public assets only when `serveStatic: "inline"` embeds them in the
493
684
  bundle (a service VM has nothing next to it to read from disk). That is how
494
685
  one service ships an API and its UI (`publicAssets: [{ dir: "dist" }]`
@@ -499,8 +690,12 @@ production build the module:
499
690
  A complete app — steps, a retried step, `sleep`, a webhook (plain
500
691
  `createWebhook()` and `webhook.url`), a progress stream — is in
501
692
  [`examples/workflow-nitro`](./examples/workflow-nitro). Once it is deployed,
502
- `UNFZ_APP=<alias> npx workflow inspect runs --backend unfazed` lists its runs
693
+ `npx unfazed workflow inspect runs --app <alias>` lists its runs
503
694
  ([Inspect runs from your terminal](#inspect-runs-from-your-terminal)).
695
+ [`examples/birthday-card-generator`](./examples/birthday-card-generator) is
696
+ Vercel's Workflow launch demo on unfazed: a React UI built by Vite and served
697
+ from the same bundle, AI steps, webhooks resumed from emails, `sleep` until a
698
+ date, and a stream the UI reconnects to; it runs without any provider keys.
504
699
 
505
700
  ### Without Nitro
506
701
 
@@ -569,12 +764,11 @@ database connection afterwards; `wake` and `cancel` are Workflow SDK's own
569
764
  ### Inspect runs from your terminal
570
765
 
571
766
  Workflow SDK's own CLI reads an app's runs through the platform, the way
572
- `--backend vercel` reads a Vercel project. From the app's project (where
573
- `unfazed` is installed; the CLI loads `createWorld()` from it):
767
+ `--backend vercel` reads a Vercel project. From any directory:
574
768
 
575
769
  ```sh
576
- export UNFZ_API_TOKEN=... # a token from https://unfazed.dev/auth.md
577
- UNFZ_APP=birthday-card npx workflow inspect runs --backend unfazed
770
+ npx unfazed login # once
771
+ npx unfazed workflow inspect runs --app birthday-card
578
772
  ```
579
773
 
580
774
  ```
@@ -586,25 +780,38 @@ wrun_01KPBDAY00000000000000RN03 generateBirthdayCard failed 2026-10-15 09:
586
780
  ```
587
781
 
588
782
  ```sh
589
- npx workflow inspect run <run-id> --backend unfazed # input, output, error
590
- npx workflow inspect steps --runId <run-id> --backend unfazed # also: events, hooks, sleeps, streams
591
- npx workflow inspect step <step-id> --runId <run-id> --backend unfazed
592
- npx workflow inspect stream <stream-id> --runId <run-id> --backend unfazed # follows it while the run writes
593
- npx workflow cancel <run-id> --backend unfazed
594
- npx workflow web --backend unfazed # the local web UI: trace, events, streams,
595
- # Wake Up Sleep and Cancel
783
+ npx unfazed workflow inspect run <run-id> --app birthday-card # input, output, error
784
+ npx unfazed workflow inspect steps --runId <run-id> --app birthday-card # also: events, hooks, sleeps, streams
785
+ npx unfazed workflow inspect step <step-id> --runId <run-id> --app birthday-card
786
+ npx unfazed workflow inspect stream <stream-id> --runId <run-id> --app birthday-card # follows it while the run writes
787
+ npx unfazed workflow cancel <run-id> --app birthday-card
788
+ npx unfazed workflow web --app birthday-card # the local web UI: trace, events, streams,
789
+ # Wake Up Sleep and Cancel
790
+ ```
791
+
792
+ `npx unfazed workflow …` is Workflow SDK's CLI with this package as its
793
+ backend: everything after `workflow` but `--app` is passed to it, and its
794
+ output and exit code come back unchanged. It runs the project's own
795
+ `workflow` (major 5) when the directory has one, else `npx -y workflow@5`.
796
+
797
+ **Inside a project** that has `unfazed` installed, the CLI's native form works
798
+ too, since the CLI resolves `--backend unfazed` from the project:
799
+
800
+ ```sh
801
+ UNFZ_APP=birthday-card npx workflow inspect runs --backend unfazed
596
802
  ```
597
803
 
598
- The usual options work (`--json`, `--limit`, `--cursor`, `--all`, `--status`,
804
+ `export WORKFLOW_TARGET_WORLD=unfazed` saves typing `--backend` there. The
805
+ usual options work (`--json`, `--limit`, `--cursor`, `--all`, `--status`,
599
806
  `--workflowName`, `--interactive`); the exceptions are below.
600
- `export WORKFLOW_TARGET_WORLD=unfazed` saves typing `--backend`.
601
-
602
- - **Which app:** `UNFZ_APP` (its alias, the `<alias>` of
603
- `https://<alias>.unfz.to`); without it, the `alias` in `./unfazed.json`, or
604
- its only `apps[]` entry. `UNFZ_API_URL` points at another control plane.
605
- - **The token** needs `unfz:read`; cancel and Wake Up Sleep need `unfz:deploy`
606
- too, and the caller must be allowed to restart the app (owner, team member,
607
- operator).
807
+
808
+ - **Which app:** `--app` (`npx unfazed workflow` only), else `UNFZ_APP` (its
809
+ alias, the `<alias>` of `https://<alias>.unfz.to`), else the `alias` in
810
+ `./unfazed.json`, or its only `apps[]` entry. `UNFZ_API_URL` points at
811
+ another control plane.
812
+ - **The token** is your `npx unfazed login`, or `UNFZ_API_TOKEN` when set. It
813
+ needs `unfz:read`; cancel and Wake Up Sleep need `unfz:deploy` too, and the
814
+ caller must be allowed to restart the app (owner, team member, operator).
608
815
  - **Every command is one platform call relayed to the app**, through the same
609
816
  inspection route the dashboard reads; an `on_demand` app cold-starts for it,
610
817
  as for a visitor.
@@ -726,6 +933,7 @@ WORKFLOW_POSTGRES_URL=$UNFAZED_TEST_DATABASE_URL bun run test:conformance
726
933
  # delivery cold-started in a fresh process
727
934
  bun run test:example # the example, bundled as unfazed builds it,
728
935
  # one fresh process per delivery and request
936
+ bun run test:birthday # the same for examples/birthday-card-generator
729
937
  bun run test:cli # Workflow SDK's CLI with --backend unfazed (and --web)
730
938
  # against a fake of the platform's inspection API
731
939
  ```
@@ -745,6 +953,11 @@ After bumping the `@workflow/world-postgres` devDependency, run
745
953
  `bun run embed-migrations`: `bun run check` fails while
746
954
  `src/workflow/migrations.generated.ts` is stale.
747
955
 
956
+ `REFERENCE.md` is what `help()` returns: after editing it run
957
+ `bun run embed-reference` (`bun run check` fails while
958
+ `src/reference.generated.ts` is stale). `test/reference.test.ts` fails when a
959
+ public method of `Unfazed` is missing from it.
960
+
748
961
  ### Regenerating from the schema
749
962
 
750
963
  `schema/schema.graphqls` is a **vendored copy** of `graph/schema.graphqls` from
@@ -783,6 +996,14 @@ bun run check # typecheck + tests + build + dist smoke
783
996
  npm publish # or: bun publish --access public
784
997
  ```
785
998
 
999
+ Then bring the release to Code Mode: MCP `run_script` runs a copy of the SDK
1000
+ vendored in the unfazed repo, not npm's. There, `hack/update-script-sdk.sh
1001
+ <version>` vendors the published tarball (checked against npm's integrity),
1002
+ and a worker release ships it, since scripts run on workers (unfazed
1003
+ `docs/code-mode.md`, "Shipping a new SDK"). Its `script-sdk` check turns red
1004
+ within a day of a publish that was not vendored. A release that needs new
1005
+ platform fields goes out after the platform change that adds them.
1006
+
786
1007
  ## License
787
1008
 
788
1009
  MIT — see [LICENSE](./LICENSE).