@cparkerwebm/webmonterey 1.0.0 → 1.1.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 +28 -0
- package/README.md +6 -1
- package/dist/webm.mjs +30 -29
- package/package.json +1 -1
- package/skills/launch/SKILL.md +2 -2
- package/skills/start/SKILL.md +21 -15
- package/skills/traps/SKILL.md +2 -2
- package/src/cli/new.ts +34 -17
- package/src/cli/scaffold.test.ts +27 -20
- package/src/cli/scaffold.ts +8 -7
- package/src/cli/slug.test.ts +25 -28
- package/src/cli/slug.ts +31 -36
- package/src/cli/sync.ts +1 -1
- package/src/includes/cloudflare/r2/README.md +3 -3
- package/src/includes/cloudflare/r2/media.ts +3 -3
- package/src/includes/webmonterey/config.test.ts +1 -1
- package/template/migrations/README.md +1 -1
- package/template/site/CLAUDE.md +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,34 @@ build. See `/webm:upgrade`.
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## 1.1.0 — 2026-09-02
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **One name, everywhere.** `webm new` now derives a single name from the domain — `example.com`
|
|
19
|
+
becomes `example` — and uses it for the GitHub repo, the Worker, the D1 database, the R2 bucket
|
|
20
|
+
and any KV namespace. The `webm-` prefix and the `-db` / `-media` suffixes are gone from cloud
|
|
21
|
+
resources: Cloudflare scopes every one of those names to the account, so in an account that holds
|
|
22
|
+
nothing but client sites a prefix said nothing, and one-of-each needs no suffix. The TLD is still
|
|
23
|
+
dropped, for the same reason as before — it keeps a domain out of preview hostnames, which is
|
|
24
|
+
what trips Chrome's lookalike warning.
|
|
25
|
+
|
|
26
|
+
**Existing sites are untouched.** Every name is read from the site's own `webmonterey.json` and
|
|
27
|
+
`wrangler.jsonc`; nothing renames a resource that exists. A rebuild onto the new convention is a
|
|
28
|
+
new repo and new resources beside the old, tested in full, then a domain cutover — which is the
|
|
29
|
+
clean way to do it anyway.
|
|
30
|
+
|
|
31
|
+
- **No agency defaults left in the package.** `webm new` reads the GitHub owner from
|
|
32
|
+
`git config webm.org` and the staging inbox from `git config webm.stagingEmail` (falling back
|
|
33
|
+
to `user.email`); `--org` and `--staging-email` override for one run. Without an owner it refuses
|
|
34
|
+
and says how to set one. Set it once per machine:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
git config --global webm.org <your-github-owner>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
14
42
|
## 1.0.0 — 2026-09-02
|
|
15
43
|
|
|
16
44
|
The first public release, on npmjs. This is the framework as it stands after two private
|
package/README.md
CHANGED
|
@@ -23,11 +23,16 @@ client's repo. Every default has a documented way to opt out of it.
|
|
|
23
23
|
## Creating a client site
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
+
git config --global webm.org <your-github-owner> # once per machine
|
|
26
27
|
npx @cparkerwebm/webmonterey new example.com --client="Example Co"
|
|
27
|
-
cd
|
|
28
|
+
cd example
|
|
28
29
|
claude # then /webm:start
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
One name everywhere: `example.com` becomes `example` — the repo, the Worker, the D1 database and
|
|
33
|
+
the R2 bucket. The domain minus its TLD is the one shape every resource accepts, and it keeps a
|
|
34
|
+
domain out of preview hostnames, which is what trips Chrome's lookalike warning.
|
|
35
|
+
|
|
31
36
|
`webm new` writes the identity and design files, a working contact form, a home page and the
|
|
32
37
|
fleet skills, then runs `git init` and `npm install`. It touches nothing outside the directory.
|
|
33
38
|
`/webm:start` takes it from there: the GitHub repo, Cloudflare resources, Workers Builds, the
|
package/dist/webm.mjs
CHANGED
|
@@ -67,9 +67,6 @@ function normalizeDomain(input) {
|
|
|
67
67
|
}
|
|
68
68
|
return cleaned;
|
|
69
69
|
}
|
|
70
|
-
function repoName(domain) {
|
|
71
|
-
return normalizeDomain(domain).replace(/\./g, "_");
|
|
72
|
-
}
|
|
73
70
|
function slugFor(domain) {
|
|
74
71
|
const clean = normalizeDomain(domain);
|
|
75
72
|
const parts = clean.split(".");
|
|
@@ -78,16 +75,12 @@ function slugFor(domain) {
|
|
|
78
75
|
const kept = parts.slice(0, Math.max(1, parts.length - drop));
|
|
79
76
|
return kept.join("-");
|
|
80
77
|
}
|
|
78
|
+
function repoName(domain) {
|
|
79
|
+
return slugFor(domain);
|
|
80
|
+
}
|
|
81
81
|
function resourceNames(domain) {
|
|
82
82
|
const slug = slugFor(domain);
|
|
83
|
-
return {
|
|
84
|
-
slug,
|
|
85
|
-
repo: repoName(domain),
|
|
86
|
-
worker: `webm-${slug}`,
|
|
87
|
-
d1: `webm-${slug}-db`,
|
|
88
|
-
r2Media: `webm-${slug}-media`,
|
|
89
|
-
r2App: `webm-${slug}-app`
|
|
90
|
-
};
|
|
83
|
+
return { slug, repo: slug, worker: slug, d1: slug, r2: slug, kv: slug };
|
|
91
84
|
}
|
|
92
85
|
var TWO_PART_SUFFIXES, DomainError;
|
|
93
86
|
var init_slug = __esm({
|
|
@@ -213,7 +206,7 @@ function scaffold(options) {
|
|
|
213
206
|
const { domain, packageVersion: packageVersion2 } = options;
|
|
214
207
|
const n = resourceNames(domain);
|
|
215
208
|
const client = options.client ?? "CHANGEME";
|
|
216
|
-
const org = options
|
|
209
|
+
const { org } = options;
|
|
217
210
|
const { today } = options;
|
|
218
211
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
|
|
219
212
|
throw new Error(`scaffold: today must be YYYY-MM-DD, got ${JSON.stringify(today)}`);
|
|
@@ -549,12 +542,11 @@ ${domain} \u2014 built on [@cparkerwebm/webmonterey](https://github.com/cparkerw
|
|
|
549
542
|
|
|
550
543
|
| | |
|
|
551
544
|
| --- | --- |
|
|
552
|
-
| Worker | \`${n.
|
|
553
|
-
| D1 | \`${n.d1}\` |
|
|
554
|
-
| R2 media | \`${n.r2Media}\` |
|
|
545
|
+
| Worker \xB7 D1 \xB7 R2 | \`${n.slug}\` |
|
|
555
546
|
|
|
556
|
-
|
|
557
|
-
|
|
547
|
+
One name everywhere: the domain minus its TLD. A preview hostname then never embeds a
|
|
548
|
+
domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose
|
|
549
|
+
suffix - \`${n.slug}-portal\`.
|
|
558
550
|
|
|
559
551
|
## Deploying
|
|
560
552
|
|
|
@@ -818,26 +810,33 @@ function parseArgs(argv2) {
|
|
|
818
810
|
return {
|
|
819
811
|
domain: positional[0],
|
|
820
812
|
client: flag("client"),
|
|
821
|
-
org: flag("org") ?? "
|
|
822
|
-
stagingEmail: flag("staging-email"),
|
|
813
|
+
org: flag("org") ?? gitConfig("webm.org"),
|
|
814
|
+
stagingEmail: flag("staging-email") ?? gitConfig("webm.stagingEmail") ?? gitConfig("user.email"),
|
|
823
815
|
into: flag("into"),
|
|
824
816
|
install: !argv2.includes("--no-install")
|
|
825
817
|
};
|
|
826
818
|
}
|
|
827
|
-
function
|
|
819
|
+
function gitConfig(key) {
|
|
828
820
|
try {
|
|
829
|
-
|
|
821
|
+
const value = execFileSync("git", ["config", key], { encoding: "utf8" }).trim();
|
|
822
|
+
return value || void 0;
|
|
830
823
|
} catch {
|
|
831
|
-
return
|
|
824
|
+
return void 0;
|
|
832
825
|
}
|
|
833
826
|
}
|
|
834
827
|
function run2(argv2) {
|
|
835
828
|
const args = parseArgs(argv2);
|
|
836
829
|
if (!args.domain) {
|
|
837
830
|
console.error(
|
|
838
|
-
'webm new <domain> [--client="Name"] [--org
|
|
831
|
+
'webm new <domain> [--client="Name"] [--org=<github-owner>] [--staging-email=you@example.com] [--into=path] [--no-install]'
|
|
832
|
+
);
|
|
833
|
+
console.error('\n webm new example.com --client="Example Co"');
|
|
834
|
+
return 1;
|
|
835
|
+
}
|
|
836
|
+
if (!args.org) {
|
|
837
|
+
console.error(
|
|
838
|
+
"webm new: no GitHub owner for the repo. Either pass --org=<owner> or set it once:\n\n git config --global webm.org <owner>\n"
|
|
839
839
|
);
|
|
840
|
-
console.error('\n webm new autire.com --client="Autire Technologies"');
|
|
841
840
|
return 1;
|
|
842
841
|
}
|
|
843
842
|
let domain;
|
|
@@ -857,7 +856,7 @@ function run2(argv2) {
|
|
|
857
856
|
domain,
|
|
858
857
|
client: args.client,
|
|
859
858
|
org: args.org,
|
|
860
|
-
stagingEmail: args.stagingEmail
|
|
859
|
+
stagingEmail: args.stagingEmail,
|
|
861
860
|
packageVersion: packageVersion(),
|
|
862
861
|
/* Real today, not a constant - see ScaffoldOptions.today for what a stale one does. */
|
|
863
862
|
today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
@@ -874,12 +873,14 @@ function run2(argv2) {
|
|
|
874
873
|
console.log(` + ${seeded.length} seeded (favicons, headers, CLAUDE.md, a contact form)`);
|
|
875
874
|
console.log(` + ${kept.length} .gitkeep, so the empty directories survive a clone
|
|
876
875
|
`);
|
|
877
|
-
console.log(` repo
|
|
876
|
+
console.log(` repo ${args.org}/${names.repo}`);
|
|
878
877
|
console.log(
|
|
879
|
-
` worker ${names.
|
|
878
|
+
` worker \xB7 d1 \xB7 r2 ${names.slug} (the domain minus its TLD, so Chrome does not flag previews)`
|
|
879
|
+
);
|
|
880
|
+
console.log(
|
|
881
|
+
`
|
|
882
|
+
One name everywhere. If ${names.slug} is already taken in the account - ${domain.replace(/^[^.]+/, names.slug)} and another TLD both want it - pick another with --into and edit webmonterey.json.`
|
|
880
883
|
);
|
|
881
|
-
console.log(` d1 ${names.d1}`);
|
|
882
|
-
console.log(` r2 ${names.r2Media}`);
|
|
883
884
|
try {
|
|
884
885
|
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
885
886
|
} catch {
|
package/package.json
CHANGED
package/skills/launch/SKILL.md
CHANGED
|
@@ -124,8 +124,8 @@ Public values - the Turnstile site key, a GTM container id - are not secrets. Th
|
|
|
124
124
|
## 7. D1, if the site uses it
|
|
125
125
|
|
|
126
126
|
```sh
|
|
127
|
-
npx wrangler d1 migrations list
|
|
128
|
-
npx wrangler d1 migrations apply
|
|
127
|
+
npx wrangler d1 migrations list <slug> --remote
|
|
128
|
+
npx wrangler d1 migrations apply <slug> --remote
|
|
129
129
|
```
|
|
130
130
|
|
|
131
131
|
`--remote` is the step people forget. Local migrations do nothing in production, and local and
|
package/skills/start/SKILL.md
CHANGED
|
@@ -35,19 +35,25 @@ cd <domain_with_underscores>
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
It writes the identity files, a working contact form, a home page, the fleet skills, and runs
|
|
38
|
-
`git init` and `npm install`. Nothing outside the directory is touched.
|
|
38
|
+
`git init` and `npm install`. Nothing outside the directory is touched. The GitHub owner comes
|
|
39
|
+
from `git config webm.org` (set once per machine) or `--org`; the staging inbox from
|
|
40
|
+
`git config webm.stagingEmail`, falling back to `user.email`.
|
|
39
41
|
|
|
40
|
-
**
|
|
42
|
+
**One name, everywhere.** The domain minus its TLD is the GitHub repo, the Worker, the D1
|
|
43
|
+
database, the R2 bucket and any KV namespace:
|
|
41
44
|
|
|
42
|
-
| |
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
45
|
+
| Domain | Name |
|
|
46
|
+
| ------------------ | -------------- |
|
|
47
|
+
| `example.com` | `example` |
|
|
48
|
+
| `shop.example.com` | `shop-example` |
|
|
49
|
+
| `example.co.uk` | `example` |
|
|
47
50
|
|
|
48
|
-
The
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
The TLD is dropped because a Worker named `example-com` puts `example-com` into every preview
|
|
52
|
+
hostname, and Chrome's lookalike check then warns the client the site looks fake. A second
|
|
53
|
+
resource of one kind for the same client takes a purpose suffix: `example-portal`.
|
|
54
|
+
|
|
55
|
+
`example.com` and `example.org` both want `example`. Check the name is free in the account
|
|
56
|
+
before committing; the second one gets a name chosen by you, not by the tool.
|
|
51
57
|
|
|
52
58
|
## 2. Create the GitHub repo
|
|
53
59
|
|
|
@@ -79,9 +85,9 @@ Create only what the site needs. A marketing site with a contact form needs D1;
|
|
|
79
85
|
R2 until someone has a video.
|
|
80
86
|
|
|
81
87
|
```sh
|
|
82
|
-
npx wrangler d1 create
|
|
83
|
-
npx wrangler d1 migrations apply
|
|
84
|
-
npx wrangler r2 bucket create
|
|
88
|
+
npx wrangler d1 create <slug> --update-config # writes the binding into wrangler.jsonc
|
|
89
|
+
npx wrangler d1 migrations apply <slug> --local
|
|
90
|
+
npx wrangler r2 bucket create <slug> # only if media is going to R2
|
|
85
91
|
```
|
|
86
92
|
|
|
87
93
|
Set `features.d1: true` once the binding exists. `features.turnstile` waits for `/webm:launch`,
|
|
@@ -94,8 +100,8 @@ forms.** Miss one and it returns 200 to curl and a 404 page to Chrome. `webm doc
|
|
|
94
100
|
## 5. Workers Builds
|
|
95
101
|
|
|
96
102
|
Connect the repo in the Cloudflare dashboard: **Workers & Pages → Create → Import a repository**.
|
|
97
|
-
The Worker name must be
|
|
98
|
-
|
|
103
|
+
The Worker name must be the slug exactly as `wrangler.jsonc` has it - Workers Builds fails on a
|
|
104
|
+
mismatch.
|
|
99
105
|
|
|
100
106
|
No build variables are needed. **Push to deploy from then on** - a `wrangler deploy` from a
|
|
101
107
|
laptop creates a version no build produced, so history stops describing what is live, and the
|
package/skills/traps/SKILL.md
CHANGED
|
@@ -122,8 +122,8 @@ wildcards, and the free tier allows 10 hostnames per widget.
|
|
|
122
122
|
|
|
123
123
|
**Chrome's "This site looks fake" warning on preview links comes from the Worker's NAME.** Its
|
|
124
124
|
lookalike-domain check flags "domains that embed other domain names within their own hostname",
|
|
125
|
-
and a Worker called `
|
|
126
|
-
|
|
125
|
+
and a Worker called `acme-com` puts `acme-com` into every preview hostname. That is why every
|
|
126
|
+
resource is named by the slug with the TLD dropped — `acme` embeds nothing that reads as a
|
|
127
127
|
domain. `webm new` does this; do not name a Worker after the full domain.
|
|
128
128
|
|
|
129
129
|
If a warning still appears on a correctly named Worker it is a URL-shape false positive, not a
|
package/src/cli/new.ts
CHANGED
|
@@ -28,26 +28,33 @@ function parseArgs(argv: string[]) {
|
|
|
28
28
|
return {
|
|
29
29
|
domain: positional[0],
|
|
30
30
|
client: flag('client'),
|
|
31
|
-
org: flag('org') ?? '
|
|
32
|
-
stagingEmail:
|
|
31
|
+
org: flag('org') ?? gitConfig('webm.org'),
|
|
32
|
+
stagingEmail:
|
|
33
|
+
flag('staging-email') ?? gitConfig('webm.stagingEmail') ?? gitConfig('user.email'),
|
|
33
34
|
into: flag('into'),
|
|
34
35
|
install: !argv.includes('--no-install'),
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/*
|
|
39
|
-
*
|
|
40
|
+
* WHO IS SCAFFOLDING, read from the machine rather than baked into the package.
|
|
40
41
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* A public package carries no agency defaults: not a GitHub org, not an inbox. A default org
|
|
43
|
+
* would only ever be wrong for anyone else, and a default address means a stranger's staging site
|
|
44
|
+
* mails the package author. So both come from git config, set once per machine -
|
|
45
|
+
*
|
|
46
|
+
* git config --global webm.org webmonterey
|
|
47
|
+
* git config --global webm.stagingEmail dev@example.com # optional; user.email otherwise
|
|
48
|
+
*
|
|
49
|
+
* - and a flag overrides either for one run. `webm doctor` fails a staging site that ends up
|
|
50
|
+
* with no address.
|
|
45
51
|
*/
|
|
46
|
-
function
|
|
52
|
+
function gitConfig(key: string): string | undefined {
|
|
47
53
|
try {
|
|
48
|
-
|
|
54
|
+
const value = execFileSync('git', ['config', key], { encoding: 'utf8' }).trim();
|
|
55
|
+
return value || undefined;
|
|
49
56
|
} catch {
|
|
50
|
-
return
|
|
57
|
+
return undefined;
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -56,9 +63,17 @@ export function run(argv: string[]): number {
|
|
|
56
63
|
|
|
57
64
|
if (!args.domain) {
|
|
58
65
|
console.error(
|
|
59
|
-
'webm new <domain> [--client="Name"] [--org
|
|
66
|
+
'webm new <domain> [--client="Name"] [--org=<github-owner>] [--staging-email=you@example.com] [--into=path] [--no-install]',
|
|
67
|
+
);
|
|
68
|
+
console.error('\n webm new example.com --client="Example Co"');
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!args.org) {
|
|
73
|
+
console.error(
|
|
74
|
+
'webm new: no GitHub owner for the repo. Either pass --org=<owner> or set it once:\n\n' +
|
|
75
|
+
' git config --global webm.org <owner>\n',
|
|
60
76
|
);
|
|
61
|
-
console.error('\n webm new autire.com --client="Autire Technologies"');
|
|
62
77
|
return 1;
|
|
63
78
|
}
|
|
64
79
|
|
|
@@ -86,7 +101,7 @@ export function run(argv: string[]): number {
|
|
|
86
101
|
domain,
|
|
87
102
|
client: args.client,
|
|
88
103
|
org: args.org,
|
|
89
|
-
stagingEmail: args.stagingEmail
|
|
104
|
+
stagingEmail: args.stagingEmail,
|
|
90
105
|
packageVersion: packageVersion(),
|
|
91
106
|
/* Real today, not a constant - see ScaffoldOptions.today for what a stale one does. */
|
|
92
107
|
today: new Date().toISOString().slice(0, 10),
|
|
@@ -114,12 +129,14 @@ export function run(argv: string[]): number {
|
|
|
114
129
|
console.log(`Scaffolded ${Object.keys(files).length} files into ${root}`);
|
|
115
130
|
console.log(` + ${seeded.length} seeded (favicons, headers, CLAUDE.md, a contact form)`);
|
|
116
131
|
console.log(` + ${kept.length} .gitkeep, so the empty directories survive a clone\n`);
|
|
117
|
-
console.log(` repo
|
|
132
|
+
console.log(` repo ${args.org}/${names.repo}`);
|
|
133
|
+
console.log(
|
|
134
|
+
` worker · d1 · r2 ${names.slug} (the domain minus its TLD, so Chrome does not flag previews)`,
|
|
135
|
+
);
|
|
118
136
|
console.log(
|
|
119
|
-
|
|
137
|
+
`\n One name everywhere. If ${names.slug} is already taken in the account - ${domain.replace(/^[^.]+/, names.slug)}` +
|
|
138
|
+
` and another TLD both want it - pick another with --into and edit webmonterey.json.`,
|
|
120
139
|
);
|
|
121
|
-
console.log(` d1 ${names.d1}`);
|
|
122
|
-
console.log(` r2 ${names.r2Media}`);
|
|
123
140
|
|
|
124
141
|
try {
|
|
125
142
|
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: root });
|
package/src/cli/scaffold.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ const files = (over = {}) =>
|
|
|
7
7
|
scaffold({
|
|
8
8
|
domain: 'autire.com',
|
|
9
9
|
client: 'Autire Technologies',
|
|
10
|
+
org: 'webmonterey',
|
|
10
11
|
packageVersion: '1.0.0',
|
|
11
12
|
today: '2026-08-26',
|
|
12
13
|
...over,
|
|
@@ -14,15 +15,15 @@ const files = (over = {}) =>
|
|
|
14
15
|
|
|
15
16
|
const json = (f: Record<string, string>, path: string) => JSON.parse(f[path]!);
|
|
16
17
|
|
|
17
|
-
test('
|
|
18
|
+
test('one name everywhere: repo, Worker, D1 and R2 are all the slug', () => {
|
|
18
19
|
const f = files();
|
|
19
20
|
const site = json(f, 'webmonterey.json');
|
|
20
|
-
|
|
21
|
-
assert.equal(site.
|
|
22
|
-
assert.equal(site.worker, 'webm-autire');
|
|
21
|
+
assert.equal(site.repo, 'webmonterey/autire');
|
|
22
|
+
assert.equal(site.worker, 'autire');
|
|
23
23
|
assert.equal(site.slug, 'autire');
|
|
24
|
-
assert.match(f['wrangler.jsonc']!, /"name": "
|
|
25
|
-
assert.match(f['README.md']!,
|
|
24
|
+
assert.match(f['wrangler.jsonc']!, /"name": "autire"/);
|
|
25
|
+
assert.match(f['README.md']!, /\| Worker · D1 · R2 \| `autire` \|/);
|
|
26
|
+
assert.equal(json(f, 'package.json').name, 'autire');
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
test('MCP is declared AND pre-approved - a declaration alone is inert', () => {
|
|
@@ -84,7 +85,7 @@ test('secrets are gitignored and the example names the password-manager habit',
|
|
|
84
85
|
assert.match(f['.dev.vars.example']!, /password manager/);
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
test('an unnamed client gets CHANGEME, which
|
|
88
|
+
test('an unnamed client gets CHANGEME, which launch refuses to launch with', () => {
|
|
88
89
|
const site = json(files({ client: undefined }), 'webmonterey.json');
|
|
89
90
|
assert.equal(site.client, 'CHANGEME');
|
|
90
91
|
});
|
|
@@ -176,20 +177,26 @@ test('compatibility_date is the date passed in, never a constant baked into the
|
|
|
176
177
|
* scaffold.ts, which exists because a date NEWER than the installed runtime does not build at
|
|
177
178
|
* all. Both directions are traps and they pull in opposite ways.
|
|
178
179
|
*/
|
|
179
|
-
const config = scaffold({
|
|
180
|
-
'
|
|
181
|
-
|
|
180
|
+
const config = scaffold({
|
|
181
|
+
domain: 'a.com',
|
|
182
|
+
org: 'o',
|
|
183
|
+
packageVersion: '1.0.0',
|
|
184
|
+
today: '2026-08-26',
|
|
185
|
+
})['wrangler.jsonc']!;
|
|
182
186
|
assert.match(config, /"compatibility_date":\s*"2026-08-12"/, 'derived from it, a fortnight back');
|
|
183
187
|
|
|
184
|
-
const other = scaffold({
|
|
185
|
-
'
|
|
186
|
-
|
|
188
|
+
const other = scaffold({
|
|
189
|
+
domain: 'a.com',
|
|
190
|
+
org: 'o',
|
|
191
|
+
packageVersion: '1.0.0',
|
|
192
|
+
today: '2027-03-04',
|
|
193
|
+
})['wrangler.jsonc']!;
|
|
187
194
|
assert.match(other, /"compatibility_date":\s*"2027-02-18"/, 'and it still tracks the argument');
|
|
188
195
|
});
|
|
189
196
|
|
|
190
197
|
test('a malformed date is refused rather than written into wrangler.jsonc', () => {
|
|
191
198
|
assert.throws(
|
|
192
|
-
() => scaffold({ domain: 'a.com', packageVersion: '1.0.0', today: 'today' }),
|
|
199
|
+
() => scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today: 'today' }),
|
|
193
200
|
/YYYY-MM-DD/,
|
|
194
201
|
);
|
|
195
202
|
});
|
|
@@ -205,9 +212,9 @@ test('the scaffolded compatibility_date is never in the future of the installed
|
|
|
205
212
|
* machine that just installed. The margin is for every machine that did not.
|
|
206
213
|
*/
|
|
207
214
|
const written = (today: string) =>
|
|
208
|
-
scaffold({ domain: 'a.com', packageVersion: '1.0.0', today })[
|
|
209
|
-
|
|
210
|
-
)![1]!;
|
|
215
|
+
scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today })[
|
|
216
|
+
'wrangler.jsonc'
|
|
217
|
+
]!.match(/"compatibility_date":\s*"([\d-]+)"/)![1]!;
|
|
211
218
|
|
|
212
219
|
for (const today of ['2026-08-27', '2026-01-01', '2026-03-01', '2027-12-31']) {
|
|
213
220
|
const gap = (Date.parse(today) - Date.parse(written(today))) / 86_400_000;
|
|
@@ -222,9 +229,9 @@ test('the scaffolded compatibility_date is never in the future of the installed
|
|
|
222
229
|
test('the margin crosses a month and a year boundary correctly', () => {
|
|
223
230
|
// Naive string arithmetic gets 2026-01-05 minus 14 days wrong; this is why it goes through Date.
|
|
224
231
|
const at = (today: string) =>
|
|
225
|
-
scaffold({ domain: 'a.com', packageVersion: '1.0.0', today })[
|
|
226
|
-
|
|
227
|
-
)![1]!;
|
|
232
|
+
scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today })[
|
|
233
|
+
'wrangler.jsonc'
|
|
234
|
+
]!.match(/"compatibility_date":\s*"([\d-]+)"/)![1]!;
|
|
228
235
|
assert.equal(at('2026-01-05'), '2025-12-22');
|
|
229
236
|
assert.equal(at('2026-03-05'), '2026-02-19', 'and February');
|
|
230
237
|
});
|
package/src/cli/scaffold.ts
CHANGED
|
@@ -13,10 +13,10 @@ import { MCP_NAMES, mcpConfig } from './mcp.ts';
|
|
|
13
13
|
|
|
14
14
|
export interface ScaffoldOptions {
|
|
15
15
|
domain: string;
|
|
16
|
-
/** Display name. Falls back to CHANGEME, which `
|
|
16
|
+
/** Display name. Falls back to CHANGEME, which `/webm:launch` refuses to launch with. */
|
|
17
17
|
client?: string;
|
|
18
|
-
/** GitHub
|
|
19
|
-
org
|
|
18
|
+
/** GitHub owner for the repo. Required - the package carries no agency default. */
|
|
19
|
+
org: string;
|
|
20
20
|
/**
|
|
21
21
|
* Where a staging deployment's mail goes. `webm new` fills it from `git config user.email`;
|
|
22
22
|
* the package itself carries no inbox, because a default address in a public package means a
|
|
@@ -49,7 +49,7 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
|
49
49
|
const { domain, packageVersion } = options;
|
|
50
50
|
const n = resourceNames(domain);
|
|
51
51
|
const client = options.client ?? 'CHANGEME';
|
|
52
|
-
const org = options
|
|
52
|
+
const { org } = options;
|
|
53
53
|
const { today } = options;
|
|
54
54
|
|
|
55
55
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
|
|
@@ -413,9 +413,10 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
|
413
413
|
`| \`npx webm doctor\` | the things that fail silently |\n\n` +
|
|
414
414
|
`## Cloudflare\n\n` +
|
|
415
415
|
`| | |\n| --- | --- |\n` +
|
|
416
|
-
`| Worker
|
|
417
|
-
`
|
|
418
|
-
`
|
|
416
|
+
`| Worker · D1 · R2 | \`${n.slug}\` |\n\n` +
|
|
417
|
+
`One name everywhere: the domain minus its TLD. A preview hostname then never embeds a\n` +
|
|
418
|
+
`domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose\n` +
|
|
419
|
+
`suffix - \`${n.slug}-portal\`.\n\n` +
|
|
419
420
|
`## Deploying\n\n` +
|
|
420
421
|
`Push to deploy. A \`wrangler deploy\` from a laptop creates a version no build produced, so\n` +
|
|
421
422
|
`history stops describing what is live and the next push reverts it.\n`;
|
package/src/cli/slug.test.ts
CHANGED
|
@@ -20,24 +20,11 @@ test('something that is not a domain is refused, naming what was expected', () =
|
|
|
20
20
|
assert.throws(() => normalizeDomain('exa mple.com'), DomainError);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test('the repo keeps the full domain, dots to UNDERSCORES', () => {
|
|
24
|
-
assert.equal(repoName('autire.com'), 'autire_com');
|
|
25
|
-
assert.equal(repoName('friendsofthemarinalibrary.org'), 'friendsofthemarinalibrary_org');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('the three names are all different, and each has a job', () => {
|
|
29
|
-
// repo is unambiguous about the site; slug drops the TLD so Chrome does not flag preview
|
|
30
|
-
// hostnames; worker is the slug prefixed.
|
|
31
|
-
const n = resourceNames('autire.com');
|
|
32
|
-
assert.equal(n.repo, 'autire_com');
|
|
33
|
-
assert.equal(n.slug, 'autire');
|
|
34
|
-
assert.equal(n.worker, 'webm-autire');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
23
|
test('the slug drops the TLD - this is what stops Chrome flagging preview links', () => {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
assert.equal(slugFor('
|
|
24
|
+
// A Worker named example-com puts example-com into every preview hostname, which Chrome reads
|
|
25
|
+
// as a registrable domain. `example` embeds nothing.
|
|
26
|
+
assert.equal(slugFor('example.com'), 'example');
|
|
27
|
+
assert.equal(slugFor('acme-widgets.org'), 'acme-widgets');
|
|
41
28
|
});
|
|
42
29
|
|
|
43
30
|
test('a two-part public suffix drops both labels', () => {
|
|
@@ -45,7 +32,7 @@ test('a two-part public suffix drops both labels', () => {
|
|
|
45
32
|
assert.equal(slugFor('example.com.au'), 'example');
|
|
46
33
|
});
|
|
47
34
|
|
|
48
|
-
test('a subdomain is kept -
|
|
35
|
+
test('a subdomain is kept - an indexable subdomain is its own site', () => {
|
|
49
36
|
assert.equal(slugFor('shop.example.com'), 'shop-example');
|
|
50
37
|
});
|
|
51
38
|
|
|
@@ -53,18 +40,28 @@ test('a single-label result never comes back empty', () => {
|
|
|
53
40
|
assert.ok(slugFor('a.com').length > 0);
|
|
54
41
|
});
|
|
55
42
|
|
|
56
|
-
test('
|
|
57
|
-
assert.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
43
|
+
test('one name, everywhere: repo, Worker, D1, R2 and KV are all the slug', () => {
|
|
44
|
+
assert.equal(repoName('example.com'), 'example');
|
|
45
|
+
assert.deepEqual(resourceNames('example.com'), {
|
|
46
|
+
slug: 'example',
|
|
47
|
+
repo: 'example',
|
|
48
|
+
worker: 'example',
|
|
49
|
+
d1: 'example',
|
|
50
|
+
r2: 'example',
|
|
51
|
+
kv: 'example',
|
|
64
52
|
});
|
|
65
53
|
});
|
|
66
54
|
|
|
55
|
+
test('every name is valid for the strictest resource - lowercase, digits and dashes only', () => {
|
|
56
|
+
// R2 and Workers accept nothing else, and R2 also refuses a leading or trailing dash.
|
|
57
|
+
for (const domain of ['example.com', 'shop.example.com', 'acme-widgets.co.uk', 'a1.io']) {
|
|
58
|
+
for (const name of Object.values(resourceNames(domain))) {
|
|
59
|
+
assert.match(name, /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, `${domain} -> ${name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
67
64
|
test('two clients on the same name under different TLDs collide, which the caller must handle', () => {
|
|
68
|
-
//
|
|
69
|
-
assert.equal(slugFor('
|
|
65
|
+
// `webm new` says so; the second one gets a name chosen by a person.
|
|
66
|
+
assert.equal(slugFor('example.com'), slugFor('example.org'));
|
|
70
67
|
});
|
package/src/cli/slug.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Turning a domain into the
|
|
2
|
+
* Turning a domain into the ONE name everything else uses.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* example.com -> example
|
|
5
|
+
* shop.example.com -> shop-example
|
|
6
|
+
* example.co.uk -> example
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
+
* The GitHub repo, the Worker, the D1 database, the R2 bucket and any KV namespace all carry
|
|
9
|
+
* that same name. It is the domain minus its public suffix, which is the one shape valid for
|
|
10
|
+
* every resource at once - Workers and R2 accept only `[a-z0-9-]`, and in an agency account that
|
|
11
|
+
* holds nothing but client sites a prefix says nothing.
|
|
8
12
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* The TLD is dropped for a reason beyond brevity: a Worker named `example-com` puts `example-com`
|
|
14
|
+
* into every preview hostname, and Chrome's lookalike-domain check reads that as a registrable
|
|
15
|
+
* domain and warns the client their own preview looks fake. `example` embeds nothing.
|
|
16
|
+
*
|
|
17
|
+
* A second resource of the same kind for one client takes a purpose suffix - `example-portal` -
|
|
18
|
+
* and is the exception, not the pattern.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
/** Public suffixes that take two labels, so `example.co.uk` slugs to `example`. */
|
|
@@ -63,28 +69,16 @@ export function normalizeDomain(input: string): string {
|
|
|
63
69
|
return cleaned;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/**
|
|
67
|
-
* The GitHub repo name. Dots become UNDERSCORES: `autire.com` -> `autire_com`.
|
|
68
|
-
*
|
|
69
|
-
* Underscores, not dashes, and not the slug. Three names, three jobs:
|
|
70
|
-
*
|
|
71
|
-
* repo autire_com the full domain, unambiguous about which site this is
|
|
72
|
-
* slug autire no TLD, because a Cloudflare Worker named webm-autire-com
|
|
73
|
-
* embeds autire-com and Chrome reads that as a domain
|
|
74
|
-
* worker webm-autire the slug, prefixed
|
|
75
|
-
*
|
|
76
|
-
* A rebuild creates a NEW repo under the underscore name beside the old dashed one, which is what
|
|
77
|
-
* lets the old site keep serving until the cutover.
|
|
78
|
-
*/
|
|
79
|
-
export function repoName(domain: string): string {
|
|
80
|
-
return normalizeDomain(domain).replace(/\./g, '_');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
72
|
/**
|
|
84
73
|
* The client slug - the domain with its public suffix removed.
|
|
85
74
|
*
|
|
86
|
-
* `
|
|
87
|
-
* `shop.example.com` and `example.com` are different
|
|
75
|
+
* `example.com` -> `example`, `example.co.uk` -> `example`. Subdomains are kept, joined with a
|
|
76
|
+
* dash, because `shop.example.com` and `example.com` are different sites if they are ever both
|
|
77
|
+
* ours - and an indexable subdomain is always its own site.
|
|
78
|
+
*
|
|
79
|
+
* `example.com` and `example.org` slug to the same thing. That is a real collision inside one
|
|
80
|
+
* account, and `webm new` says so rather than silently picking - the second one gets a name
|
|
81
|
+
* chosen by a person.
|
|
88
82
|
*/
|
|
89
83
|
export function slugFor(domain: string): string {
|
|
90
84
|
const clean = normalizeDomain(domain);
|
|
@@ -95,15 +89,16 @@ export function slugFor(domain: string): string {
|
|
|
95
89
|
return kept.join('-');
|
|
96
90
|
}
|
|
97
91
|
|
|
98
|
-
/**
|
|
92
|
+
/** The GitHub repo name. The slug - one name, everywhere. */
|
|
93
|
+
export function repoName(domain: string): string {
|
|
94
|
+
return slugFor(domain);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every resource name for a site, from one domain. They are all the slug; the fields exist so a
|
|
99
|
+
* caller says which resource it means, and so a purpose suffix has an obvious place to go.
|
|
100
|
+
*/
|
|
99
101
|
export function resourceNames(domain: string) {
|
|
100
102
|
const slug = slugFor(domain);
|
|
101
|
-
return {
|
|
102
|
-
slug,
|
|
103
|
-
repo: repoName(domain),
|
|
104
|
-
worker: `webm-${slug}`,
|
|
105
|
-
d1: `webm-${slug}-db`,
|
|
106
|
-
r2Media: `webm-${slug}-media`,
|
|
107
|
-
r2App: `webm-${slug}-app`,
|
|
108
|
-
};
|
|
103
|
+
return { slug, repo: slug, worker: slug, d1: slug, r2: slug, kv: slug };
|
|
109
104
|
}
|
package/src/cli/sync.ts
CHANGED
|
@@ -51,7 +51,7 @@ import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* The namespace. Rule 5's prefix: the CLI is `webm`, the tokens are --webm-*, the classes are
|
|
54
|
-
* .webm-*, the
|
|
54
|
+
* .webm-*, the cloud resources carry one name each - the domain minus its TLD. `/webm:launch` rather than `/webmonterey:launch`.
|
|
55
55
|
*/
|
|
56
56
|
const NAMESPACE = 'webm';
|
|
57
57
|
|
|
@@ -13,7 +13,7 @@ domain on a bucket requires the zone to be on the same account as the bucket, so
|
|
|
13
13
|
earlier fails for the same reason `preview.<client-domain>` does.
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
npx wrangler r2 bucket create
|
|
16
|
+
npx wrangler r2 bucket create <slug> # the domain minus its TLD, same as the Worker and D1
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Then in the dashboard: **R2 → the bucket → Settings → Custom Domains → Connect Domain**, and
|
|
@@ -49,8 +49,8 @@ no_check_bucket = true
|
|
|
49
49
|
every transfer fails with an error naming an operation you never asked for.
|
|
50
50
|
|
|
51
51
|
```sh
|
|
52
|
-
rclone copy ./uploads webm-media
|
|
53
|
-
rclone check ./uploads webm-media
|
|
52
|
+
rclone copy ./uploads webm-media:<slug> --transfers 2 --progress
|
|
53
|
+
rclone check ./uploads webm-media:<slug>
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Two things learned the hard way:
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* The deciding question is not file size, it is "should `git clone` carry this?" A 40MB
|
|
14
14
|
* showreel makes every clone slower forever and can never be optimised by the build.
|
|
15
15
|
*
|
|
16
|
-
* NAMING
|
|
17
|
-
* bucket
|
|
18
|
-
* hostname media.<client-domain>
|
|
16
|
+
* NAMING:
|
|
17
|
+
* bucket <slug> e.g. example - one name for every resource, see cli/slug.ts
|
|
18
|
+
* hostname media.<client-domain> e.g. media.example.com
|
|
19
19
|
*
|
|
20
20
|
* WHY A CUSTOM DOMAIN AND NOT r2.dev: Cloudflare's r2.dev subdomain is rate-limited and
|
|
21
21
|
* documented as unsuitable for production. It is also a hostname the client does not own,
|
|
@@ -68,7 +68,7 @@ test('staging is decided by config OR by a workers.dev hostname, each covering t
|
|
|
68
68
|
// A cron has no hostname, so config is the only signal it can read; a branch preview of a
|
|
69
69
|
// launched site inherits `production` from main, so the hostname is the only signal there.
|
|
70
70
|
assert.equal(isStagingDeployment('staging', null), true);
|
|
71
|
-
assert.equal(isStagingDeployment('production', 'x-
|
|
71
|
+
assert.equal(isStagingDeployment('production', 'x-acme.acct.workers.dev'), true);
|
|
72
72
|
assert.equal(isStagingDeployment('production', 'acme.com'), false);
|
|
73
73
|
assert.equal(isStagingDeployment(undefined, 'www.acme.com'), false, 'a www variant still sends');
|
|
74
74
|
assert.equal(isStagingDeployment('production', 'notworkers.dev'), false, 'label, not substring');
|
|
@@ -35,7 +35,7 @@ Wrangler tracks applied migrations in a `d1_migrations` table inside the databas
|
|
|
35
35
|
The database must exist and be bound in `wrangler.jsonc`:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npx wrangler d1 create
|
|
38
|
+
npx wrangler d1 create <slug> --update-config
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
`--update-config` writes the `d1_databases` binding into `wrangler.jsonc` for you.
|
package/template/site/CLAUDE.md
CHANGED
|
@@ -103,8 +103,12 @@ Private data — user logins, form submissions — goes to **Cloudflare D1**, ne
|
|
|
103
103
|
|
|
104
104
|
### 5. The prefix is `webm-`, never `wm-`
|
|
105
105
|
|
|
106
|
-
Custom properties (`--webm-action`), class names (`.webm-section`),
|
|
107
|
-
|
|
106
|
+
Custom properties (`--webm-action`), class names (`.webm-section`), the CLI, the skills
|
|
107
|
+
namespace. No exceptions.
|
|
108
|
+
|
|
109
|
+
Cloud resources are the other convention: the GitHub repo, Worker, D1, R2 and KV all carry **one
|
|
110
|
+
name**, the domain minus its TLD (`example.com` → `example`). A second resource of one kind takes
|
|
111
|
+
a purpose suffix (`example-portal`).
|
|
108
112
|
|
|
109
113
|
### 6. Cloudflare bindings come from `cloudflare:workers`
|
|
110
114
|
|