@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
|
@@ -16,9 +16,12 @@ import type { Application } from "@zerotal/core";
|
|
|
16
16
|
import { ArchConfig } from "../config.ts";
|
|
17
17
|
import type { ArchConfigShape } from "../config.ts";
|
|
18
18
|
import { NoProjectRootError } from "../errors.ts";
|
|
19
|
-
import {
|
|
19
|
+
import { declaredPackages } 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
|
|
|
@@ -51,12 +54,18 @@ export class ArchInstallCommand extends Command {
|
|
|
51
54
|
const config = this._config();
|
|
52
55
|
|
|
53
56
|
const detected = await detectAgents(root);
|
|
54
|
-
|
|
57
|
+
// Declared, not installed: what an install layout hoists into node_modules
|
|
58
|
+
// differs between machines, and the guidance must not.
|
|
59
|
+
const packages = (await declaredPackages(root)).map((pkg) => pkg.name);
|
|
60
|
+
// Read off disk rather than from a booted app: a project that will not boot is
|
|
61
|
+
// often exactly why someone is installing the agent surface.
|
|
62
|
+
const shape = await detectShape(root);
|
|
55
63
|
|
|
56
64
|
const changes: Change[] = [
|
|
57
65
|
...(config.mcpConfig ? await this._mcpChanges(root, config, detected.targets) : []),
|
|
58
|
-
...(config.agentsFile ? [await this._agentsChange(root, config, packages)] : []),
|
|
66
|
+
...(config.agentsFile ? [await this._agentsChange(root, config, packages, shape)] : []),
|
|
59
67
|
...(config.claudeFile ? [await this._claudeChange(root)] : []),
|
|
68
|
+
...(config.skills ? await this._skillChanges(root, packages, shape, detected.agents) : []),
|
|
60
69
|
];
|
|
61
70
|
|
|
62
71
|
this.section(dry ? "arch:install — dry run" : "arch:install");
|
|
@@ -77,7 +86,7 @@ export class ArchInstallCommand extends Command {
|
|
|
77
86
|
continue;
|
|
78
87
|
}
|
|
79
88
|
if (change.status === "unchanged") {
|
|
80
|
-
this.dim(` · ${change.path} — already up to date`);
|
|
89
|
+
this.dim(` · ${change.path} — ${change.detail ?? "already up to date"}`);
|
|
81
90
|
continue;
|
|
82
91
|
}
|
|
83
92
|
if (!dry && change.text !== undefined) {
|
|
@@ -153,10 +162,11 @@ export class ArchInstallCommand extends Command {
|
|
|
153
162
|
root: string,
|
|
154
163
|
config: ArchConfigShape,
|
|
155
164
|
packages: string[],
|
|
165
|
+
shape: ProjectShape,
|
|
156
166
|
): Promise<Change> {
|
|
157
167
|
const outcome = applyBlock(
|
|
158
168
|
await readIfPresent(join(root, "AGENTS.md")),
|
|
159
|
-
buildGuidelines({ packages, serverName: config.serverName }),
|
|
169
|
+
buildGuidelines({ packages, serverName: config.serverName, shape }),
|
|
160
170
|
agentsPreamble(),
|
|
161
171
|
);
|
|
162
172
|
return outcome.status === "conflict"
|
|
@@ -164,6 +174,41 @@ export class ArchInstallCommand extends Command {
|
|
|
164
174
|
: { path: "AGENTS.md", status: outcome.status, text: outcome.text };
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The skill files, one per applicable skill per agent directory.
|
|
179
|
+
*
|
|
180
|
+
* A file without {@link SKILL_MARKER} is left exactly as it is. That is the
|
|
181
|
+
* whole override mechanism: to replace a skill this ships, edit it and delete
|
|
182
|
+
* the marker line. Anything cleverer would be a second config to keep in step
|
|
183
|
+
* with the thing it configures.
|
|
184
|
+
*/
|
|
185
|
+
private async _skillChanges(
|
|
186
|
+
root: string,
|
|
187
|
+
packages: string[],
|
|
188
|
+
shape: ProjectShape,
|
|
189
|
+
agents: string[],
|
|
190
|
+
): Promise<Change[]> {
|
|
191
|
+
const changes: Change[] = [];
|
|
192
|
+
|
|
193
|
+
for (const skill of selectSkills(packages, shape)) {
|
|
194
|
+
const text = renderSkill(skill, shape);
|
|
195
|
+
for (const path of skillPaths(skill.name, agents)) {
|
|
196
|
+
const existing = await readIfPresent(join(root, path));
|
|
197
|
+
if (existing !== undefined && !existing.includes(SKILL_MARKER)) {
|
|
198
|
+
changes.push({ path, status: "unchanged", detail: "yours — left alone" });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
changes.push(
|
|
202
|
+
existing === text
|
|
203
|
+
? { path, status: "unchanged" }
|
|
204
|
+
: { path, status: existing === undefined ? "created" : "updated", text },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return changes;
|
|
210
|
+
}
|
|
211
|
+
|
|
167
212
|
private async _claudeChange(root: string): Promise<Change> {
|
|
168
213
|
const outcome = applyBlock(await readIfPresent(join(root, "CLAUDE.md")), claudeShim());
|
|
169
214
|
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
|
"",
|
|
@@ -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
|
+
}
|