@zerotal/arch 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +99 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +21 -21
- package/docs/flow/layouts.md +99 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +29 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
package/docs/validator.md
CHANGED
|
@@ -89,7 +89,7 @@ export class StorePostRequest extends FormRequest {
|
|
|
89
89
|
Call `validate()` as a static method on the class. It reads `HttpContext` from
|
|
90
90
|
`AsyncLocalStorage` — no arguments needed.
|
|
91
91
|
|
|
92
|
-
```typescript
|
|
92
|
+
```typescript fragment
|
|
93
93
|
// in a controller
|
|
94
94
|
const data = await StorePostRequest.validate();
|
|
95
95
|
|
|
@@ -116,7 +116,7 @@ never need to catch them yourself.
|
|
|
116
116
|
`authorize()` runs **before** validation. Returning `false` throws a
|
|
117
117
|
`ForbiddenError` (403) without touching the request body.
|
|
118
118
|
|
|
119
|
-
```typescript
|
|
119
|
+
```typescript fragment
|
|
120
120
|
// in a FormRequest subclass
|
|
121
121
|
|
|
122
122
|
// Check the current user's role:
|
|
@@ -141,7 +141,7 @@ async authorize(): Promise<boolean> {
|
|
|
141
141
|
Because `rules()` is an instance method, you have full access to `this.context`
|
|
142
142
|
for rules that depend on the current user, route params, or session state:
|
|
143
143
|
|
|
144
|
-
```typescript
|
|
144
|
+
```typescript fragment
|
|
145
145
|
// in a FormRequest subclass
|
|
146
146
|
rules(r: RuleBuilder) {
|
|
147
147
|
const userId = this.context.user?.id;
|
|
@@ -157,7 +157,7 @@ rules(r: RuleBuilder) {
|
|
|
157
157
|
|
|
158
158
|
For simple one-off validation without a dedicated class:
|
|
159
159
|
|
|
160
|
-
```typescript
|
|
160
|
+
```typescript fragment
|
|
161
161
|
// in a controller
|
|
162
162
|
import { validate } from "@zerotal/validator";
|
|
163
163
|
|
|
@@ -180,7 +180,7 @@ For CLI commands, services, or background jobs — where there is no request to
|
|
|
180
180
|
redirect and no response to throw — use the `Validator` facade. It returns a
|
|
181
181
|
`{ success, data, errors }` outcome and never throws.
|
|
182
182
|
|
|
183
|
-
```typescript
|
|
183
|
+
```typescript fragment
|
|
184
184
|
// in a command or service
|
|
185
185
|
import { Validator } from "@zerotal/validator";
|
|
186
186
|
|
|
@@ -200,7 +200,7 @@ if (!result.success) {
|
|
|
200
200
|
|
|
201
201
|
Start every rule chain with a type method on `RuleBuilder`:
|
|
202
202
|
|
|
203
|
-
```typescript
|
|
203
|
+
```typescript fragment
|
|
204
204
|
// in a rules() method or factory
|
|
205
205
|
const r = new RuleBuilder(); // or the `r` param in rules()
|
|
206
206
|
|
|
@@ -247,7 +247,7 @@ These are available on **every** rule type:
|
|
|
247
247
|
|
|
248
248
|
### Custom validator
|
|
249
249
|
|
|
250
|
-
```typescript
|
|
250
|
+
```typescript fragment
|
|
251
251
|
// in a rules() method or factory
|
|
252
252
|
username: r.string().custom(async (value) => {
|
|
253
253
|
const taken = await User.findBy("username", value as string);
|
|
@@ -257,7 +257,7 @@ username: r.string().custom(async (value) => {
|
|
|
257
257
|
|
|
258
258
|
## String rules
|
|
259
259
|
|
|
260
|
-
```typescript
|
|
260
|
+
```typescript fragment
|
|
261
261
|
// in a rules() method or factory
|
|
262
262
|
r.string()
|
|
263
263
|
.min(3) // minimum character length
|
|
@@ -292,7 +292,7 @@ r.string()
|
|
|
292
292
|
|
|
293
293
|
## Number rules
|
|
294
294
|
|
|
295
|
-
```typescript
|
|
295
|
+
```typescript fragment
|
|
296
296
|
// in a rules() method or factory
|
|
297
297
|
r.number()
|
|
298
298
|
.min(0) // minimum value (inclusive)
|
|
@@ -305,7 +305,7 @@ r.number()
|
|
|
305
305
|
|
|
306
306
|
## Boolean rules
|
|
307
307
|
|
|
308
|
-
```typescript
|
|
308
|
+
```typescript fragment
|
|
309
309
|
// in a rules() method or factory
|
|
310
310
|
r.boolean()
|
|
311
311
|
.accepted() // truthy — for "agree to terms" checkboxes
|
|
@@ -314,7 +314,7 @@ r.boolean()
|
|
|
314
314
|
|
|
315
315
|
## Date rules
|
|
316
316
|
|
|
317
|
-
```typescript
|
|
317
|
+
```typescript fragment
|
|
318
318
|
// in a rules() method or factory
|
|
319
319
|
r.date()
|
|
320
320
|
.after("2026-01-01") // strictly after date
|
|
@@ -325,7 +325,7 @@ r.date()
|
|
|
325
325
|
|
|
326
326
|
## Array rules
|
|
327
327
|
|
|
328
|
-
```typescript
|
|
328
|
+
```typescript fragment
|
|
329
329
|
// in a rules() method or factory
|
|
330
330
|
r.array(r.string()) // array of strings
|
|
331
331
|
.min(1) // minimum item count
|
|
@@ -340,7 +340,7 @@ r.array(r.object({ name: r.string(), age: r.number() }));
|
|
|
340
340
|
|
|
341
341
|
## Object rules
|
|
342
342
|
|
|
343
|
-
```typescript
|
|
343
|
+
```typescript fragment
|
|
344
344
|
// in a rules() method or factory
|
|
345
345
|
r.object({
|
|
346
346
|
street: r.string(),
|
|
@@ -357,7 +357,7 @@ r.object({
|
|
|
357
357
|
|
|
358
358
|
Validates `File` objects from `multipart/form-data` uploads:
|
|
359
359
|
|
|
360
|
-
```typescript
|
|
360
|
+
```typescript fragment
|
|
361
361
|
// in a rules() method or factory
|
|
362
362
|
avatar: r.file()
|
|
363
363
|
.mimes(["jpg", "jpeg", "png", "webp"]) // allowed extensions
|
|
@@ -368,7 +368,7 @@ avatar: r.file()
|
|
|
368
368
|
|
|
369
369
|
## Password rules
|
|
370
370
|
|
|
371
|
-
```typescript
|
|
371
|
+
```typescript fragment
|
|
372
372
|
// in a rules() method or factory
|
|
373
373
|
r.password()
|
|
374
374
|
.min(8) // minimum length (default: 8)
|
|
@@ -390,7 +390,7 @@ The third argument ignores a record on update — pass the current record's ID,
|
|
|
390
390
|
a `UniqueOptions` object (`{ ignoreId }`) for clarity. A fourth argument
|
|
391
391
|
overrides the error message.
|
|
392
392
|
|
|
393
|
-
```typescript
|
|
393
|
+
```typescript fragment
|
|
394
394
|
// in a rules() method or factory
|
|
395
395
|
email: r.string().email().unique("users", "email");
|
|
396
396
|
|
|
@@ -403,7 +403,7 @@ email: r.string().email().unique("users", "email", { ignoreId: userId });
|
|
|
403
403
|
|
|
404
404
|
### exists — value must exist in the DB
|
|
405
405
|
|
|
406
|
-
```typescript
|
|
406
|
+
```typescript fragment
|
|
407
407
|
// in a rules() method or factory
|
|
408
408
|
userId: r.number().exists("users", "id");
|
|
409
409
|
roleSlug: r.string().exists("roles", "slug");
|
|
@@ -450,7 +450,7 @@ back to the previous page.
|
|
|
450
450
|
Errors and old input are stored in the [session](/docs/session) under the keys
|
|
451
451
|
`'errors'` and `'old'`. Read them on the next request:
|
|
452
452
|
|
|
453
|
-
```typescript
|
|
453
|
+
```typescript fragment
|
|
454
454
|
// in a controller
|
|
455
455
|
const errors = ctx.flashed<Record<string, string[]>>("errors");
|
|
456
456
|
const old = ctx.flashed<Record<string, unknown>>("old");
|
|
@@ -500,7 +500,7 @@ would not.
|
|
|
500
500
|
**A `FormRequest` fails the HTTP request**, so test it through the route it
|
|
501
501
|
guards. The status tells you which failure mode you got:
|
|
502
502
|
|
|
503
|
-
```typescript
|
|
503
|
+
```typescript fragment
|
|
504
504
|
// tests/http/register.test.ts
|
|
505
505
|
const res = await app.post("/register", { email: "not-an-email" });
|
|
506
506
|
|
package/docs/view.md
CHANGED
|
@@ -67,7 +67,7 @@ use the [`safe()` helper or `Raw` component](#embedding-raw-html).
|
|
|
67
67
|
|
|
68
68
|
The runtime renders attributes from props with a few rules:
|
|
69
69
|
|
|
70
|
-
```tsx
|
|
70
|
+
```tsx fragment
|
|
71
71
|
// in a component
|
|
72
72
|
<input type="text" value={name} disabled={isLocked} required={false} data-id={42} />
|
|
73
73
|
// → <input type="text" value="…" disabled data-id="42">
|
|
@@ -97,7 +97,7 @@ Children are rendered by type, which is the engine's security boundary:
|
|
|
97
97
|
|
|
98
98
|
Two consequences worth remembering:
|
|
99
99
|
|
|
100
|
-
```tsx
|
|
100
|
+
```tsx fragment
|
|
101
101
|
// in a component
|
|
102
102
|
{
|
|
103
103
|
user && <Welcome name={user.name} />;
|
|
@@ -123,7 +123,7 @@ Call `view()` from a controller action to set the response. It accepts any value
|
|
|
123
123
|
whose `.toString()` returns HTML — a `SafeHtml` instance from JSX is the normal
|
|
124
124
|
case:
|
|
125
125
|
|
|
126
|
-
```tsx
|
|
126
|
+
```tsx fragment
|
|
127
127
|
// app/controllers/PostController.tsx
|
|
128
128
|
import { view } from "zerotal";
|
|
129
129
|
import type { HttpContext } from "zerotal";
|
|
@@ -159,7 +159,7 @@ export default function Welcome(ctx: HttpContext, { title }: { title: string })
|
|
|
159
159
|
}
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
```ts
|
|
162
|
+
```ts fragment
|
|
163
163
|
// routes/index.ts — the HttpContext is injected; you only pass the extra props
|
|
164
164
|
import { Router } from "zerotal";
|
|
165
165
|
import Welcome from "../resources/views/Welcome.tsx";
|
|
@@ -213,7 +213,7 @@ export function AppLayout({ title, children }: AppLayoutProps) {
|
|
|
213
213
|
}
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
-
```tsx
|
|
216
|
+
```tsx fragment
|
|
217
217
|
// resources/views/About.tsx
|
|
218
218
|
import { defineLayout } from "zerotal/view";
|
|
219
219
|
import { AppLayout } from "./layouts/AppLayout.tsx";
|
|
@@ -229,7 +229,7 @@ export const AboutPage = wrap<{ heading: string }>(({ heading }) => (
|
|
|
229
229
|
));
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
```ts
|
|
232
|
+
```ts fragment
|
|
233
233
|
// routes/index.ts
|
|
234
234
|
import { Router } from "zerotal";
|
|
235
235
|
import { AboutPage } from "../resources/views/About.tsx";
|
|
@@ -246,7 +246,7 @@ directly with `Router.view()`. The third argument is the props — a **static
|
|
|
246
246
|
object**, or a **per-request factory** that receives the `HttpContext` and may be
|
|
247
247
|
async:
|
|
248
248
|
|
|
249
|
-
```ts
|
|
249
|
+
```ts fragment
|
|
250
250
|
// routes/index.ts
|
|
251
251
|
// Static props — evaluated once at registration (marketing / info pages):
|
|
252
252
|
Router.view("/about", AboutPage, { title: "About Us" });
|
|
@@ -263,7 +263,7 @@ Router.view("/privacy", PrivacyPage);
|
|
|
263
263
|
|
|
264
264
|
The returned registration is chainable:
|
|
265
265
|
|
|
266
|
-
```ts
|
|
266
|
+
```ts fragment
|
|
267
267
|
// routes/index.ts
|
|
268
268
|
Router.view("/terms", TermsPage)
|
|
269
269
|
.name("terms") // name the route for url() generation
|
|
@@ -276,7 +276,7 @@ Under [file-based routing](/docs/routing), a page file's **default export**
|
|
|
276
276
|
becomes a `GET` route automatically. The page is a function of `(http, params)`
|
|
277
277
|
and may be async:
|
|
278
278
|
|
|
279
|
-
```tsx
|
|
279
|
+
```tsx fragment
|
|
280
280
|
// app/views/posts/[slug].tsx → GET /posts/:slug
|
|
281
281
|
import type { HttpContext } from "zerotal";
|
|
282
282
|
import { Post } from "../../models/Post.ts";
|
|
@@ -311,7 +311,7 @@ export default definePage((http, params) => `<h1>Hello ${params.name}</h1>`);
|
|
|
311
311
|
When you have pre-rendered markup (e.g. from a Markdown renderer) use `safe()` or
|
|
312
312
|
`<Raw>` to bypass escaping:
|
|
313
313
|
|
|
314
|
-
```tsx
|
|
314
|
+
```tsx fragment
|
|
315
315
|
// in a component
|
|
316
316
|
import { safe, Raw } from "zerotal/view";
|
|
317
317
|
|
|
@@ -335,7 +335,7 @@ import { safe, Raw } from "zerotal/view";
|
|
|
335
335
|
`esc()` escapes a value for use in raw string templates, where automatic JSX
|
|
336
336
|
escaping isn't available:
|
|
337
337
|
|
|
338
|
-
```ts
|
|
338
|
+
```ts fragment
|
|
339
339
|
// in a helper
|
|
340
340
|
import { esc } from "zerotal/view";
|
|
341
341
|
|
|
@@ -367,7 +367,7 @@ function returning a node that stringifies, so most of it tests without a server
|
|
|
367
367
|
**Render the component and assert on the string.** No request, no router, no
|
|
368
368
|
browser:
|
|
369
369
|
|
|
370
|
-
```typescript
|
|
370
|
+
```typescript fragment
|
|
371
371
|
// tests/views/PostCard.test.ts
|
|
372
372
|
import { test, expect } from "bun:test";
|
|
373
373
|
import { PostCard } from "../../resources/views/PostCard.tsx";
|
|
@@ -383,7 +383,7 @@ test("renders the title and author", () => {
|
|
|
383
383
|
**Escaping is the test that matters.** Every view that renders user input has one
|
|
384
384
|
job beyond looking right, and it is the job that becomes a security incident:
|
|
385
385
|
|
|
386
|
-
```typescript
|
|
386
|
+
```typescript fragment
|
|
387
387
|
// tests/views/PostCard.test.ts
|
|
388
388
|
test("escapes markup in user-supplied text", () => {
|
|
389
389
|
const html = String(PostCard({ post: { title: "<script>alert(1)</script>" } }));
|
|
@@ -400,7 +400,7 @@ fails if someone later routes user input into it.
|
|
|
400
400
|
**Through a route, assert the rendered text** rather than the markup around it.
|
|
401
401
|
`assertSee` survives a class rename; a full-HTML comparison does not:
|
|
402
402
|
|
|
403
|
-
```typescript
|
|
403
|
+
```typescript fragment
|
|
404
404
|
// tests/http/posts.test.ts
|
|
405
405
|
const res = await app.get("/posts");
|
|
406
406
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/arch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"maturity": "
|
|
5
|
+
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"typecheck": "tsc --noEmit"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@zerotal/core": "1.
|
|
38
|
+
"@zerotal/core": "1.8.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0",
|
|
42
|
-
"@zerotal/orm": "1.
|
|
42
|
+
"@zerotal/orm": "1.8.0"
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,15 @@ export * from "./errors.ts";
|
|
|
27
27
|
// serve a subset, or add its own alongside them.
|
|
28
28
|
export { archTools, vendoredDocsDir } from "./tools/index.ts";
|
|
29
29
|
export type { ToolContext } from "./tools/index.ts";
|
|
30
|
+
// Subprocess plumbing, not API. `_probe.ts` carries the underscore this repo uses
|
|
31
|
+
// for a module nobody outside its package should reach for, nothing in the tree
|
|
32
|
+
// imports these from here, and the export block above says why the tools and the
|
|
33
|
+
// probe topics are public without ever saying why these are. Marked before the
|
|
34
|
+
// stable promise attaches, because withdrawing them afterwards is a breaking
|
|
35
|
+
// change made on behalf of a caller that does not exist.
|
|
36
|
+
/** @internal */
|
|
30
37
|
export { findApp, spawnProbe } from "./tools/_probe.ts";
|
|
38
|
+
/** @internal */
|
|
31
39
|
export type { ProbeResult, ProbeRunner, SpawnProbeOptions } from "./tools/_probe.ts";
|
|
32
40
|
|
|
33
41
|
// The in-app reads, for anyone wanting them without the subprocess.
|
|
@@ -45,12 +53,28 @@ export type {
|
|
|
45
53
|
SchemaReport,
|
|
46
54
|
} from "./probe/topics.ts";
|
|
47
55
|
|
|
48
|
-
// The
|
|
56
|
+
// The writers behind `arch:install`. Exported for the command and its tests to
|
|
57
|
+
// reach, not as API.
|
|
58
|
+
//
|
|
59
|
+
// They were public on the reasoning that "a project can generate the same files
|
|
60
|
+
// its own way" — a use nobody has, and a promise about the shape of `.mcp.json`
|
|
61
|
+
// writing, agent detection and marker fencing that would hold for the rest of
|
|
62
|
+
// the 1.x line. `ArchInstallCommand` is the only caller in the tree. Under `beta`
|
|
63
|
+
// that was a cheap bet; the stable label is what makes it expensive, so they are
|
|
64
|
+
// marked now rather than withdrawn later on behalf of a caller who never arrived.
|
|
65
|
+
/** @internal */
|
|
49
66
|
export { detectAgents } from "./install/detect.ts";
|
|
67
|
+
/** @internal */
|
|
50
68
|
export type { Detected, McpTarget } from "./install/detect.ts";
|
|
69
|
+
/** @internal */
|
|
51
70
|
export { applyMcpConfig, serverEntry, SERVER_ENTRY_PATH } from "./install/mcpConfig.ts";
|
|
71
|
+
/** @internal */
|
|
52
72
|
export type { ConfigOutcome } from "./install/mcpConfig.ts";
|
|
73
|
+
/** @internal */
|
|
53
74
|
export { applyBlock, fence, BLOCK_END, BLOCK_START } from "./install/markers.ts";
|
|
75
|
+
/** @internal */
|
|
54
76
|
export type { BlockOutcome } from "./install/markers.ts";
|
|
77
|
+
/** @internal */
|
|
55
78
|
export { agentsPreamble, buildGuidelines, claudeShim } from "./install/guidelines.ts";
|
|
79
|
+
/** @internal */
|
|
56
80
|
export type { GuidelineOptions } from "./install/guidelines.ts";
|
|
@@ -19,6 +19,9 @@ import { NoProjectRootError } from "../errors.ts";
|
|
|
19
19
|
import { installedPackages } from "../probe/topics.ts";
|
|
20
20
|
import { detectAgents } from "./detect.ts";
|
|
21
21
|
import { agentsPreamble, buildGuidelines, claudeShim } from "./guidelines.ts";
|
|
22
|
+
import { detectShape } from "./shape.ts";
|
|
23
|
+
import { selectSkills, renderSkill, skillPaths, SKILL_MARKER } from "./skills.ts";
|
|
24
|
+
import type { ProjectShape } from "./shape.ts";
|
|
22
25
|
import { applyBlock } from "./markers.ts";
|
|
23
26
|
import { applyMcpConfig } from "./mcpConfig.ts";
|
|
24
27
|
|
|
@@ -52,11 +55,15 @@ export class ArchInstallCommand extends Command {
|
|
|
52
55
|
|
|
53
56
|
const detected = await detectAgents(root);
|
|
54
57
|
const packages = (await installedPackages(root)).map((pkg) => pkg.name);
|
|
58
|
+
// Read off disk rather than from a booted app: a project that will not boot is
|
|
59
|
+
// often exactly why someone is installing the agent surface.
|
|
60
|
+
const shape = await detectShape(root);
|
|
55
61
|
|
|
56
62
|
const changes: Change[] = [
|
|
57
63
|
...(config.mcpConfig ? await this._mcpChanges(root, config, detected.targets) : []),
|
|
58
|
-
...(config.agentsFile ? [await this._agentsChange(root, config, packages)] : []),
|
|
64
|
+
...(config.agentsFile ? [await this._agentsChange(root, config, packages, shape)] : []),
|
|
59
65
|
...(config.claudeFile ? [await this._claudeChange(root)] : []),
|
|
66
|
+
...(config.skills ? await this._skillChanges(root, packages, shape, detected.agents) : []),
|
|
60
67
|
];
|
|
61
68
|
|
|
62
69
|
this.section(dry ? "arch:install — dry run" : "arch:install");
|
|
@@ -77,7 +84,7 @@ export class ArchInstallCommand extends Command {
|
|
|
77
84
|
continue;
|
|
78
85
|
}
|
|
79
86
|
if (change.status === "unchanged") {
|
|
80
|
-
this.dim(` · ${change.path} — already up to date`);
|
|
87
|
+
this.dim(` · ${change.path} — ${change.detail ?? "already up to date"}`);
|
|
81
88
|
continue;
|
|
82
89
|
}
|
|
83
90
|
if (!dry && change.text !== undefined) {
|
|
@@ -153,10 +160,11 @@ export class ArchInstallCommand extends Command {
|
|
|
153
160
|
root: string,
|
|
154
161
|
config: ArchConfigShape,
|
|
155
162
|
packages: string[],
|
|
163
|
+
shape: ProjectShape,
|
|
156
164
|
): Promise<Change> {
|
|
157
165
|
const outcome = applyBlock(
|
|
158
166
|
await readIfPresent(join(root, "AGENTS.md")),
|
|
159
|
-
buildGuidelines({ packages, serverName: config.serverName }),
|
|
167
|
+
buildGuidelines({ packages, serverName: config.serverName, shape }),
|
|
160
168
|
agentsPreamble(),
|
|
161
169
|
);
|
|
162
170
|
return outcome.status === "conflict"
|
|
@@ -164,6 +172,41 @@ export class ArchInstallCommand extends Command {
|
|
|
164
172
|
: { path: "AGENTS.md", status: outcome.status, text: outcome.text };
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The skill files, one per applicable skill per agent directory.
|
|
177
|
+
*
|
|
178
|
+
* A file without {@link SKILL_MARKER} is left exactly as it is. That is the
|
|
179
|
+
* whole override mechanism: to replace a skill this ships, edit it and delete
|
|
180
|
+
* the marker line. Anything cleverer would be a second config to keep in step
|
|
181
|
+
* with the thing it configures.
|
|
182
|
+
*/
|
|
183
|
+
private async _skillChanges(
|
|
184
|
+
root: string,
|
|
185
|
+
packages: string[],
|
|
186
|
+
shape: ProjectShape,
|
|
187
|
+
agents: string[],
|
|
188
|
+
): Promise<Change[]> {
|
|
189
|
+
const changes: Change[] = [];
|
|
190
|
+
|
|
191
|
+
for (const skill of selectSkills(packages, shape)) {
|
|
192
|
+
const text = renderSkill(skill, shape);
|
|
193
|
+
for (const path of skillPaths(skill.name, agents)) {
|
|
194
|
+
const existing = await readIfPresent(join(root, path));
|
|
195
|
+
if (existing !== undefined && !existing.includes(SKILL_MARKER)) {
|
|
196
|
+
changes.push({ path, status: "unchanged", detail: "yours — left alone" });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
changes.push(
|
|
200
|
+
existing === text
|
|
201
|
+
? { path, status: "unchanged" }
|
|
202
|
+
: { path, status: existing === undefined ? "created" : "updated", text },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return changes;
|
|
208
|
+
}
|
|
209
|
+
|
|
167
210
|
private async _claudeChange(root: string): Promise<Change> {
|
|
168
211
|
const outcome = applyBlock(await readIfPresent(join(root, "CLAUDE.md")), claudeShim());
|
|
169
212
|
return outcome.status === "conflict"
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* would summarise are the ones those tools return in full.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ProjectShape } from "./shape.ts";
|
|
15
|
+
|
|
14
16
|
/** A block contributed by one installed package. */
|
|
15
17
|
interface PackageBlock {
|
|
16
18
|
/** The package that triggers it. */
|
|
@@ -144,6 +146,11 @@ export interface GuidelineOptions {
|
|
|
144
146
|
packages: string[];
|
|
145
147
|
/** The key the MCP server is registered under, so the text names it correctly. */
|
|
146
148
|
serverName: string;
|
|
149
|
+
/**
|
|
150
|
+
* How this project is configured, from {@link detectShape}. Omitted, the block
|
|
151
|
+
* is what it always was — a function of the package list.
|
|
152
|
+
*/
|
|
153
|
+
shape?: ProjectShape;
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
/**
|
|
@@ -191,6 +198,9 @@ export function buildGuidelines(options: GuidelineOptions): string {
|
|
|
191
198
|
);
|
|
192
199
|
}
|
|
193
200
|
|
|
201
|
+
const shape = options.shape ? shapeSection(options.shape) : "";
|
|
202
|
+
if (shape) sections.push("", shape);
|
|
203
|
+
|
|
194
204
|
sections.push(rulesSection());
|
|
195
205
|
|
|
196
206
|
return sections
|
|
@@ -258,6 +268,80 @@ function commandSection(): string {
|
|
|
258
268
|
].join("\n");
|
|
259
269
|
}
|
|
260
270
|
|
|
271
|
+
/**
|
|
272
|
+
* What this project is configured to be, where that changes what to write.
|
|
273
|
+
*
|
|
274
|
+
* The rest of this file describes the framework; this describes *this app*. The
|
|
275
|
+
* distinction matters because the framework's contracts are not uniform, and the
|
|
276
|
+
* places they differ are places where guessing wrong compiles cleanly and fails
|
|
277
|
+
* at runtime. An `EmailVerification` mixin needs a migration in one app and not
|
|
278
|
+
* in another, and the app that needed one lost 419 tests to a column that was
|
|
279
|
+
* never created.
|
|
280
|
+
*
|
|
281
|
+
* Only facts that change an instruction are emitted. "This app uses SQLite" is
|
|
282
|
+
* trivia; "migrations own the schema here, so a new column needs one" is a
|
|
283
|
+
* decision the agent would otherwise get wrong half the time.
|
|
284
|
+
*/
|
|
285
|
+
function shapeSection(shape: ProjectShape): string {
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
|
|
288
|
+
if (shape.schemaSource === "migrations") {
|
|
289
|
+
lines.push(
|
|
290
|
+
"- **Migrations own the schema.** A new or changed column needs a migration — including " +
|
|
291
|
+
"columns a mixin registers imperatively, which are added to tables that already exist " +
|
|
292
|
+
"but never create one. Generate with `bun zt make:migration`, guard an added column " +
|
|
293
|
+
"with `Schema.hasColumn`, and never hand-edit a migration that has run.",
|
|
294
|
+
);
|
|
295
|
+
} else if (shape.schemaSource === "models") {
|
|
296
|
+
lines.push(
|
|
297
|
+
"- **The models own the schema** (`database.synchronize`). The table is built from what " +
|
|
298
|
+
"the models declare, so a `@column` is the whole change and there is no migration to " +
|
|
299
|
+
"write. Check `schema` after adding one to confirm it landed.",
|
|
300
|
+
);
|
|
301
|
+
} else if (shape.schemaSource === "both") {
|
|
302
|
+
lines.push(
|
|
303
|
+
"- **Both `database.synchronize` and migrations are present**, which is deliberate in " +
|
|
304
|
+
"some apps (sync locally, migrations in production) and a mistake in others. Run " +
|
|
305
|
+
"`doctor` and read what it says about the source of truth before adding a column — " +
|
|
306
|
+
"the answer decides whether a migration is required or would collide.",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (shape.routeTypes) {
|
|
311
|
+
lines.push(
|
|
312
|
+
"- **Route names are typed.** `types/routes.generated.ts` exists, so `route()` is checked " +
|
|
313
|
+
"against it and a stale file turns a working call into a type error. Run " +
|
|
314
|
+
"`bun zt route:types` after any route change.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (shape.strict.exactOptionalPropertyTypes) {
|
|
319
|
+
lines.push(
|
|
320
|
+
"- **`exactOptionalPropertyTypes` is on.** `{ x: undefined }` is not assignable to " +
|
|
321
|
+
"`{ x?: T }`. Build optional properties conditionally — `...(v ? { x: v } : {})` — " +
|
|
322
|
+
"rather than assigning `undefined` and expecting the key to be treated as absent.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (shape.strict.noUncheckedIndexedAccess) {
|
|
327
|
+
lines.push(
|
|
328
|
+
"- **`noUncheckedIndexedAccess` is on.** Every index read is `T | undefined`. Narrow it; " +
|
|
329
|
+
"do not reach for `!` to silence it.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (shape.hasTests) {
|
|
334
|
+
lines.push(
|
|
335
|
+
"- **This app has tests.** Run `bun zt test` before calling a task done — `doctor` checks " +
|
|
336
|
+
"configuration, not behaviour, and only one of the two notices that a change broke " +
|
|
337
|
+
"something.",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (lines.length === 0) return "";
|
|
342
|
+
return ["### How this app is set up", "", ...lines].join("\n");
|
|
343
|
+
}
|
|
344
|
+
|
|
261
345
|
function rulesSection(): string {
|
|
262
346
|
return [
|
|
263
347
|
"",
|