@akanjs/cli 3.0.2 → 3.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.build-stamp +1 -1
  2. package/{CodeAgent-szbjv4gv.js → CodeAgent-kyprmy06.js} +2 -2
  3. package/{CodeTui-5nw86ab6.js → CodeTui-21mtze3j.js} +1 -1
  4. package/{TunnelShare-cwh9ezsm.js → TunnelShare-q4jq08qg.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-5s780vnz.js} +37 -15
  8. package/{applicationBuildRunner-wx09n9g6.js → applicationBuildRunner-1yaqsrdq.js} +115 -103
  9. package/buildBatch.proc.js +16 -5
  10. package/cloud.command-yj6xb2a7.js +192 -0
  11. package/cloud.script-h6pvcpa2.js +20 -0
  12. package/{code.command-hmtx14b2.js → code.command-3wkn4ja5.js} +6 -6
  13. package/commandManifest.json +1 -1
  14. package/{context.command-6aqvdx43.js → context.command-9jjn3gcw.js} +13 -13
  15. package/context.runner-atcrzz5b.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 +70 -25
  19. package/guidelines/cssRule/cssRule.instruction.md +19 -0
  20. package/guidelines/mcpRule/mcpRule.instruction.md +72 -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-jk9xnck6.js → index-1w2qaee2.js} +240 -133
  29. package/{index-g38nhv87.js → index-2etcv0sc.js} +1 -1
  30. package/{index-zyw622y1.js → index-2mx692qq.js} +3 -3
  31. package/{index-50wb2a26.js → index-55mynnqs.js} +354 -129
  32. package/{index-2wc41k5w.js → index-56d2g8ms.js} +2 -2
  33. package/{index-q6ft9fkc.js → index-6jjtnhkw.js} +8 -3
  34. package/index-6w0rbr9d.js +68 -0
  35. package/{index-hxnnfy57.js → index-8ggztp8k.js} +204 -8
  36. package/{index-zwd84m0w.js → index-8r4d7cmt.js} +4 -4
  37. package/{index-j38qkvkk.js → index-ae5pwhgs.js} +12 -10
  38. package/{index-gtjgnv36.js → index-aprt7pkz.js} +6 -9
  39. package/{index-1bjkfvm9.js → index-c1x0be5j.js} +2 -2
  40. package/{index-z750gk1j.js → index-c2yrsxdh.js} +13 -7
  41. package/{index-cb6yc5mb.js → index-c76k723e.js} +2 -2
  42. package/{index-6keyf4sg.js → index-ctanya41.js} +4 -4
  43. package/{index-f4e08ptp.js → index-e0cvze1s.js} +4 -4
  44. package/{index-hp3stk1m.js → index-fm46xrf1.js} +3 -3
  45. package/{index-3xmzr1p1.js → index-gnyf1fqy.js} +2 -2
  46. package/{index-nwd6qzvv.js → index-jx4bzvwg.js} +138 -55
  47. package/{index-jb0hmt3k.js → index-krq7btbt.js} +3 -3
  48. package/{index-94s080qm.js → index-pgj2rt78.js} +139 -1
  49. package/index-q958tqqg.js +77 -0
  50. package/{index-50jjwg8y.js → index-qt22xstj.js} +1 -1
  51. package/index-rc5rwyps.js +201 -0
  52. package/{index-xwprn9nr.js → index-rtct7d43.js} +1 -1
  53. package/{index-bsv901fd.js → index-rwq3ekqc.js} +7 -7
  54. package/{index-1gs9g12n.js → index-v29746vb.js} +4 -4
  55. package/index-ww9bhp67.js +62 -0
  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-vsreyy42.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-md2teygq.js} +158 -91
  70. package/templates/app/env/env.client.type.ts +2 -3
  71. package/templates/app/page/styles.css.template +8 -0
  72. package/templates/appSample/page/task/[taskId]/_index.tsx +2 -2
  73. package/templates/appSample/page/task/[taskId]/edit.tsx +2 -2
  74. package/templates/appSample/page/task/_index.tsx +2 -2
  75. package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -2
  76. package/templates/crudPages/[__model__Id]/page.tsx +3 -3
  77. package/templates/crudPages/page.tsx +2 -2
  78. package/templates/crudSinglePage/page.tsx +3 -3
  79. package/templates/lib/cnst.ts +2 -2
  80. package/templates/lib/sig.ts +3 -3
  81. package/{tunnel.command-b3ymdk4f.js → tunnel.command-jx3tdm2d.js} +16 -13
  82. package/{workflow.command-z1srcdfw.js → workflow.command-jx0wag5r.js} +10 -10
  83. package/{workspace.command-vp6vmx3z.js → workspace.command-yxd7rr7g.js} +57 -27
  84. package/cloud.command-tw06613r.js +0 -95
  85. package/cloud.script-64gzba8r.js +0 -20
  86. package/context.runner-hnkvv3z3.js +0 -21
  87. package/index-t91a7339.js +0 -104
@@ -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 ? `