@cparkerwebm/webmonterey 1.2.0 → 1.3.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 CHANGED
@@ -11,6 +11,61 @@ build. See `/webm:upgrade`.
11
11
 
12
12
  ---
13
13
 
14
+ ## 1.3.0 — 2026-09-02
15
+
16
+ ### Changed
17
+
18
+ - **A staging site is a preview build everywhere.** `environment: "staging"` in webmonterey.json
19
+ now makes every build a preview — every page noindex with no canonical, no sitemap, `robots.txt`
20
+ `Disallow: /`, no Google Tag Manager — on every hostname and in every build: a feature branch,
21
+ `main` on Workers Builds, a laptop. Until now only a non-production branch was a preview, so a
22
+ site that had not launched was crawlable on its `workers.dev` URL the moment `main` deployed.
23
+ The branch rule stays: a feature branch of a launched site is still a preview. An unset
24
+ `environment` is production, as everywhere else, so a site predating the field builds as before.
25
+ The decision is one pure function, `isPreviewBuild`; the build log says which signal made a
26
+ build a preview, and `virtual:webm/build` carries it as `reason`.
27
+
28
+ **A launched site whose webmonterey.json still says `"staging"` disappears from search after
29
+ this update:** every page goes noindex and the sitemap is gone. `/webm:launch` sets
30
+ `"environment": "production"` and `launched` together; `webm doctor` already fails a launched
31
+ site still declared staging, and its message now names this consequence. Flipping `environment`
32
+ is what makes a site indexable, so it must not happen before the custom domain is live —
33
+ `/webm:start` and `/webm:launch` both say so.
34
+
35
+ - **`/webm:start` creates the Worker.** Step 5 is now one deploy from the laptop — `npm run build
36
+ && npx wrangler deploy`, guarded by `wrangler deployments list` so a re-run skips it — and step 6
37
+ connects the repo to the Worker that now exists (Settings → Builds), a smaller dashboard step
38
+ than importing a repository and typing the Worker name in by hand. The skill used to stop and
39
+ ask for the Worker to be made in the dashboard; on one site it was not, and the result was a
40
+ repo, a database and nothing serving. That laptop deploy is the only one a site ever gets: once
41
+ the repo is connected, a laptop deploy is a version no build produced, which the next push
42
+ reverts. The adapter's auto-provisioned `<slug>-session` KV namespace is expected and stays out
43
+ of wrangler.jsonc. The scaffolded README's deploy note says the same.
44
+
45
+ ### Added
46
+
47
+ - **`webm doctor` checks that the Worker exists.** It asks wrangler for the deployments of the
48
+ Worker named in wrangler.jsonc and warns when there are none — the silent failure above. It skips
49
+ with a note when wrangler is not installed or not logged in, so CI and a fresh laptop are not
50
+ failed for being unable to ask.
51
+
52
+ - **Hard rule 12 in a site's CLAUDE.md: a session in a client repo never edits the package.** Not
53
+ in `node_modules`, and not in the package's checkout when it sits on the same machine. The
54
+ deliverable for an upstream problem is a description of the fix — what, where, expected
55
+ behaviour, how to verify — as a prompt for a session opened in the package repo; the site takes
56
+ the fix with `npm update`. Enforced as far as Claude Code's rules reach: `.claude/settings.json`
57
+ denies `Edit(**/node_modules/**)`, and **`webm sync` now merges the package's deny rules into a
58
+ site's settings on every install** — adding what is missing, leaving the site's own rules alone,
59
+ and dropping the two `Write(...)` rules 1.2.0 scaffolded, which Claude Code never consults and
60
+ warns about at startup. The docs have no rule syntax for "any path outside this project", so
61
+ that half stays prose.
62
+
63
+ **On an existing site:** the settings arrive with the next install. `CLAUDE.md` is the site's
64
+ own file, so copy rule 12 in from
65
+ `node_modules/@cparkerwebm/webmonterey/template/site/CLAUDE.md`.
66
+
67
+ ---
68
+
14
69
  ## 1.2.0 — 2026-09-02
15
70
 
16
71
  ### Added
package/README.md CHANGED
@@ -20,6 +20,12 @@ the reset, the Cloudflare includes, the consent system, the form pipeline, the r
20
20
  It ships **zero visible components**. Every block a visitor sees is built per client, in that
21
21
  client's repo. Every default has a documented way to opt out of it.
22
22
 
23
+ **The package is edited in this repo, and only here.** A session in a client site that finds a
24
+ package bug does not reach into `node_modules` or into this checkout; its deliverable is a
25
+ description of the fix, run later in a session opened here, and the site takes the result with
26
+ `npm update`. That is rule 12 of the site's `CLAUDE.md`, with an `Edit` deny on `node_modules`
27
+ behind it that `webm sync` keeps in place. The inverse holds: nothing here edits a client site.
28
+
23
29
  ## Creating a client site
24
30
 
25
31
  ```sh
package/dist/webm.mjs CHANGED
@@ -196,6 +196,53 @@ var init_mcp = __esm({
196
196
  }
197
197
  });
198
198
 
199
+ // src/cli/settings.ts
200
+ function projectSettings(repo) {
201
+ return {
202
+ "//": `Project settings for ${repo}.`,
203
+ "//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
204
+ includeCoAuthoredBy: false,
205
+ enabledMcpjsonServers: [...MCP_NAMES],
206
+ "//permissions": PERMISSIONS_NOTE,
207
+ permissions: { deny: [...DENY_RULES] }
208
+ };
209
+ }
210
+ function withDenyRules(settings) {
211
+ const permissions = settings.permissions && typeof settings.permissions === "object" ? { ...settings.permissions } : {};
212
+ const current = Array.isArray(permissions.deny) ? permissions.deny.filter((r) => typeof r === "string") : [];
213
+ const removed = current.filter((r) => STALE_RULES.includes(r));
214
+ const kept = current.filter((r) => !STALE_RULES.includes(r));
215
+ const added = DENY_RULES.filter((r) => !kept.includes(r));
216
+ if (!added.length && !removed.length) return { settings, added, removed };
217
+ permissions.deny = [...kept, ...added];
218
+ return {
219
+ settings: { ...settings, "//permissions": PERMISSIONS_NOTE, permissions },
220
+ added,
221
+ removed
222
+ };
223
+ }
224
+ var DENY_RULES, STALE_RULES, PERMISSIONS_NOTE;
225
+ var init_settings = __esm({
226
+ "src/cli/settings.ts"() {
227
+ "use strict";
228
+ init_mcp();
229
+ DENY_RULES = [
230
+ "Read(**/.dev.vars)",
231
+ "Read(**/.dev.vars.*)",
232
+ "Read(**/.env)",
233
+ "Read(**/.env.*)",
234
+ "Read(**/*.pem)",
235
+ "Read(**/*.key)",
236
+ "Read(**/.npmrc)",
237
+ "Edit(**/.dev.vars)",
238
+ "Edit(**/.env)",
239
+ "Edit(**/node_modules/**)"
240
+ ];
241
+ STALE_RULES = ["Write(**/.dev.vars)", "Write(**/.env)"];
242
+ PERMISSIONS_NOTE = "The deny list is package-managed: `webm sync` adds any rule that is missing on every install and leaves everything else in this file alone. Edit(**/node_modules/**) is rule 12 of CLAUDE.md made mechanical - a session in this repo never edits the package.";
243
+ }
244
+ });
245
+
199
246
  // src/cli/scaffold.ts
200
247
  function shiftDays(date, days) {
201
248
  const shifted = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
@@ -279,7 +326,7 @@ export default defineConfig({
279
326
  worker: n.worker,
280
327
  slug: n.slug,
281
328
  launched: null,
282
- "//environment": "What this deployment is FOR. 'staging' redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production'. Anything served from workers.dev is treated as staging regardless, so a branch preview of a live site is covered too.",
329
+ "//environment": "What this deployment is FOR. 'staging' makes every build a PREVIEW - every page noindex with no canonical, no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, main included, so a site that has not launched cannot be indexed before it exists; and it redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production' once the custom domain is live, and that flip is what makes the site indexable. A branch other than main is a preview regardless, and anything served from workers.dev redirects its mail regardless, so a branch preview of a live site is covered too.",
283
330
  environment: "staging",
284
331
  "//stagingEmail": "Where staging email goes instead of its real recipients. REQUIRED while environment is staging - a staging site with nowhere to send refuses to send rather than guessing. webm doctor checks.",
285
332
  stagingEmail: options.stagingEmail ?? "",
@@ -372,31 +419,7 @@ export default defineConfig({
372
419
  null,
373
420
  2
374
421
  ) + "\n";
375
- files[".claude/settings.json"] = JSON.stringify(
376
- {
377
- "//": `Project settings for ${n.repo}.`,
378
- "//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
379
- includeCoAuthoredBy: false,
380
- enabledMcpjsonServers: MCP_NAMES,
381
- permissions: {
382
- deny: [
383
- "Read(**/.dev.vars)",
384
- "Read(**/.dev.vars.*)",
385
- "Read(**/.env)",
386
- "Read(**/.env.*)",
387
- "Read(**/*.pem)",
388
- "Read(**/*.key)",
389
- "Read(**/.npmrc)",
390
- "Edit(**/.dev.vars)",
391
- "Edit(**/.env)",
392
- "Write(**/.dev.vars)",
393
- "Write(**/.env)"
394
- ]
395
- }
396
- },
397
- null,
398
- 2
399
- ) + "\n";
422
+ files[".claude/settings.json"] = JSON.stringify(projectSettings(n.repo), null, 2) + "\n";
400
423
  files[".mcp.json"] = JSON.stringify(mcpConfig(), null, 2) + "\n";
401
424
  files["src/actions/index.ts"] = `/*
402
425
  * Form handlers.
@@ -550,8 +573,11 @@ suffix - \`${n.slug}-portal\`.
550
573
 
551
574
  ## Deploying
552
575
 
553
- Push to deploy. A \`wrangler deploy\` from a laptop creates a version no build produced, so
554
- history stops describing what is live and the next push reverts it.
576
+ The Worker is created ONCE from a laptop - \`npm run build && npx wrangler deploy\` - and the
577
+ repo is then connected to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start does
578
+ both. From then on, push to deploy: a \`wrangler deploy\` from a laptop after that creates a
579
+ version no build produced, so history stops describing what is live and the next push
580
+ reverts it.
555
581
  `;
556
582
  return files;
557
583
  }
@@ -560,6 +586,7 @@ var init_scaffold = __esm({
560
586
  "use strict";
561
587
  init_slug();
562
588
  init_mcp();
589
+ init_settings();
563
590
  }
564
591
  });
565
592
 
@@ -578,7 +605,7 @@ import {
578
605
  writeFileSync,
579
606
  readdirSync
580
607
  } from "node:fs";
581
- import { basename, join as join2 } from "node:path";
608
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
582
609
  function syncDir(source, target) {
583
610
  if (!existsSync2(source)) return [];
584
611
  mkdirSync(target, { recursive: true });
@@ -604,6 +631,30 @@ function addMissing(source, target) {
604
631
  }
605
632
  return added.sort();
606
633
  }
634
+ function ensureSettings(siteRoot) {
635
+ const path = join2(siteRoot, ".claude", "settings.json");
636
+ if (!existsSync2(path)) {
637
+ mkdirSync(dirname2(path), { recursive: true });
638
+ writeFileSync(path, JSON.stringify(projectSettings(basename(siteRoot)), null, 2) + "\n");
639
+ return { added: [...DENY_RULES], removed: [], created: true, skipped: null };
640
+ }
641
+ let parsed;
642
+ try {
643
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
644
+ } catch {
645
+ return {
646
+ added: [],
647
+ removed: [],
648
+ created: false,
649
+ skipped: ".claude/settings.json is not valid JSON, so it was left alone"
650
+ };
651
+ }
652
+ const { settings, added, removed } = withDenyRules(parsed);
653
+ if (added.length || removed.length) {
654
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
655
+ }
656
+ return { added, removed, created: false, skipped: null };
657
+ }
607
658
  function listSkills(dir) {
608
659
  if (!existsSync2(dir)) return [];
609
660
  return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync2(join2(dir, e.name, "SKILL.md"))).map((e) => e.name).sort();
@@ -660,7 +711,8 @@ function sync(siteRoot) {
660
711
  * which is how code depending on an unpublished package reached main without a word.
661
712
  */
662
713
  workflows: syncDir(join2(template, "workflows"), join2(siteRoot, ".github/workflows")),
663
- migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations"))
714
+ migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations")),
715
+ settings: ensureSettings(siteRoot)
664
716
  };
665
717
  }
666
718
  function ensureGitignored(siteRoot) {
@@ -691,6 +743,19 @@ function run(argv2) {
691
743
  for (const m of result.migrations) {
692
744
  console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
693
745
  }
746
+ if (result.settings.created) {
747
+ console.log(` + .claude/settings.json`);
748
+ } else if (result.settings.skipped) {
749
+ console.log(` ${result.settings.skipped}`);
750
+ } else if (result.settings.added.length || result.settings.removed.length) {
751
+ const parts = [
752
+ result.settings.added.length && `+${result.settings.added.length} deny`,
753
+ result.settings.removed.length && `-${result.settings.removed.length} stale`
754
+ ].filter(Boolean);
755
+ console.log(
756
+ ` .claude/settings.json: ${parts.join(", ")} (package-managed rules; yours are kept)`
757
+ );
758
+ }
694
759
  if (ensureGitignored(siteRoot)) {
695
760
  console.log(` gitignored .claude/skills/${NAMESPACE}/`);
696
761
  }
@@ -707,13 +772,14 @@ var init_sync = __esm({
707
772
  "src/cli/sync.ts"() {
708
773
  "use strict";
709
774
  init_package_root();
775
+ init_settings();
710
776
  NAMESPACE = "webm";
711
777
  }
712
778
  });
713
779
 
714
780
  // src/cli/seed.ts
715
781
  import { cpSync as cpSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
716
- import { dirname as dirname2, join as join3, relative } from "node:path";
782
+ import { dirname as dirname3, join as join3, relative } from "node:path";
717
783
  function walk(dir) {
718
784
  if (!existsSync3(dir)) return [];
719
785
  return readdirSync2(dir, { withFileTypes: true }).flatMap((e) => {
@@ -732,7 +798,7 @@ function seed(packageRoot, siteRoot) {
732
798
  const rel = relative(source, file);
733
799
  const dest = join3(siteRoot, to === "." ? rel : join3(to, rel));
734
800
  if (existsSync3(dest)) continue;
735
- mkdirSync2(dirname2(dest), { recursive: true });
801
+ mkdirSync2(dirname3(dest), { recursive: true });
736
802
  cpSync2(file, dest);
737
803
  written.push(relative(siteRoot, dest));
738
804
  }
@@ -800,7 +866,7 @@ __export(new_exports, {
800
866
  });
801
867
  import { execFileSync } from "node:child_process";
802
868
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
803
- import { dirname as dirname3, join as join4, resolve } from "node:path";
869
+ import { dirname as dirname4, join as join4, resolve } from "node:path";
804
870
  function parseArgs(argv2) {
805
871
  const positional = argv2.filter((a) => !a.startsWith("--"));
806
872
  const flag = (name) => {
@@ -863,7 +929,7 @@ function run2(argv2) {
863
929
  });
864
930
  for (const [path, contents] of Object.entries(files)) {
865
931
  const full = join4(root, path);
866
- mkdirSync3(dirname3(full), { recursive: true });
932
+ mkdirSync3(dirname4(full), { recursive: true });
867
933
  writeFileSync3(full, contents);
868
934
  }
869
935
  const seeded = seed(PACKAGE_ROOT, root);
@@ -1982,7 +2048,7 @@ var init_checks = __esm({
1982
2048
  {
1983
2049
  id: "environment",
1984
2050
  title: "The declared environment matches where the site actually is",
1985
- silentAs: "a launched site whose client email is still being diverted to the agency's inbox",
2051
+ silentAs: "a launched site whose client email is still being diverted to the agency's inbox, and whose every page is noindex",
1986
2052
  run(ctx) {
1987
2053
  const declared = ctx.site.environment;
1988
2054
  if (declared !== void 0 && declared !== "production" && declared !== "staging") {
@@ -1992,12 +2058,37 @@ var init_checks = __esm({
1992
2058
  }
1993
2059
  if (declared === "staging" && isConfigured(ctx.site.launched)) {
1994
2060
  return fail(
1995
- `this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients. Set "environment": "production" in webmonterey.json.`
2061
+ `this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients - and since 1.3.0 every build of a staging site is a preview: noindex on every page, no canonical, no sitemap, robots.txt disallowing everything. The live site is dropping out of search. Set "environment": "production" in webmonterey.json.`
1996
2062
  );
1997
2063
  }
1998
2064
  if (declared !== "staging" && !isConfigured(ctx.site.launched)) {
1999
2065
  return warn(
2000
- `this site has no launch date but is treated as production, so testing a form will email the client's real contacts. Set "environment": "staging" in webmonterey.json until /webm:launch.`
2066
+ `this site has no launch date but is treated as production, so testing a form will email the client's real contacts and every page is indexable on its workers.dev hostname. Set "environment": "staging" in webmonterey.json until /webm:launch.`
2067
+ );
2068
+ }
2069
+ return pass;
2070
+ }
2071
+ },
2072
+ {
2073
+ /*
2074
+ * THE WORKER EXISTS. /webm:start used to end with a repo, a D1 database and an instruction
2075
+ * to create the Worker in the dashboard by hand - and on one site nobody did. Nothing local
2076
+ * notices: the build is green, every other check here is green, and the site is a
2077
+ * workers.dev hostname that answers nothing. The Worker is the one resource whose absence
2078
+ * has no symptom on disk, so this asks Cloudflare through wrangler - the one thing a laptop
2079
+ * can ask - and steps aside with a note when it cannot.
2080
+ */
2081
+ id: "worker-exists",
2082
+ title: "The Worker exists",
2083
+ silentAs: "a site with a repo, a database and nothing serving",
2084
+ run(ctx) {
2085
+ if (ctx.worker.skipped) return { status: "pass", detail: `skipped: ${ctx.worker.skipped}` };
2086
+ if (!ctx.worker.name) {
2087
+ return warn("wrangler.jsonc names no Worker, so there is nothing to look for");
2088
+ }
2089
+ if (!ctx.worker.deployments) {
2090
+ return warn(
2091
+ `no deployment of a Worker named "${ctx.worker.name}" on this account. Create it once from the laptop - npm run build && npx wrangler deploy - then connect the repo to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start, steps 5 and 6.`
2001
2092
  );
2002
2093
  }
2003
2094
  return pass;
@@ -2079,8 +2170,10 @@ __export(doctor_exports, {
2079
2170
  buildContext: () => buildContext,
2080
2171
  run: () => run5
2081
2172
  });
2173
+ import { execFileSync as execFileSync2 } from "node:child_process";
2082
2174
  import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync6 } from "node:fs";
2083
- import { join as join7, relative as relative3 } from "node:path";
2175
+ import { createRequire } from "node:module";
2176
+ import { dirname as dirname5, join as join7, relative as relative3, resolve as resolve2 } from "node:path";
2084
2177
  function parseJsonc(source) {
2085
2178
  const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
2086
2179
  return JSON.parse(stripped);
@@ -2140,13 +2233,63 @@ function readMcp(siteRoot) {
2140
2233
  enabled: read(".claude/settings.json", (p) => p.enabledMcpjsonServers ?? null)
2141
2234
  };
2142
2235
  }
2236
+ function workerState(siteRoot, name) {
2237
+ const worker = { name: name ?? null, deployments: null, skipped: null };
2238
+ if (!worker.name) return worker;
2239
+ let bin;
2240
+ try {
2241
+ const require2 = createRequire(join7(resolve2(siteRoot), "package.json"));
2242
+ bin = join7(dirname5(require2.resolve("wrangler/package.json")), "bin/wrangler.js");
2243
+ } catch {
2244
+ return {
2245
+ ...worker,
2246
+ skipped: "wrangler is not installed here, so the Worker was not looked for"
2247
+ };
2248
+ }
2249
+ try {
2250
+ const out = execFileSync2(
2251
+ process.execPath,
2252
+ [bin, "deployments", "list", "--name", worker.name, "--json"],
2253
+ {
2254
+ cwd: siteRoot,
2255
+ encoding: "utf8",
2256
+ stdio: ["ignore", "pipe", "pipe"],
2257
+ timeout: 3e4,
2258
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false", NO_COLOR: "1" }
2259
+ }
2260
+ );
2261
+ const start = out.indexOf("[");
2262
+ const parsed = start >= 0 ? JSON.parse(out.slice(start)) : [];
2263
+ return { ...worker, deployments: Array.isArray(parsed) ? parsed.length : 0 };
2264
+ } catch (error) {
2265
+ const e = error;
2266
+ const text = `${e.stdout ?? ""}
2267
+ ${e.stderr ?? ""}
2268
+ ${e.message ?? ""}`;
2269
+ if (/code: 10007\]|does not exist on your account/i.test(text)) {
2270
+ return { ...worker, deployments: 0 };
2271
+ }
2272
+ if (/CLOUDFLARE_API_TOKEN|not (logged in|authenticated)|Authentication error|code: (10000|6111|9109)\]/i.test(
2273
+ text
2274
+ )) {
2275
+ return {
2276
+ ...worker,
2277
+ skipped: "wrangler is not logged in (npx wrangler login), so whether the Worker exists was not checked"
2278
+ };
2279
+ }
2280
+ const line = text.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").trim()).find((l) => l && !l.startsWith("\u{1FAB5}"));
2281
+ return { ...worker, skipped: `wrangler could not answer: ${line ?? "no output"}` };
2282
+ }
2283
+ }
2143
2284
  function buildContext(siteRoot) {
2144
2285
  const { site } = loadSiteFiles(siteRoot);
2145
2286
  const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join7(siteRoot, f)).find(existsSync8);
2146
2287
  const syncPath = join7(siteRoot, ".claude/skills/webm/.webm-sync.json");
2288
+ const wrangler = wranglerPath ? parseJsonc(readFileSync7(wranglerPath, "utf8")) : null;
2147
2289
  return {
2148
2290
  site,
2149
- wrangler: wranglerPath ? parseJsonc(readFileSync7(wranglerPath, "utf8")) : null,
2291
+ wrangler,
2292
+ worker: workerState(siteRoot, wrangler?.name),
2150
2293
  pages: readTree(siteRoot, "src/pages", [".astro", ".ts"]),
2151
2294
  components: readTree(siteRoot, "src/components", [".astro", ".ts"]),
2152
2295
  today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
@@ -2227,7 +2370,7 @@ __export(audit_exports, {
2227
2370
  run: () => run6
2228
2371
  });
2229
2372
  import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
2230
- import { join as join8, relative as relative4, resolve as resolve2 } from "node:path";
2373
+ import { join as join8, relative as relative4, resolve as resolve3 } from "node:path";
2231
2374
  function attr(tag, name) {
2232
2375
  const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
2233
2376
  if (!m) return null;
@@ -2376,7 +2519,7 @@ async function probe(urls) {
2376
2519
  return bad.sort((a, b) => a.url.localeCompare(b.url));
2377
2520
  }
2378
2521
  async function run6(argv2) {
2379
- const dist = resolve2(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2522
+ const dist = resolve3(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2380
2523
  const noExternal = argv2.includes("--no-external");
2381
2524
  if (!existsSync9(join8(dist, "index.html"))) {
2382
2525
  console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
@@ -2473,11 +2616,11 @@ var upgrade_exports = {};
2473
2616
  __export(upgrade_exports, {
2474
2617
  run: () => run7
2475
2618
  });
2476
- import { execFileSync as execFileSync2 } from "node:child_process";
2619
+ import { execFileSync as execFileSync3 } from "node:child_process";
2477
2620
  import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
2478
2621
  import { join as join9 } from "node:path";
2479
2622
  function git(siteRoot, args) {
2480
- return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2623
+ return execFileSync3("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2481
2624
  }
2482
2625
  function installedVersion(siteRoot) {
2483
2626
  const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
@@ -2507,7 +2650,7 @@ function run7(argv2) {
2507
2650
  git(siteRoot, ["checkout", "-b", branch]);
2508
2651
  console.log(`Branched to ${branch}. Never upgrade on main.`);
2509
2652
  }
2510
- execFileSync2("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2653
+ execFileSync3("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2511
2654
  const to = installedVersion(siteRoot);
2512
2655
  if (!to) {
2513
2656
  console.error("webm upgrade: install did not produce a version. Check the npm output above.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "The WebMonterey Astro framework: plumbing, design system, and Claude Code skills for client sites on Cloudflare Workers",
6
6
  "license": "MIT",
@@ -211,13 +211,21 @@ In one change:
211
211
  "launched": "YYYY-MM-DD"
212
212
  ```
213
213
 
214
+ **This flip is also what makes the site indexable, so it happens here and not before.** While
215
+ `environment` says `staging`, every build is a preview - every page noindex with no canonical,
216
+ no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, `main`
217
+ included. Flip it before the custom domain is live and the `workers.dev` copy is what gets
218
+ indexed.
219
+
214
220
  Until `environment` flips, every message the site sends is redirected to `stagingEmail` - correct
215
221
  right up to the moment the domain is attached and wrong immediately after: the form keeps saying
216
222
  thank you, the client's inbox stays empty, and the first anyone hears of it is a customer asking
217
- why nobody called back. `webm doctor` fails a launched site still declared staging, which is why
218
- both fields change together.
223
+ why nobody called back. And a launched site left on `staging` is invisible to search: every page
224
+ noindex, no sitemap, `Disallow: /`. `webm doctor` fails a launched site still declared staging,
225
+ which is why both fields change together.
219
226
 
220
- Anything served from `workers.dev` is still treated as staging whatever this says, so branch
221
- previews of the live site keep redirecting. That is deliberate.
227
+ Anything served from `workers.dev` is still treated as staging for MAIL whatever this says, and
228
+ any branch other than `main` still builds as a preview, so branch previews of the live site keep
229
+ redirecting and stay out of the index. That is deliberate.
222
230
 
223
231
  Commit, push, and confirm the deploy. Then run `npx webm doctor` one last time: zero failures.
@@ -97,23 +97,70 @@ like a bot does.
97
97
  **Every route with `export const prerender = false` goes in `run_worker_first`, in both slash
98
98
  forms.** Miss one and it returns 200 to curl and a 404 page to Chrome. `webm doctor` checks.
99
99
 
100
- ## 5. Workers Builds
100
+ ## 5. Create the Worker - once, from the laptop
101
101
 
102
- Connect the repo in the Cloudflare dashboard: **Workers & Pages Create Import a repository**.
103
- The Worker name must be the slug exactly as `wrangler.jsonc` has it - Workers Builds fails on a
104
- mismatch.
102
+ Guarded and re-runnable: skip this step when the Worker already exists.
105
103
 
106
- No build variables are needed. **Push to deploy from then on** - a `wrangler deploy` from a
107
- laptop creates a version no build produced, so history stops describing what is live, and the
108
- next push reverts it.
104
+ ```sh
105
+ npx wrangler deployments list --name <slug> # a list: it exists. "does not exist [code: 10007]": create it
106
+ ```
107
+
108
+ The name is `name` in `wrangler.jsonc`. When there is nothing:
109
+
110
+ ```sh
111
+ npm run build # a real build; `dev` proves nothing here
112
+ npx wrangler deploy
113
+ ```
114
+
115
+ **This is the ONLY laptop deploy a site ever gets.** Before Workers Builds is connected, one
116
+ deploy is exactly how the Worker comes into existence. After it is connected (step 6) a laptop
117
+ deploy is the mistake: a version no build produced, which the next push reverts. This skill used
118
+ to stop here and say to create the Worker in the dashboard by hand, and on one site nobody did -
119
+ the result was a repo, a D1 database and nothing serving. `webm doctor` now warns on that.
120
+
121
+ The deploy provisions something `wrangler.jsonc` does not name, and that is expected: the Astro
122
+ Cloudflare adapter adds a `SESSION` KV binding, which wrangler auto-provisions as
123
+ `<slug>-session`. Do not add it to the config by hand.
124
+
125
+ Then verify. Wait a minute first - a brand-new Worker can return `error code: 1042` on valid
126
+ paths for about that long. In a real browser with the console open, load
127
+ `https://<slug>.<account>.workers.dev`: the home page renders, the console is clean. Then check
128
+ one on-demand route with a document-style request, if the site has one yet - a fresh scaffold's
129
+ only Worker route is the form action, which is POST-only:
130
+
131
+ ```sh
132
+ curl -sI -H 'Sec-Fetch-Dest: document' https://<slug>.<account>.workers.dev/<on-demand-route>
133
+ ```
134
+
135
+ The asset router keys off that header and plain curl does not send it, so a route that is 200 to
136
+ `curl` and 404 here is missing from `run_worker_first` (step 4).
137
+
138
+ ## 6. Connect the repo
139
+
140
+ In the dashboard, on the Worker that now exists: **Settings → Builds → connect `<org>/<slug>`**,
141
+ production branch `main`, no build variables. Connecting a repo to an EXISTING Worker is the
142
+ smaller step - importing a repository and typing the Worker name in by hand is where the name
143
+ mismatch Workers Builds fails on comes from.
144
+
145
+ **Push to deploy from then on.** The first push supersedes the laptop version. A `wrangler
146
+ deploy` from a laptop after this point creates a version no build produced, so history stops
147
+ describing what is live and the next push reverts it.
148
+
149
+ Confirm it took:
150
+
151
+ ```sh
152
+ npx wrangler deployments list --name <slug>
153
+ ```
154
+
155
+ The newest deployment's Source is no longer `Upload`.
109
156
 
110
- ## 6. Verify the first deploy
157
+ ## 7. Verify the first push
111
158
 
112
- Wait a minute after the build reports success - a brand-new Worker can return `error code:
113
- 1042` on valid paths for about that long. Then, in a real browser with the console open, load
114
- the `workers.dev` URL. The home page renders, the console is clean.
159
+ Wait a minute after the build reports success - the 1042 window again - then load the
160
+ `workers.dev` URL in a real browser with the console open. The home page renders, the console is
161
+ clean.
115
162
 
116
- ## 7. Hand over
163
+ ## 8. Hand over
117
164
 
118
165
  Workers Builds comments the preview URL on every PR - that is the client's review link. Preview
119
166
  hostnames use the slug, so Chrome's lookalike warning should not appear; if it does, it is a
@@ -121,7 +168,10 @@ URL-shape false positive and **Ignore is safe**.
121
168
 
122
169
  A preview build is safe to hand out: every page is noindex, there is no sitemap, robots.txt
123
170
  disallows everything, analytics does not load, and mail is redirected to `stagingEmail`. The
124
- client can click anything. The production branch is `main`; anything else previews.
171
+ client can click anything. **Every build of this site is a preview while `environment` is
172
+ `staging`** - `main` and the laptop included - so nothing is indexable before `/webm:launch`
173
+ flips it, and that flip is what makes the site indexable. On a launched site the production
174
+ branch is `main`; anything else previews.
125
175
 
126
176
  Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
127
177
  content-complete and approved on a preview.
@@ -329,6 +329,14 @@ divert every enquiry the day a site answers on `www.`.
329
329
  nightly sweep — the check that reads a hostname cannot see a cron at all. This is why the switch
330
330
  is config rather than something derived from the URL.
331
331
 
332
+ **A staging site is a preview build everywhere, and flipping `environment` is what makes it
333
+ indexable.** On `staging` every build — a branch, `main`, a laptop — is noindex on every page with
334
+ no canonical, no sitemap, `Disallow: /` and no Google Tag Manager. So a site that has not launched
335
+ cannot be indexed on its `workers.dev` URL, and a launched site left on `staging` after 1.3.0
336
+ drops out of search with no symptom on the page. `webm doctor` fails the second; `/webm:launch`
337
+ flips the switch only once the custom domain is live. A branch other than `main` previews
338
+ regardless of the switch.
339
+
332
340
  **Check the client's existing DMARC before adding a sending subdomain.** A DMARC record on
333
341
  `example.com` applies to its subdomains by default. If the client publishes `p=reject` and DKIM on
334
342
  the new subdomain is not right, **every message vanishes** — no bounce, no error, nothing in the