@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this project is configured to be, as distinct from what it has installed.
|
|
3
|
+
*
|
|
4
|
+
* The guidance in `guidelines.ts` was a function of the package list alone, and
|
|
5
|
+
* a package list cannot answer the questions that actually decide what an agent
|
|
6
|
+
* should write. The framework's contracts are not uniform: the same mixin needs a
|
|
7
|
+
* migration in one app and not in another, `route()` is checked against generated
|
|
8
|
+
* names in one and unchecked in another, and an optional property is written one
|
|
9
|
+
* way under `exactOptionalPropertyTypes` and another way without it.
|
|
10
|
+
*
|
|
11
|
+
* An agent that guesses wrong there does not get a type error. It gets a working
|
|
12
|
+
* build and a runtime failure — `no such column: email_verified_at`, hundreds of
|
|
13
|
+
* tests down at once, in an app whose schema is owned by migrations and whose
|
|
14
|
+
* `AGENTS.md` never said so.
|
|
15
|
+
*
|
|
16
|
+
* ## Read from files, not from a booted app
|
|
17
|
+
*
|
|
18
|
+
* `arch:install` runs in a project that may not boot — that is often *why*
|
|
19
|
+
* someone is installing the agent surface. Everything here comes off disk.
|
|
20
|
+
*
|
|
21
|
+
* ## What is deliberately not read
|
|
22
|
+
*
|
|
23
|
+
* `.env`, and anything else holding credentials. This output is written into a
|
|
24
|
+
* file that is committed and pasted into prompts; a detector that reads secrets
|
|
25
|
+
* is one refactor away from emitting them. Where a fact is available from both a
|
|
26
|
+
* config file and the environment, the config file is the only source used.
|
|
27
|
+
*/
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** How the database schema comes to exist, which decides who must write a column. */
|
|
31
|
+
export type SchemaSource = "migrations" | "models" | "both" | "unknown";
|
|
32
|
+
|
|
33
|
+
export interface ProjectShape {
|
|
34
|
+
schemaSource: SchemaSource;
|
|
35
|
+
/** `types/routes.generated.ts` exists, so `route()` names are checked. */
|
|
36
|
+
routeTypes: boolean;
|
|
37
|
+
/** tsconfig flags that change how correct code is written, not just how it is checked. */
|
|
38
|
+
strict: {
|
|
39
|
+
exactOptionalPropertyTypes: boolean;
|
|
40
|
+
noUncheckedIndexedAccess: boolean;
|
|
41
|
+
strict: boolean;
|
|
42
|
+
};
|
|
43
|
+
/** A `tests/` directory with something in it. */
|
|
44
|
+
hasTests: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read `root`'s shape. Every probe fails soft: an unknown fact is simply not stated. */
|
|
48
|
+
export async function detectShape(root: string): Promise<ProjectShape> {
|
|
49
|
+
const [schemaSource, routeTypes, strict, hasTests] = await Promise.all([
|
|
50
|
+
_schemaSource(root),
|
|
51
|
+
_exists(join(root, "types", "routes.generated.ts")),
|
|
52
|
+
_strictness(root),
|
|
53
|
+
_hasFiles(join(root, "tests")),
|
|
54
|
+
]);
|
|
55
|
+
return { schemaSource, routeTypes, strict, hasTests };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Migrations, models, or both.
|
|
60
|
+
*
|
|
61
|
+
* Mirrors `zt doctor`'s `synchronize-vs-migrations` check, which decides the same
|
|
62
|
+
* question from the same two inputs — but reports it as a *problem* when both are
|
|
63
|
+
* on. Here both is a legitimate answer worth stating plainly, because an agent
|
|
64
|
+
* needs to know it is looking at an app where either route might be the intended
|
|
65
|
+
* one and it should ask rather than assume.
|
|
66
|
+
*/
|
|
67
|
+
async function _schemaSource(root: string): Promise<SchemaSource> {
|
|
68
|
+
const migrations = await _hasFiles(join(root, "database", "migrations"));
|
|
69
|
+
const synchronize = await _synchronizeOn(root);
|
|
70
|
+
|
|
71
|
+
if (migrations && synchronize) return "both";
|
|
72
|
+
if (migrations) return "migrations";
|
|
73
|
+
if (synchronize) return "models";
|
|
74
|
+
return "unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether `database.synchronize` is on.
|
|
79
|
+
*
|
|
80
|
+
* Read as text rather than by importing the config, which would need the app's
|
|
81
|
+
* environment and its whole provider graph. The value is commonly an expression —
|
|
82
|
+
* `env("APP_ENV") !== "production"` — so a literal `false` is the only confident
|
|
83
|
+
* "off"; anything else is treated as "may be on", which errs toward telling the
|
|
84
|
+
* agent the arrangement is ambiguous rather than asserting the wrong half.
|
|
85
|
+
*/
|
|
86
|
+
async function _synchronizeOn(root: string): Promise<boolean> {
|
|
87
|
+
const source = await _read(join(root, "config", "database.ts"));
|
|
88
|
+
if (!source) return false;
|
|
89
|
+
const match = /\bsynchronize\s*:\s*([^,\n}]+)/.exec(source);
|
|
90
|
+
if (!match) return false;
|
|
91
|
+
const value = (match[1] ?? "").trim();
|
|
92
|
+
return value !== "false";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the strictness flags through the whole `extends` chain.
|
|
97
|
+
*
|
|
98
|
+
* Reading only the project's own `tsconfig.json` gets this wrong for most real
|
|
99
|
+
* apps, and wrong in the quiet direction: a workspace app that extends a strict
|
|
100
|
+
* base has none of these flags in its own file, so every one reads as off and the
|
|
101
|
+
* guidance says nothing. The app the framework itself ships did exactly that —
|
|
102
|
+
* detected as unstrict while compiling under a base that turns all three on.
|
|
103
|
+
*
|
|
104
|
+
* Nearest wins, as tsc resolves it: a flag set in the extending file overrides
|
|
105
|
+
* the one it inherits, including turning an inherited flag off.
|
|
106
|
+
*/
|
|
107
|
+
async function _strictness(root: string): Promise<ProjectShape["strict"]> {
|
|
108
|
+
const chain = await _tsconfigChain(join(root, "tsconfig.json"));
|
|
109
|
+
|
|
110
|
+
const on = (flag: string): boolean => {
|
|
111
|
+
for (const source of chain) {
|
|
112
|
+
const match = new RegExp(`"${flag}"\\s*:\\s*(true|false)`).exec(source);
|
|
113
|
+
if (match) return match[1] === "true";
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// The two that matter here are not implied by `strict` — each has to be asked
|
|
119
|
+
// for, which is why they are worth reporting separately from it.
|
|
120
|
+
return {
|
|
121
|
+
strict: on("strict"),
|
|
122
|
+
exactOptionalPropertyTypes: on("exactOptionalPropertyTypes"),
|
|
123
|
+
noUncheckedIndexedAccess: on("noUncheckedIndexedAccess"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A tsconfig and everything it extends, nearest first. */
|
|
128
|
+
async function _tsconfigChain(path: string, depth = 0): Promise<string[]> {
|
|
129
|
+
// Bounded rather than cycle-tracked: a chain this long is already pathological,
|
|
130
|
+
// and the cost of being wrong here is a missing line of guidance.
|
|
131
|
+
if (depth > 8) return [];
|
|
132
|
+
|
|
133
|
+
const source = await _read(path);
|
|
134
|
+
if (source === undefined) return [];
|
|
135
|
+
|
|
136
|
+
const extended = /"extends"\s*:\s*"([^"]+)"/.exec(source)?.[1];
|
|
137
|
+
if (!extended) return [source];
|
|
138
|
+
|
|
139
|
+
// Only a relative path is resolvable from here. A package reference
|
|
140
|
+
// (`@tsconfig/bun`) lives in node_modules under a layout this does not chase.
|
|
141
|
+
if (!extended.startsWith(".")) return [source];
|
|
142
|
+
|
|
143
|
+
const { dirname, resolve: resolvePath } = await import("node:path");
|
|
144
|
+
const next = resolvePath(
|
|
145
|
+
dirname(path),
|
|
146
|
+
extended.endsWith(".json") ? extended : `${extended}.json`,
|
|
147
|
+
);
|
|
148
|
+
return [source, ...(await _tsconfigChain(next, depth + 1))];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function _read(path: string): Promise<string | undefined> {
|
|
152
|
+
try {
|
|
153
|
+
const file = Bun.file(path);
|
|
154
|
+
return (await file.exists()) ? await file.text() : undefined;
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function _exists(path: string): Promise<boolean> {
|
|
161
|
+
try {
|
|
162
|
+
return await Bun.file(path).exists();
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A directory that exists and holds at least one file. */
|
|
169
|
+
async function _hasFiles(dir: string): Promise<boolean> {
|
|
170
|
+
try {
|
|
171
|
+
const { readdir } = await import("node:fs/promises");
|
|
172
|
+
const entries = await readdir(dir);
|
|
173
|
+
return entries.some((entry) => !entry.startsWith("."));
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Depth that costs nothing until it is needed.
|
|
3
|
+
*
|
|
4
|
+
* `guidelines.ts` opens by saying that every prompt its output lands in pays for
|
|
5
|
+
* its whole length, and that is why the block there is short and points rather
|
|
6
|
+
* than teaches. The cost is real, and so is what it buys: an agent gets a map and
|
|
7
|
+
* no detail, and the detail is where the expensive mistakes live. Knowing that
|
|
8
|
+
* migrations exist is not the same as knowing that a mixin's column needs one
|
|
9
|
+
* here and not in the app next door.
|
|
10
|
+
*
|
|
11
|
+
* A skill is the other half. It sits on disk with a one-line description, costs
|
|
12
|
+
* nothing while it is not relevant, and is loaded whole when it is — so the depth
|
|
13
|
+
* that could never be afforded upfront can be written out properly.
|
|
14
|
+
*
|
|
15
|
+
* ## What belongs here rather than in the block
|
|
16
|
+
*
|
|
17
|
+
* Anything procedural. The block says what exists and who owns it; a skill says
|
|
18
|
+
* how to do one thing correctly, including the parts that look optional and are
|
|
19
|
+
* not. If a line would only ever matter to someone already doing the task, it is
|
|
20
|
+
* a skill.
|
|
21
|
+
*
|
|
22
|
+
* ## What does not belong here at all
|
|
23
|
+
*
|
|
24
|
+
* Anything the tools answer better. `api_surface` gives exact signatures for the
|
|
25
|
+
* installed version and `search_docs` gives the pages; a skill that paraphrases
|
|
26
|
+
* either is a copy that starts wrong on the next release. These describe
|
|
27
|
+
* *sequences and traps* — what the documentation is worst at and what costs the
|
|
28
|
+
* most to learn by hitting it.
|
|
29
|
+
*/
|
|
30
|
+
import type { ProjectShape } from "./shape.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Marks a file as this tool's to rewrite.
|
|
34
|
+
*
|
|
35
|
+
* Placed as the first body line, after the frontmatter, because frontmatter has
|
|
36
|
+
* to open the file. A `SKILL.md` without it is somebody's own and is never
|
|
37
|
+
* touched — overriding a shipped skill should be as simple as editing it.
|
|
38
|
+
*/
|
|
39
|
+
export const SKILL_MARKER = "<!-- zerotal:arch:generated -->";
|
|
40
|
+
|
|
41
|
+
export interface Skill {
|
|
42
|
+
/** Directory name, and the name in the frontmatter. */
|
|
43
|
+
name: string;
|
|
44
|
+
/** The one line an agent reads to decide whether to load the rest. */
|
|
45
|
+
description: string;
|
|
46
|
+
/** Packages that must be installed for this skill to apply. */
|
|
47
|
+
requires?: string[];
|
|
48
|
+
/** Body, given the project's shape. Empty means "not applicable here". */
|
|
49
|
+
body: (shape: ProjectShape | undefined) => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SCHEMA_SKILL: Skill = {
|
|
53
|
+
name: "zerotal-schema-changes",
|
|
54
|
+
description:
|
|
55
|
+
"Add or change a database column in a Zerotal app — deciding whether a migration is " +
|
|
56
|
+
"required, writing one that is safe to re-run, and the mixin columns that need one even " +
|
|
57
|
+
"though nothing declares them.",
|
|
58
|
+
requires: ["@zerotal/orm"],
|
|
59
|
+
body: (shape) => {
|
|
60
|
+
const source = shape?.schemaSource ?? "unknown";
|
|
61
|
+
|
|
62
|
+
// Emphasis is written `_like this_`, not `*like this*`. These files land in
|
|
63
|
+
// somebody's repository and are checked by their formatter, and a generator
|
|
64
|
+
// whose output fails the project's own format check is one nobody can run in
|
|
65
|
+
// CI.
|
|
66
|
+
const owner =
|
|
67
|
+
source === "migrations"
|
|
68
|
+
? "**Migrations own the schema in this app.**"
|
|
69
|
+
: source === "models"
|
|
70
|
+
? "**The models own the schema in this app** (`database.synchronize` is on)."
|
|
71
|
+
: source === "both"
|
|
72
|
+
? "**Both are in play here** — `database.synchronize` is on _and_ migrations exist. " +
|
|
73
|
+
"Run `doctor` and read the source-of-truth line before you touch anything."
|
|
74
|
+
: "**Which one owns the schema here is not obvious from the config.** Run `doctor` " +
|
|
75
|
+
"and read the source-of-truth line first.";
|
|
76
|
+
|
|
77
|
+
return `
|
|
78
|
+
# Changing the schema
|
|
79
|
+
|
|
80
|
+
${owner}
|
|
81
|
+
|
|
82
|
+
## Decide who owns the schema before writing anything
|
|
83
|
+
|
|
84
|
+
Two arrangements, and they need different work for the same change:
|
|
85
|
+
|
|
86
|
+
- **Models own it** (\`database.synchronize\`). The table is built from what the models
|
|
87
|
+
declare. A \`@column\` is the whole change; there is no migration to write.
|
|
88
|
+
- **Migrations own it.** The table is built from what a migration says. A \`@column\` alone
|
|
89
|
+
changes nothing on disk, and every query touching it fails at runtime.
|
|
90
|
+
|
|
91
|
+
\`bun zt doctor\` reports which. Ask it rather than guessing — the failure mode for guessing
|
|
92
|
+
wrong is a clean type-check and a runtime error under load.
|
|
93
|
+
|
|
94
|
+
## The columns nothing declares
|
|
95
|
+
|
|
96
|
+
Some mixins register a column imperatively rather than with \`@column\` — \`EmailVerification\`
|
|
97
|
+
adds \`email_verified_at\`, \`Authenticatable\` adds \`remember_token\`. A boot-time concern
|
|
98
|
+
adds those to their table **if the table already exists**.
|
|
99
|
+
|
|
100
|
+
It never creates a table, and it never revisits one. So where migrations own the schema, a
|
|
101
|
+
\`create users\` migration that does not mention \`email_verified_at\` produces a table
|
|
102
|
+
without it, permanently:
|
|
103
|
+
|
|
104
|
+
\`\`\`
|
|
105
|
+
SQLiteError: no such column: email_verified_at
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
Composing such a mixin in a migrations-owned app means writing the migration too.
|
|
109
|
+
|
|
110
|
+
## Write the migration so it can meet a database that already has the column
|
|
111
|
+
|
|
112
|
+
The concern above may already have added it — on any database that has booted the app since
|
|
113
|
+
the mixin was composed. An unguarded \`ALTER TABLE\` then fails with \`duplicate column name\`,
|
|
114
|
+
during the release's \`migrate\` step:
|
|
115
|
+
|
|
116
|
+
\`\`\`ts
|
|
117
|
+
import { Schema } from "@zerotal/orm";
|
|
118
|
+
|
|
119
|
+
export default class extends Migration {
|
|
120
|
+
async up(): Promise<void> {
|
|
121
|
+
if (!(await Schema.hasColumn("users", "email_verified_at"))) {
|
|
122
|
+
await Schema.table("users", (table) => {
|
|
123
|
+
table.dateTime("email_verified_at").nullable();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
## Sequence
|
|
131
|
+
|
|
132
|
+
1. \`bun zt doctor\` — who owns the schema.
|
|
133
|
+
2. \`bun zt make:migration\` if migrations do. Never hand-edit one that has run: the runner
|
|
134
|
+
records it as applied and will not run it again, so the edit reaches no database that
|
|
135
|
+
already migrated.
|
|
136
|
+
3. Call the \`schema\` tool afterwards to confirm the column is really there. It reads the
|
|
137
|
+
database, not the models, which is the difference that matters here.
|
|
138
|
+
4. \`bun zt test\`.
|
|
139
|
+
`.trim();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const RELEASE_SKILL: Skill = {
|
|
144
|
+
name: "zerotal-releases",
|
|
145
|
+
description:
|
|
146
|
+
"Ship a Zerotal app — ordering the release steps, replacing built assets rather than " +
|
|
147
|
+
"merging into them, and the proxy and shell settings that fail quietly in production.",
|
|
148
|
+
body: () =>
|
|
149
|
+
`
|
|
150
|
+
# Shipping a release
|
|
151
|
+
|
|
152
|
+
## Name your own steps
|
|
153
|
+
|
|
154
|
+
\`deploy:<env>\` runs the steps named in \`config/deploy.ts\`, defaulting to build-and-migrate.
|
|
155
|
+
A preflight command of your own runs only if you name it, and nothing prompts you to:
|
|
156
|
+
|
|
157
|
+
\`\`\`ts
|
|
158
|
+
// config/deploy.ts
|
|
159
|
+
export default {
|
|
160
|
+
targets: {
|
|
161
|
+
production: {
|
|
162
|
+
url: "https://example.com",
|
|
163
|
+
steps: ["release:check", "assets:build", "inertia:build", "migrate"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
\`\`\`
|
|
168
|
+
|
|
169
|
+
Put the check first. A step that fails stops the release, and a check that runs after the
|
|
170
|
+
migration has missed its moment.
|
|
171
|
+
|
|
172
|
+
\`deploy:<env>\` runs **where the app runs**, with that environment's variables. It does not
|
|
173
|
+
reach another machine.
|
|
174
|
+
|
|
175
|
+
## Replace the asset directory, do not merge into it
|
|
176
|
+
|
|
177
|
+
Each build emits a fresh set of content-hashed chunks and cleans up the set it replaced. It
|
|
178
|
+
can only clean a directory it is run in. A release unpacked over the top of the running one
|
|
179
|
+
— \`tar -xzf\` into the app directory, \`rsync\` without \`--delete\` — merges: files in the
|
|
180
|
+
archive are written, files not in it are left exactly where they were. Nothing on that
|
|
181
|
+
machine ever runs a build, so last release's bundles stay, and they stay **publicly
|
|
182
|
+
fetchable at their hashed URLs**. Copy you withdrew is still readable by anyone with the
|
|
183
|
+
link.
|
|
184
|
+
|
|
185
|
+
\`\`\`bash
|
|
186
|
+
rm -rf "$APP_DIR/public/assets" # before extracting
|
|
187
|
+
tar -xzf release.tgz -C "$APP_DIR"
|
|
188
|
+
|
|
189
|
+
# or
|
|
190
|
+
rsync -a --delete public/assets/ "$HOST:$APP_DIR/public/assets/"
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
Clearing takes the running release's bundles away, so do it close to the swap, or stage into
|
|
194
|
+
a new directory and move it into place.
|
|
195
|
+
|
|
196
|
+
\`--clean\` on \`assets:build\` / \`inertia:build\` removes anything in the output directory the
|
|
197
|
+
build did not write. It is for output some other naming produced; it does not help a
|
|
198
|
+
directory nothing runs in, and it refuses \`public/\` itself.
|
|
199
|
+
|
|
200
|
+
## Rate limiting counts the proxy, not the visitor
|
|
201
|
+
|
|
202
|
+
Behind a proxy every request arrives from the same address, so one bucket is shared by
|
|
203
|
+
everybody and a single client can lock the site out. Say how many proxies are in front:
|
|
204
|
+
|
|
205
|
+
\`\`\`ts
|
|
206
|
+
ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 });
|
|
207
|
+
\`\`\`
|
|
208
|
+
|
|
209
|
+
It defaults to zero because \`X-Forwarded-For\` is client-written until something trusted
|
|
210
|
+
overwrites it. Count the proxies you actually run — too many reads an entry the client
|
|
211
|
+
supplied.
|
|
212
|
+
|
|
213
|
+
## A pipe hides the exit status
|
|
214
|
+
|
|
215
|
+
\`\`\`bash
|
|
216
|
+
bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
A deploy script gated that way prints \`1 fail\` and carries on to upload and restart. Use
|
|
220
|
+
\`set -o pipefail\`, or capture the status. \`set -e\` alone does not cover it — the pipeline
|
|
221
|
+
succeeded, as far as the shell is concerned.
|
|
222
|
+
`.trim(),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Everything that could be installed, before the project narrows it. */
|
|
226
|
+
export const SKILLS: Skill[] = [SCHEMA_SKILL, RELEASE_SKILL];
|
|
227
|
+
|
|
228
|
+
/** The skills that apply to a project with these packages and this shape. */
|
|
229
|
+
export function selectSkills(
|
|
230
|
+
packages: readonly string[],
|
|
231
|
+
shape: ProjectShape | undefined,
|
|
232
|
+
all: readonly Skill[] = SKILLS,
|
|
233
|
+
): Skill[] {
|
|
234
|
+
const installed = new Set(packages);
|
|
235
|
+
return all.filter(
|
|
236
|
+
(skill) =>
|
|
237
|
+
(skill.requires ?? []).every((pkg) => installed.has(pkg)) &&
|
|
238
|
+
skill.body(shape).trim().length > 0,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** One `SKILL.md`: frontmatter, the marker, then the body. */
|
|
243
|
+
export function renderSkill(skill: Skill, shape: ProjectShape | undefined): string {
|
|
244
|
+
return [
|
|
245
|
+
"---",
|
|
246
|
+
`name: ${skill.name}`,
|
|
247
|
+
// Quoted: descriptions contain commas and colons, which unquoted YAML reads
|
|
248
|
+
// as structure.
|
|
249
|
+
`description: ${JSON.stringify(skill.description)}`,
|
|
250
|
+
"---",
|
|
251
|
+
"",
|
|
252
|
+
SKILL_MARKER,
|
|
253
|
+
"",
|
|
254
|
+
skill.body(shape),
|
|
255
|
+
"",
|
|
256
|
+
].join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Where a skill directory goes.
|
|
261
|
+
*
|
|
262
|
+
* `.agents/skills` is the cross-client path; `.claude/skills` is read by Claude
|
|
263
|
+
* Code, and some clients scan it too. Written to both when that agent is present,
|
|
264
|
+
* because a skill in the wrong directory is not a degraded skill — it is an
|
|
265
|
+
* inert file that looks installed.
|
|
266
|
+
*/
|
|
267
|
+
export function skillPaths(name: string, agents: readonly string[]): string[] {
|
|
268
|
+
const paths = [`.agents/skills/${name}/SKILL.md`];
|
|
269
|
+
if (agents.includes("Claude Code")) paths.push(`.claude/skills/${name}/SKILL.md`);
|
|
270
|
+
return paths;
|
|
271
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ServiceProvider } from "@zerotal/core";
|
|
2
2
|
import type { AppEnvironment, DoctorCheck } from "@zerotal/core";
|
|
3
|
+
import type { ProjectShape } from "../install/shape.ts";
|
|
3
4
|
import { SERVER_ENTRY_PATH } from "../install/mcpConfig.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -96,6 +97,153 @@ export class ArchProvider extends ServiceProvider {
|
|
|
96
97
|
}
|
|
97
98
|
},
|
|
98
99
|
},
|
|
100
|
+
{
|
|
101
|
+
id: "arch-agents-current",
|
|
102
|
+
label: "Agent instructions",
|
|
103
|
+
run: async () => agentsFileCheck(process.cwd()),
|
|
104
|
+
},
|
|
99
105
|
];
|
|
100
106
|
}
|
|
101
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Is the generated block still true?
|
|
111
|
+
*
|
|
112
|
+
* It was a description of the framework, which changed about as often as the
|
|
113
|
+
* framework did. It is now also a description of *this project* — which packages
|
|
114
|
+
* are installed, who owns the schema, which strictness flags are on — and every
|
|
115
|
+
* one of those moves without anyone thinking about `AGENTS.md`. Add a migration
|
|
116
|
+
* directory, turn `synchronize` off, install a package, upgrade the framework:
|
|
117
|
+
* the file still reads as current and is quietly describing the app you had.
|
|
118
|
+
*
|
|
119
|
+
* That is worse than having no file. Guidance nobody wrote is obviously absent;
|
|
120
|
+
* guidance that is confidently out of date gets followed.
|
|
121
|
+
*
|
|
122
|
+
* A warning rather than a failure: a stale instruction file misleads a person or
|
|
123
|
+
* an agent, and does not stop the application working. `doctor` earns the right
|
|
124
|
+
* to gate a deploy by failing only for things that would break one.
|
|
125
|
+
*
|
|
126
|
+
* Takes its root rather than reading `process.cwd()`, so it is testable without
|
|
127
|
+
* moving the process into a fixture.
|
|
128
|
+
*
|
|
129
|
+
* @internal
|
|
130
|
+
*/
|
|
131
|
+
export async function agentsFileCheck(
|
|
132
|
+
root: string,
|
|
133
|
+
): Promise<{ status: "ok" | "warn"; message: string; fix?: string }> {
|
|
134
|
+
const file = Bun.file(`${root}/AGENTS.md`);
|
|
135
|
+
if (!(await file.exists())) {
|
|
136
|
+
return {
|
|
137
|
+
status: "warn",
|
|
138
|
+
message:
|
|
139
|
+
"No AGENTS.md, so an agent working here has no instructions and none of the " +
|
|
140
|
+
"project-specific facts the MCP tools cannot infer.",
|
|
141
|
+
fix: "bun zt arch:install",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const [{ buildGuidelines }, { detectShape }, { installedPackages }, markers] = await Promise.all([
|
|
146
|
+
import("../install/guidelines.ts"),
|
|
147
|
+
import("../install/shape.ts"),
|
|
148
|
+
import("../probe/topics.ts"),
|
|
149
|
+
import("../install/markers.ts"),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const current = _blockOf(await file.text(), markers.BLOCK_START, markers.BLOCK_END);
|
|
153
|
+
if (current === undefined) {
|
|
154
|
+
return {
|
|
155
|
+
status: "warn",
|
|
156
|
+
message:
|
|
157
|
+
"AGENTS.md has no generated block, so nothing here describes the framework or how " +
|
|
158
|
+
"this app is set up.",
|
|
159
|
+
fix: "bun zt arch:install",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const packages = (await installedPackages(root)).map((pkg) => pkg.name);
|
|
164
|
+
const shape = await detectShape(root);
|
|
165
|
+
const expected = buildGuidelines({
|
|
166
|
+
packages,
|
|
167
|
+
serverName: await _serverName(root),
|
|
168
|
+
shape,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (current.trim() !== expected.trim()) {
|
|
172
|
+
return {
|
|
173
|
+
status: "warn",
|
|
174
|
+
message:
|
|
175
|
+
"AGENTS.md describes a different project than this one — its packages, its setup or " +
|
|
176
|
+
"its framework version have moved since it was written.",
|
|
177
|
+
fix: "bun zt arch:update",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Skills rot the same way and are easier to miss: nothing reads them until an
|
|
182
|
+
// agent decides one is relevant, and by then it is being followed.
|
|
183
|
+
const stale = await _staleSkills(root, packages, shape);
|
|
184
|
+
if (stale.length > 0) {
|
|
185
|
+
return {
|
|
186
|
+
status: "warn",
|
|
187
|
+
message: `AGENTS.md is current, but ${stale.length} skill file(s) are not: ${stale.join(", ")}.`,
|
|
188
|
+
fix: "bun zt arch:update",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { status: "ok", message: "AGENTS.md and skills match this project" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Skill files that are missing, or generated and no longer what they would be. */
|
|
196
|
+
async function _staleSkills(
|
|
197
|
+
root: string,
|
|
198
|
+
packages: string[],
|
|
199
|
+
shape: ProjectShape,
|
|
200
|
+
): Promise<string[]> {
|
|
201
|
+
const { selectSkills, renderSkill, skillPaths, SKILL_MARKER } =
|
|
202
|
+
await import("../install/skills.ts");
|
|
203
|
+
const { detectAgents } = await import("../install/detect.ts");
|
|
204
|
+
const { agents } = await detectAgents(root);
|
|
205
|
+
|
|
206
|
+
const stale: string[] = [];
|
|
207
|
+
for (const skill of selectSkills(packages, shape)) {
|
|
208
|
+
for (const path of skillPaths(skill.name, agents)) {
|
|
209
|
+
const file = Bun.file(`${root}/${path}`);
|
|
210
|
+
if (!(await file.exists())) {
|
|
211
|
+
stale.push(path);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const text = await file.text();
|
|
215
|
+
// A file somebody took ownership of is theirs, current or not.
|
|
216
|
+
if (!text.includes(SKILL_MARKER)) continue;
|
|
217
|
+
if (text !== renderSkill(skill, shape)) stale.push(path);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return stale;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The generated body, unfenced, or `undefined` when there is no block. */
|
|
224
|
+
function _blockOf(text: string, start: string, end: string): string | undefined {
|
|
225
|
+
const from = text.indexOf(start);
|
|
226
|
+
const to = text.indexOf(end);
|
|
227
|
+
if (from === -1 || to === -1 || to < from) return undefined;
|
|
228
|
+
return text.slice(from + start.length, to);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The key the server is registered under, read back from `.mcp.json`.
|
|
233
|
+
*
|
|
234
|
+
* The block names it, so comparing against a guessed name would report every
|
|
235
|
+
* project that renamed its server as permanently stale. Read rather than
|
|
236
|
+
* `require`d, which would cache the first answer and miss a rename.
|
|
237
|
+
*/
|
|
238
|
+
async function _serverName(root: string): Promise<string> {
|
|
239
|
+
try {
|
|
240
|
+
const document = (await Bun.file(`${root}/.mcp.json`).json()) as {
|
|
241
|
+
mcpServers?: Record<string, unknown>;
|
|
242
|
+
servers?: Record<string, unknown>;
|
|
243
|
+
};
|
|
244
|
+
const names = Object.keys(document.mcpServers ?? document.servers ?? {});
|
|
245
|
+
return names[0] ?? "zerotal";
|
|
246
|
+
} catch {
|
|
247
|
+
return "zerotal";
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/tools/searchDocs.ts
CHANGED
|
@@ -181,6 +181,13 @@ export function parsePage(path: string, raw: string): DocPage {
|
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
for (const line of body.split("\n")) {
|
|
184
|
+
// A fence delimiter is markup, and its info string is metadata about the
|
|
185
|
+
// block rather than anything the page says. Indexing them put the language
|
|
186
|
+
// name into every page that shows code — a search for "typescript" matched
|
|
187
|
+
// most of the corpus on the strength of its fences — and, once documentation
|
|
188
|
+
// examples grew a `fragment` marker, that word too.
|
|
189
|
+
if (/^\s*`{3,}/.test(line)) continue;
|
|
190
|
+
|
|
184
191
|
const match = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
185
192
|
if (match) {
|
|
186
193
|
flush();
|