stitchkit 0.113.2 → 0.113.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/contract/errors.d.ts +1 -0
  3. package/dist/contract/errors.d.ts.map +1 -1
  4. package/dist/entrypoints/agent-runtime/browser.js +2 -2
  5. package/dist/entrypoints/agent-runtime/coding-tools.js +4 -4
  6. package/dist/entrypoints/agent-runtime/harness.js +5 -5
  7. package/dist/entrypoints/agent-runtime/realtime.js +2 -2
  8. package/dist/entrypoints/agent-runtime/sandbox.js +4 -4
  9. package/dist/entrypoints/agent-runtime/sqlite/bun.js +6 -6
  10. package/dist/entrypoints/agent-runtime/sqlite/node.js +6 -6
  11. package/dist/entrypoints/agent-runtime/testing.js +6 -6
  12. package/dist/entrypoints/agent-runtime.js +11 -11
  13. package/dist/entrypoints/application/grammy.js +1 -1
  14. package/dist/entrypoints/application.js +2 -2
  15. package/dist/entrypoints/cli.js +8 -8
  16. package/dist/entrypoints/contract.js +2 -2
  17. package/dist/entrypoints/files.js +1 -1
  18. package/dist/entrypoints/index.js +5 -5
  19. package/dist/entrypoints/node.js +7 -7
  20. package/dist/entrypoints/observability.js +1 -1
  21. package/dist/entrypoints/realtime.js +3 -3
  22. package/dist/entrypoints/remote.js +1 -1
  23. package/dist/entrypoints/server.js +8 -8
  24. package/dist/entrypoints/testing.js +3 -3
  25. package/dist/entrypoints/tools/connections.js +5 -5
  26. package/dist/entrypoints/tools/invoker.js +5 -5
  27. package/dist/entrypoints/tools/mcp.js +7 -7
  28. package/dist/entrypoints/tools.js +13 -13
  29. package/dist/{index-cn7xenet.js → index-095ya5hm.js} +5 -5
  30. package/dist/{index-6ez1r4j2.js → index-1csp1hwm.js} +4 -4
  31. package/dist/{index-sps9s3aj.js → index-2bqf0a74.js} +1 -1
  32. package/dist/{index-grn4brx1.js → index-3za04re9.js} +1 -1
  33. package/dist/{index-09dyy94r.js → index-523tq7y4.js} +1 -1
  34. package/dist/{index-8swt2h9y.js → index-7fc0c2np.js} +1 -1
  35. package/dist/{index-2s7wv547.js → index-86nbfysb.js} +3 -3
  36. package/dist/{index-5gmrx6sc.js → index-98hv3sba.js} +1 -1
  37. package/dist/{index-4e36ts8b.js → index-9dyd8xzf.js} +1 -1
  38. package/dist/{index-zbs0g0v2.js → index-aykhzcnf.js} +1 -1
  39. package/dist/{index-0pfk3c1p.js → index-bjcjqj46.js} +1 -1
  40. package/dist/{index-vgtzdykw.js → index-cqmqyjax.js} +1 -1
  41. package/dist/{index-bv51s0f4.js → index-ega3yz4c.js} +1 -1
  42. package/dist/{index-awbed0mk.js → index-fy4av0hq.js} +1 -1
  43. package/dist/{index-0gbn42hr.js → index-htxh46ej.js} +3 -3
  44. package/dist/{index-shg5xc9t.js → index-jk29rwkr.js} +1 -1
  45. package/dist/{index-e40rzw1m.js → index-k42ckyzx.js} +1 -1
  46. package/dist/{index-xtc8k5n9.js → index-k7z3j3hr.js} +47 -16
  47. package/dist/{index-pd578538.js → index-qcn84byh.js} +1 -0
  48. package/dist/{index-vf9c59t3.js → index-rhzkndr5.js} +2 -2
  49. package/dist/{index-z8fnfj42.js → index-tpwbct8j.js} +1 -1
  50. package/dist/{index-7ysby53h.js → index-v1tswj0z.js} +1 -1
  51. package/dist/{index-a31p9ekx.js → index-v6vb5qx8.js} +4 -4
  52. package/dist/{index-5zxe0f3h.js → index-vtann8bz.js} +3 -3
  53. package/dist/{index-kvga921h.js → index-vwhrnp67.js} +4 -4
  54. package/dist/{index-ptjx84pr.js → index-zktz242w.js} +2 -2
  55. package/dist/tools/cli/command.d.ts +2 -1
  56. package/dist/tools/cli/command.d.ts.map +1 -1
  57. package/dist/tools/cli/format.d.ts +5 -1
  58. package/dist/tools/cli/format.d.ts.map +1 -1
  59. package/dist/tools/cli/run-native.d.ts.map +1 -1
  60. package/llms/cli.2.txt +281 -0
  61. package/llms/cli.txt +18 -285
  62. package/llms/server.4.txt +1 -1
  63. package/llms-full.txt +19 -5
  64. package/llms.txt +1 -1
  65. package/package.json +1 -1
@@ -12,7 +12,7 @@
12
12
  * still parsing a success.
13
13
  */
14
14
  import type { ErrorHintFn } from '../execute-hooks.js';
15
- import type { ToolResult } from '../execute-result.js';
15
+ import type { ToolFailure, ToolResult } from '../execute-result.js';
16
16
  /** Map a `ToolResult.code` to a process exit code. */
17
17
  export type ExitCodeMap = Record<string, number>;
18
18
  /**
@@ -47,4 +47,8 @@ export interface EmitOptions {
47
47
  */
48
48
  export declare function cliExitCode(result: ToolResult, exitCodes?: ExitCodeMap): number;
49
49
  export declare function emitResult(result: ToolResult, writers: CliWriters, opts: EmitOptions): number;
50
+ /** A presentation failure cannot erase a successfully completed operation. */
51
+ export declare function emitPresentationFailure(result: Extract<ToolResult, {
52
+ ok: true;
53
+ }>, failure: ToolFailure, writers: CliWriters, opts: EmitOptions): number;
50
54
  //# sourceMappingURL=format.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../src/tools/cli/format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,sDAAsD;AACtD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEjD;;;GAGG;AACH,eAAO,MAAM,kBAAkB,EAAE,WAkBhC,CAAC;AAEF,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,SAAS,CAAC,EAAE,WAAW,CAAC;CACzB;AAED;;;;GAIG;AACH;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,WAAW,GAAG,MAAM,CAY/E;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,UAAU,EACnB,IAAI,EAAE,WAAW,GAChB,MAAM,CAeR"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../src/tools/cli/format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGjE,sDAAsD;AACtD,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEjD;;;GAGG;AACH,eAAO,MAAM,kBAAkB,EAAE,WAmBhC,CAAC;AAEF,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,SAAS,CAAC,EAAE,WAAW,CAAC;CACzB;AAED;;;;GAIG;AACH;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,WAAW,GAAG,MAAM,CAY/E;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,UAAU,EACnB,IAAI,EAAE,WAAW,GAChB,MAAM,CAeR;AAED,8EAA8E;AAC9E,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,EACzC,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,UAAU,EACnB,IAAI,EAAE,WAAW,GAChB,MAAM,CAmBR"}
@@ -1 +1 @@
1
- {"version":3,"file":"run-native.d.ts","sourceRoot":"","sources":["../../../src/tools/cli/run-native.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AACrC,OAAO,EACL,KAAK,oBAAoB,EAG1B,MAAM,WAAW,CAAC;AAInB,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAkB,MAAM,WAAW,CAAC;AAE3E;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EACL,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,QAAQ,SAAS,SAAS,EAE1B,OAAO,EAAE,UAAU,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAC9C,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,EACzB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA+Df"}
1
+ {"version":3,"file":"run-native.d.ts","sourceRoot":"","sources":["../../../src/tools/cli/run-native.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,KAAK,CAAC;AACrC,OAAO,EACL,KAAK,oBAAoB,EAG1B,MAAM,WAAW,CAAC;AAInB,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAkB,MAAM,WAAW,CAAC;AAE3E;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EACL,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,QAAQ,SAAS,SAAS,EAE1B,OAAO,EAAE,UAAU,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,CAAC,EAC9C,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,EACzB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAyEf"}
package/llms/cli.2.txt CHANGED
@@ -11,6 +11,287 @@ Parts of this slice:
11
11
  # Guide: CLI (docs/guide/cli.md) (continued)
12
12
  ==============================================================================
13
13
 
14
+ ## Distribution and self-update
15
+
16
+ `createCli` ships no executable, and that is right — but the step after the
17
+ executable is not application logic either. It is the same problem for every
18
+ consumer, with the same three traps:
19
+
20
+ 1. **The installer cannot parse the manifest.** A `curl … | sh` runs on a
21
+ machine where nothing is installed yet, including `jq`. So the installer is
22
+ generated *from* the manifest, server-side, with the URL and digest already
23
+ substituted — it parses no JSON at all.
24
+ 2. **Replacing a running binary is a rename, not a write.** Anything else can
25
+ leave a half-written executable on someone's PATH when the connection drops.
26
+ 3. **The digest covers the decompressed bytes** — the file that will actually be
27
+ executed, not the archive that was transferred.
28
+
29
+ The framework owns the manifest shape, the installer generation and the update
30
+ primitive. The application owns where the assets live, which platforms it
31
+ publishes and who may download them.
32
+
33
+ ```ts
34
+ import {
35
+ CliBuildManifestSchema, assertCliPublishable, renderCliInstaller,
36
+ selectCliBuildAsset, checkCliUpdate, applyCliUpdate,
37
+ } from 'stitchkit/cli'
38
+
39
+ // Publishing: refuse to republish one version from a different commit —
40
+ // otherwise everyone who already installed it never receives the fix.
41
+ assertCliPublishable(previous, next)
42
+
43
+ // Serving: omit `asset` and one script covers every published target, selecting
44
+ // by uname at run time — otherwise that dispatch is the last hand-written piece
45
+ // of the install path, and every publisher writes the same x86_64 → x64 table.
46
+ renderCliInstaller({ manifest, binaryName: 'myapp' })
47
+ // Or pin one target explicitly:
48
+ renderCliInstaller({ manifest, asset: selectCliBuildAsset(manifest, target), binaryName: 'myapp' })
49
+
50
+ // Checking: bounded, at most once per interval, silent on any failure.
51
+ const check = await checkCliUpdate({ manifestUrl, currentVersion, lastCheckedAt })
52
+ if (check.status === 'outdated' && check.asset) {
53
+ // Replacing is always an explicit command, never a side effect of a check.
54
+ await applyCliUpdate({ asset: check.asset })
55
+ }
56
+ ```
57
+
58
+ `checkCliUpdate` has **four** answers, not three: `skipped`, `current`,
59
+ `outdated` and `unknown`. "Could not ask" is not "up to date" — collapsing them
60
+ is how a tool goes quiet about its own staleness for months. It never throws,
61
+ and a command still exits with the code it earned.
62
+
63
+ Carry the build stamp inside the binary (`CliBuildStampSchema`,
64
+ `formatCliBuildStamp`) so the tool can say what it is rather than leaving the
65
+ reader to infer it from behaviour.
66
+
67
+ #### A manifest inside your own network
68
+
69
+ The check goes through the same SSRF boundary every outbound fetch does, so a
70
+ manifest served from `10.x`, `localhost` or a `.internal` name is refused and
71
+ the answer is `unknown`. For a self-hosted deployment that is a wrong answer
72
+ dressed as a careful one, and the refusal says so — it names the field that
73
+ lifts it rather than reading like a timeout:
74
+
75
+ ```text
76
+ refusing to fetch an internal host — the manifest endpoint is not public.
77
+ Set `allowPrivateHosts` if it is your own deployment; the download keeps its
78
+ own setting, because an asset URL comes from the document.
79
+ ```
80
+
81
+ ```ts
82
+ const check = await checkCliUpdate({ manifestUrl, currentVersion, allowPrivateHosts: true })
83
+ ```
84
+
85
+ Set it from whether **your** endpoint is private, not as a blanket switch, and
86
+ leave `applyCliUpdate` at its default. The two are not the same decision: the
87
+ manifest URL is configuration you wrote, while an asset URL arrives inside a
88
+ document the endpoint served, and a document is exactly what an SSRF boundary
89
+ exists to distrust.
90
+
91
+ ### Publishing a complete version
92
+
93
+ `publishCli` is an opt-in publisher for a local application-owned distribution
94
+ directory. It commits every target and its version manifest before atomically
95
+ replacing the public `manifest.json`. It ships from its own evolving entrypoint,
96
+ `stitchkit/cli/publish`: release infrastructure follows how distributions are
97
+ published, so it is not held to the stable promise of `stitchkit/cli`
98
+ ([ADR 0245](../decisions/0245-cli-publication-is-an-evolving-leaf.md)). The
99
+ manifest, signatures, installer and updater it writes for stay in
100
+ `stitchkit/cli`. Your application selects the CLI source identity and admits
101
+ that snapshot at each phase:
102
+
103
+ ```ts
104
+ import { publishCli } from 'stitchkit/cli/publish'
105
+
106
+ const result = await publishCli({
107
+ storageRoot: '/srv/myapp/cli',
108
+ name: 'myapp', version: cliVersion, commit: selectedCliCommit,
109
+ baseUrl: 'https://downloads.example.com/cli/',
110
+ targets: [{ platform: 'linux', arch: 'x64' }],
111
+ signal,
112
+ admit: ({ phase, identity, signal }) => assertSelectedSource({ phase, identity, signal }),
113
+ build: ({ target, stamp, signal }) => compileSelectedSource({ target, stamp, signal }),
114
+ })
115
+ // result.outcome: 'published' | 'existing'; result.manifest is ready to serve.
116
+ ```
117
+
118
+ The builder returns `Uint8Array` or `ReadableStream<Uint8Array>` containing the
119
+ executable. Every target receives the same immutable `{ version, commit,
120
+ builtAt }` stamp; carry it inside the binary. The publisher owns staging,
121
+ gzip assets, paths and manifests. Callbacks receive the cancellation signal and
122
+ run under the publication lock. Admission phases are `prepare`, each target's
123
+ `build`, `commit`, and `promote`; validate the selected source through commit
124
+ and promotion. A backend release can explicitly select an unchanged older CLI
125
+ identity. The helper never substitutes a different commit or rebuilds an
126
+ existing version under a new identity.
127
+
128
+ Assets live at `<version>/<name>-<platform>-<arch>.gz`, with an immutable
129
+ `<version>/manifest.json` and a public `manifest.json`. A verified repeat keeps
130
+ the exact bytes and `builtAt`, requires the same target set and base URL, and
131
+ returns `existing` without invoking the builder. A version committed before a
132
+ failed promotion is recovered and promoted without rebuilding. Conflicting
133
+ same-version commits, downgrades, unsafe components, corrupt manifests and
134
+ assets are refused. Version/name/target components are validated before any
135
+ build; `storageRoot` is absolute and its existing parent must be trusted.
136
+
137
+ All limits are finite positive safe integers; `limits` overrides these defaults:
138
+
139
+ | Field | Default | Enforced boundary |
140
+ | --- | --- | --- |
141
+ | `maxTargets` | 16 | Requested and verified target count |
142
+ | `maxManifestBytes` | 256 KiB | Read and written manifest bytes |
143
+ | `maxAssetBytes` | 256 MiB | Builder and decompressed executable bytes |
144
+ | `maxCompressedBytes` | 256 MiB | Archived asset bytes |
145
+ | `maxDirectoryEntries` | 128 | Version layout, root scan and commit space |
146
+ | `maxStoredVersions` | 16 | Verified publisher-owned history |
147
+ | `lockTimeoutMs` | 10,000 | Wait to acquire the lock |
148
+ | `timeoutMs` | 600,000 | Whole publication deadline |
149
+
150
+ `retention` defaults to 2 and keeps the current and previous published versions.
151
+ It must be at least 2 and less than `maxStoredVersions` (at least 3), leaving a
152
+ recovery slot. Older versions are pruned only after their own identity, exact
153
+ layout, raw size and digest are verified. Runtime folders and foreign semver
154
+ directories remain untouched. A full or unverifiable store refuses further
155
+ publication; repeated completed candidates stay inside the owned history cap.
156
+ Cancelled or failed incomplete staging is removed only while its root and
157
+ directory generations still match. The shared asynchronous gzip decoder also
158
+ bounds updater expansion by the declared raw size and configured byte cap.
159
+
160
+ The publisher rejects symlink roots/versions/assets, non-regular files and
161
+ multiply linked files, and checks held-lock, root and prior-pointer generations
162
+ before commit and promotion. This is cooperative publication under a trusted
163
+ parent directory. Portable path checks do not provide an atomic comparison with
164
+ a hostile process replacing ancestors between syscalls. Durability uses file
165
+ and directory sync on POSIX; Windows publication is refused. A deadline stops
166
+ callback/stream waits and blocks late promotion; trusted callbacks remain
167
+ responsible for cancelling their own subprocesses and external effects.
168
+
169
+ A failure after an immutable commit reports that the publication outcome must
170
+ be checked. A failure after public-pointer rename may already have published;
171
+ `AtomicFilePublicationError.published` remains available when the atomic writer
172
+ reports that boundary. Read the durable manifest and retry the admitted same
173
+ identity to recover; an error does not promise rollback. Optional `signing` and
174
+ `trust` reuse the Ed25519 contract below. The generated shell installer checks
175
+ raw digest and size; the updater checks manifest authorship when given a trust
176
+ root.
177
+
178
+ ### Proving who built it — signing the manifest
179
+
180
+ The asset digest proves the bytes that arrived are the bytes the manifest named.
181
+ It proves nothing about who named them: the manifest and the assets come from
182
+ one origin, so whoever can replace one can replace the other. Authorship needs a
183
+ key the build carries and the server never holds.
184
+
185
+ ```ts
186
+ // Publishing — the only side that holds a private key.
187
+ const signature = signCliManifest(manifest, { keyId: 'release-2026', privateKey })
188
+ publish({ ...manifest, signature })
189
+
190
+ // Installing — the trust root is compiled into the binary. One fetched at run
191
+ // time from the same origin as the manifest would prove nothing.
192
+ const trust = { keys: { 'release-2026': PUBLISHED_KEY } }
193
+ const check = await checkCliUpdate({ manifestUrl, currentVersion, trust })
194
+ if (check.status === 'outdated' && check.asset) {
195
+ await applyCliUpdate({ asset: check.asset, manifest: check.manifest, trust, backupPath })
196
+ }
197
+ ```
198
+
199
+ The signature covers `{name, version, commit, builtAt, assets[]}` with **every
200
+ asset's digest**, so the chain closes on the file that will execute rather than
201
+ on the document describing it; moving an asset to a new URL does not invalidate
202
+ it, changing what an asset contains does.
203
+
204
+ Five verdicts, and `unenforced` is the one that earns its keep: a build with no
205
+ pinned key keeps updating, and the fact that nothing was checked is *visible*
206
+ rather than assumed. `missing`, `unknown-key` and `invalid` are distinguished
207
+ because they need different answers — a key you forgot to pin is not a forged
208
+ signature.
209
+
210
+ A bad verdict does not produce a fifth status. `outdated` is an instruction to
211
+ install, and a manifest that failed its signature has not established that there
212
+ is a newer build worth installing — only that a document claims one. The check
213
+ answers `unknown` with the verdict in its reason, so it is never mistaken for a
214
+ network failure. `applyCliUpdate` checks again and refuses **before** the
215
+ download: a signature verified after the bytes are on the machine produces a
216
+ cleanup problem rather than a refusal.
217
+
218
+ ### A way back — `backupPath` and `rollbackCliUpdate`
219
+
220
+ Rolling back is a property of updating, not of the application:
221
+
222
+ ```ts
223
+ const applied = await applyCliUpdate({ asset, targetPath, backupPath })
224
+ // …the new build turns out to be wrong. `backupSha256` is absent when there was
225
+ // nothing to keep — a first install — and that is the case with nothing to roll
226
+ // back to:
227
+ if (applied.backupSha256) {
228
+ rollbackCliUpdate({ targetPath, backupPath, expectedSha256: applied.backupSha256 })
229
+ }
230
+ ```
231
+
232
+ The copy is taken between "the new bytes verified" and the replacement — earlier
233
+ would preserve a binary about to be replaced by a download that then failed its
234
+ digest, later has nothing left to copy — and it carries the target's file mode,
235
+ because a backup that cannot be executed is not a way back. A first install has
236
+ nothing to keep, which is not a failure.
237
+
238
+ `expectedSha256` is required, not optional. A rollback that installs whatever
239
+ happens to be at the backup path is a second install of an unverified binary,
240
+ and the day it is used is the day nobody is in a position to check.
241
+
242
+ ### Run it before it replaces anything — `verify`
243
+
244
+ A digest proves the bytes are the ones published, not that they load. A tool
245
+ that updates itself unattended — a daemon — and installs a build that does not
246
+ start takes its own updater down with it, and every machine is then repaired by
247
+ hand. `verify` is the step between "digest matched" and "target replaced":
248
+
249
+ ```ts
250
+ await applyCliUpdate({
251
+ asset, targetPath, backupPath,
252
+ verify: async (candidate, signal) => {
253
+ const child = Bun.spawn(['bun', candidate, 'version'], { signal, stdout: 'pipe' })
254
+ const printed = (await new Response(child.stdout).text()).trim()
255
+ if ((await child.exited) !== 0 || printed !== check.version) {
256
+ throw new Error(`printed ${printed}, expected ${check.version}`)
257
+ }
258
+ },
259
+ verifyTimeoutMs: 15_000, // default 30 000
260
+ })
261
+ ```
262
+
263
+ The verified bytes are written beside the target as an executable candidate
264
+ with the target's extension, and `verify` gets its path — a path rather than a
265
+ command, because a JavaScript bundle is started through its runtime. It runs
266
+ before the backup and the replacement. If it throws or outlives
267
+ `verifyTimeoutMs` (its `signal` aborts at the deadline), the candidate is
268
+ removed, the target and any earlier backup are untouched, and the update is
269
+ refused with the reason as its cause. The target is written from the checked
270
+ bytes in memory, never from the candidate file, so nothing the candidate did
271
+ while it ran can change what is installed.
272
+
273
+ ### Two release tracks
274
+
275
+ `assertCliPublishable` takes one manifest **or every manifest already
276
+ published**:
277
+
278
+ ```ts
279
+ assertCliPublishable([stableManifest, betaManifest], next)
280
+ ```
281
+
282
+ With a single argument and two tracks the check is silently useless: `1.2.3`
283
+ goes to beta from one commit and to stable from another, the function is called
284
+ with the track being published and sees no conflict, and the person who
285
+ installed the beta is told they are current forever. Nothing on their machine
286
+ looks wrong.
287
+
288
+ **The channel itself is not an argument of the framework, and will not become
289
+ one.** The framework has no model of how your URLs are built and should not: an
290
+ asset's address comes from the manifest, and the manifest's address belongs to
291
+ your server. A channel is two documents at two addresses — which of them a build
292
+ consults is your decision, and `assertCliPublishable` is told about all of them
293
+ rather than taught the shape of any.
294
+
14
295
  ## One operation per line — `createCliInvoker`
15
296
 
16
297
  The most common way an agent drives a CLI is not one command: it is a stream —
package/llms/cli.txt CHANGED
@@ -213,9 +213,11 @@ const doctor = defineCliCommand({
213
213
  it the canonical JSON output is unchanged. `exitCode` classifies a successfully
214
214
  validated result and must return an integer in `0..255`; failed `ToolResult`
215
215
  envelopes and the application-wide `exitCodes` mapping remain authoritative for
216
- failures. A throwing callback, invalid status or non-string presenter becomes a
217
- normalized `INTERNAL_SERVER_ERROR`, never partial success output. Void native
218
- commands cannot declare either callback.
216
+ failures. A throwing or invalid exit-status callback becomes a normalized
217
+ `INTERNAL_SERVER_ERROR`. A throwing or non-string `present` preserves the validated
218
+ result on stdout and reports `CLI_PRESENTATION_FAILED` separately, as described
219
+ under [Aggregate views](#aggregate-views). Void native commands cannot declare
220
+ either callback.
219
221
 
220
222
  `--version`, a selected native command and its command help run before
221
223
  `resolveAuth`, services, context or runtime-tool factories. Top-level help is
@@ -430,6 +432,7 @@ newline-terminated JSON record on its respective stream; progress and CLI usage
430
432
  diagnostics remain ordinary stderr text. This keeps stdout pipeable and
431
433
  `2>/dev/null` clean. The process exit code carries the error class (`0` ok,
432
434
  `VALIDATION_ERROR → 1`, `UNAUTHORIZED → 2`, `FORBIDDEN → 3`, `NOT_FOUND → 4`,
435
+ `CLI_PRESENTATION_FAILED → 8`,
433
436
  …) — override per app with `exitCodes`.
434
437
 
435
438
  ### A narrower question than "all of them"
@@ -651,7 +654,7 @@ would answer a question nobody asked.
651
654
 
652
655
  Three rules worth knowing before you rely on them:
653
656
 
654
- - **A field the result does not carry is an argument error.** A group of zero
657
+ - **A field the result does not carry is a presentation error.** A group of zero
655
658
  over a misspelled field is indistinguishable from a true empty answer, and the
656
659
  caller reads it as data. The message names the fields that *are* there.
657
660
  - **An aggregate needs a collection** — the result itself when it is an array,
@@ -665,6 +668,17 @@ Three rules worth knowing before you rely on them:
665
668
  A failed call still reports its own error and exit code. An aggregate over an
666
669
  error is not an answer to the question that was asked.
667
670
 
671
+ If the command succeeds but its requested view cannot be built, the CLI writes
672
+ the complete original result to stdout, using the normal JSON format (`--json`
673
+ still selects compact output). It writes a separate structured
674
+ `CLI_PRESENTATION_FAILED` diagnostic to stderr and exits with `8`, configurable
675
+ through `exitCodes`. Its `details.operationSucceeded: true` and `retryable: false`
676
+ distinguish a presentation refusal from a failed operation; `details.cause`
677
+ preserves the safe reason. Recover the answer from stdout instead of repeating
678
+ the mutation. Domain retry hints are not appended to this diagnostic. CLI
679
+ argument errors detected before execution still exit with `2`
680
+ and have no successful result to preserve.
681
+
668
682
  ## Named profiles
669
683
 
670
684
  A CLI that talks to a deployed server needs an address and a credential per
@@ -710,284 +724,3 @@ await createCli({
710
724
  Files are written `0600` in a `0700` directory — and a profile file other users
711
725
  can read is refused with the `chmod` that fixes it, because it holds a
712
726
  credential.
713
-
714
- ## Distribution and self-update
715
-
716
- `createCli` ships no executable, and that is right — but the step after the
717
- executable is not application logic either. It is the same problem for every
718
- consumer, with the same three traps:
719
-
720
- 1. **The installer cannot parse the manifest.** A `curl … | sh` runs on a
721
- machine where nothing is installed yet, including `jq`. So the installer is
722
- generated *from* the manifest, server-side, with the URL and digest already
723
- substituted — it parses no JSON at all.
724
- 2. **Replacing a running binary is a rename, not a write.** Anything else can
725
- leave a half-written executable on someone's PATH when the connection drops.
726
- 3. **The digest covers the decompressed bytes** — the file that will actually be
727
- executed, not the archive that was transferred.
728
-
729
- The framework owns the manifest shape, the installer generation and the update
730
- primitive. The application owns where the assets live, which platforms it
731
- publishes and who may download them.
732
-
733
- ```ts
734
- import {
735
- CliBuildManifestSchema, assertCliPublishable, renderCliInstaller,
736
- selectCliBuildAsset, checkCliUpdate, applyCliUpdate,
737
- } from 'stitchkit/cli'
738
-
739
- // Publishing: refuse to republish one version from a different commit —
740
- // otherwise everyone who already installed it never receives the fix.
741
- assertCliPublishable(previous, next)
742
-
743
- // Serving: omit `asset` and one script covers every published target, selecting
744
- // by uname at run time — otherwise that dispatch is the last hand-written piece
745
- // of the install path, and every publisher writes the same x86_64 → x64 table.
746
- renderCliInstaller({ manifest, binaryName: 'myapp' })
747
- // Or pin one target explicitly:
748
- renderCliInstaller({ manifest, asset: selectCliBuildAsset(manifest, target), binaryName: 'myapp' })
749
-
750
- // Checking: bounded, at most once per interval, silent on any failure.
751
- const check = await checkCliUpdate({ manifestUrl, currentVersion, lastCheckedAt })
752
- if (check.status === 'outdated' && check.asset) {
753
- // Replacing is always an explicit command, never a side effect of a check.
754
- await applyCliUpdate({ asset: check.asset })
755
- }
756
- ```
757
-
758
- `checkCliUpdate` has **four** answers, not three: `skipped`, `current`,
759
- `outdated` and `unknown`. "Could not ask" is not "up to date" — collapsing them
760
- is how a tool goes quiet about its own staleness for months. It never throws,
761
- and a command still exits with the code it earned.
762
-
763
- Carry the build stamp inside the binary (`CliBuildStampSchema`,
764
- `formatCliBuildStamp`) so the tool can say what it is rather than leaving the
765
- reader to infer it from behaviour.
766
-
767
- #### A manifest inside your own network
768
-
769
- The check goes through the same SSRF boundary every outbound fetch does, so a
770
- manifest served from `10.x`, `localhost` or a `.internal` name is refused and
771
- the answer is `unknown`. For a self-hosted deployment that is a wrong answer
772
- dressed as a careful one, and the refusal says so — it names the field that
773
- lifts it rather than reading like a timeout:
774
-
775
- ```text
776
- refusing to fetch an internal host — the manifest endpoint is not public.
777
- Set `allowPrivateHosts` if it is your own deployment; the download keeps its
778
- own setting, because an asset URL comes from the document.
779
- ```
780
-
781
- ```ts
782
- const check = await checkCliUpdate({ manifestUrl, currentVersion, allowPrivateHosts: true })
783
- ```
784
-
785
- Set it from whether **your** endpoint is private, not as a blanket switch, and
786
- leave `applyCliUpdate` at its default. The two are not the same decision: the
787
- manifest URL is configuration you wrote, while an asset URL arrives inside a
788
- document the endpoint served, and a document is exactly what an SSRF boundary
789
- exists to distrust.
790
-
791
- ### Publishing a complete version
792
-
793
- `publishCli` is an opt-in publisher for a local application-owned distribution
794
- directory. It commits every target and its version manifest before atomically
795
- replacing the public `manifest.json`. It ships from its own evolving entrypoint,
796
- `stitchkit/cli/publish`: release infrastructure follows how distributions are
797
- published, so it is not held to the stable promise of `stitchkit/cli`
798
- ([ADR 0245](../decisions/0245-cli-publication-is-an-evolving-leaf.md)). The
799
- manifest, signatures, installer and updater it writes for stay in
800
- `stitchkit/cli`. Your application selects the CLI source identity and admits
801
- that snapshot at each phase:
802
-
803
- ```ts
804
- import { publishCli } from 'stitchkit/cli/publish'
805
-
806
- const result = await publishCli({
807
- storageRoot: '/srv/myapp/cli',
808
- name: 'myapp', version: cliVersion, commit: selectedCliCommit,
809
- baseUrl: 'https://downloads.example.com/cli/',
810
- targets: [{ platform: 'linux', arch: 'x64' }],
811
- signal,
812
- admit: ({ phase, identity, signal }) => assertSelectedSource({ phase, identity, signal }),
813
- build: ({ target, stamp, signal }) => compileSelectedSource({ target, stamp, signal }),
814
- })
815
- // result.outcome: 'published' | 'existing'; result.manifest is ready to serve.
816
- ```
817
-
818
- The builder returns `Uint8Array` or `ReadableStream<Uint8Array>` containing the
819
- executable. Every target receives the same immutable `{ version, commit,
820
- builtAt }` stamp; carry it inside the binary. The publisher owns staging,
821
- gzip assets, paths and manifests. Callbacks receive the cancellation signal and
822
- run under the publication lock. Admission phases are `prepare`, each target's
823
- `build`, `commit`, and `promote`; validate the selected source through commit
824
- and promotion. A backend release can explicitly select an unchanged older CLI
825
- identity. The helper never substitutes a different commit or rebuilds an
826
- existing version under a new identity.
827
-
828
- Assets live at `<version>/<name>-<platform>-<arch>.gz`, with an immutable
829
- `<version>/manifest.json` and a public `manifest.json`. A verified repeat keeps
830
- the exact bytes and `builtAt`, requires the same target set and base URL, and
831
- returns `existing` without invoking the builder. A version committed before a
832
- failed promotion is recovered and promoted without rebuilding. Conflicting
833
- same-version commits, downgrades, unsafe components, corrupt manifests and
834
- assets are refused. Version/name/target components are validated before any
835
- build; `storageRoot` is absolute and its existing parent must be trusted.
836
-
837
- All limits are finite positive safe integers; `limits` overrides these defaults:
838
-
839
- | Field | Default | Enforced boundary |
840
- | --- | --- | --- |
841
- | `maxTargets` | 16 | Requested and verified target count |
842
- | `maxManifestBytes` | 256 KiB | Read and written manifest bytes |
843
- | `maxAssetBytes` | 256 MiB | Builder and decompressed executable bytes |
844
- | `maxCompressedBytes` | 256 MiB | Archived asset bytes |
845
- | `maxDirectoryEntries` | 128 | Version layout, root scan and commit space |
846
- | `maxStoredVersions` | 16 | Verified publisher-owned history |
847
- | `lockTimeoutMs` | 10,000 | Wait to acquire the lock |
848
- | `timeoutMs` | 600,000 | Whole publication deadline |
849
-
850
- `retention` defaults to 2 and keeps the current and previous published versions.
851
- It must be at least 2 and less than `maxStoredVersions` (at least 3), leaving a
852
- recovery slot. Older versions are pruned only after their own identity, exact
853
- layout, raw size and digest are verified. Runtime folders and foreign semver
854
- directories remain untouched. A full or unverifiable store refuses further
855
- publication; repeated completed candidates stay inside the owned history cap.
856
- Cancelled or failed incomplete staging is removed only while its root and
857
- directory generations still match. The shared asynchronous gzip decoder also
858
- bounds updater expansion by the declared raw size and configured byte cap.
859
-
860
- The publisher rejects symlink roots/versions/assets, non-regular files and
861
- multiply linked files, and checks held-lock, root and prior-pointer generations
862
- before commit and promotion. This is cooperative publication under a trusted
863
- parent directory. Portable path checks do not provide an atomic comparison with
864
- a hostile process replacing ancestors between syscalls. Durability uses file
865
- and directory sync on POSIX; Windows publication is refused. A deadline stops
866
- callback/stream waits and blocks late promotion; trusted callbacks remain
867
- responsible for cancelling their own subprocesses and external effects.
868
-
869
- A failure after an immutable commit reports that the publication outcome must
870
- be checked. A failure after public-pointer rename may already have published;
871
- `AtomicFilePublicationError.published` remains available when the atomic writer
872
- reports that boundary. Read the durable manifest and retry the admitted same
873
- identity to recover; an error does not promise rollback. Optional `signing` and
874
- `trust` reuse the Ed25519 contract below. The generated shell installer checks
875
- raw digest and size; the updater checks manifest authorship when given a trust
876
- root.
877
-
878
- ### Proving who built it — signing the manifest
879
-
880
- The asset digest proves the bytes that arrived are the bytes the manifest named.
881
- It proves nothing about who named them: the manifest and the assets come from
882
- one origin, so whoever can replace one can replace the other. Authorship needs a
883
- key the build carries and the server never holds.
884
-
885
- ```ts
886
- // Publishing — the only side that holds a private key.
887
- const signature = signCliManifest(manifest, { keyId: 'release-2026', privateKey })
888
- publish({ ...manifest, signature })
889
-
890
- // Installing — the trust root is compiled into the binary. One fetched at run
891
- // time from the same origin as the manifest would prove nothing.
892
- const trust = { keys: { 'release-2026': PUBLISHED_KEY } }
893
- const check = await checkCliUpdate({ manifestUrl, currentVersion, trust })
894
- if (check.status === 'outdated' && check.asset) {
895
- await applyCliUpdate({ asset: check.asset, manifest: check.manifest, trust, backupPath })
896
- }
897
- ```
898
-
899
- The signature covers `{name, version, commit, builtAt, assets[]}` with **every
900
- asset's digest**, so the chain closes on the file that will execute rather than
901
- on the document describing it; moving an asset to a new URL does not invalidate
902
- it, changing what an asset contains does.
903
-
904
- Five verdicts, and `unenforced` is the one that earns its keep: a build with no
905
- pinned key keeps updating, and the fact that nothing was checked is *visible*
906
- rather than assumed. `missing`, `unknown-key` and `invalid` are distinguished
907
- because they need different answers — a key you forgot to pin is not a forged
908
- signature.
909
-
910
- A bad verdict does not produce a fifth status. `outdated` is an instruction to
911
- install, and a manifest that failed its signature has not established that there
912
- is a newer build worth installing — only that a document claims one. The check
913
- answers `unknown` with the verdict in its reason, so it is never mistaken for a
914
- network failure. `applyCliUpdate` checks again and refuses **before** the
915
- download: a signature verified after the bytes are on the machine produces a
916
- cleanup problem rather than a refusal.
917
-
918
- ### A way back — `backupPath` and `rollbackCliUpdate`
919
-
920
- Rolling back is a property of updating, not of the application:
921
-
922
- ```ts
923
- const applied = await applyCliUpdate({ asset, targetPath, backupPath })
924
- // …the new build turns out to be wrong. `backupSha256` is absent when there was
925
- // nothing to keep — a first install — and that is the case with nothing to roll
926
- // back to:
927
- if (applied.backupSha256) {
928
- rollbackCliUpdate({ targetPath, backupPath, expectedSha256: applied.backupSha256 })
929
- }
930
- ```
931
-
932
- The copy is taken between "the new bytes verified" and the replacement — earlier
933
- would preserve a binary about to be replaced by a download that then failed its
934
- digest, later has nothing left to copy — and it carries the target's file mode,
935
- because a backup that cannot be executed is not a way back. A first install has
936
- nothing to keep, which is not a failure.
937
-
938
- `expectedSha256` is required, not optional. A rollback that installs whatever
939
- happens to be at the backup path is a second install of an unverified binary,
940
- and the day it is used is the day nobody is in a position to check.
941
-
942
- ### Run it before it replaces anything — `verify`
943
-
944
- A digest proves the bytes are the ones published, not that they load. A tool
945
- that updates itself unattended — a daemon — and installs a build that does not
946
- start takes its own updater down with it, and every machine is then repaired by
947
- hand. `verify` is the step between "digest matched" and "target replaced":
948
-
949
- ```ts
950
- await applyCliUpdate({
951
- asset, targetPath, backupPath,
952
- verify: async (candidate, signal) => {
953
- const child = Bun.spawn(['bun', candidate, 'version'], { signal, stdout: 'pipe' })
954
- const printed = (await new Response(child.stdout).text()).trim()
955
- if ((await child.exited) !== 0 || printed !== check.version) {
956
- throw new Error(`printed ${printed}, expected ${check.version}`)
957
- }
958
- },
959
- verifyTimeoutMs: 15_000, // default 30 000
960
- })
961
- ```
962
-
963
- The verified bytes are written beside the target as an executable candidate
964
- with the target's extension, and `verify` gets its path — a path rather than a
965
- command, because a JavaScript bundle is started through its runtime. It runs
966
- before the backup and the replacement. If it throws or outlives
967
- `verifyTimeoutMs` (its `signal` aborts at the deadline), the candidate is
968
- removed, the target and any earlier backup are untouched, and the update is
969
- refused with the reason as its cause. The target is written from the checked
970
- bytes in memory, never from the candidate file, so nothing the candidate did
971
- while it ran can change what is installed.
972
-
973
- ### Two release tracks
974
-
975
- `assertCliPublishable` takes one manifest **or every manifest already
976
- published**:
977
-
978
- ```ts
979
- assertCliPublishable([stableManifest, betaManifest], next)
980
- ```
981
-
982
- With a single argument and two tracks the check is silently useless: `1.2.3`
983
- goes to beta from one commit and to stable from another, the function is called
984
- with the track being published and sees no conflict, and the person who
985
- installed the beta is told they are current forever. Nothing on their machine
986
- looks wrong.
987
-
988
- **The channel itself is not an argument of the framework, and will not become
989
- one.** The framework has no model of how your URLs are built and should not: an
990
- asset's address comes from the manifest, and the manifest's address belongs to
991
- your server. A channel is two documents at two addresses — which of them a build
992
- consults is your decision, and `assertCliPublishable` is told about all of them
993
- rather than taught the shape of any.
package/llms/server.4.txt CHANGED
@@ -553,7 +553,7 @@ core never models them (ADR 0002). But stitchkit itself emits a set of its own:
553
553
  `FILE_INSPECTION_REJECTED`, `FILE_TOO_LARGE`, `FILE_EXISTS`,
554
554
  `REALTIME_CONTRACT_VIOLATION`, `APPLICATION_NOT_ACCEPTING`, `NOT_IMPLEMENTED`,
555
555
  `WAIT_TIMEOUT`,
556
- `WAIT_FAILED`, `CLI_COMMAND_UNAVAILABLE`, `DOWNLOAD_NOT_FOUND`, `VIEW_HTTP_ERROR`,
556
+ `WAIT_FAILED`, `CLI_COMMAND_UNAVAILABLE`, `CLI_PRESENTATION_FAILED`, `DOWNLOAD_NOT_FOUND`, `VIEW_HTTP_ERROR`,
557
557
  `CONNECTION_TIMEOUT`, `CONNECTION_RESPONSE_TOO_LARGE`,
558
558
  `CONNECTION_REQUEST_FAILED`, `UPSTREAM_TOOL_ERROR`,
559
559
  `OPERATION_NOT_SUCCEEDED`, `STREAM_ITEM_INVALID`, `STREAM_FRAME_TOO_LARGE`,