@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 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 example_com
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.org ?? "webmonterey";
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.worker}\` |
553
- | D1 | \`${n.d1}\` |
554
- | R2 media | \`${n.r2Media}\` |
545
+ | Worker \xB7 D1 \xB7 R2 | \`${n.slug}\` |
555
546
 
556
- The repo is named for the domain; Cloudflare resources use the slug, with no TLD, so a
557
- preview hostname never embeds a domain Chrome could mistake for a lookalike.
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") ?? "webmonterey",
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 gitUserEmail() {
819
+ function gitConfig(key) {
828
820
  try {
829
- return execFileSync("git", ["config", "user.email"], { encoding: "utf8" }).trim();
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=webmonterey] [--staging-email=you@example.com] [--into=path] [--no-install]'
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 ?? gitUserEmail(),
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 ${args.org}/${names.repo} (keeps the domain)`);
876
+ console.log(` repo ${args.org}/${names.repo}`);
878
877
  console.log(
879
- ` worker ${names.worker} (slug - no TLD, so Chrome does not flag previews)`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -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 webm-<slug>-db --remote
128
- npx wrangler d1 migrations apply webm-<slug>-db --remote
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
@@ -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
- **Three names, and they differ on purpose:**
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
- | | Example | |
43
- | ----------- | ---------------------------------------------------- | ---------------------------- |
44
- | GitHub repo | `webmonterey/autire_com` | Full domain, **underscores** |
45
- | Slug | `autire` | Domain minus the TLD |
46
- | Cloudflare | `webm-autire`, `webm-autire-db`, `webm-autire-media` | The slug, prefixed |
45
+ | Domain | Name |
46
+ | ------------------ | -------------- |
47
+ | `example.com` | `example` |
48
+ | `shop.example.com` | `shop-example` |
49
+ | `example.co.uk` | `example` |
47
50
 
48
- The slug drops the TLD because a Worker named `webm-autire-com` puts `autire-com` into every
49
- preview hostname, and Chrome's lookalike check then warns the client the site looks fake.
50
- `autire.com` and `autire.org` both want `autire` - check the slug is free before committing.
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 webm-<slug>-db --update-config # writes the binding into wrangler.jsonc
83
- npx wrangler d1 migrations apply webm-<slug>-db --local
84
- npx wrangler r2 bucket create webm-<slug>-media # only if media is going to R2
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 `webm-<slug>` exactly - Workers Builds fails on a mismatch with
98
- `wrangler.jsonc`.
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
@@ -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 `webm-acme-com` puts `acme-com` into every preview hostname. That is why
126
- Workers are named `webm-<slug>` with the TLD dropped — `webm-acme` embeds nothing that reads as a
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') ?? 'webmonterey',
32
- stagingEmail: flag('staging-email'),
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
- * WHERE A STAGING SITE'S MAIL GOES, defaulting to whoever is scaffolding it.
40
+ * WHO IS SCAFFOLDING, read from the machine rather than baked into the package.
40
41
  *
41
- * The package carries no inbox of its own: a default address baked into a public package means
42
- * a stranger's staging site mails the package author. `git config user.email` is the person at
43
- * the keyboard, which is the right default for a site they are about to test. The flag overrides
44
- * it; doctor fails a staging site that ends up with none.
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 gitUserEmail(): string {
52
+ function gitConfig(key: string): string | undefined {
47
53
  try {
48
- return execFileSync('git', ['config', 'user.email'], { encoding: 'utf8' }).trim();
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=webmonterey] [--staging-email=you@example.com] [--into=path] [--no-install]',
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 ?? gitUserEmail(),
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 ${args.org}/${names.repo} (keeps the domain)`);
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
- ` worker ${names.worker} (slug - no TLD, so Chrome does not flag previews)`,
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 });
@@ -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('three names, three jobs: repo, slug, Cloudflare', () => {
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
- // Repo carries the full domain with UNDERSCORES; Cloudflare carries the slug, no TLD.
21
- assert.equal(site.repo, 'webmonterey/autire_com');
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": "webm-autire"/);
25
- assert.match(f['README.md']!, /webm-autire-db/);
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 go-live refuses to launch with', () => {
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({ domain: 'a.com', packageVersion: '1.0.0', today: '2026-08-26' })[
180
- 'wrangler.jsonc'
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({ domain: 'a.com', packageVersion: '1.0.0', today: '2027-03-04' })[
185
- 'wrangler.jsonc'
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 })['wrangler.jsonc']!.match(
209
- /"compatibility_date":\s*"([\d-]+)"/,
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 })['wrangler.jsonc']!.match(
226
- /"compatibility_date":\s*"([\d-]+)"/,
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
  });
@@ -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 `go-live` refuses to launch with. */
16
+ /** Display name. Falls back to CHANGEME, which `/webm:launch` refuses to launch with. */
17
17
  client?: string;
18
- /** GitHub org for the repo. */
19
- org?: string;
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.org ?? 'webmonterey';
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 | \`${n.worker}\` |\n| D1 | \`${n.d1}\` |\n| R2 media | \`${n.r2Media}\` |\n\n` +
417
- `The repo is named for the domain; Cloudflare resources use the slug, with no TLD, so a\n` +
418
- `preview hostname never embeds a domain Chrome could mistake for a lookalike.\n\n` +
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`;
@@ -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
- // webm-autire-com contains autire-com, which reads as a domain. webm-autire does not.
39
- assert.equal(slugFor('autire.com'), 'autire');
40
- assert.equal(slugFor('stevenglaze.com'), 'stevenglaze');
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 - two of ours could differ only by it', () => {
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('every Cloudflare name derives from one domain, and the repo differs on purpose', () => {
57
- assert.deepEqual(resourceNames('autire.com'), {
58
- slug: 'autire',
59
- repo: 'autire_com',
60
- worker: 'webm-autire',
61
- d1: 'webm-autire-db',
62
- r2Media: 'webm-autire-media',
63
- r2App: 'webm-autire-app',
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
- // clients.slug carries a unique constraint; `webm new` picks the next free form and records it.
69
- assert.equal(slugFor('autire.com'), slugFor('autire.org'));
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 names everything else uses.
2
+ * Turning a domain into the ONE name everything else uses.
3
3
  *
4
- * TWO NAMES, DELIBERATELY DIFFERENT.
4
+ * example.com -> example
5
+ * shop.example.com -> shop-example
6
+ * example.co.uk -> example
5
7
  *
6
- * GitHub repo <domain_underscored> webmonterey/autire_com
7
- * Cloudflare stuff webm-<slug> webm-autire, webm-autire-db, webm-autire-media
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 repo keeps the full domain so it is unambiguous which site it serves. Cloudflare drops the
10
- * TLD token because `webm-autire-com` contains `autire-com`, which Chrome's lookalike check reads
11
- * as a registrable domain - and every preview link then warns the client the site looks fake.
12
- * `webm-autire` has nothing in it that looks like a domain. See ARCHITECTURE.md section 5.
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
- * `autire.com` -> `autire`, `example.co.uk` -> `example`. Subdomains are kept, because
87
- * `shop.example.com` and `example.com` are different clients if they are ever both ours.
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
- /** Every Cloudflare resource name for a site, from one domain. */
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 Workers are webm-<slug>. `/webm:launch` rather than `/webmonterey:launch`.
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 webm-<domain-dashed>-media
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:webm-<domain-dashed>-media --transfers 2 --progress
53
- rclone check ./uploads webm-media:webm-<domain-dashed>-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 (rule 5):
17
- * bucket webm-<domain-dashed>-media e.g. webm-example-com-media
18
- * hostname media.<client-domain> e.g. media.example.com
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-webm-acme.acct.workers.dev'), true);
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 webm-<domain-dashed>-db --update-config
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.
@@ -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`), Cloudflare resource names
107
- (`webm-<slug>`), the CLI, the skills namespace. No exceptions.
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