@cparkerwebm/webmonterey 1.2.0 → 1.4.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 +102 -0
- package/README.md +16 -0
- package/dist/webm.mjs +187 -44
- package/package.json +1 -1
- package/skills/launch/SKILL.md +17 -4
- package/skills/new-component/SKILL.md +6 -0
- package/skills/start/SKILL.md +65 -14
- package/skills/traps/SKILL.md +8 -0
- package/skills/webmaster/SKILL.md +118 -0
- package/src/cli/checks.test.ts +61 -0
- package/src/cli/checks.ts +43 -5
- package/src/cli/doctor.ts +78 -2
- package/src/cli/scaffold.test.ts +9 -0
- package/src/cli/scaffold.ts +12 -30
- package/src/cli/settings.ts +106 -0
- package/src/cli/sync.test.ts +62 -0
- package/src/cli/sync.ts +64 -1
- package/src/includes/webmonterey/config.test.ts +51 -0
- package/src/includes/webmonterey/config.ts +49 -0
- package/src/includes/webmonterey/copy-defaults.ts +9 -5
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +61 -1
- package/src/includes/webmonterey/webmaster/webmaster.ts +57 -1
- package/src/integration/index.ts +45 -12
- package/src/integration/virtual.d.ts +17 -3
- package/src/layouts/base.astro +9 -6
- package/src/pages/robots.txt.ts +5 -3
- package/src/pages/webmaster.astro +50 -28
- package/template/site/CLAUDE.md +30 -3
package/skills/start/SKILL.md
CHANGED
|
@@ -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.
|
|
100
|
+
## 5. Create the Worker - once, from the laptop
|
|
101
101
|
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
##
|
|
157
|
+
## 7. Verify the first push
|
|
111
158
|
|
|
112
|
-
Wait a minute after the build reports success -
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
##
|
|
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,11 @@ 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.
|
|
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
|
-
Next: `/webm:new-component` for each block,
|
|
176
|
+
Next: `/webm:new-component` for each block, `/webm:webmaster` once the site has its document
|
|
177
|
+
block so the package's `/webmaster` page shares its layout, then `/webm:launch` when the site is
|
|
127
178
|
content-complete and approved on a preview.
|
package/skills/traps/SKILL.md
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webmaster
|
|
3
|
+
description: Make the package's /webmaster page look like this site's other document pages by exporting a `webmasterPage` layout component from the registry. Use for "the webmaster page looks different", "style /webmaster like /privacy", "webmaster page layout", "webmasterPage".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# The `/webmaster` page
|
|
7
|
+
|
|
8
|
+
**The package owns the page; this site may own its shape.** `/webmaster` is injected on every
|
|
9
|
+
site: who built it, who to call. The route, the words, the document title, the meta description,
|
|
10
|
+
the share image and the agency JSON-LD are the package's, so the page says the same thing on
|
|
11
|
+
every site. Without help it renders as an `<h1>` (or this site's `pageHeader`) and a stack of
|
|
12
|
+
paragraphs - which looks like a different site the moment the site's own document pages have a
|
|
13
|
+
richer layout.
|
|
14
|
+
|
|
15
|
+
**Do not add `src/pages/webmaster.astro`.** It collides with the injected route, and the injected
|
|
16
|
+
route wins. The seam is a registry export.
|
|
17
|
+
|
|
18
|
+
## What the component receives
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
interface WebmasterPageProps {
|
|
22
|
+
title: string; // text - also the document title
|
|
23
|
+
description: string; // text - also the meta description
|
|
24
|
+
intro: string; // HTML - the first paragraph, agency link already in it
|
|
25
|
+
body: string[]; // HTML - one entry per remaining paragraph
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`intro` and `body` are already HTML: the agency link with its UTM parameters, and the copy's own
|
|
30
|
+
inline formatting (`**bold**`, `_italic_`, `[text](/url)`) rendered and escaped. Render them with
|
|
31
|
+
`set:html`. `title` and `description` are text.
|
|
32
|
+
|
|
33
|
+
**The component carries no copy of its own.** Not a heading, not a caption, not a sentence. The
|
|
34
|
+
words are the same on every site; when a client wants them changed, that is `copy.webmaster` in
|
|
35
|
+
`webmonterey.json` (`title`, `description`, `intro.before`, `intro.after`, `body[]`), never the
|
|
36
|
+
component.
|
|
37
|
+
|
|
38
|
+
## 1. Find this site's document layout
|
|
39
|
+
|
|
40
|
+
Look at how the privacy or terms page is rendered - usually one long-form block under
|
|
41
|
+
`src/components/content/`. The webmaster page should reuse **that block's markup and classes**,
|
|
42
|
+
so a change to the document style reaches both.
|
|
43
|
+
|
|
44
|
+
## 2. Write the component
|
|
45
|
+
|
|
46
|
+
`src/components/general/webmaster-page.astro`. The reference below is complete and works on any
|
|
47
|
+
site; replace the element names and classes with the document block's own.
|
|
48
|
+
|
|
49
|
+
```astro
|
|
50
|
+
---
|
|
51
|
+
/*
|
|
52
|
+
* The /webmaster page body. The package hands in the copy; this lays it out like the site's
|
|
53
|
+
* other document pages. See /webm:webmaster.
|
|
54
|
+
*/
|
|
55
|
+
import type { WebmasterPageProps } from '@cparkerwebm/webmonterey/webmonterey/webmaster';
|
|
56
|
+
|
|
57
|
+
type Props = WebmasterPageProps;
|
|
58
|
+
|
|
59
|
+
const { title, intro, body } = Astro.props;
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<section class="webm-section" data-space="lg">
|
|
63
|
+
<div class="webm-container" data-width="text">
|
|
64
|
+
<article class="doc">
|
|
65
|
+
<header class="doc__head">
|
|
66
|
+
<h1 class="doc__title">{title}</h1>
|
|
67
|
+
</header>
|
|
68
|
+
<div class="doc__panel">
|
|
69
|
+
<p set:html={intro} />
|
|
70
|
+
{body.map((paragraph) => <p set:html={paragraph} />)}
|
|
71
|
+
</div>
|
|
72
|
+
</article>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If the document block is itself a component that takes `title` and a slot or `html` prop, render
|
|
78
|
+
it directly rather than copying its markup:
|
|
79
|
+
|
|
80
|
+
```astro
|
|
81
|
+
---
|
|
82
|
+
import Doc from '../content/content-000001/content-000001.astro';
|
|
83
|
+
const { title, intro, body } = Astro.props;
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
<Doc title={title}>
|
|
87
|
+
<p set:html={intro} />
|
|
88
|
+
{body.map((paragraph) => <p set:html={paragraph} />)}
|
|
89
|
+
</Doc>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 3. Export it
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// src/components/registry.ts
|
|
96
|
+
export { default as webmasterPage } from './general/webmaster-page.astro';
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Not an entry in `blocks` - it is not addressable from page JSON. A named export beside
|
|
100
|
+
`pageHeader` and `structuredData`.
|
|
101
|
+
|
|
102
|
+
## 4. Verify
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
npm run build
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Then read `dist/client/webmaster/index.html`:
|
|
109
|
+
|
|
110
|
+
- the body is the new markup, and there is no `webm-stack` div left from the built-in layout
|
|
111
|
+
- the `<h1>` is the package's title (`Our Webmaster` unless `copy.webmaster.title` is set)
|
|
112
|
+
- the first paragraph has one `<a href="https://webmonterey.com/?utm_source=client…"
|
|
113
|
+
target="_blank" rel="noopener">` and nothing else links off-site
|
|
114
|
+
- `<title>`, `meta name="description"`, `og:image` (`/webmaster/og.png`) and the
|
|
115
|
+
`application/ld+json` block are present and unchanged from before the export
|
|
116
|
+
|
|
117
|
+
Then push and compare `/webmaster` against `/privacy` on the preview URL: same heading style,
|
|
118
|
+
same panel, the package's words.
|
package/src/cli/checks.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ const base = (over: Partial<CheckContext> = {}): CheckContext => ({
|
|
|
22
22
|
sync: { version: '1.0.0', skills: ['launch'] },
|
|
23
23
|
mcp: { declared: mcpConfig().mcpServers, enabled: [...MCP_NAMES] },
|
|
24
24
|
version: '1.0.0',
|
|
25
|
+
worker: { name: 'acme', deployments: 1, skipped: null },
|
|
25
26
|
...over,
|
|
26
27
|
});
|
|
27
28
|
|
|
@@ -434,6 +435,27 @@ test('a registry keyed by name rather than by number is understood', () => {
|
|
|
434
435
|
assert.equal(runCheck('block-types-registered', ctx).status, 'pass');
|
|
435
436
|
});
|
|
436
437
|
|
|
438
|
+
test('the chrome exports in a registry are not read as block types', () => {
|
|
439
|
+
/*
|
|
440
|
+
* `header`, `footer`, `pageHeader`, `structuredData` and `webmasterPage` sit in the same file
|
|
441
|
+
* as `blocks`, in either export form. None is a block type, and none may make the check trip
|
|
442
|
+
* over a registry that is otherwise complete.
|
|
443
|
+
*/
|
|
444
|
+
const ctx = base({
|
|
445
|
+
registry: [
|
|
446
|
+
`import WebmasterPage from './general/webmaster-page.astro';`,
|
|
447
|
+
`export const blocks = { 'content-000001': C };`,
|
|
448
|
+
`export const registeredTypes = () => Object.keys(blocks);`,
|
|
449
|
+
`export { default as pageHeader } from './general/page-header.astro';`,
|
|
450
|
+
`export const webmasterPage = WebmasterPage;`,
|
|
451
|
+
].join('\n'),
|
|
452
|
+
content: new Map([
|
|
453
|
+
['src/content/pages/home.json', JSON.stringify({ blocks: [{ type: 'content-000001' }] })],
|
|
454
|
+
]),
|
|
455
|
+
});
|
|
456
|
+
assert.equal(runCheck('block-types-registered', ctx).status, 'pass');
|
|
457
|
+
});
|
|
458
|
+
|
|
437
459
|
test('a site that has not launched only gets a warning about placeholders', () => {
|
|
438
460
|
/*
|
|
439
461
|
* `webm new` seeds every one of these, so a freshly scaffolded site has the full set. The
|
|
@@ -709,3 +731,42 @@ test('a server pointed at the wrong url is caught, and both urls are shown', ()
|
|
|
709
731
|
assert.match(r.detail!, /example\.com/);
|
|
710
732
|
assert.ok(r.detail!.includes(MCP_SERVERS.mdn.url));
|
|
711
733
|
});
|
|
734
|
+
|
|
735
|
+
/* --- the Worker exists --------------------------------------------------- */
|
|
736
|
+
|
|
737
|
+
test('a Worker with no deployment warns and says how to create it', () => {
|
|
738
|
+
/* A repo, a database and nothing serving: the failure /webm:start used to end on. */
|
|
739
|
+
const r = runCheck(
|
|
740
|
+
'worker-exists',
|
|
741
|
+
base({ worker: { name: 'acme', deployments: 0, skipped: null } }),
|
|
742
|
+
);
|
|
743
|
+
assert.equal(r.status, 'warn');
|
|
744
|
+
assert.match(r.detail!, /"acme"/);
|
|
745
|
+
assert.match(r.detail!, /wrangler deploy/);
|
|
746
|
+
assert.match(r.detail!, /Settings → Builds/);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('an unanswerable question skips with the reason, and is not a failure', () => {
|
|
750
|
+
const r = runCheck(
|
|
751
|
+
'worker-exists',
|
|
752
|
+
base({ worker: { name: 'acme', deployments: null, skipped: 'wrangler is not logged in' } }),
|
|
753
|
+
);
|
|
754
|
+
assert.equal(r.status, 'pass');
|
|
755
|
+
assert.match(r.detail!, /skipped: wrangler is not logged in/);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('a deployed Worker passes', () => {
|
|
759
|
+
assert.equal(runCheck('worker-exists', base()).status, 'pass');
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('a launched site still declared staging fails, and the message names the search consequence', () => {
|
|
763
|
+
const r = runCheck(
|
|
764
|
+
'environment',
|
|
765
|
+
base({
|
|
766
|
+
site: { client: 'A', domain: 'a.com', environment: 'staging', launched: '2026-09-01' },
|
|
767
|
+
}),
|
|
768
|
+
);
|
|
769
|
+
assert.equal(r.status, 'fail');
|
|
770
|
+
assert.match(r.detail!, /noindex/);
|
|
771
|
+
assert.match(r.detail!, /out of search/);
|
|
772
|
+
});
|
package/src/cli/checks.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface CheckContext {
|
|
|
32
32
|
site: SiteConfig;
|
|
33
33
|
/** Parsed wrangler.jsonc, or null when absent. */
|
|
34
34
|
wrangler: {
|
|
35
|
+
name?: string;
|
|
35
36
|
assets?: { run_worker_first?: string[] };
|
|
36
37
|
compatibility_date?: string;
|
|
37
38
|
triggers?: { crons?: string[] };
|
|
@@ -73,6 +74,12 @@ export interface CheckContext {
|
|
|
73
74
|
};
|
|
74
75
|
/** The installed package version. */
|
|
75
76
|
version: string;
|
|
77
|
+
/**
|
|
78
|
+
* Whether the Worker named in wrangler.jsonc exists on the account, asked of wrangler by the
|
|
79
|
+
* doctor. `deployments` is how many it listed - null when the question was not asked, and
|
|
80
|
+
* `skipped` then says why: wrangler not installed, not logged in, no network.
|
|
81
|
+
*/
|
|
82
|
+
worker: { name: string | null; deployments: number | null; skipped: string | null };
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
export interface Check {
|
|
@@ -745,7 +752,9 @@ export const CHECKS: Check[] = [
|
|
|
745
752
|
{
|
|
746
753
|
id: 'environment',
|
|
747
754
|
title: 'The declared environment matches where the site actually is',
|
|
748
|
-
silentAs:
|
|
755
|
+
silentAs:
|
|
756
|
+
"a launched site whose client email is still being diverted to the agency's inbox, and " +
|
|
757
|
+
'whose every page is noindex',
|
|
749
758
|
run(ctx) {
|
|
750
759
|
const declared = ctx.site.environment;
|
|
751
760
|
|
|
@@ -771,22 +780,51 @@ export const CHECKS: Check[] = [
|
|
|
771
780
|
if (declared === 'staging' && isConfigured(ctx.site.launched)) {
|
|
772
781
|
return fail(
|
|
773
782
|
`this site launched on ${ctx.site.launched} but is still declared staging, so every ` +
|
|
774
|
-
`email it sends is being redirected away from its real recipients.
|
|
775
|
-
`
|
|
783
|
+
`email it sends is being redirected away from its real recipients - and since 1.3.0 ` +
|
|
784
|
+
`every build of a staging site is a preview: noindex on every page, no canonical, ` +
|
|
785
|
+
`no sitemap, robots.txt disallowing everything. The live site is dropping out of ` +
|
|
786
|
+
`search. Set "environment": "production" in webmonterey.json.`,
|
|
776
787
|
);
|
|
777
788
|
}
|
|
778
789
|
|
|
779
790
|
if (declared !== 'staging' && !isConfigured(ctx.site.launched)) {
|
|
780
791
|
return warn(
|
|
781
792
|
`this site has no launch date but is treated as production, so testing a form will ` +
|
|
782
|
-
`email the client's real contacts
|
|
783
|
-
`webmonterey.json until /webm:launch.`,
|
|
793
|
+
`email the client's real contacts and every page is indexable on its workers.dev ` +
|
|
794
|
+
`hostname. Set "environment": "staging" in webmonterey.json until /webm:launch.`,
|
|
784
795
|
);
|
|
785
796
|
}
|
|
786
797
|
|
|
787
798
|
return pass;
|
|
788
799
|
},
|
|
789
800
|
},
|
|
801
|
+
{
|
|
802
|
+
/*
|
|
803
|
+
* THE WORKER EXISTS. /webm:start used to end with a repo, a D1 database and an instruction
|
|
804
|
+
* to create the Worker in the dashboard by hand - and on one site nobody did. Nothing local
|
|
805
|
+
* notices: the build is green, every other check here is green, and the site is a
|
|
806
|
+
* workers.dev hostname that answers nothing. The Worker is the one resource whose absence
|
|
807
|
+
* has no symptom on disk, so this asks Cloudflare through wrangler - the one thing a laptop
|
|
808
|
+
* can ask - and steps aside with a note when it cannot.
|
|
809
|
+
*/
|
|
810
|
+
id: 'worker-exists',
|
|
811
|
+
title: 'The Worker exists',
|
|
812
|
+
silentAs: 'a site with a repo, a database and nothing serving',
|
|
813
|
+
run(ctx) {
|
|
814
|
+
if (ctx.worker.skipped) return { status: 'pass', detail: `skipped: ${ctx.worker.skipped}` };
|
|
815
|
+
if (!ctx.worker.name) {
|
|
816
|
+
return warn('wrangler.jsonc names no Worker, so there is nothing to look for');
|
|
817
|
+
}
|
|
818
|
+
if (!ctx.worker.deployments) {
|
|
819
|
+
return warn(
|
|
820
|
+
`no deployment of a Worker named "${ctx.worker.name}" on this account. Create it once ` +
|
|
821
|
+
`from the laptop - npm run build && npx wrangler deploy - then connect the repo to ` +
|
|
822
|
+
`it in the dashboard (Worker → Settings → Builds). /webm:start, steps 5 and 6.`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return pass;
|
|
826
|
+
},
|
|
827
|
+
},
|
|
790
828
|
{
|
|
791
829
|
id: 'seeded-files',
|
|
792
830
|
title: 'The files Astro copies verbatim are present',
|
package/src/cli/doctor.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Exit code is 1 on any failure, so it can gate a build or a go-live.
|
|
10
10
|
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
11
12
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
-
import {
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
15
|
import { CHECKS, type CheckContext } from './checks.ts';
|
|
14
16
|
import { loadSiteFiles } from '../integration/config.ts';
|
|
15
17
|
|
|
@@ -108,16 +110,90 @@ function readMcp(siteRoot: string): CheckContext['mcp'] {
|
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Ask wrangler whether the Worker exists - the one check here that leaves the machine.
|
|
115
|
+
*
|
|
116
|
+
* `wrangler deployments list --name <name> --json` is a read: it lists what is deployed and
|
|
117
|
+
* changes nothing. wrangler is resolved from the site upward, the way `npx` would find it,
|
|
118
|
+
* rather than downloaded - a doctor that installs things is not a doctor. Every way the question
|
|
119
|
+
* can go unanswered - no wrangler, not logged in, no network - is a SKIP with the reason, never
|
|
120
|
+
* a failure: the check exists to catch a missing Worker, and a laptop that cannot ask is not
|
|
121
|
+
* evidence of one.
|
|
122
|
+
*
|
|
123
|
+
* The two answers that matter are told apart by wrangler's own words: a missing Worker is
|
|
124
|
+
* "does not exist [code: 10007]"; a missing login is a request to set CLOUDFLARE_API_TOKEN, an
|
|
125
|
+
* authentication error, or a rejected token.
|
|
126
|
+
*/
|
|
127
|
+
function workerState(siteRoot: string, name: string | null | undefined): CheckContext['worker'] {
|
|
128
|
+
const worker = { name: name ?? null, deployments: null, skipped: null };
|
|
129
|
+
if (!worker.name) return worker;
|
|
130
|
+
|
|
131
|
+
/* Absolute, or createRequire refuses it - `webm doctor examples/minimal` passes a relative root. */
|
|
132
|
+
let bin: string;
|
|
133
|
+
try {
|
|
134
|
+
const require = createRequire(join(resolve(siteRoot), 'package.json'));
|
|
135
|
+
bin = join(dirname(require.resolve('wrangler/package.json')), 'bin/wrangler.js');
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
...worker,
|
|
139
|
+
skipped: 'wrangler is not installed here, so the Worker was not looked for',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const out = execFileSync(
|
|
145
|
+
process.execPath,
|
|
146
|
+
[bin, 'deployments', 'list', '--name', worker.name, '--json'],
|
|
147
|
+
{
|
|
148
|
+
cwd: siteRoot,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
timeout: 30_000,
|
|
152
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: 'false', NO_COLOR: '1' },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
const start = out.indexOf('[');
|
|
156
|
+
const parsed: unknown = start >= 0 ? JSON.parse(out.slice(start)) : [];
|
|
157
|
+
return { ...worker, deployments: Array.isArray(parsed) ? parsed.length : 0 };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const e = error as { stdout?: string; stderr?: string; message?: string };
|
|
160
|
+
const text = `${e.stdout ?? ''}\n${e.stderr ?? ''}\n${e.message ?? ''}`;
|
|
161
|
+
if (/code: 10007\]|does not exist on your account/i.test(text)) {
|
|
162
|
+
return { ...worker, deployments: 0 };
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
/CLOUDFLARE_API_TOKEN|not (logged in|authenticated)|Authentication error|code: (10000|6111|9109)\]/i.test(
|
|
166
|
+
text,
|
|
167
|
+
)
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
...worker,
|
|
171
|
+
skipped:
|
|
172
|
+
'wrangler is not logged in (npx wrangler login), so whether the Worker exists was not checked',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const line = text
|
|
176
|
+
.split('\n')
|
|
177
|
+
.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim())
|
|
178
|
+
.find((l) => l && !l.startsWith('🪵'));
|
|
179
|
+
return { ...worker, skipped: `wrangler could not answer: ${line ?? 'no output'}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
111
183
|
export function buildContext(siteRoot: string): CheckContext {
|
|
112
184
|
const { site } = loadSiteFiles(siteRoot);
|
|
113
185
|
const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
|
|
114
186
|
.map((f) => join(siteRoot, f))
|
|
115
187
|
.find(existsSync);
|
|
116
188
|
const syncPath = join(siteRoot, '.claude/skills/webm/.webm-sync.json');
|
|
189
|
+
const wrangler: CheckContext['wrangler'] = wranglerPath
|
|
190
|
+
? parseJsonc(readFileSync(wranglerPath, 'utf8'))
|
|
191
|
+
: null;
|
|
117
192
|
|
|
118
193
|
return {
|
|
119
194
|
site,
|
|
120
|
-
wrangler
|
|
195
|
+
wrangler,
|
|
196
|
+
worker: workerState(siteRoot, wrangler?.name),
|
|
121
197
|
pages: readTree(siteRoot, 'src/pages', ['.astro', '.ts']),
|
|
122
198
|
components: readTree(siteRoot, 'src/components', ['.astro', '.ts']),
|
|
123
199
|
today: new Date().toISOString().slice(0, 10),
|
package/src/cli/scaffold.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { scaffold } from './scaffold.ts';
|
|
4
4
|
import { MCP_NAMES, mcpConfig } from './mcp.ts';
|
|
5
|
+
import { DENY_RULES } from './settings.ts';
|
|
5
6
|
|
|
6
7
|
const files = (over = {}) =>
|
|
7
8
|
scaffold({
|
|
@@ -235,3 +236,11 @@ test('the margin crosses a month and a year boundary correctly', () => {
|
|
|
235
236
|
assert.equal(at('2026-01-05'), '2025-12-22');
|
|
236
237
|
assert.equal(at('2026-03-05'), '2026-02-19', 'and February');
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
test('a session in a client repo cannot edit the package: node_modules is denied', () => {
|
|
241
|
+
const deny: string[] = json(files(), '.claude/settings.json').permissions.deny;
|
|
242
|
+
assert.deepEqual(deny, [...DENY_RULES], 'the one list in cli/settings.ts');
|
|
243
|
+
assert.ok(deny.includes('Edit(**/node_modules/**)'));
|
|
244
|
+
/* Claude Code checks Edit and Read rules only; a Write rule is ignored and warned about. */
|
|
245
|
+
assert.ok(!deny.some((r) => r.startsWith('Write(')), 'no Write rule');
|
|
246
|
+
});
|
package/src/cli/scaffold.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { resourceNames } from './slug.ts';
|
|
12
12
|
import { MCP_NAMES, mcpConfig } from './mcp.ts';
|
|
13
|
+
import { projectSettings } from './settings.ts';
|
|
13
14
|
|
|
14
15
|
export interface ScaffoldOptions {
|
|
15
16
|
domain: string;
|
|
@@ -151,7 +152,7 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
|
151
152
|
slug: n.slug,
|
|
152
153
|
launched: null,
|
|
153
154
|
'//environment':
|
|
154
|
-
"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'.
|
|
155
|
+
"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.",
|
|
155
156
|
environment: 'staging',
|
|
156
157
|
'//stagingEmail':
|
|
157
158
|
'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.',
|
|
@@ -250,33 +251,11 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
|
250
251
|
2,
|
|
251
252
|
) + '\n';
|
|
252
253
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
'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.',
|
|
259
|
-
includeCoAuthoredBy: false,
|
|
260
|
-
enabledMcpjsonServers: MCP_NAMES,
|
|
261
|
-
permissions: {
|
|
262
|
-
deny: [
|
|
263
|
-
'Read(**/.dev.vars)',
|
|
264
|
-
'Read(**/.dev.vars.*)',
|
|
265
|
-
'Read(**/.env)',
|
|
266
|
-
'Read(**/.env.*)',
|
|
267
|
-
'Read(**/*.pem)',
|
|
268
|
-
'Read(**/*.key)',
|
|
269
|
-
'Read(**/.npmrc)',
|
|
270
|
-
'Edit(**/.dev.vars)',
|
|
271
|
-
'Edit(**/.env)',
|
|
272
|
-
'Write(**/.dev.vars)',
|
|
273
|
-
'Write(**/.env)',
|
|
274
|
-
],
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
null,
|
|
278
|
-
2,
|
|
279
|
-
) + '\n';
|
|
254
|
+
/*
|
|
255
|
+
* The deny list comes from cli/settings.ts, the one place it is declared, so a new site and a
|
|
256
|
+
* `webm sync` on an old one agree about what a session may not touch.
|
|
257
|
+
*/
|
|
258
|
+
files['.claude/settings.json'] = JSON.stringify(projectSettings(n.repo), null, 2) + '\n';
|
|
280
259
|
|
|
281
260
|
files['.mcp.json'] = JSON.stringify(mcpConfig(), null, 2) + '\n';
|
|
282
261
|
|
|
@@ -418,8 +397,11 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
|
418
397
|
`domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose\n` +
|
|
419
398
|
`suffix - \`${n.slug}-portal\`.\n\n` +
|
|
420
399
|
`## Deploying\n\n` +
|
|
421
|
-
`
|
|
422
|
-
`
|
|
400
|
+
`The Worker is created ONCE from a laptop - \`npm run build && npx wrangler deploy\` - and the\n` +
|
|
401
|
+
`repo is then connected to it in the dashboard (Worker → Settings → Builds). /webm:start does\n` +
|
|
402
|
+
`both. From then on, push to deploy: a \`wrangler deploy\` from a laptop after that creates a\n` +
|
|
403
|
+
`version no build produced, so history stops describing what is live and the next push\n` +
|
|
404
|
+
`reverts it.\n`;
|
|
423
405
|
|
|
424
406
|
return files;
|
|
425
407
|
}
|