@akanjs/cli 3.0.2 → 3.1.0-rc.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 (84) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-szbjv4gv.js → CodeAgent-q7a4srt6.js} +2 -2
  3. package/{CodeTui-5nw86ab6.js → CodeTui-ak23dxc9.js} +1 -1
  4. package/{TunnelShare-cwh9ezsm.js → TunnelShare-21mm6dr5.js} +2 -2
  5. package/{agent.command-np1dffkb.js → agent.command-f3gsc1gd.js} +5 -5
  6. package/akanCode.js +2 -2
  7. package/{application.command-gk69v1xs.js → application.command-3v2d53gd.js} +37 -15
  8. package/{applicationBuildRunner-wx09n9g6.js → applicationBuildRunner-1yaqsrdq.js} +115 -103
  9. package/buildBatch.proc.js +16 -5
  10. package/cloud.command-kk2rwkvw.js +187 -0
  11. package/cloud.script-r7eyfey4.js +20 -0
  12. package/{code.command-hmtx14b2.js → code.command-30sca09d.js} +6 -6
  13. package/commandManifest.json +1 -1
  14. package/{context.command-6aqvdx43.js → context.command-k7eqacq6.js} +13 -13
  15. package/context.runner-hqd1ppqe.js +21 -0
  16. package/{guideline.command-axvf68gc.js → guideline.command-dtczrqpf.js} +2 -2
  17. package/guidelines/agentRule/agentRule.instruction.md +10 -2
  18. package/guidelines/conventions/conventions.instruction.md +68 -25
  19. package/guidelines/cssRule/cssRule.instruction.md +19 -0
  20. package/guidelines/mcpRule/mcpRule.instruction.md +67 -12
  21. package/guidelines/queryRule/queryRule.instruction.md +11 -1
  22. package/guidelines/runtimeRule/runtimeRule.instruction.md +97 -16
  23. package/guidelines/ssrRule/ssrRule.instruction.md +10 -7
  24. package/guidelines/transportRule/transportRule.instruction.md +19 -0
  25. package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +6 -6
  26. package/incrementalBuilder.proc.js +24 -3
  27. package/{index-h9ht6s60.js → index-05g3jayg.js} +1 -1
  28. package/{index-j38qkvkk.js → index-0bm04n5g.js} +12 -10
  29. package/{index-g38nhv87.js → index-2etcv0sc.js} +1 -1
  30. package/{index-zyw622y1.js → index-2mx692qq.js} +3 -3
  31. package/{index-jb0hmt3k.js → index-2t4tqd1c.js} +3 -3
  32. package/{index-50wb2a26.js → index-55mynnqs.js} +354 -129
  33. package/{index-q6ft9fkc.js → index-6jjtnhkw.js} +8 -3
  34. package/index-6w0rbr9d.js +68 -0
  35. package/{index-2wc41k5w.js → index-7bymavqk.js} +2 -2
  36. package/{index-gtjgnv36.js → index-aprt7pkz.js} +6 -9
  37. package/{index-1bjkfvm9.js → index-c1x0be5j.js} +2 -2
  38. package/{index-z750gk1j.js → index-c2yrsxdh.js} +13 -7
  39. package/{index-cb6yc5mb.js → index-c76k723e.js} +2 -2
  40. package/{index-6keyf4sg.js → index-ctanya41.js} +4 -4
  41. package/{index-f4e08ptp.js → index-e0cvze1s.js} +4 -4
  42. package/{index-hp3stk1m.js → index-fm46xrf1.js} +3 -3
  43. package/{index-3xmzr1p1.js → index-gnyf1fqy.js} +2 -2
  44. package/{index-zwd84m0w.js → index-jyv387qw.js} +4 -4
  45. package/{index-jk9xnck6.js → index-kjvrq3nc.js} +231 -133
  46. package/{index-xwprn9nr.js → index-nz15k0b6.js} +1 -1
  47. package/{index-nwd6qzvv.js → index-p9ystjrp.js} +138 -55
  48. package/index-q958tqqg.js +77 -0
  49. package/{index-50jjwg8y.js → index-qt22xstj.js} +1 -1
  50. package/{index-94s080qm.js → index-r2tpeb3n.js} +127 -1
  51. package/{index-bsv901fd.js → index-rwq3ekqc.js} +7 -7
  52. package/{index-t91a7339.js → index-tn4rpqp1.js} +96 -12
  53. package/{index-1gs9g12n.js → index-v29746vb.js} +4 -4
  54. package/index-ww9bhp67.js +62 -0
  55. package/{index-hxnnfy57.js → index-y8q31011.js} +180 -8
  56. package/index.js +22 -21
  57. package/{library.command-9m2rdwwz.js → library.command-gaga5bmq.js} +3 -3
  58. package/{localRegistry.command-kda81jyk.js → localRegistry.command-9e84e32q.js} +9 -9
  59. package/{module.command-q63dffya.js → module.command-7vpf6db4.js} +5 -5
  60. package/{package.command-y2jh0m99.js → package.command-twmvvf69.js} +3 -3
  61. package/package.json +2 -2
  62. package/{page.command-f3e4cwhh.js → page.command-5asq7fdf.js} +3 -3
  63. package/pipeline.command-d0v38572.js +1334 -0
  64. package/{primitive.command-r4kq75qj.js → primitive.command-dyrs2fm8.js} +6 -6
  65. package/projectTypecheck-cvtkrzp2.js +7 -0
  66. package/{quality.command-f7gzh26z.js → quality.command-6emsqpbg.js} +5 -4
  67. package/{repair.command-0qmyr6qa.js → repair.command-8t6aegv5.js} +5 -5
  68. package/{scalar.command-kddn7av4.js → scalar.command-bnggy1x7.js} +4 -4
  69. package/{subspace.command-ft65qvc4.js → subspace.command-f02j5tge.js} +158 -91
  70. package/templates/appSample/page/task/[taskId]/_index.tsx +2 -2
  71. package/templates/appSample/page/task/[taskId]/edit.tsx +2 -2
  72. package/templates/appSample/page/task/_index.tsx +2 -2
  73. package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -2
  74. package/templates/crudPages/[__model__Id]/page.tsx +3 -3
  75. package/templates/crudPages/page.tsx +2 -2
  76. package/templates/crudSinglePage/page.tsx +3 -3
  77. package/templates/lib/cnst.ts +2 -2
  78. package/templates/lib/sig.ts +3 -3
  79. package/{tunnel.command-b3ymdk4f.js → tunnel.command-9x8nfn75.js} +16 -13
  80. package/{workflow.command-z1srcdfw.js → workflow.command-jx0wag5r.js} +10 -10
  81. package/{workspace.command-vp6vmx3z.js → workspace.command-cmj6mqyk.js} +57 -27
  82. package/cloud.command-tw06613r.js +0 -95
  83. package/cloud.script-64gzba8r.js +0 -20
  84. package/context.runner-hnkvv3z3.js +0 -21
@@ -62,6 +62,25 @@ Screen code is invariant — a plain `<Button variant="primary">` never changes.
62
62
  - Theme-following colors still belong to the app: a lib token is for a color that must **not** change with `data-theme`.
63
63
  - An `@import` the CSS pipeline cannot resolve fails the build; it is never dropped. A stylesheet under `page/` that no route imports is compiled by nothing and warns at build time.
64
64
 
65
+ ## Frame Variables (the page's chrome and the device's insets)
66
+
67
+ The frame writes these on `#frameRoot` and `<html>`, so any class can read them, web and native alike:
68
+
69
+ - `--akan-top-safe-area` / `--akan-bottom-safe-area` — the notch and the home indicator. On the web they are
70
+ `env(safe-area-inset-*)` where the route chain declares `safeArea` (`.config({ safeArea: true })` on the root
71
+ layout) and `0px` where it does not; in a native app they are the shell's inset. Use the variable alone: never
72
+ write `env(safe-area-inset-*)` beside it, and never subtract one from the other. The frame paints the bottom strip
73
+ only under bottom chrome (a `BottomInset` or `BottomTab`) or a route's own `bottomSafeAreaColor`; elsewhere a page's
74
+ `fixed bottom-0` bar paints its own safe-area padding.
75
+ - `--akan-top-inset` / `--akan-bottom-inset` — the height of the page's top bar and bottom tab slot, per breakpoint
76
+ when the page declares a responsive inset. A slot whose inset is 0 at a width clips its chrome there and hides it
77
+ from the keyboard and screen readers, so a phone-only bar needs no `md:hidden`.
78
+ - `--akan-keyboard-inset`, `--akan-viewport-height`, `--akan-viewport-offset-top` — the on-screen keyboard over the
79
+ page's bottom, the visible height, and how far iOS panned the page to show a field (the top slots follow it).
80
+ - `--akan-content-height` — the room between the insets. iOS pans under the keyboard instead of shrinking the page,
81
+ so a column sized by it leaves the panned-to area blank: keep a keyboard-heavy screen's input bar in flow at the end
82
+ of its column, or in the keyboard slot.
83
+
65
84
  ## Codegen Rules
66
85
  - Do not hardcode hex or raw-palette colors (`bg-red-500`, `#ff0000`); use semantic tokens.
67
86
  - Do not put inline color literals in `style={{ }}`; use token classes.
@@ -10,7 +10,8 @@ The always-loaded convention set carries the short version; this is the full con
10
10
  Every signal is served to AI agents as an MCP server on `POST /mcp`. **`/mcp` is mounted by default and exposure
11
11
  follows an endpoint's guards — there is no per-endpoint opt-in, and nothing to write in a signal file.** An endpoint
12
12
  that declares a real guard is published; one that declares none is refused, and so is a mutation whose only guard is
13
- `Public`. `AKAN_MCP=false` takes the whole surface off. The reasoning is that the guards are already the
13
+ `Public`. `AKAN_MCP=false` takes the whole surface off, and an edge server (`AKAN_PUBLIC_OPERATION_MODE=edge`) starts
14
+ with it off — `AKAN_MCP=true` or `setMcp(true)` puts it back. The reasoning is that the guards are already the
14
15
  authorization decision and `filterForAccount` re-reads them per caller on every listing, so a second per-endpoint
15
16
  switch says nothing the guards do not — while guaranteeing that every endpoint added later is invisible to agents
16
17
  until somebody remembers it.
@@ -38,6 +39,9 @@ export class FileSlice extends slice(srv.file, {
38
39
  `update`, `remove`), same fallbacks (`create`/`update`/`remove` inherit `cru`), and the same scope: it narrows
39
40
  the root slice and the generated CRUD, and never reaches a named slice or a custom endpoint. Those declare
40
41
  their own `mcp` beside their own `guards`. The bare `mcp: false` is every key at once, still within that scope.
42
+ - **A screen keeps reaching it.** To a host that renders MCP Apps views, an endpoint refused for `mcp: false` alone
43
+ is published with `visibility: ["app"]`: the view the page tool opens may call it, the model is never shown it
44
+ (below). Every other refusal still holds there.
41
45
  - **`mcp: true` is the default and is a no-op**; write it only to say out loud that an entry is deliberately on
42
46
  the shelf. It does not override a shape refusal (an `Any` return stays refused) and it cannot publish something
43
47
  the guard rules keep out.
@@ -50,13 +54,14 @@ export class FileSlice extends slice(srv.file, {
50
54
 
51
55
  ## Configuration
52
56
  Settings live in the app's `lib/option.ts` — `option.setMcp({ … })`, taking `enabled`, `readOnly`, `path`,
53
- `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`, and `auth`. **Not `main.ts`**: the gateway
57
+ `version`, `instructions`, `allowedOrigins`, `pageSize`, `language`, `legacyTextBlock`, `outputSchema`, `rateLimit`,
58
+ `promptBudget`, `apps`, `modern`, and `auth`. **Not `main.ts`**: the gateway
54
59
  there only spawns children, and `option.ts` is the app-authored file `server.ts` already hands to the process that
55
60
  mounts `/mcp`. Every lib's option is read in mount order with the app's last, so an app tightens what a library
56
61
  declared without restating it. Each field also has an env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`,
57
62
  `AKAN_MCP_PATH`, `AKAN_MCP_VERSION`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`, `AKAN_MCP_PAGE_SIZE`,
58
63
  `AKAN_MCP_LANGUAGE`, `AKAN_MCP_LEGACY_TEXT`, `AKAN_MCP_OUTPUT_SCHEMA`, `AKAN_MCP_RATE_LIMIT`, `AKAN_MCP_CONCURRENT`,
59
- `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
64
+ `AKAN_MCP_PROMPT_BUDGET`, `AKAN_MCP_APPS`, `AKAN_MCP_MODERN`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`) for a deployment that configures what the source does
60
65
  not, which the option overrides.
61
66
  The two booleans answer to `AKAN_PUBLIC_MCP` / `AKAN_PUBLIC_MCP_READONLY` too, the same pairing `AKAN_OPENAPI`
62
67
  has, and a value written in code wins over the env of the same name — an explicit `undefined` is not a value.
@@ -100,8 +105,8 @@ export class TaskEndpoint extends endpoint(srv.task, ({ mutation }) => ({
100
105
  export default page()
101
106
  .param("taskId", ID, { desc: "The task to review." })
102
107
  .prompt("reviewTask", "Review this task and suggest next steps.")
103
- .render(async ({ taskId }) => {
104
- const { taskView } = await fetch.viewTask(taskId);
108
+ .render(({ taskId }) => {
109
+ const { taskView } = fetch.viewTask(taskId);
105
110
  return <Task.Zone.View view={taskView} />;
106
111
  });
107
112
  ```
@@ -226,7 +231,9 @@ export default page()
226
231
  wire-identical over the POST-only surface this implements — a client whose proposal is not listed is told to
227
232
  disconnect. An unknown proposal is answered at whichever end of that list it is closer to, and an unimplemented
228
233
  method answers `404` to a modern client but `200` to a legacy one, whose era spends `404` on "your session is
229
- gone".
234
+ gone". **While MCP Apps renders views only the legacy pair is spoken**: `server/discover` answers `-32601` and
235
+ `initialize` settles on `2025-11-25`, because claude.ai renders a view only for a server it reached through
236
+ `initialize`. `modern: true` / `AKAN_MCP_MODERN=true` offers `2026-07-28` anyway; `false` withholds it everywhere.
230
237
  - **A modern-era request mirrors `MCP-Protocol-Version` and `Mcp-Method` into headers** (plus `Mcp-Name` when the
231
238
  body names one), and one that leaves a mirror out is refused just like one that contradicts the body: a gateway
232
239
  rule keyed on a header never fires for the request that omitted it. Legacy requests are not checked. Capabilities
@@ -354,6 +361,53 @@ in-page chat lists none of them: its `/` menu is its six built-in commands. Pick
354
361
  rule: a page taking no arguments is the most useful prompt, since MCP has no completion — one taking an id is for
355
362
  when the id is already in hand.
356
363
 
364
+ ## MCP Apps — The Page, Rendered In The Chat
365
+ **A `.prompt()` page is also a tool**, of the same name and arguments, marked `readOnlyHint`. Its result is the
366
+ prompt's briefing plus `Open this screen in a browser: <url>` — what a client without the
367
+ `io.modelcontextprotocol/ui` extension (Claude Code, Codex) reads. A host that declares the extension (claude.ai,
368
+ Claude Desktop, ChatGPT, VS Code, Goose) is also given `_meta.ui.resourceUri` (`ui://<app>/app`, or
369
+ `ui://<app>/app/<basePath>`) and renders the screen in place of the result: the app's CSR bundle, one HTML document,
370
+ in a sandboxed iframe started on the page the tool ran. Its first queries are answered from that run — carried in
371
+ the result's `_meta`, the one part no host shows the model — so the first paint costs no round trip; every later
372
+ call is relayed by the host as `tools/call` under the host's token. Nothing is declared for any of it.
373
+
374
+ - **What a view may call** is every published tool and every endpoint kept off the shelf by `mcp: false` alone,
375
+ listed with `visibility: ["app"]` — which a host offers to the view and never to the model. Visibility is a host's
376
+ UX, not authorization: the guards decide, as everywhere. A client that did not declare the extension is listed
377
+ neither those tools nor the `ui://` resources, a call to one answers `Unknown tool`, and a page tool's result
378
+ carries none of the view's data for it.
379
+ - **The capability is read per request.** A legacy client declares it once at `initialize` and is issued an
380
+ `Mcp-Session-Id` (`ui.<uuid>`) that stores nothing, since its id is the capability; with `modern: true` a modern
381
+ request carries it in `_meta`. A client that declares nothing is issued no session, exactly as before.
382
+ - **The view has no network.** `connect-src` stays closed; the app's own origin is opened for what a page loads by
383
+ URL — fonts (served with `Access-Control-Allow-Origin: *`) and stored images — and every root-relative URL the
384
+ page writes is pointed at it, since a host may block the document's `<base>` (Claude does).
385
+ Cookies and Web Storage are in-memory, history is in-memory, and the websocket, live sync, pubsub, the reconnect
386
+ overlay and `<Agent.Chat />` are off. A path link routes inside the view; any other link opens through the host.
387
+ - **The view looks like the host.** Its neutrals (`--background`, `--foreground`, `--muted`, `--border`, `--ring`) and
388
+ `--font-sans` / `--font-mono` follow the host's theme variables, its theme follows the host's, and so does its
389
+ language when the app speaks it; the app's brand colors and radii stay its own.
390
+ - **The host's model drives the screen too.** The view offers the host the screen's own tools — the surface
391
+ `<Agent.Chat />` reads: `st.tool`, the form setters, `fill<Model>Form`, `navigate` / `readScreen` / `readState` /
392
+ `highlight` — and, to a host that takes it, the screen's context (route, live state, `<Agent.Guide>`) through
393
+ `ui/update-model-context` whenever it moves. A tool that declares `confirm` asks the person in the view through
394
+ the `AgentApproval` slot, whatever the host asks on its own; a card tool, which a person answers in a chat, is not
395
+ offered.
396
+ - **A person tapping in the view acts with the agent's token**, so `isAgentCall` is true and a `Person` act is
397
+ refused there. That act belongs on the web page the result links to. The view holds no token: it is handed the
398
+ caller's claims, so `getSelf({ unauthorize })` in a layout renders the screen instead of a sign-in page, and each
399
+ call it makes is relayed and judged by the guards.
400
+ - **A view's call answers what a browser would get**, in `_meta` beside the masked result: the value with its
401
+ `visual` fields, or the domain `Err` with its key and data, toasted in the user's language. A refusal and a crash
402
+ stay generalized.
403
+ - **What a document load would do, the view does in place.** `router.refresh()` renders the route afresh, a
404
+ download (`<a download>`, a data table's export) goes to a host that saves files, and a full navigation off the
405
+ app shows a notice instead of a blank frame.
406
+ - **A page named like an endpoint stays a prompt only**, published or not, and the boot log says so: the endpoint
407
+ keeps its name. A refused screen answers as a tool error (`isError`), which the model reads.
408
+ - `option.setMcp({ apps: false })` / `AKAN_MCP_APPS=false` keeps every page a prompt only. `web: { csr: false }`
409
+ publishes the tool with no view, and an API-only build (`web: false`) has no pages to publish.
410
+
357
411
  ## Progress Reporting
358
412
  **`McpProgress.report(n, { total, message })`** reports progress from anywhere inside a call, a service or adapter
359
413
  frames down included, and is a no-op when nobody is streaming — so the same code runs unchanged over HTTP, a
@@ -421,8 +475,9 @@ the same guards.** What stays closed is closed because no agent has a reason to
421
475
 
422
476
  ## Writing `instructions`
423
477
 
424
- `*.abstract.md` and `<Agent.Guide>` never reach an MCP client. Exactly three texts do: `instructions`, every
425
- published entry's `.desc()`, and a page's `.prompt()` description. All three are English, whatever the users speak
478
+ `*.abstract.md` never reaches an MCP client, and `<Agent.Guide>` only through an MCP Apps view, beside the screen's own
479
+ tools it explains. Exactly three texts reach every client: `instructions`, every published entry's `.desc()`, and a
480
+ page's `.prompt()` description. All three are English, whatever the users speak
426
481
  — the reader is a model, and the catalogue is built once in one `language` and cached by clients.
427
482
 
428
483
  - **`instructions` is the workflow.** Five to eight sentences, in this order: what the app is and what its nouns
@@ -453,10 +508,10 @@ published entry's `.desc()`, and a page's `.prompt()` description. All three are
453
508
  - The lib's own suites run wherever it is mounted: the OAuth signal test (metadata, registration, both `authorize`
454
509
  redirects, code exchange, `/mcp` with the minted token and the refusal of a first-party or forged one, refresh
455
510
  rotation) and the refresh-session and option unit tests.
456
- - **One exposure test per app**, in a `lib/<model>.signal.test.ts`: list `tools/list` with a user token and pin the
457
- names with `toEqual`, and assert the curated and admin-only ones with `not.toContain`. Exposure is derived from
458
- the guards, so a guard change fails this test first — which is the point. The framework's own pattern is
459
- `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
511
+ - **One exposure test per app**, in `srvkit/mcpExposure.signal.test.ts`, since it spans every module: list
512
+ `tools/list` with a user token and pin the names with `toEqual`, and assert the curated and admin-only ones with
513
+ `not.toContain`. Exposure is derived from the guards, so a guard change fails this test first — which is the point.
514
+ The framework's own pattern is `pkgs/akanjs/server/mcp/mcp.integration.test.ts`.
460
515
  - Live: connect a client, consent, call a tool, `claude mcp logout`, authenticate again. `AKAN_PUBLIC_LOG_LEVEL=debug`
461
516
  prints one `OAuth token request grant_type=… client_id=…` line per token call; two refreshes in the same second
462
517
  both answering 200 is the grace window working, not a fault.
@@ -65,9 +65,15 @@ with it. `conventions` carries the invariants — this is the full contract behi
65
65
  - `removeOne` / `updateOne` hit the **newest** match — the subquery they compile to is ordered `createdAt` descending
66
66
  and there is no way to change that. They also report only counts, never which row they touched, so they are for
67
67
  "there is at most one of these", not for claiming the next item off a queue. Pass a query that matches one row.
68
+ The query is a compare-and-set in every mode: of two concurrent `updateOne({ id, balance: { $gte: n } }, …)` that
69
+ both read the row as matching, only one applies, so `modifiedCount` is the verdict.
68
70
  - **A filter may not be keyed after its own model.** Filter methods are assigned after CRUD, so a filter `chat` on
69
71
  model `chat` would silently swap the single-document `removeChat`/`updateChat` for a hookless query-level one. It
70
72
  throws at boot instead (`assertFilterFitsCrud`).
73
+ - **An insight field counts or sums.** `accumulate: {}` counts the rows the query matches and `accumulate: { status:
74
+ "active" }` those that also match the condition; `accumulate: { $sum: "totalValue", status: "active" }` adds the
75
+ field up over the same rows (`field(Float, { default: 0, accumulate: … })`), a stored value that is not a number
76
+ counting as nothing — the same answer on SQLite and Postgres.
71
77
  - **"Has no value" is `q.empty`, never `q.missing`.** The three absence operators are distinct: `q.exists` is the
72
78
  key being present, `q.missing` is the key being *absent from the stored JSON*, and `q.empty` is either — absent,
73
79
  or present and null. An optional field is written both ways over its life: an insert that omits it leaves the key
@@ -119,7 +125,7 @@ with it. `conventions` carries the invariants — this is the full contract behi
119
125
  - Declaring roles is all the wiring there is. Mirror rows are maintained by SQL triggers — not document hooks —
120
126
  because `updateOneByQuery` and friends fire no hooks, and most searchable-field mutations go through exactly that
121
127
  path.
122
- - Search runs in every database mode: fts5 on SQLite/libSQL, a weighted `tsvector` under a GIN index on Postgres.
128
+ - Search runs in every database mode: fts5 on SQLite, a weighted `tsvector` under a GIN index on Postgres.
123
129
  For the same text the same documents match — punctuation splits words the way unicode61 does, and a phrase never
124
130
  runs from one column into the next — but the order may differ, since Postgres ranks without document frequency.
125
131
  Assert on the match set, never on a Postgres order the weights alone do not decide.
@@ -185,6 +191,10 @@ shape, so `cascade` never means "related" — it means one of exactly these:
185
191
  unmounted `refPath` candidate only warns, since that list spans optional modules by design.
186
192
  - **Nothing checks whether another document still references the same target.** `File` in particular is deduped
187
193
  by `origin`, so two parents can share one row; `removeRef` claims the field owns its target exclusively.
194
+ - **A reference no cascade removes keeps pointing at the removed row, and a response reads it live-only**: a list
195
+ leaves the target out, an optional field reads `null`, and a required one answers 404
196
+ `base.error.relationNotFound` naming the model and the field path. Make a reference whose target can go first
197
+ `.optional()`, or let it go with the target through `removeWith`.
188
198
  - Removal is soft (`removedAt`), but the storage delete a `_postRemove` performs is not — a cascade is not
189
199
  restorable, and reviving the owner does not revive what went with it.
190
200
  - A `removeWith` declaration **auto-creates its index** (`{ removedAt, fk }`, or `{ removedAt, typeKey, fk }` when
@@ -1,4 +1,4 @@
1
- # Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets
1
+ # Runtime Rule — Serving, Processes, Logging, Image, Native Apps, Desktop Server, Assets, Build Pipeline
2
2
 
3
3
  How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
4
4
  its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
@@ -255,31 +255,62 @@ const config: AppConfig = {
255
255
  other CPU of that OS: the Rust library, Bun's executable, the server's `bun install --cpu` and each `bin` file
256
256
  follow it, and a server addon with no binary for that CPU stops the build. A macOS app is Apple silicon only —
257
257
  Intel Macs are not a target, so `--arch x64` on macOS is refused.
258
- - **A macOS release for download is signed with a Developer ID and notarized, from the environment.**
258
+ - **A release is signed only with `--sign` and notarized only with `--notarize`** (`build-desktop`,
259
+ `publish-update`); the environment names the identity and never decides to use it, so a shell exporting a Developer
260
+ ID signs and uploads nothing unasked. Before either, the server the app carries is searched for every secret value
261
+ of the workspace's server env files and refused with the paths, and one line names the identity and the service
262
+ the app goes to.
263
+ - **A macOS release for download is signed with a Developer ID and notarized.**
259
264
  `AKAN_NATIVE_MACOS_IDENTITY` names a keychain identity, or `AKAN_NATIVE_MACOS_CERTIFICATE` +
260
265
  `_CERTIFICATE_PASSWORD` a `.p12` the build imports into a keychain of its own (a CI runner);
261
- `AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App Store Connect API key) or `_NOTARY_PROFILE` turn on
262
- notarization and stapling of the app and the dmg. Every Mach-O file — the server's addons and `bin` included — is
266
+ `AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App Store Connect API key) or `_NOTARY_PROFILE` are the
267
+ credentials `--notarize` staples the app and the dmg with. Every Mach-O file — the server's addons and `bin` included — is
263
268
  signed inside out with the hardened runtime and a timestamp; the executable gets Bun's JIT entitlements and the
264
269
  camera's or microphone's when a usage text asks for them, and `desktop.entitlements` adds the app's own.
265
- `publish-update` signs with the same identity, since the updater checks the installed app's signature. Without a
270
+ `publish-update --sign` signs with the same identity, since the updater checks the installed app's signature. Without a
266
271
  Developer ID the build warns, because Gatekeeper blocks a downloaded copy.
267
- - **A Windows release is signed with Authenticode, from the environment.** `AKAN_NATIVE_WINDOWS_CERTIFICATE` +
272
+ - **A Windows release is signed with Authenticode.** `AKAN_NATIVE_WINDOWS_CERTIFICATE` +
268
273
  `_CERTIFICATE_PASSWORD` (a `.pfx`), `AKAN_NATIVE_WINDOWS_THUMBPRINT` (a certificate in the store, a token's or an
269
274
  HSM's), or `AKAN_NATIVE_WINDOWS_SIGN_COMMAND` (a JSON array run per file with `{file}`, e.g. signtool with Azure
270
275
  Trusted Signing's dlib) signs every PE file of the app — the server's `.node` and `bin` included — the setup program
271
276
  and the uninstaller it writes, SHA-256 with a timestamp (`AKAN_NATIVE_WINDOWS_TIMESTAMP_URL`).
272
- - **`updates: { url, publicKey }` lets an installed app update itself** — the whole app on a desktop, the web
277
+ - **`updates: { url, publicKeys }` lets an installed app update itself** — the whole app on a desktop, the web
273
278
  bundle on a phone. `akan update-keygen <app>` makes the signing key once
274
- (`~/.akan/native/keys/<app id>.update.key`, or the path `AKAN_NATIVE_UPDATE_KEY` names) and prints the
275
- `publicKey`. `akan publish-update <app>` builds and signs a release into `updates/`: upload that folder to `url`,
276
- the manifests last. `akan pack-update` writes an unsigned phone bundle for whoever holds the key to sign. The
277
- channel is the backend env the binary was built for unless `channel` names one, and a release that does not
278
- come up is rolled back to the one before.
279
+ (`~/.akan/native/keys/<app id>.update.key`, or the path `AKAN_NATIVE_UPDATE_KEY` names) and prints its public
280
+ key. List the online signing key first and offline spares after it (up to three): a phone binary trusts all of
281
+ them, and a key may revoke only the keys listed before it, so a leaked key cannot lock out its spares. A desktop
282
+ app trusts the first; `publicKey` is the one-key form. `akan publish-update <app>` builds and signs a release
283
+ into `updates/`: upload that folder to `url`, the manifests last, or let `--upload s3://<bucket>/<prefix>` do it
284
+ (credentials and endpoint from `S3_*` or `AWS_*`; a release file the bucket holds is skipped). A phone reads only
285
+ `<url>/<platform>/<channel>/<nativeApi>.json`, so each store generation on a channel takes its own fixes.
286
+ `akan pack-update` writes an unsigned phone bundle for whoever holds the key to sign and records it in
287
+ `<out>/pack.json`, exiting 2 when `--against` says it needs a new store build; `akan push-update` packs and uploads to
288
+ an akasys project that holds the key, and `akan register-binary` registers a store build after `release-*`. A
289
+ release that hangs is rolled back while the app runs, and `report: { url }` has a phone post what became of each
290
+ release (applied, confirmed, rolled back and why) at its next launch, so rollback rates are visible. `devtools: true`
291
+ opens the web inspector of a release build for an internal test target. The channel is the backend env the binary was built for unless `channel` names one;
292
+ on a desktop, `updates.setChannel({ channel })` moves the installed app to another manifest until set back with
293
+ `null` — a server putting a device on a pilot channel — and never to a release older than the one running. A
294
+ release that does not come up is rolled back to the one before.
279
295
  - **An unattended screen** — signage, a kiosk — takes `desktop.recovery: "reload"` (a crashed page loads again,
280
- and the app relaunches when the webview ends), `desktop.window: { fullscreen, skipTaskbar }`,
296
+ and the app relaunches when the webview ends), `desktop.window: { fullscreen, skipTaskbar, frameless }`,
281
297
  `desktop.screenCapture: "auto"` (Windows answers `getDisplayMedia()` with the first screen, no picker) and
282
- `android.autoplay` (media plays with sound without a tap).
298
+ `android.autoplay` (media plays with sound without a tap). Its carried server takes `desktop.server: { restart:
299
+ "forever", watchdog: true }` — restarted after every crash, never given up on, and killed and restarted when its
300
+ health route stops answering (every 10 s, 3 misses; `{ intervalMs, failures }` to tune) — and
301
+ `desktop.webview2: "standalone"` puts the whole WebView2 Runtime (~200 MB) in the Windows setup for a PC offline at
302
+ install. `desktop.server.env` adds variables to the server's start; the build refuses a secret-named one and those
303
+ the stage owns. The `window` plugin places it: `placeOnDisplay({ index, bounds, unit: "physical" })` puts a window
304
+ on one display in that display's device pixels — a 640×128 LED panel — lowering the minimum size to fit, beside
305
+ `setAlwaysOnTop`, `setCursorVisible` (which hides the pointer over its external views too, on Windows) and
306
+ `setDecorations`. `native.backgroundColor` (or `{ light, dark }`) is what a window and an external view show before
307
+ their page paints, so a dark board never flashes white, and `desktop.publisher` names who publishes a Windows app in
308
+ Apps & features and its executable's company (the app name when unset, never Bun's own).
309
+ - **A third-party page shown first-party** — a scoreboard, a menu board — is the `external-view` plugin, listed in
310
+ `native.plugins`: a webview over the window with a browser profile of its own, held to the origins it was opened
311
+ for, with an init script, `evaluate`, a trusted `sendKey`, `capture`, and `loadFailed` / `gone` (it reloads, or is
312
+ rebuilt, by itself) / `consoleMessage` events. Every method on Windows; macOS 14+ lacks `sendKey`, `setMuted` and
313
+ `capture`.
283
314
 
284
315
  ## A Desktop App's Server — `bin` And `trustedDependencies`
285
316
 
@@ -340,7 +371,8 @@ const config: AppConfig = {
340
371
  - **An executable the desktop app spawns goes in `bin`** — its server's code or its native plugins, whether or not
341
372
  it carries a server — keyed by the name the code spawns and then by
342
373
  `${process.platform}-${process.arch}` (`darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win32-arm64`,
343
- `win32-x64`). A source is `{ url, sha256, file? }` — downloaded when the app is built, refused unless it hashes
374
+ `win32-x64`) — Node's names, unlike an update feed's folder, which is `<os>-<arch>` with the OS spelled `macos`,
375
+ `windows` or `linux` (`windows-x64/<channel>.json`). A source is `{ url, sha256, file? }` — downloaded when the app is built, refused unless it hashes
344
376
  to `sha256` (so plain http is as safe as https), kept in `apps/<app>/.akan/cache/bin/<sha256>` — or
345
377
  `{ path, file? }`, relative to the `akan.config.ts` that declares it. `file` is the executable inside an archive
346
378
  (`.zip`, `.tar.gz`, `.tgz`, `.tar.xz`, `.tar.bz2`, `.tar`), unpacked with the OS's own `tar` (`unzip` for a zip
@@ -378,6 +410,18 @@ was still starting — the server sees its parent gone and ends the group itself
378
410
  later for whatever ignored it. On Windows the job object ends them all with the app. A process started `detached`
379
411
  leaves the group, or the job, and keeps running.
380
412
 
413
+ **An edge server carries no cloud secret.** The carried server runs `operationMode` edge, and the same app's cloud
414
+ deployment runs cloud: a module whose `serve(ref, { operationMode: ["edge"] }, …)` lists the modes it belongs to mounts
415
+ in those alone, and `modules: ["@edge"]` (or `AKAN_MODULES=@edge`) names every module that lists edge, with what they
416
+ inject, cascade to and hold a relation to. `desktop: { server: { profile: "edge", modules: ["@edge"] } }` builds the
417
+ carried server that way: `env/env.server.<env>.edge.ts` is bundled in place of `env/env.server.<env>.ts`, which never
418
+ enters it, and the build evaluates the profile file with no app env and refuses one whose secret-named keys hold a
419
+ value — a `process.env.X ?? "literal"` counts as the literal. Each lib's env defaults (`env.server.testing.ts`) are
420
+ swapped the same way: for the lib's `env/env.server.testing.edge.ts`, held to the same check, or for the defaults with
421
+ every secret-named value left out, which the build names in a warning. `akan build <app> --profile edge` does the
422
+ same for an image. A module listing only edge does not mount under `akan start` (local): list `local` too to develop
423
+ it there.
424
+
381
425
  **A file the user picks reaches the server as a grant, never as a copy or a path.** With `"file-picker"` in
382
426
  `native.plugins`, `filePicker.pickFiles({ forServer: true })` (also `pickDirectory` and `saveFile`,
383
427
  from `akanjs/client/native`) copies nothing, whatever the size: its FileRefs serve the originals for a preview, and
@@ -417,7 +461,7 @@ lacks is added there: an app's own plugin runs as Bun code in the plugin host an
417
461
  `single` and a cloud cluster in `cluster`: declare both.
418
462
  - **Where the data lives is the deployment's env, ahead of `env.server.ts`**: `SQLITE_DATABASE_PATH`,
419
463
  `AKAN_SOLID_DB_PATH`, `POSTGRES_URL` (or `POSTGRES_HOST`/`PORT`/`DATABASE`/`USER`/`PASSWORD`),
420
- `POSTGRES_INSIGHT_URL`, `LIBSQL_URL`. `REDIS_URI` is required outside local development. Pool size, SSL and
464
+ `POSTGRES_INSIGHT_URL`. `REDIS_URI` is required outside local development. Pool size, SSL and
421
465
  prepared statements ride the Postgres URL (`?max=20&ssl=require`, `prepare=false` behind PgBouncer).
422
466
  - **Uploads**: a deployed `multiple`/`cluster` app uses object storage, or a volume every instance mounts with
423
467
  `AKAN_STORAGE_SHARED=true`.
@@ -508,3 +552,40 @@ const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve,
508
552
  - The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
509
553
  the phase finds nothing to do.
510
554
 
555
+ ## The Build Pipeline — `pipeline` In `akan.config.ts`
556
+
557
+ Akan Cloud builds and deploys each app as `build` → `docker` → `deploy`, and `ota` after them when asked. **An app's own stages go in
558
+ `apps/<app>/akan.config.ts` under `pipeline`; stages around every app go in the workspace root's
559
+ `akan.config.ts`**, typed `WorkspaceConfig` (`before`, `after`, `post`, `env`, `timeout`). Both are `import type`
560
+ objects (or `(ctx) => …`), and `akan pipeline dry-run | validate | plan` turns them into the plan Akan Cloud runs —
561
+ locally, running nothing.
562
+
563
+ ```ts
564
+ const config: AppConfig = {
565
+ pipeline: { stages: [{ use: "build" }, { name: "Test", sh: "bun run akan test web" }, { use: "docker" }, { use: "deploy" }] },
566
+ };
567
+ const config: WorkspaceConfig = {
568
+ pipeline: { post: { failure: [{ name: "Notify", sh: "./scripts/notify.sh", secrets: ["DISCORD_WEBHOOK"] }] } },
569
+ };
570
+ ```
571
+
572
+ - **A declared list is the whole pipeline.** Without `pipeline` an app runs the three actions; with it, write them
573
+ — a list without `{ use: "deploy" }` never deploys. Each action appears at most once, in that order, and
574
+ `docker` needs a `build` before it (the image is made from that build).
575
+ - **`{ use: "ota" }` ships the native web bundle over the air**, after `deploy` so the API it calls is already up.
576
+ It is opt-in, makes its own CSR build, and packs every native target that declares `native.updates` for iOS
577
+ and Android — `targets` and `platforms` narrow it, and a target without `updates` is a plan error. Akan Cloud
578
+ signs and publishes the packs; the plan never holds a key.
579
+ - **A stage runs after the action before it.** Stages between two actions share a phase across apps, and a phase
580
+ waits for every stage of the one before; `deploy` runs the apps one at a time in `after` order.
581
+ - **App stages stand on their own.** Never rely on what a root `before` stage left on disk: the root config never
582
+ travels to a subspace, so an app built there runs its own stages only. Keep the files an app stage needs inside
583
+ the app or its libs.
584
+ - **Secrets are named and write-only.** `akan upload-secret <NAME>` stores one per project (`--branch` for a
585
+ branch-only value); a step lists it in `secrets` and reads `$NAME`. Anyone who can push to the repo can make a
586
+ step print it — the GitHub Actions model — so push access is secret access.
587
+ - **`allowFailure` only means "this failure does not fail the run"**; how the run is reported is Akan Cloud's
588
+ call. `env` and secret names may not be `CI`, `AKAN_CI_*` or shell names such as `PATH`, and every step gets
589
+ `AKAN_CI_BRANCH`, `AKAN_CI_APP` and the rest of the build's facts.
590
+ - `akan pipeline` exits 0 with a plan, 1 with an envelope of errors, and 2 for anything else (no envelope). A lib
591
+ config takes no `pipeline`; the root config takes no app or lib settings.
@@ -125,21 +125,24 @@ Three rules the shape enforces:
125
125
  **A failure now surfaces where it happened.** One `Promise.all` in the page body reports nothing about which of
126
126
  eight queries threw; per-section boundaries put each rejection at its own boundary.
127
127
 
128
- **Keep the auth gate in the shell.** A redirect thrown after the shell has flushed can only be delivered as a
129
- soft-redirect script, so `getSelf({ unauthorize: "/signin" })` stays in `_layout.tsx` or the page body — never
130
- inside a streamed section.
128
+ **Know which redirects are real.** A streaming response starts while layout and page bodies still run, so a redirect
129
+ is a real 3xx only when it was raised before any I/O was awaited, the body's own or a layout's above it (a layout that
130
+ awaits `fetch.viewOrg` makes every page under it late); after one — `getSelf({ unauthorize: "/signin" })` fetches —
131
+ it reaches the browser as an HTTP 200 that navigates in the page, which a crawler or `curl` does not
132
+ follow. Either way no markup below the redirect is sent. A route that must answer a real 3xx after I/O — a gate an
133
+ external client hits, a moved URL — takes `.config({ ssr: "block" })`: the response waits for the whole render.
134
+ Never redirect from inside a streamed section.
131
135
 
132
136
  **⑥ Use named `ReactNode` slots, not just `children`.** A client shell that takes one slot per region lets a
133
137
  server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
134
- declares `title` / `left` / `right` alongside `back` and `children`, but its render body uses only `back` and
135
- `children` — copy the shape, not that component.
138
+ is the shape: `title` / `left` / `right` beside `back` and `children`, each filled by the server.
136
139
 
137
140
  **⑦ Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>` (`isNew()`,
138
141
  `canWrite(user?)`, `formatTimes()`), and enum→class lookups belong in a module-scope `as const` map. Both sides call
139
142
  the same method, so a client component that exists only to compute a label is markup in the wrong place.
140
143
 
141
- **⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any HTML is
142
- sent. A client-side check costs a hydration round-trip and flashes the wrong UI first.
144
+ **⑧ Gate auth on the server.** `getSelf({ unauthorize: "/signin" })` in `_layout.tsx` redirects before any of the
145
+ page's markup is rendered. A client-side check costs a hydration round-trip and flashes the wrong UI first.
143
146
 
144
147
  **⑨ Prefer CSS over client state for pure visibility.** A `data-*` attribute plus `group-data-[…]` variants (see
145
148
  `libs/util/ui/Grid/*`) or `<details>`/`<summary>` keeps both branches server-rendered. Reach for `useState` when the
@@ -21,8 +21,27 @@ mutation is mounted under. `conventions` carries the invariants — this is the
21
21
  `null` rather than a placeholder when genuinely unknown — a loopback-looking address for an unknown caller
22
22
  is the failure this replaced. The gateway also forwards the client port (`x-forwarded-port`, read with
23
23
  `context.getClientPort()`) along with host and protocol.
24
+ - **A device or another server proves itself with a machine token.** `MachineToken.issue({ kind, id, version },
25
+ { expiresAt? })` in `akanjs/server` signs an EdDSA JWT (`typ: akan-machine+jwt`, `aud: <app>/<environment>`) with
26
+ `AKAN_MACHINE_PRIVATE_KEY`; it never expires unless asked. `akan machine-keygen <file>` writes that key to a file
27
+ and prints only the public one. A server with either key verifies the bearer of every call — HTTP, and the
28
+ websocket's auth frame — after every lib middleware, and sets `account.machine = { kind, id, version }` from a
29
+ verified token; it takes off a `machine` any other middleware or a session's claims put there, so a staff token
30
+ can never pass for one, and a session middleware already reads the EdDSA token as anonymous. `.with(Machine)` hands
31
+ the identity (401 without one; `{ nullable: true }` takes `null`), and a pubsub room's guards see it on subscribe
32
+ and on every credential change. Revocation is the app's: a guard compares `machine.version` with the record's
33
+ `tokenVersion`, which a removal or a reinstall bumps. An endpoint taking `.with(Machine)` on a server with no key
34
+ is an error at boot. The calling side is `fetch.clone({ origin, jwt: token, connect: true })`.
24
35
  - **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
25
36
 
37
+ - **A socket that dropped comes back on a backoff, and asks for what it missed.** The client waits 1 s doubling to
38
+ 30 s, cut at random so a deploy's dropped clients return apart; `fetch.clone({ ws: { reconnect: { min, max, jitter,
39
+ maxAttempts } | false, headers, tls, onStateChange, onRevoked, onError } })` (or `fetch.instance.configureWs(…)`)
40
+ tunes it, and a server-side client sends `headers` and its token with the upgrade. A server numbers the JSON frames
41
+ each room delivers (`seq`); a client drops a repeat, and a hole — a frame dropped under backpressure — calls the
42
+ room's `onResync`. A resubscribe names its last frame (`since`): a server that saw every publish (a solo process)
43
+ replays what followed it ahead of the ack, and otherwise answers a gap, which calls `onResync` as before. Binary
44
+ rooms are not numbered.
26
45
  - **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it.**
27
46
  From a `pubsub` subscribe it belongs to that room — `unsubscribe` runs when the client leaves it or a credential
28
47
  change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
@@ -187,8 +187,8 @@ export class TaskFilter extends from(cnst.Task, (filter) => ({
187
187
  }))
188
188
 
189
189
  // 4. In page — init the slice inside the page chain's render and hand the init to a Zone.
190
- export default page().render(async () => {
191
- const [{ taskInitInTodo }] = await Promise.all([fetch.initTaskInTodo()]);
190
+ export default page().render(() => {
191
+ const { taskInitInTodo } = fetch.initTaskInTodo();
192
192
  return <Task.Zone.Card init={taskInitInTodo} sliceName="taskInTodo" />;
193
193
  });
194
194
  ```
@@ -349,8 +349,8 @@ export function generateHead({ params }: PageProps) {
349
349
  }
350
350
  export default async function Page({ params: { projectId }, searchParams }: PageProps) {
351
351
  const { l } = usePage();
352
- const { project } = await fetch.viewProject(projectId);
353
- return <Project.Zone.View view={project} tab={searchParams.tab} />;
352
+ const { projectView } = await fetch.viewProject(projectId);
353
+ return <Project.Zone.View view={projectView} tab={searchParams.tab} />;
354
354
  }
355
355
  export const pageConfig = { transition: "stack" } satisfies PageConfig;
356
356
 
@@ -367,8 +367,8 @@ export default page()
367
367
  .prompt("briefProject", "Brief one project: its status and what is due.")
368
368
  .render(async ({ projectId, tab }) => {
369
369
  const { l } = usePage();
370
- const { project } = await fetch.viewProject(projectId);
371
- return <Project.Zone.View view={project} tab={tab} />;
370
+ const { projectView } = await fetch.viewProject(projectId);
371
+ return <Project.Zone.View view={projectView} tab={tab} />;
372
372
  });
373
373
  ```
374
374
 
@@ -8,7 +8,7 @@ import {
8
8
  CodegenLock,
9
9
  WorkspaceExecutor,
10
10
  AppExecutor
11
- } from "./index-50wb2a26.js";
11
+ } from "./index-55mynnqs.js";
12
12
  import"./index-52w0fvh8.js";
13
13
  import {
14
14
  GraphClientEntryDiscovery,
@@ -20,7 +20,7 @@ import {
20
20
  DevChangePlanner,
21
21
  DevGeneratedIndexSync,
22
22
  SsrDevBundler
23
- } from "./index-gtjgnv36.js";
23
+ } from "./index-aprt7pkz.js";
24
24
  import {
25
25
  prepareDevWatchBatch
26
26
  } from "./index-a5vmm6x4.js";
@@ -235,6 +235,7 @@ class IncrementalBuilder {
235
235
  #autoImportSync;
236
236
  #watcher = null;
237
237
  #generation = 0;
238
+ #shellGeneration = null;
238
239
  #discoveredThrough = 0;
239
240
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
240
241
  #csrBundler;
@@ -852,7 +853,7 @@ class IncrementalBuilder {
852
853
  if (crashedNeeds.includes("ssr"))
853
854
  this.#ssrPatcher?.releaseHandedOver();
854
855
  for (const need of crashedNeeds)
855
- if (need !== "base")
856
+ if (need !== "base" && need !== "shell")
856
857
  this.#sendBuildStatus(need, {
857
858
  generation,
858
859
  ok: false,
@@ -916,6 +917,10 @@ class IncrementalBuilder {
916
917
  this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
917
918
  }
918
919
  async handleBuildCsr(msg) {
920
+ if (msg.shell) {
921
+ await this.#buildShell(msg);
922
+ return;
923
+ }
919
924
  this.#beginCsrArm();
920
925
  this.#csrActive = true;
921
926
  const armed = this.#workQueue.enqueue("build-csr", async () => {
@@ -940,6 +945,22 @@ class IncrementalBuilder {
940
945
  this.#csrGate = this.#armingCsr(armed);
941
946
  await armed;
942
947
  }
948
+ async#buildShell(msg) {
949
+ await this.#workQueue.enqueue("build-shell", async () => {
950
+ const generation = this.#generation;
951
+ let error;
952
+ if (this.#shellGeneration !== generation)
953
+ try {
954
+ const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }));
955
+ error = result.errors.shell;
956
+ if (!error)
957
+ this.#shellGeneration = generation;
958
+ } catch (err) {
959
+ error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
960
+ }
961
+ await BuilderChannel.send(error ? { type: "build-csr-res", id: msg.id, ok: false, error } : { type: "build-csr-res", id: msg.id, ok: true });
962
+ });
963
+ }
943
964
  #beginCsrArm() {
944
965
  if (this.#csrArms === 0) {
945
966
  this.#csrWasActive = this.#csrActive;
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  AppExecutor,
4
4
  LibExecutor
5
- } from "./index-50wb2a26.js";
5
+ } from "./index-55mynnqs.js";
6
6
 
7
7
  // pkgs/@akanjs/devkit/workflow/utils.ts
8
8
  var jsonText = (value, { trailingNewline = true } = {}) => `${JSON.stringify(value, null, 2)}${trailingNewline ? `
@@ -1,13 +1,13 @@
1
1
  // @bun
2
2
  import {
3
3
  AppExecutor
4
- } from "./index-50wb2a26.js";
4
+ } from "./index-55mynnqs.js";
5
5
  import {
6
6
  jsonText,
7
7
  createWorkflowBaselineSummary,
8
8
  buildAkanModuleContextIndex,
9
9
  toolingRolloutGate
10
- } from "./index-h9ht6s60.js";
10
+ } from "./index-05g3jayg.js";
11
11
  import {
12
12
  buildResourceList,
13
13
  codexMcpConfigPath,
@@ -17,25 +17,27 @@ import {
17
17
  upsertCodexMcpServerBlock,
18
18
  renderDoctorText,
19
19
  AkanContextAnalyzer
20
- } from "./index-zyw622y1.js";
20
+ } from "./index-2mx692qq.js";
21
21
  import {
22
22
  runner
23
- } from "./index-q6ft9fkc.js";
23
+ } from "./index-6jjtnhkw.js";
24
24
  import {
25
- getMobileTargets,
26
- appIdsOf,
27
25
  NativeApp,
28
26
  isPlaceholderAppId
29
- } from "./index-jk9xnck6.js";
27
+ } from "./index-kjvrq3nc.js";
30
28
  import {
31
29
  Prompter2
32
30
  } from "./index-zzvvc845.js";
33
31
  import {
34
32
  RepairRunner
35
- } from "./index-hp3stk1m.js";
33
+ } from "./index-fm46xrf1.js";
36
34
  import {
37
35
  WorkflowRunner
38
- } from "./index-bsv901fd.js";
36
+ } from "./index-rwq3ekqc.js";
37
+ import {
38
+ getMobileTargets,
39
+ appIdsOf
40
+ } from "./index-q958tqqg.js";
39
41
 
40
42
  // pkgs/@akanjs/devkit/akanMcpContract.ts
41
43
  import path from "path";
@@ -947,7 +949,7 @@ ${payload}`);
947
949
  "add-enum-field": "`akan add-enum-field --app <app-or-lib> --module <module> --field <field> --values a,b --format json` adds an enum field to source constant/dictionary files without editing generated files.",
948
950
  sync: "`akan sync <app-or-lib>` refreshes generated Akan files from source conventions.",
949
951
  lint: "`akan lint <app-or-lib-or-pkg>` runs Biome linting after preparing generated files.",
950
- typecheck: "`akan typecheck <app-name>` runs an application typecheck after preparing generated files.",
952
+ typecheck: "`akan typecheck <app-or-lib>` typechecks an app or a library after preparing generated files, its tests and specs included (`--tests false` to leave them out; a build does).",
951
953
  test: "`akan test <app-or-lib-or-pkg>` prepares the target and runs its tests.",
952
954
  build: "`akan build <app-name>` creates a production build after preparing generated files.",
953
955
  doctor: "`akan doctor --strict --format json` reports agent-readable workspace convention diagnostics and validation hints.",
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  runner,
4
4
  script
5
- } from "./index-q6ft9fkc.js";
5
+ } from "./index-6jjtnhkw.js";
6
6
 
7
7
  // pkgs/@akanjs/cli/page/page.runner.ts
8
8
  class PageRunner extends runner("page") {