@nurix/etna 0.19.63 → 0.19.65
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/bin/etna.js +155 -557
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/harness.json +1 -1
- package/data/harnesses/apollo/hooks/approved-packages.md +2 -0
- package/data/harnesses/apollo/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/code/harness.json +1 -1
- package/data/harnesses/code/hooks/approved-packages.md +2 -0
- package/data/harnesses/code/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/delivery/harness.json +1 -1
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/etna/harness.json +1 -1
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +34 -1
- package/lib/install/materialize.mjs +402 -0
- package/lib/install/plan.mjs +271 -0
- package/package.json +2 -2
- package/data/harnesses/code/rules/cloudflare.md +0 -167
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// packages/cli/lib/install/plan.mjs
|
|
2
|
+
// THE HARNESS COMPILER: one bundled harness definition in, the exact file list a fresh install
|
|
3
|
+
// produces out. Pure — it reads the bundle and returns data, and writes nothing.
|
|
4
|
+
//
|
|
5
|
+
// This is the single authority on WHERE a harness's files land and WHAT is in them. Both shippers
|
|
6
|
+
// consume it and neither re-derives it:
|
|
7
|
+
//
|
|
8
|
+
// push-harnesses.mjs → publishes the array to Apollo verbatim
|
|
9
|
+
// materializeHarness → writes the array to a project, applying each entry's policy
|
|
10
|
+
//
|
|
11
|
+
// They diverged once, and that is the whole reason this file exists. The Apollo push used to walk
|
|
12
|
+
// the BUNDLE directory and send those paths, so a project downloaded from Apollo Studio extracted
|
|
13
|
+
// with its instruction tree at the archive root where no runtime looks for it, no AGENTS.md, and no
|
|
14
|
+
// hooks wired into settings.json — while the export manifest claimed the folder was ready to open.
|
|
15
|
+
// Nothing failed: every file was present, only its location was wrong.
|
|
16
|
+
//
|
|
17
|
+
// PURE, AND THAT IS THE POINT. Deriving the payload by installing into a temp directory and reading
|
|
18
|
+
// it back works, but it is disk I/O to compute a mapping — and it hides the mapping inside a
|
|
19
|
+
// sequence of file operations where no test can see it. As data, the mapping is a value you can
|
|
20
|
+
// assert on.
|
|
21
|
+
//
|
|
22
|
+
// EVERY ENTRY CARRIES A POLICY, because "what a fresh install produces" and "what a refresh writes
|
|
23
|
+
// over an existing project" differ for exactly two kinds of file. A payload flattens all three (an
|
|
24
|
+
// empty target takes every write); an install honours them.
|
|
25
|
+
//
|
|
26
|
+
// own — harness-owned. Overwritten on every refresh.
|
|
27
|
+
// seed — one-time scaffolding. Written only where the path is ABSENT; once present it is the
|
|
28
|
+
// project's living copy, never refreshed and never pruned.
|
|
29
|
+
// merge — folded into whatever the project already has, key by key. Only `.claude/settings.json`,
|
|
30
|
+
// which carries the user's own hooks and permissions beside the harness's.
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
33
|
+
import { join, relative, sep } from 'node:path';
|
|
34
|
+
import { buildCodexAdapter } from '../codex/agents-md.mjs';
|
|
35
|
+
import { toCodexCommand } from '../codex/hooks.mjs';
|
|
36
|
+
|
|
37
|
+
// Subfolders bundled inside each harness payload that map 1:1 onto <project>/.claude/<name>/.
|
|
38
|
+
// Order matches Claude Code's own folder layout. hooks/ carries the hook scripts + lib/ (and
|
|
39
|
+
// hooks.json, shipped harmlessly — the settings merge reads it from the bundle, not this copy).
|
|
40
|
+
export const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
41
|
+
|
|
42
|
+
// Component subdirs whose membership is tracked so a removed-from-the-harness component can be
|
|
43
|
+
// pruned on refresh. Hooks are excluded — retireOldHooks owns them, because it must also prune the
|
|
44
|
+
// matching settings.json entry, which a name-only diff cannot reach.
|
|
45
|
+
export const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
|
|
46
|
+
|
|
47
|
+
// Codex targets rebuilt wholesale on every install rather than merged, so a refresh must delete
|
|
48
|
+
// them first. Recorded in harness.json's `owned.codex` so a later version can prune one it drops.
|
|
49
|
+
export const CODEX_REBUILT_DIRS = ['.agents/skills', '.codex/agents'];
|
|
50
|
+
|
|
51
|
+
// Every file under dir, as posix paths relative to it. Posix regardless of host, because these
|
|
52
|
+
// become ZIP entry names on Apollo's side and a backslash is a legal filename character on the
|
|
53
|
+
// systems that extract them.
|
|
54
|
+
function filesUnder(dir, prefix = '') {
|
|
55
|
+
const out = [];
|
|
56
|
+
if (!existsSync(dir)) return out;
|
|
57
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
58
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
59
|
+
if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
|
|
60
|
+
else if (entry.isFile()) out.push(rel);
|
|
61
|
+
// A dirent that is neither is almost certainly a symlink. Refusing beats skipping: Apollo
|
|
62
|
+
// stores content, so there is no link to follow on the other side, and a silently short
|
|
63
|
+
// payload is the exact failure mode this module exists to prevent.
|
|
64
|
+
else
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${join(dir, entry.name)} is neither a file nor a directory — a harness may not ship one`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One bundle path → one or more entries. A `files:`/`seeds:` entry may legally name a directory
|
|
73
|
+
// (the installer copies it with `recursive: true`), so a single declared path can expand.
|
|
74
|
+
function entriesFor(bundleDir, rel, policy) {
|
|
75
|
+
const source = join(bundleDir, rel);
|
|
76
|
+
if (!existsSync(source)) return [];
|
|
77
|
+
if (statSync(source).isDirectory()) {
|
|
78
|
+
return filesUnder(source).map((child) => ({
|
|
79
|
+
path: `${rel}/${child}`,
|
|
80
|
+
content: readFileSync(join(source, child), 'utf8'),
|
|
81
|
+
policy,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
return [{ path: rel.split(sep).join('/'), content: readFileSync(source, 'utf8'), policy }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2-space indent + trailing newline matches Claude Code's own writes to settings.json, and the
|
|
88
|
+
// installed bytes must equal the payload's bytes or the two shippers disagree on content.
|
|
89
|
+
function asJson(value) {
|
|
90
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sorted base names of *.<ext> files directly under dir (e.g. rules/*.md → rule names).
|
|
94
|
+
function listByExt(dir, ext) {
|
|
95
|
+
if (!existsSync(dir)) return [];
|
|
96
|
+
return readdirSync(dir)
|
|
97
|
+
.filter((f) => f.endsWith(ext))
|
|
98
|
+
.map((f) => f.slice(0, -ext.length))
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The manifest of what THIS harness owns, per prunable subdir — raw top-level entry names, which
|
|
103
|
+
// is what the next refresh diffs against to find components the harness has since dropped.
|
|
104
|
+
function bundledOwned(bundleDir) {
|
|
105
|
+
const owned = {};
|
|
106
|
+
for (const sub of PRUNABLE_SUBDIRS) {
|
|
107
|
+
const dir = join(bundleDir, sub);
|
|
108
|
+
owned[sub] = existsSync(dir) ? readdirSync(dir).sort() : [];
|
|
109
|
+
}
|
|
110
|
+
return owned;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `.claude/settings.json` as a FRESH install writes it: the bundle's hooks.json folded into an
|
|
114
|
+
// empty settings object. `hooks` is created before the permissions loop, so it comes first in key
|
|
115
|
+
// order — and key order is part of the bytes, so it is part of the contract.
|
|
116
|
+
function freshSettings(bundleDir) {
|
|
117
|
+
const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
|
|
118
|
+
if (!existsSync(hooksJson)) return null;
|
|
119
|
+
const bundled = JSON.parse(readFileSync(hooksJson, 'utf8'));
|
|
120
|
+
const settings = { hooks: {} };
|
|
121
|
+
for (const rule of bundled.permissions?.allow || []) {
|
|
122
|
+
settings.permissions = settings.permissions || {};
|
|
123
|
+
settings.permissions.allow = settings.permissions.allow || [];
|
|
124
|
+
if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
|
|
125
|
+
}
|
|
126
|
+
for (const [event, groups] of Object.entries(bundled.hooks || {})) {
|
|
127
|
+
settings.hooks[event] = groups;
|
|
128
|
+
}
|
|
129
|
+
if (!Object.keys(settings.hooks).length && !settings.permissions) return null;
|
|
130
|
+
return asJson(settings);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `.codex/hooks.json` — the Claude hooks.json filtered to type:"command", the only kind Codex
|
|
134
|
+
// executes, with each command prefixed so `$CLAUDE_PROJECT_DIR` resolves under a runtime that does
|
|
135
|
+
// not export it. The translation itself is `toCodexCommand`, imported rather than restated.
|
|
136
|
+
function codexHooks(bundleDir) {
|
|
137
|
+
const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
|
|
138
|
+
if (!existsSync(hooksJson)) return null;
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(readFileSync(hooksJson, 'utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [event, groups] of Object.entries(parsed.hooks || {})) {
|
|
147
|
+
const kept = [];
|
|
148
|
+
for (const group of groups) {
|
|
149
|
+
const hooks = (group.hooks || [])
|
|
150
|
+
.filter((h) => h.type === 'command')
|
|
151
|
+
.map((h) => ({ ...h, command: toCodexCommand(h.command) }));
|
|
152
|
+
if (hooks.length) kept.push({ ...group, hooks });
|
|
153
|
+
}
|
|
154
|
+
if (kept.length) out[event] = kept;
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(out).length ? asJson({ hooks: out }) : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compile one bundled harness into the file list a fresh install produces.
|
|
161
|
+
*
|
|
162
|
+
* Returns `{ files, owned, meta }` — `files` sorted by path, each with a policy; `owned` the
|
|
163
|
+
* manifest that goes into `.claude/harness.json`; `meta` the parsed bundle marker.
|
|
164
|
+
*
|
|
165
|
+
* Throws when the bundle has no CLAUDE.md: that is the one file every harness must carry, and a
|
|
166
|
+
* harness without a policy is not a harness.
|
|
167
|
+
*/
|
|
168
|
+
export function planHarnessFiles(bundleDir) {
|
|
169
|
+
const claudeMdPath = join(bundleDir, 'CLAUDE.md');
|
|
170
|
+
if (!existsSync(claudeMdPath)) {
|
|
171
|
+
throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const files = [];
|
|
175
|
+
const claudeMd = readFileSync(claudeMdPath, 'utf8');
|
|
176
|
+
files.push({ path: 'CLAUDE.md', content: claudeMd, policy: 'own' });
|
|
177
|
+
|
|
178
|
+
// The component subtrees. This is the mapping the whole module exists for: a bundle says
|
|
179
|
+
// `skills/design/SKILL.md`, a project says `.claude/skills/design/SKILL.md`.
|
|
180
|
+
for (const sub of PLUGIN_SUBDIRS) {
|
|
181
|
+
for (const rel of filesUnder(join(bundleDir, sub))) {
|
|
182
|
+
files.push({
|
|
183
|
+
path: `.claude/${sub}/${rel}`,
|
|
184
|
+
content: readFileSync(join(bundleDir, sub, rel), 'utf8'),
|
|
185
|
+
policy: 'own',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Dual-runtime: the Codex artifacts the CLI derives at install time. A ZIP extract runs no
|
|
191
|
+
// installer, so if these are not in the payload a downloaded project has no Codex support at
|
|
192
|
+
// all and no way to generate it.
|
|
193
|
+
const owned = bundledOwned(bundleDir);
|
|
194
|
+
const codexOwned = [];
|
|
195
|
+
|
|
196
|
+
const adapter = buildCodexAdapter(bundleDir);
|
|
197
|
+
const separator = claudeMd.endsWith('\n') ? '\n' : '\n\n';
|
|
198
|
+
files.push({
|
|
199
|
+
path: 'AGENTS.md',
|
|
200
|
+
content: adapter ? claudeMd + separator + adapter : claudeMd,
|
|
201
|
+
policy: 'own',
|
|
202
|
+
});
|
|
203
|
+
codexOwned.push('AGENTS.md');
|
|
204
|
+
|
|
205
|
+
// `.agents/skills/` is Codex's official scan path and a byte mirror of `.claude/skills/`. Both
|
|
206
|
+
// runtimes require exactly name+description in SKILL.md frontmatter, so the mirrored files are
|
|
207
|
+
// valid for Codex unchanged.
|
|
208
|
+
const skillFiles = filesUnder(join(bundleDir, 'skills'));
|
|
209
|
+
if (skillFiles.length) {
|
|
210
|
+
for (const rel of skillFiles) {
|
|
211
|
+
files.push({
|
|
212
|
+
path: `.agents/skills/${rel}`,
|
|
213
|
+
content: readFileSync(join(bundleDir, 'skills', rel), 'utf8'),
|
|
214
|
+
policy: 'own',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
codexOwned.push('.agents/skills');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const tomls = listByExt(join(bundleDir, 'codex', 'agents'), '.toml');
|
|
221
|
+
if (tomls.length) {
|
|
222
|
+
for (const name of tomls) {
|
|
223
|
+
files.push({
|
|
224
|
+
path: `.codex/agents/${name}.toml`,
|
|
225
|
+
content: readFileSync(join(bundleDir, 'codex', 'agents', `${name}.toml`), 'utf8'),
|
|
226
|
+
policy: 'own',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
codexOwned.push('.codex/agents');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const hooks = codexHooks(bundleDir);
|
|
233
|
+
if (hooks) {
|
|
234
|
+
files.push({ path: '.codex/hooks.json', content: hooks, policy: 'own' });
|
|
235
|
+
codexOwned.push('.codex/hooks.json');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
owned.codex = codexOwned;
|
|
239
|
+
|
|
240
|
+
// The marker, at the path the CLI writes and the maintenance agent resolves the installed
|
|
241
|
+
// version from — never at the bundle root, where nothing reads it.
|
|
242
|
+
const markerPath = join(bundleDir, 'harness.json');
|
|
243
|
+
const meta = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, 'utf8')) : {};
|
|
244
|
+
if (existsSync(markerPath)) {
|
|
245
|
+
files.push({
|
|
246
|
+
path: '.claude/harness.json',
|
|
247
|
+
content: asJson({ ...meta, owned }),
|
|
248
|
+
policy: 'own',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Project-root config the harness owns outright (.prettierrc.json, .vscode/settings.json, …),
|
|
253
|
+
// then the one-time scaffolding it seeds. Same landing, opposite ownership.
|
|
254
|
+
for (const rel of meta.files || []) files.push(...entriesFor(bundleDir, rel, 'own'));
|
|
255
|
+
for (const rel of meta.seeds || []) files.push(...entriesFor(bundleDir, rel, 'seed'));
|
|
256
|
+
|
|
257
|
+
const settings = freshSettings(bundleDir);
|
|
258
|
+
if (settings) files.push({ path: '.claude/settings.json', content: settings, policy: 'merge' });
|
|
259
|
+
|
|
260
|
+
// Sorted so the plan is byte-stable across machines — readdir order is not guaranteed, and an
|
|
261
|
+
// unstable order would make two identical builds look different to anything comparing them.
|
|
262
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
263
|
+
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
if (seen.has(file.path)) throw new Error(`the harness plans ${file.path} twice`);
|
|
267
|
+
seen.add(file.path);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { files, owned, meta };
|
|
271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.65",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"agent",
|
|
43
43
|
"plugin"
|
|
44
44
|
],
|
|
45
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "b0d947332bf0"
|
|
46
46
|
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# Cloudflare Worker
|
|
2
|
-
|
|
3
|
-
Conventions for Cloudflare Workers — `wrangler.jsonc` setup, vars and secrets, and code shape (dispatch, errors, modules, WebSockets). Schema design doctrine is the `schema-design` skill.
|
|
4
|
-
|
|
5
|
-
## 1. Project & wrangler.jsonc Setup
|
|
6
|
-
|
|
7
|
-
### 1.1 Wrangler version
|
|
8
|
-
|
|
9
|
-
- Use Wrangler **v4** — v3's bundled config schema is missing newer keys (e.g. `secrets_store_secrets`), so editors flag valid config as invalid.
|
|
10
|
-
- **No `package.json`** (dep-free worker): use `npx wrangler@4` in every command and doc — the `@4` is the pin. Don't create a `package.json` just to record it.
|
|
11
|
-
- **`package.json` exists**: pin wrangler to the exact version tested against (not `^4.0.0`), reinstall, and verify with `wrangler deploy --dry-run` before pushing.
|
|
12
|
-
|
|
13
|
-
### 1.2 Config format
|
|
14
|
-
|
|
15
|
-
- Use `wrangler.jsonc` (not `wrangler.toml`).
|
|
16
|
-
- Include `"$schema": "node_modules/wrangler/config-schema.json"` so the editor validates against the exact installed wrangler version.
|
|
17
|
-
|
|
18
|
-
### 1.3 Comments in jsonc
|
|
19
|
-
|
|
20
|
-
None by default. Keep a comment only when it captures a non-obvious **WHY** — a hidden constraint, workaround, or account-specific quirk. Never narrate what a key already says.
|
|
21
|
-
|
|
22
|
-
### 1.4 workers.dev URLs
|
|
23
|
-
|
|
24
|
-
- Set `"workers_dev": false` once a custom domain is wired up, so the custom domain is the only entry point.
|
|
25
|
-
- Pair with a `routes` entry using `custom_domain: true` so Cloudflare manages the DNS record and cert.
|
|
26
|
-
|
|
27
|
-
### 1.5 Observability
|
|
28
|
-
|
|
29
|
-
Default block for every Worker:
|
|
30
|
-
|
|
31
|
-
```jsonc
|
|
32
|
-
"observability": {
|
|
33
|
-
"logs": {
|
|
34
|
-
"enabled": false,
|
|
35
|
-
"invocation_logs": true
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
`enabled: false` avoids per-log billing; `invocation_logs: true` keeps the cheap per-request audit trail. **Bootstrap override:** a new Worker (especially cron-only) runs with `enabled: true` until a few real runs look clean — with logs off, `console.log` is visible only live via `npx wrangler tail` and nothing persists after a failed run. Flip back once stable.
|
|
41
|
-
|
|
42
|
-
### 1.6 Project structure & dependencies
|
|
43
|
-
|
|
44
|
-
- Main entry file: `index.ts`. Reusable helpers: `helper.ts`.
|
|
45
|
-
- TypeScript under `strict: true` — wrangler bundles it natively, no separate build step; a minimal `tsconfig.json` ships even in dep-free Workers.
|
|
46
|
-
- Don't run `npm run build` or `npm run deploy` in examples.
|
|
47
|
-
- **`package.json` / `package-lock.json` are opt-in, not default.** Create them only when code under `src/` imports an npm package at runtime — a Worker on built-in Web/Workers APIs alone needs no manifest; run wrangler, linters, and one-offs via `npx <tool>`. Add both files the moment the first real dep lands. `node_modules/` stays in `.gitignore` either way.
|
|
48
|
-
|
|
49
|
-
### 1.7 Environment & configuration
|
|
50
|
-
|
|
51
|
-
- Use the deployed Cloudflare URL in docs/tests, not `localhost`.
|
|
52
|
-
- Prefer SQLite-based Durable Objects over in-memory-only designs; add classes under `migrations.new_sqlite_classes`.
|
|
53
|
-
|
|
54
|
-
### 1.8 Cloudflare Secrets Store
|
|
55
|
-
|
|
56
|
-
Use when the user mentions it. Use the same `binding` name as the `secret_name`.
|
|
57
|
-
|
|
58
|
-
```jsonc
|
|
59
|
-
"secrets_store_secrets": [
|
|
60
|
-
{ "binding": "CLICKUP_KEY", "store_id": "792206780c0c46e8ba8ade3f21b44eac", "secret_name": "CLICKUP_KEY" }
|
|
61
|
-
]
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Access in code: `await env.CLICKUP_KEY.get()`.
|
|
65
|
-
|
|
66
|
-
### 1.9 Naming
|
|
67
|
-
|
|
68
|
-
- Repo/dir: `cf-<name>` (e.g. `cf-extract-design`).
|
|
69
|
-
- Worker name: `<name>` — repo name minus the `cf-` prefix.
|
|
70
|
-
- R2 bucket, D1 database, and KV namespace names: equal to the worker name. One name, one resource set.
|
|
71
|
-
|
|
72
|
-
### 1.10 Vars & secrets
|
|
73
|
-
|
|
74
|
-
**Non-secret vars live in the `vars` block in `wrangler.jsonc`** — checked in, one source of truth. Vars are configuration, not runtime state; do not move them to the dashboard. A single-tenant application Worker (one deployment — e.g. Apollo) declares them at the top level; a multi-tenant Worker declares per-tenant vars under each `env.<tenant>` block (§11).
|
|
75
|
-
|
|
76
|
-
**Secrets never touch git.** Local dev: a gitignored `.dev.vars` (`KEY=VALUE` dotenv, auto-loaded by `wrangler dev`) with a committed `.dev.vars.example` listing the keys and placeholder values. Production: `wrangler secret put <NAME>`, a gitignored `.secrets` file pushed with `wrangler secret bulk .secrets` (or shipped with a deploy via `wrangler deploy --secrets-file .secrets`), or the Secrets Store (§1.8). Never log or echo a secret value.
|
|
77
|
-
|
|
78
|
-
**No `keep_vars`.** Leave it unset (default `false`) — each deploy reconciles vars to exactly what the config declares. `keep_vars` only guards plaintext vars set out-of-band in the dashboard; it never touches secrets — `wrangler secret put` secrets survive every deploy regardless. Only the multi-tenant pattern sets `keep_vars: true` (§11).
|
|
79
|
-
|
|
80
|
-
**Never name a deployed-value file `.env` or `.env.<anything>`** — `wrangler dev` auto-loads a root `.env` as local dev vars, so a prod-valued `.env` silently becomes the local environment on a fresh clone or in CI. Committed `*.example` files carry keys with placeholder values only.
|
|
81
|
-
|
|
82
|
-
`.gitignore`: `.dev.vars`, `.secrets`, `.secrets.*`, `.env`, `.env.*`
|
|
83
|
-
|
|
84
|
-
## 2. Code & Helpers
|
|
85
|
-
|
|
86
|
-
- Use `generateUuidV7()` in `helper.ts` for IDs (time-sortable UUIDv7).
|
|
87
|
-
- Only `await` what's needed (e.g. `request.json()`, `do.fetch(...)`).
|
|
88
|
-
|
|
89
|
-
## 3. fetch() Structure
|
|
90
|
-
|
|
91
|
-
`validateRequest()` first, then `switch (path)` for dispatch. Single `try/catch` wrapping the entire handler. No if-chains, no regex in fetch.
|
|
92
|
-
|
|
93
|
-
```js
|
|
94
|
-
export default {
|
|
95
|
-
async fetch(request, env, ctx) {
|
|
96
|
-
try {
|
|
97
|
-
const route = validateRequest(request);
|
|
98
|
-
switch (route.path) {
|
|
99
|
-
case "monitors":
|
|
100
|
-
return await handleMonitors({ ...route, env, ctx });
|
|
101
|
-
default:
|
|
102
|
-
throw { code: 404, message: "Route not found" };
|
|
103
|
-
}
|
|
104
|
-
} catch (error) {
|
|
105
|
-
console.error("Unhandled error in fetch()", error);
|
|
106
|
-
return createErrorResponse(
|
|
107
|
-
error.message || "Internal server error",
|
|
108
|
-
error.code || 500,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
No nested `try/catch` except around WebSocket `send`/`close` (see §9).
|
|
116
|
-
|
|
117
|
-
## 4. validateRequest()
|
|
118
|
-
|
|
119
|
-
Lives in `helper.ts`. Parses the URL in one place and returns `{ path, method, query, resourceId, subPath }`; throws `{ code: 400 }` when a path requires a resource ID and none is present.
|
|
120
|
-
|
|
121
|
-
## 5. Validation
|
|
122
|
-
|
|
123
|
-
Fail fast with thrown `{ code, message }` (`400` bad input, `401` missing/invalid auth). Validate required fields explicitly — no optional chaining for required fields.
|
|
124
|
-
|
|
125
|
-
## 6. Errors & Responses
|
|
126
|
-
|
|
127
|
-
- Throw structured errors: `{ code, message }`. Always `console.error()` with context (path, user, etc.). No empty catch blocks.
|
|
128
|
-
- Response envelope `{ status: 1|0, message, data }` — the shared cross-service contract.
|
|
129
|
-
- `createResponse(data, message, code)` in `helper.ts` bakes CORS in, returns a null body for 204/304, and uses proper HTTP codes; `createErrorResponse(message, code)` delegates to it.
|
|
130
|
-
- CORS is passive: a `CORS_HEADERS` const in `helper.ts`, spread into `createResponse()` — no explicit OPTIONS handler, no wrapper functions. Origin-restricted CORS builds the headers from an allowlist (plus `localhost` in dev), still inside `createResponse()`.
|
|
131
|
-
|
|
132
|
-
## 7. Handler Signatures
|
|
133
|
-
|
|
134
|
-
Single destructured object — never positional args. Compose by spreading route with env/ctx: `handleGetStats({ request, env, resourceId })`, not `handleGetStats(request, id, env)`.
|
|
135
|
-
|
|
136
|
-
## 8. Module Structure
|
|
137
|
-
|
|
138
|
-
Feature-based folders with `handle.ts` + `helper.ts` pairs. `index.ts` only does dispatch.
|
|
139
|
-
|
|
140
|
-
| Path | Role |
|
|
141
|
-
| --- | --- |
|
|
142
|
-
| `src/index.ts` | Entry point — dispatch only |
|
|
143
|
-
| `src/helper.ts` | Shared CORS, responses, validation, utilities |
|
|
144
|
-
| `src/database.ts` | Shared D1 queries |
|
|
145
|
-
| `src/<feature>/handle.ts` | Route handlers |
|
|
146
|
-
| `src/<feature>/helper.ts` | Feature-specific helpers & DB ops |
|
|
147
|
-
|
|
148
|
-
## 9. WebSockets
|
|
149
|
-
|
|
150
|
-
Standard `WebSocketPair` handshake (`server.accept()`, return 101 with `webSocket: client`). Check `readyState === WebSocket.OPEN` before sending. The only acceptable nested `try/catch`: wrap `send`; in its catch, `close(1011)` inside a bare inner try, then evict the session.
|
|
151
|
-
|
|
152
|
-
## 10. D1 Schema
|
|
153
|
-
|
|
154
|
-
Follows the shared database naming and column-type vocabulary; what earns a table and the rest of the design doctrine is the `schema-design` skill.
|
|
155
|
-
|
|
156
|
-
## 11. Multi-tenant Workers (native environments)
|
|
157
|
-
|
|
158
|
-
Rare — only when **one Worker codebase ships as several independent tenants** (separate Workers, domains, and data stores from a single `src/`, e.g. `~/dev/auth` → `auth-nuplay`, `auth-nustack`). Use **native wrangler environments**, never one Worker that switches tenant at runtime. For a single-tenant application Worker, §1.10 applies and this section does not.
|
|
159
|
-
|
|
160
|
-
- **One config, `env.<tenant>` blocks.** The top-level block is **local dev** (`wrangler dev`): a `*-dev` name and placeholder D1/KV ids, simulated locally. Each `env.<tenant>` block is **one production tenant**; deploy it with `wrangler deploy --env <tenant>`.
|
|
161
|
-
- **Named environments never inherit** `vars` / `d1_databases` / `kv_namespaces` / `routes` / `secrets_store_secrets` / `assets` from the top level — **each `env.<tenant>` declares them in full**. Dropping a binding from an env block disables that capability for that one tenant.
|
|
162
|
-
- **Per-tenant non-secret vars live in `env.<tenant>.vars`** — still checked in; one file holds every tenant's config side by side.
|
|
163
|
-
- **Local-dev vars** come from a gitignored `.vars.local`, injected as `--var` by the dev script (the top-level block carries no in-config `vars`).
|
|
164
|
-
- **Per-tenant secrets, never in git** — `wrangler secret put <NAME> --env <tenant>`, or a gitignored per-tenant `.secrets` (e.g. `worker/tenants/<tenant>/.secrets`) pushed with `wrangler secret bulk --env <tenant>`, plus a committed `.secrets.example`. Secrets Store bindings are declared inside each env block.
|
|
165
|
-
- **`keep_vars: true`** (top level) keeps a per-tenant deploy **additive**: tenants inject their non-secret vars via `--var` (or the dashboard), and without it a deploy would wipe them. On secrets it changes nothing (§1.10).
|
|
166
|
-
|
|
167
|
-
`.gitignore`: `.vars.local`, `.secrets`, `.secrets.*`, `.env`, `.env.*`
|