@zerotal/arch 1.7.5 → 1.8.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.
- package/CHANGELOG.md +52 -0
- package/api-surface.md +2 -0
- package/docs/changelog.md +196 -0
- package/docs/commands.md +32 -0
- package/docs/deployment.md +97 -0
- package/docs/flow/index.md +1 -1
- package/docs/flow/layouts.md +64 -0
- package/docs/testing/index.md +26 -0
- package/package.json +3 -3
- package/src/config.ts +8 -0
- package/src/install/ArchInstallCommand.ts +50 -5
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/probe/topics.ts +40 -0
- package/src/provider/ArchProvider.ts +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,58 @@ against. `mcp-surface.md` is diffed alongside it.
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [1.8.0] — 2026-08-24
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Agent skills — the depth the upfront block cannot afford.** The generated `AGENTS.md`
|
|
21
|
+
is short because every prompt it lands in pays for its whole length, so it points rather
|
|
22
|
+
than teaches. That constraint has a cost: an agent gets a map and no detail, and the
|
|
23
|
+
detail is where the expensive mistakes live. A skill is a file with a one-line
|
|
24
|
+
description that costs nothing until an agent decides it is relevant, so a procedure can
|
|
25
|
+
be written out in full. Two ship — `zerotal-schema-changes` (who owns the schema here,
|
|
26
|
+
the mixin columns nothing declares, and why an unguarded `ALTER TABLE` collides during a
|
|
27
|
+
release's `migrate`) and `zerotal-releases` (naming your own deploy steps, replacing the
|
|
28
|
+
asset directory rather than merging into it, `trustedProxies` behind a proxy, and the
|
|
29
|
+
pipe that hides a test suite's exit status). Both are shaped by the project like the
|
|
30
|
+
block is: the schema skill states this app's actual answer, and refuses to pick a side
|
|
31
|
+
when `synchronize` and migrations are both in play.
|
|
32
|
+
|
|
33
|
+
Written to `.agents/skills`, the cross-client path, plus `.claude/skills` when that agent
|
|
34
|
+
is detected. Frontmatter descriptions are JSON-quoted, because unquoted a colon makes the
|
|
35
|
+
rest a nested mapping and the file stops parsing — an inert skill looks exactly like an
|
|
36
|
+
installed one. Overriding a shipped skill is deleting its marker line: a `SKILL.md`
|
|
37
|
+
without the marker is yours and is never rewritten. Turn the whole feature off with
|
|
38
|
+
`ArchConfig({ skills: false })`.
|
|
39
|
+
|
|
40
|
+
- **`doctor` reports agent instructions that no longer describe this project.** The block
|
|
41
|
+
used to describe the framework, which moved when the framework did. It now also describes
|
|
42
|
+
the project, and every fact in it moves without anyone thinking about the file — add a
|
|
43
|
+
migrations directory, turn `synchronize` off, install a package. It still reads as current
|
|
44
|
+
while describing the app you used to have, and guidance that is confidently out of date
|
|
45
|
+
gets followed. Skills rot the same way and are easier to miss, because nothing reads one
|
|
46
|
+
until an agent decides it is relevant, by which point it is being acted on. The check
|
|
47
|
+
regenerates both in memory and compares, naming `arch:update` as the fix. A warning, never
|
|
48
|
+
a failure: a misleading instruction file does not stop an application working, and this
|
|
49
|
+
check earns the right to gate a deploy by failing only for what would. The server name is
|
|
50
|
+
read back from `.mcp.json` rather than assumed, so a project that renamed its server is
|
|
51
|
+
not reported as permanently stale.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **The generated `AGENTS.md` block describes how this app is set up, not only what it
|
|
56
|
+
installed.** A package list answers "what is available here", which is not the question that
|
|
57
|
+
decides what to write: the framework's contracts are not uniform across projects, and the
|
|
58
|
+
places they differ are the places where guessing wrong compiles cleanly and fails at runtime.
|
|
59
|
+
`detectShape()` reads four facts off disk and the block states only the ones that change an
|
|
60
|
+
instruction — who owns the schema, whether route names are typed, whether
|
|
61
|
+
`exactOptionalPropertyTypes` or `noUncheckedIndexedAccess` are on, and whether there are
|
|
62
|
+
tests to run. Read from files rather than a booted app, because a project that will not boot
|
|
63
|
+
is often why the agent surface is being installed; `.env` is deliberately not among them,
|
|
64
|
+
since this output is committed and pasted into prompts. The `extends` chain is followed, so
|
|
65
|
+
an app inheriting a strict base is not read as unstrict. Additive — a caller that passes no
|
|
66
|
+
shape gets the block it got before.
|
|
67
|
+
|
|
16
68
|
## [1.7.5] — 2026-08-22
|
|
17
69
|
|
|
18
70
|
### Changed
|
package/api-surface.md
CHANGED
|
@@ -94,6 +94,7 @@ interface ArchConfigShape = {
|
|
|
94
94
|
mcpConfig: boolean
|
|
95
95
|
mcpConfigPath: string
|
|
96
96
|
serverName: string
|
|
97
|
+
skills: boolean
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
interface Detected = {
|
|
@@ -118,6 +119,7 @@ interface DoctorReport = {
|
|
|
118
119
|
interface GuidelineOptions = {
|
|
119
120
|
packages: string[]
|
|
120
121
|
serverName: string
|
|
122
|
+
shape?: ProjectShape
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
interface InstalledPackage = {
|
package/docs/changelog.md
CHANGED
|
@@ -27,6 +27,202 @@ the section for every version you cross and apply its migration notes, not only
|
|
|
27
27
|
majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
|
|
28
28
|
when that carve-out ends.
|
|
29
29
|
|
|
30
|
+
## 1.8.1 — 2026-08-26
|
|
31
|
+
|
|
32
|
+
DevTools showed you the wrong request, accurately.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **A page keeps the DevTools panel while its own assets load.** Opening `/login` selected
|
|
37
|
+
`/login`, then `/favicon.ico` a few milliseconds later, then `/css/app.css`. Live mode
|
|
38
|
+
selected every trace as it arrived and a page's sub-resources arrive right behind it, so the
|
|
39
|
+
bar named a request nobody asked about, the detail below described that request's headers
|
|
40
|
+
and its empty session, and the page you were inspecting had scrolled into the list. Nothing
|
|
41
|
+
shown was wrong; it was all about the wrong request.
|
|
42
|
+
|
|
43
|
+
Traces are now classified into three kinds rather than two, because "not the document" would
|
|
44
|
+
have suppressed the form post and the Inertia visit — the requests most worth watching. What
|
|
45
|
+
gets skipped over is narrower: a sub-resource the browser fetched on its own initiative. The
|
|
46
|
+
browser is asked rather than the URL, since an app may serve an API from a `.js` route and a
|
|
47
|
+
build that hashes its asset names has no extension to read; what was actually served is the
|
|
48
|
+
fallback, so a page fetched by `curl` still reads as a page. Anything unclassifiable counts
|
|
49
|
+
as app traffic, never as an asset — being wrong there decides whether a request is skipped,
|
|
50
|
+
and skipping the wrong one is how the panel stops showing what you came to see.
|
|
51
|
+
|
|
52
|
+
An asset still takes the selection when nothing else has it, so a panel opened mid-load
|
|
53
|
+
shows a request rather than an empty pane. A paused panel still counts assets toward its
|
|
54
|
+
pending badge.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **A `kind` facet on the DevTools All tab**, beside method and status. Assets were never the
|
|
59
|
+
problem, only their claim on the selection, so they are not hidden: pick `page` and `api`
|
|
60
|
+
for a list without fifty stylesheet fetches in it, or `asset` alone for what the browser
|
|
61
|
+
pulled in, what it cost and which of it 404'd — which was not visible anywhere before.
|
|
62
|
+
|
|
63
|
+
## 1.8.0 — 2026-08-24
|
|
64
|
+
|
|
65
|
+
The first render mode, the codemod runner 2.0 depends on, and five failures that
|
|
66
|
+
each looked like something other than what they were.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **`static interactive = false` — the first rung of Flow's render modes.** Every component
|
|
71
|
+
until now was maximally interactive: rendered on the server, dehydrated into a snapshot,
|
|
72
|
+
tracked by the client, reachable over a socket. Right for a counter, wasteful for a nav rail.
|
|
73
|
+
A static component is rendered in full by its parent and nothing else — no `onDehydrate`, no
|
|
74
|
+
snapshot, no `<script type="application/json">`, no entry in the client's registry, and no
|
|
75
|
+
`data-flow-root`, which would freeze it at its first render since its only route to an update
|
|
76
|
+
is the parent re-rendering it. It takes no place in `_childIds` and does not shift its
|
|
77
|
+
interactive siblings' ids, so no sibling remounts and loses its state when a static one
|
|
78
|
+
appears above it. `lazy`, `defer` and `stream` throw rather than being ignored: each waits
|
|
79
|
+
for the client to ask for the real render, and a static child never registers to do the
|
|
80
|
+
asking.
|
|
81
|
+
|
|
82
|
+
Opt-in — nothing existing changes. `this.isInteractive` reports the mode from inside the
|
|
83
|
+
component, and being a new public member it takes that name away from applications; it is on
|
|
84
|
+
the documented reserved list. See [Static children](/docs/flow/layouts#static-children).
|
|
85
|
+
|
|
86
|
+
- **`zt upgrade` — the codemod runner.** The 2.0 ledger's rule is that every entry that can
|
|
87
|
+
have a codemod has one before 2.0 ships, and until now nothing had been built, which made
|
|
88
|
+
the ledger a list of changes nobody could afford to make.
|
|
89
|
+
|
|
90
|
+
**Dry by default**, which is backwards from most tools and deliberate: it rewrites source
|
|
91
|
+
across a whole project, and the first run should be something you can read and disagree with.
|
|
92
|
+
`--write` applies it. Nothing is written until the whole plan is known, so a run that fails
|
|
93
|
+
halfway leaves no half-upgraded tree, and `--dry` exercises the same code path as the real
|
|
94
|
+
thing rather than a parallel one that can drift from it. Codemods see each other's output,
|
|
95
|
+
because two of them touching one file across a version range is ordinary.
|
|
96
|
+
|
|
97
|
+
**What it could not do is the headline.** A codemod that walks past what it does not
|
|
98
|
+
understand is worse than none, since the changes it _did_ make imply the job is finished. So
|
|
99
|
+
every codemod returns two lists and the runner prints the second last and loudest, with file,
|
|
100
|
+
line and a reason. The first codemod covers the deprecated aliases — `BaseModel` → `Model`,
|
|
101
|
+
`routes:types` → `route:types`, `serve --dev` → `dev`. See [Commands](/docs/commands).
|
|
102
|
+
|
|
103
|
+
- **`--clean` on `assets:build` and `inertia:build`.** Pruning is conservative by default:
|
|
104
|
+
chunk-shaped filenames, plus whatever the last build on this machine recorded in `.zerotal/`.
|
|
105
|
+
That cannot recognise output some other naming produced. `--clean` needs no record — the
|
|
106
|
+
output directory belongs to the build, and what the build did not write does not belong in
|
|
107
|
+
it. It refuses `public/` and the project root, where deleting what was not rebuilt takes the
|
|
108
|
+
app's images and favicon with it, which is the one failure here that building again cannot
|
|
109
|
+
undo. Pruning stays the default; only you can say the directory holds nothing else.
|
|
110
|
+
|
|
111
|
+
- **Agent skills, from `@zerotal/arch`.** `AGENTS.md` is short because every prompt it lands
|
|
112
|
+
in pays for its whole length, so it points rather than teaches. That has a cost: an agent
|
|
113
|
+
gets a map and no detail, and the detail is where the expensive mistakes live. A skill is a
|
|
114
|
+
file with a one-line description that costs nothing until an agent decides it is relevant,
|
|
115
|
+
so a procedure can be written out in full. Two ship — one on changing the schema (who owns
|
|
116
|
+
it in your app, the mixin columns nothing declares, and why an unguarded `ALTER TABLE`
|
|
117
|
+
collides during a release's `migrate`) and one on shipping a release (naming your own deploy
|
|
118
|
+
steps, replacing the asset directory rather than merging into it, `trustedProxies` behind a
|
|
119
|
+
proxy, and the pipe that hides a test suite's exit status).
|
|
120
|
+
|
|
121
|
+
Written to `.agents/skills`, plus `.claude/skills` when that agent is detected. To replace
|
|
122
|
+
one this ships, edit it and delete its marker line — a `SKILL.md` without the marker is
|
|
123
|
+
yours and is never rewritten. `ArchConfig({ skills: false })` turns the feature off. Run
|
|
124
|
+
`zt arch:update` to install them.
|
|
125
|
+
|
|
126
|
+
- **`zt doctor` reports agent instructions that no longer describe your project.** Every fact
|
|
127
|
+
in the generated block moves without anyone thinking about the file: add a migrations
|
|
128
|
+
directory, turn `synchronize` off, install a package. It goes on reading as current while
|
|
129
|
+
describing the app you used to have, and guidance that is confidently out of date gets
|
|
130
|
+
followed rather than questioned. Skills rot the same way and are easier to miss, since
|
|
131
|
+
nothing reads one until an agent decides it is relevant — by which point it is being acted
|
|
132
|
+
on. The check regenerates both and compares, and names `zt arch:update` as the fix. A
|
|
133
|
+
warning, not a failure: it misleads a reader, it does not stop the app working.
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
|
|
137
|
+
- **A Flow page with nothing interactive on it opens no socket.** Every page connected at
|
|
138
|
+
boot, unconditionally — so a marketing page, a docs article or a rendered report held a
|
|
139
|
+
WebSocket per visitor, open on both ends for the life of the visit, to carry nothing. Both
|
|
140
|
+
paths that write to the socket take a `FlowComponent`, so with none registered there was not
|
|
141
|
+
a frame that _could_ be sent. The connection is made when something needs it now: after the
|
|
142
|
+
initial scan, after an SPA navigation, after a patch registers a child. `<Link navigate>`
|
|
143
|
+
fetches over HTTP, so a static page with links stays disconnected. A routed page honours the
|
|
144
|
+
same static, which is the half that matters — a page is a component, and one whose children
|
|
145
|
+
are static but which is interactive itself still connects.
|
|
146
|
+
|
|
147
|
+
- **The `@zerotal/arch` agent block describes how your app is set up, not only what it
|
|
148
|
+
installed.** A package list answers "what is available here", which is not the question that
|
|
149
|
+
decides what an agent should write: the framework's contracts are not uniform across
|
|
150
|
+
projects, and the places they differ are the places where guessing wrong compiles cleanly and
|
|
151
|
+
fails at runtime. `AGENTS.md` now states the four facts that change an instruction — who owns
|
|
152
|
+
the schema, whether route names are typed, whether `exactOptionalPropertyTypes` or
|
|
153
|
+
`noUncheckedIndexedAccess` are on, and whether there are tests to run. Read off disk rather
|
|
154
|
+
than from a booted app, because a project that will not boot is often why the agent surface
|
|
155
|
+
is being installed. `.env` is deliberately not among the files read: this output is committed
|
|
156
|
+
and pasted into prompts, and a detector that reads secrets is one refactor away from emitting
|
|
157
|
+
them. Re-run `zt arch:update` to pick it up.
|
|
158
|
+
|
|
159
|
+
### Fixed
|
|
160
|
+
|
|
161
|
+
- **No mail could be sent over port 587.** A STARTTLS upgrade hands back a new socket and
|
|
162
|
+
leaves the old one attached, still firing its callbacks — and what that one delivers from
|
|
163
|
+
then on is the undecrypted TLS stream. Both sets of handlers appended to a single reply
|
|
164
|
+
buffer, so handshake records and ciphertext sat in the middle of the server's replies and no
|
|
165
|
+
line in the buffer matched a reply any more: the driver waited out its timeout without ever
|
|
166
|
+
parsing the `250`, and the server logged a connection lost after STARTTLS. Measured, 1,737
|
|
167
|
+
bytes of ciphertext went into the discarded socket's handler while the TLS handler received
|
|
168
|
+
the replies.
|
|
169
|
+
|
|
170
|
+
`close` and `error` were worse than `data`. The plaintext socket ending is a normal part of
|
|
171
|
+
handing over to TLS, and it marked the live connection closed — rejecting whatever was
|
|
172
|
+
waiting on the session that had just replaced it. Each set of callbacks now captures the
|
|
173
|
+
generation it was installed for, and an upgrade bumps it.
|
|
174
|
+
|
|
175
|
+
- **An Inertia `303` redirect left the browser doing nothing at all.** `X-Inertia: true` was
|
|
176
|
+
set inside the 302-to-303 conversion, so it only ever reached a redirect that arrived as a
|
|
177
|
+
301 or 302 from a non-GET handler. A handler returning the 303 the protocol asks for skipped
|
|
178
|
+
the only line that marked its response — and `redirect(to, 303)` is what
|
|
179
|
+
[Authentication](/docs/authentication) tells people to write, in eight places. The form
|
|
180
|
+
submitted, the row was written, the mail went out, and the fields stayed filled in: a hang
|
|
181
|
+
from both ends, which is the worst shape a failure can take. Marking now happens for every
|
|
182
|
+
redirect status on an Inertia request, with the conversion a separate decision on top of it.
|
|
183
|
+
`307` and `308` are marked but left alone, since preserving the method is the whole reason to
|
|
184
|
+
choose them.
|
|
185
|
+
|
|
186
|
+
- **Answering the busy-port menu killed `serve --dev` on the spot.** The banner printed, then
|
|
187
|
+
`exited with code 1`, and nothing said why. Reading a prompt locks Bun's stdin stream, and
|
|
188
|
+
the lock is deliberately held for the life of the command so a second prompt can still read —
|
|
189
|
+
so the dev deck taking the terminal over threw `ReadableStream is locked`. It died inside the
|
|
190
|
+
alternate screen buffer, and restoring the terminal on the way out erased the error along
|
|
191
|
+
with everything else drawn there, which is why this was reported as "it just exits" rather
|
|
192
|
+
than as the error it was. The prompt hands stdin back where it took it; a deck that still
|
|
193
|
+
cannot have stdin degrades to streaming rather than dying, and a dev-mode failure stops the
|
|
194
|
+
deck before it reports.
|
|
195
|
+
|
|
196
|
+
- **Two builds sharing an output directory deleted each other's files.** Nothing forbids
|
|
197
|
+
`inertia:build` and `assets:build` writing to the same place, and the defaults invite it: one
|
|
198
|
+
writes to `public/assets`, `app.assets.outDir` often names the same directory, and the
|
|
199
|
+
default release pipeline runs them one after the other. The record of what to prune was one
|
|
200
|
+
flat list per directory, so each build read the other's files as its own previous build and
|
|
201
|
+
removed them. The release ended with whichever ran last and nothing reported a problem — the
|
|
202
|
+
build that lost still said "Build complete" on its way out, and the page it served then 404'd
|
|
203
|
+
its own script. The record is keyed by entry point now: a file another build claimed is not
|
|
204
|
+
this one's to remove, while chunks nobody claims are still swept.
|
|
205
|
+
|
|
206
|
+
- **`zt doctor` failed a schema configuration that works.** Sync on plus migrations present
|
|
207
|
+
read as "the schema needs exactly one source of truth", which misses the documented
|
|
208
|
+
arrangement where sync builds the schema from the models so a fresh clone runs without a
|
|
209
|
+
migration step, and `synchronize` is an expression that is false in production, where the
|
|
210
|
+
deploy runs `migrate`. The two never apply in the same environment. It fails in production
|
|
211
|
+
now, where the deploy really does run both, and warns elsewhere. A check that cries wolf
|
|
212
|
+
against a correct configuration is what stops `zt doctor` ever being trusted to gate a
|
|
213
|
+
deploy.
|
|
214
|
+
|
|
215
|
+
### Documented
|
|
216
|
+
|
|
217
|
+
- **Replace a release directory, do not merge into it.** Chasing 195 orphaned chunks on a
|
|
218
|
+
server showed the build was never the problem — ten releases of a code-split app into one
|
|
219
|
+
directory hold steady at the build's own output, and every one of the reporting app's 68
|
|
220
|
+
chunks is referenced. What accumulates is the _release_: the archive is extracted over the
|
|
221
|
+
running directory, so every file in it is written and every file not in it is left alone, and
|
|
222
|
+
nothing on that machine ever runs a build. They stay publicly fetchable at their
|
|
223
|
+
content-hashed URLs, which is how copy that was taken down went on being served.
|
|
224
|
+
[Deployment](/docs/deployment) now gives the two spellings that replace the directory.
|
|
225
|
+
|
|
30
226
|
## 1.7.5 — 2026-08-23
|
|
31
227
|
|
|
32
228
|
Two bugs that shipped to every deployed app, a package promoted to `stable`, and
|
package/docs/commands.md
CHANGED
|
@@ -315,6 +315,38 @@ Packages register their own — see
|
|
|
315
315
|
| `bun zt doctor` | Check the app for silent misconfigurations |
|
|
316
316
|
| `bun zt key:generate` | Generate a new `APP_KEY` and write it to `.env` |
|
|
317
317
|
| `bun zt lint:packages` | Check every workspace package against convention rules |
|
|
318
|
+
| `bun zt upgrade` | Apply the codemods for a version upgrade |
|
|
319
|
+
|
|
320
|
+
### Upgrading between versions
|
|
321
|
+
|
|
322
|
+
`bun zt upgrade --to <version>` applies the codemods a version gap calls for —
|
|
323
|
+
mechanical rewrites the framework can make on your behalf when an API changes.
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
bun zt upgrade --to 2.0.0 # print the plan, change nothing
|
|
327
|
+
bun zt upgrade --to 2.0.0 --write # apply it
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**It writes nothing unless you ask.** That is the opposite of most tools and
|
|
331
|
+
deliberate: it rewrites source across your whole project, so the first run should
|
|
332
|
+
be something you can read and disagree with. `--from` defaults to the `zerotal`
|
|
333
|
+
version in your `package.json`.
|
|
334
|
+
|
|
335
|
+
The half worth reading is the last one. A codemod that quietly walks past
|
|
336
|
+
something it does not understand is worse than none, because the changes it _did_
|
|
337
|
+
make suggest the job is finished — so anything it recognised and deliberately did
|
|
338
|
+
not touch is listed with a file, a line and a reason:
|
|
339
|
+
|
|
340
|
+
```text
|
|
341
|
+
1 place(s) need a decision this cannot make for you:
|
|
342
|
+
app/models/Post.ts:9 export function all<T extends BaseModel>(rows: T[]): T[] {
|
|
343
|
+
`BaseModel` in a type position. It resolves to the same class as `Model`, so
|
|
344
|
+
this compiles either way — renaming it is a readability call, not a
|
|
345
|
+
correctness one.
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Run it again after applying: a second run should report no changes. Codemods are
|
|
349
|
+
idempotent, and that is the cheapest way to confirm one did what it said.
|
|
318
350
|
|
|
319
351
|
`doctor` runs every static sanity check against the booted app and prints each
|
|
320
352
|
finding with its fix: APP_KEY strength, `database.synchronize` colliding with
|
package/docs/deployment.md
CHANGED
|
@@ -71,6 +71,30 @@ Each entry is a `DeployTarget`:
|
|
|
71
71
|
Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
|
|
72
72
|
`staging`, both with the default steps.
|
|
73
73
|
|
|
74
|
+
The defaults build and migrate; they do not check anything you wrote. A preflight command
|
|
75
|
+
of your own — `release:check`, a smoke test — has to be named in `steps` to run, and
|
|
76
|
+
nothing prompts you to add it, so a command written precisely to guard a release can sit
|
|
77
|
+
there never running. Name every step you want, in the order you want them:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// config/deploy.ts
|
|
81
|
+
export default {
|
|
82
|
+
targets: {
|
|
83
|
+
production: {
|
|
84
|
+
url: "https://example.com",
|
|
85
|
+
steps: ["release:check", "assets:build", "inertia:build", "migrate"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Put a check first. A step that fails stops the release, and a check that runs after the
|
|
92
|
+
migration has already run has missed its moment.
|
|
93
|
+
|
|
94
|
+
Two things worth adding while you are there: `assets:build` and `inertia:build` accept
|
|
95
|
+
`--clean`, which removes anything in the output directory the build did not write — see
|
|
96
|
+
[Build assets](#build-assets).
|
|
97
|
+
|
|
74
98
|
> **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
|
|
75
99
|
> variables. It does not reach another machine over SSH. Run it on the box, or in
|
|
76
100
|
> the container build, as the step before the restart.
|
|
@@ -176,6 +200,56 @@ release that built its assets ahead of time and locked the tree down serves what
|
|
|
176
200
|
shipped, and logs one line saying so. That is what lets the service run under a properly
|
|
177
201
|
hardened unit; see [Hardening the service](#hardening-the-service).
|
|
178
202
|
|
|
203
|
+
### Replace the asset directory on release, do not merge into it
|
|
204
|
+
|
|
205
|
+
Code splitting names every chunk after its content, so each build emits a new set and
|
|
206
|
+
abandons the last. Both build commands clean those up as they go, and they do it without
|
|
207
|
+
needing anything to have survived from the previous build — so the directory a build
|
|
208
|
+
writes holds that build's output and nothing else, on a developer's machine and a fresh CI
|
|
209
|
+
checkout alike.
|
|
210
|
+
|
|
211
|
+
What the build cannot clean is a directory it never sees. A release that is **unpacked over
|
|
212
|
+
the top** of the running one — `tar -xzf` into the app directory, `rsync` without
|
|
213
|
+
`--delete` — merges: every file in the archive is written, and every file that is not in
|
|
214
|
+
the archive is left exactly where it was. Nothing on that server ever ran a build, so
|
|
215
|
+
nothing ever removes last release's chunks, and they collect one release at a time.
|
|
216
|
+
|
|
217
|
+
That is not only clutter. They stay publicly fetchable at their content-hashed URLs, so a
|
|
218
|
+
page whose copy you withdrew is still readable by anyone holding the link — pricing you
|
|
219
|
+
took down, a policy you replaced, a feature you pulled.
|
|
220
|
+
|
|
221
|
+
Clear the directory as part of the release, before the new files land:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# on the server, before extracting
|
|
225
|
+
rm -rf "$APP_DIR/public/assets"
|
|
226
|
+
tar -xzf release.tgz -C "$APP_DIR"
|
|
227
|
+
|
|
228
|
+
# or let rsync do it
|
|
229
|
+
rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Ordering matters if the old release is still serving traffic: clearing the directory takes
|
|
233
|
+
its bundles away, so do it as close to the swap as you can, or stage the release in a new
|
|
234
|
+
directory and move it into place.
|
|
235
|
+
|
|
236
|
+
### `--clean` for a directory the build does not own outright
|
|
237
|
+
|
|
238
|
+
The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
|
|
239
|
+
`naming`, or that writes a second bundle into the same directory by other means, can leave
|
|
240
|
+
output the build does not recognise as its own. `--clean` needs no recognition — whatever
|
|
241
|
+
this build did not write, goes:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# in your project root
|
|
245
|
+
bun zt assets:build --clean
|
|
246
|
+
bun zt inertia:build --production --clean
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
It refuses `public/` itself and the project root, where deleting what was not rebuilt would
|
|
250
|
+
take the app's images and favicon with it. Point the build at a directory of its own —
|
|
251
|
+
`public/assets` is the Inertia default.
|
|
252
|
+
|
|
179
253
|
Bump your asset version (or hash the bundle) so clients reload onto the new build — see
|
|
180
254
|
[Inertia › Asset versioning](/docs/inertia/middleware#asset-versioning).
|
|
181
255
|
|
|
@@ -253,6 +327,29 @@ Origins are compared exactly: no wildcards and no suffix matching, because
|
|
|
253
327
|
|
|
254
328
|
> **Warning** — an app with the wrong origin configured renders every page correctly and refuses every action. There is no 500, nothing in the logs, and a status-code health check passes. The only symptom is that buttons do nothing.
|
|
255
329
|
|
|
330
|
+
### Rate limiting counts the proxy, not the visitor
|
|
331
|
+
|
|
332
|
+
`ThrottleMiddleware` identifies a client by the address the request arrived from. Behind a
|
|
333
|
+
proxy that address is the proxy — `127.0.0.1` for every visitor on the site — so one bucket
|
|
334
|
+
is shared by everybody, and a single busy client can lock the whole site out.
|
|
335
|
+
|
|
336
|
+
Tell it how many proxies sit in front of the app:
|
|
337
|
+
|
|
338
|
+
```ts fragment
|
|
339
|
+
// One proxy (Caddy, nginx, a load balancer) between the internet and the app.
|
|
340
|
+
ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The count is how many entries to skip from the right of `X-Forwarded-For`. It is opt-in and
|
|
344
|
+
defaults to zero because the header is written by the client until something trusted
|
|
345
|
+
overwrites it: trusting it by default would let anyone forge an address and walk around
|
|
346
|
+
every limit you set. Zero is the safe default and the wrong answer once you deploy behind
|
|
347
|
+
something — which is easy to do and never revisit, because nothing about it fails loudly.
|
|
348
|
+
The symptom is a legitimate visitor getting a 429 they did not earn.
|
|
349
|
+
|
|
350
|
+
Count the proxies you actually run. Setting `trustedProxies: 3` with one proxy in front
|
|
351
|
+
reads an entry the client supplied.
|
|
352
|
+
|
|
256
353
|
### Never gate the transport path
|
|
257
354
|
|
|
258
355
|
**Browsers do not attach basic-auth credentials to a WebSocket handshake.** An HTTP auth
|
package/docs/flow/index.md
CHANGED
|
@@ -195,7 +195,7 @@ The names in use:
|
|
|
195
195
|
| Group | Names |
|
|
196
196
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
197
197
|
| Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
|
|
198
|
-
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child`
|
|
198
|
+
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` `isInteractive` |
|
|
199
199
|
| Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
|
|
200
200
|
| Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
|
|
201
201
|
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
|
package/docs/flow/layouts.md
CHANGED
|
@@ -365,6 +365,70 @@ export class CounterWidget extends Component {
|
|
|
365
365
|
|
|
366
366
|
Props that need to survive WebSocket round-trips must be `@locked` so they are included in the snapshot. A `@locked` prop is set once at mount and stays fixed for the child's lifetime.
|
|
367
367
|
|
|
368
|
+
### Static children
|
|
369
|
+
|
|
370
|
+
A child that will never receive an action does not need to be an island. Declare
|
|
371
|
+
`static interactive = false` and it is rendered as plain markup:
|
|
372
|
+
|
|
373
|
+
```tsx fragment
|
|
374
|
+
export class SiteHeader extends Component {
|
|
375
|
+
static interactive = false;
|
|
376
|
+
|
|
377
|
+
override async render() {
|
|
378
|
+
return <header class="site-header">{/* … */}</header>;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
No snapshot, no state script, no entry in the client's component registry — and
|
|
384
|
+
nothing on the wire for it beyond the HTML itself. Use it for the parts of a page
|
|
385
|
+
that are structure rather than behaviour: headers, nav rails, footers, marketing
|
|
386
|
+
sections.
|
|
387
|
+
|
|
388
|
+
**A static child re-renders with its parent.** That is the difference that
|
|
389
|
+
matters. An interactive child is preserved across a parent update — its DOM and
|
|
390
|
+
state are left alone, which is the point of an island. A static child has no
|
|
391
|
+
state to preserve, so it is rendered fresh every time the parent renders, and
|
|
392
|
+
props passed to it are simply current.
|
|
393
|
+
|
|
394
|
+
It cannot be `lazy`, `defer` or `stream`: each of those waits for the client to
|
|
395
|
+
ask for the real render, and a static child never registers with the client to
|
|
396
|
+
do the asking. Passing one throws rather than leaving a placeholder that never
|
|
397
|
+
resolves.
|
|
398
|
+
|
|
399
|
+
Inside the component, `this.isInteractive` reports which mode it is in — useful
|
|
400
|
+
for leaving out something that only makes sense with a client attached.
|
|
401
|
+
|
|
402
|
+
### A wholly static page opens no socket
|
|
403
|
+
|
|
404
|
+
`static interactive = false` works on a routed page as well as on a child, and the
|
|
405
|
+
page is the one that matters: a page is a component too, so a page whose children
|
|
406
|
+
are all static but which is interactive itself still registers with the client —
|
|
407
|
+
and still connects.
|
|
408
|
+
|
|
409
|
+
```tsx fragment
|
|
410
|
+
export class ChangelogPage extends Component {
|
|
411
|
+
static interactive = false;
|
|
412
|
+
|
|
413
|
+
override async render() {
|
|
414
|
+
return <article>{/* … */}</article>;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
When nothing on the page registers, **no WebSocket is opened at all**. Not opened
|
|
420
|
+
and idle — never created. For a marketing page, a documentation article or a
|
|
421
|
+
rendered report, that is one connection per visitor that no longer exists on
|
|
422
|
+
either end.
|
|
423
|
+
|
|
424
|
+
The connection is made when it is needed rather than at boot, so a page that
|
|
425
|
+
becomes interactive later — an SPA navigation to one that is, a deferred child
|
|
426
|
+
arriving — connects at that moment. `<Link navigate>` needs nothing: it fetches
|
|
427
|
+
over HTTP.
|
|
428
|
+
|
|
429
|
+
A page with a single interactive component still connects, as before. One is
|
|
430
|
+
enough.
|
|
431
|
+
|
|
368
432
|
### Slots
|
|
369
433
|
|
|
370
434
|
Where props pass **data** into a child, slots pass **markup**. A child component's plain children become its **default slot**; a `slots={{ … }}` prop supplies **named slots**. Inside the child, place each with `this.slot(name)` (or `this.slot()` for the default), and branch on `this.hasSlot(name)` to drop an optional wrapper entirely. This is the pattern for reusable shells — cards, modals, panels, page headers — where the container is fixed but the contents vary per use.
|
package/docs/testing/index.md
CHANGED
|
@@ -209,6 +209,32 @@ observers, global scopes, and state-machine callbacks, plus framework event
|
|
|
209
209
|
subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
|
|
210
210
|
using those helpers don't need the explicit `afterEach`.
|
|
211
211
|
|
|
212
|
+
## Running the suite from a script
|
|
213
|
+
|
|
214
|
+
A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The suite is verbose enough that piping it somewhere is the natural thing to write, which
|
|
221
|
+
is what makes this worth saying: a deploy script written that way prints `1 fail` and
|
|
222
|
+
carries straight on to upload and restart. Nothing is wrong with the output — it is the
|
|
223
|
+
`$?` behind it that belongs to the last command in the pipe.
|
|
224
|
+
|
|
225
|
+
Either turn the pipe honest, or do not pipe:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
set -o pipefail # bash/zsh: the pipeline fails if any stage does
|
|
229
|
+
bun test 2>&1 | tail -3
|
|
230
|
+
|
|
231
|
+
# or keep the status and the output separately
|
|
232
|
+
bun test > test.log 2>&1 || { tail -20 test.log; exit 1; }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`set -e` alone does not cover it — the pipeline succeeded, as far as the shell is
|
|
236
|
+
concerned.
|
|
237
|
+
|
|
212
238
|
## References
|
|
213
239
|
|
|
214
240
|
The most-used members exported from `@zerotal/testing`. Each area's page documents
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"typecheck": "tsc --noEmit"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zerotal/core": "1.
|
|
38
|
+
"@zerotal/core": "1.8.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
42
|
+
"@zerotal/orm": "1.8.1"
|
|
43
43
|
},
|
|
44
44
|
"description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
|
|
45
45
|
"keywords": [
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface ArchConfigShape {
|
|
|
22
22
|
* `AGENTS.md` natively, and two files of guidance drift apart.
|
|
23
23
|
*/
|
|
24
24
|
claudeFile: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Write the agent skill files.
|
|
27
|
+
*
|
|
28
|
+
* On by default: they cost nothing until an agent decides one is relevant,
|
|
29
|
+
* which is the entire reason they are files rather than more of `AGENTS.md`.
|
|
30
|
+
*/
|
|
31
|
+
skills: boolean;
|
|
25
32
|
/** Write the MCP client configuration. Default: `true`. */
|
|
26
33
|
mcpConfig: boolean;
|
|
27
34
|
/** Path of the MCP client config, relative to the project root. */
|
|
@@ -37,6 +44,7 @@ export interface ArchConfigShape {
|
|
|
37
44
|
const defaults: ArchConfigShape = {
|
|
38
45
|
agentsFile: true,
|
|
39
46
|
claudeFile: true,
|
|
47
|
+
skills: true,
|
|
40
48
|
mcpConfig: true,
|
|
41
49
|
mcpConfigPath: ".mcp.json",
|
|
42
50
|
serverName: "zerotal",
|