@cparkerwebm/webmonterey 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +6 -0
- package/dist/webm.mjs +187 -44
- package/package.json +1 -1
- package/skills/launch/SKILL.md +12 -4
- package/skills/start/SKILL.md +63 -13
- package/skills/traps/SKILL.md +8 -0
- package/src/cli/checks.test.ts +40 -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 +61 -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/integration/index.ts +36 -12
- package/src/integration/virtual.d.ts +6 -3
- package/src/layouts/base.astro +9 -6
- package/src/pages/robots.txt.ts +5 -3
- package/template/site/CLAUDE.md +25 -2
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
|
|
|
@@ -709,3 +710,42 @@ test('a server pointed at the wrong url is caught, and both urls are shown', ()
|
|
|
709
710
|
assert.match(r.detail!, /example\.com/);
|
|
710
711
|
assert.ok(r.detail!.includes(MCP_SERVERS.mdn.url));
|
|
711
712
|
});
|
|
713
|
+
|
|
714
|
+
/* --- the Worker exists --------------------------------------------------- */
|
|
715
|
+
|
|
716
|
+
test('a Worker with no deployment warns and says how to create it', () => {
|
|
717
|
+
/* A repo, a database and nothing serving: the failure /webm:start used to end on. */
|
|
718
|
+
const r = runCheck(
|
|
719
|
+
'worker-exists',
|
|
720
|
+
base({ worker: { name: 'acme', deployments: 0, skipped: null } }),
|
|
721
|
+
);
|
|
722
|
+
assert.equal(r.status, 'warn');
|
|
723
|
+
assert.match(r.detail!, /"acme"/);
|
|
724
|
+
assert.match(r.detail!, /wrangler deploy/);
|
|
725
|
+
assert.match(r.detail!, /Settings → Builds/);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('an unanswerable question skips with the reason, and is not a failure', () => {
|
|
729
|
+
const r = runCheck(
|
|
730
|
+
'worker-exists',
|
|
731
|
+
base({ worker: { name: 'acme', deployments: null, skipped: 'wrangler is not logged in' } }),
|
|
732
|
+
);
|
|
733
|
+
assert.equal(r.status, 'pass');
|
|
734
|
+
assert.match(r.detail!, /skipped: wrangler is not logged in/);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('a deployed Worker passes', () => {
|
|
738
|
+
assert.equal(runCheck('worker-exists', base()).status, 'pass');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('a launched site still declared staging fails, and the message names the search consequence', () => {
|
|
742
|
+
const r = runCheck(
|
|
743
|
+
'environment',
|
|
744
|
+
base({
|
|
745
|
+
site: { client: 'A', domain: 'a.com', environment: 'staging', launched: '2026-09-01' },
|
|
746
|
+
}),
|
|
747
|
+
);
|
|
748
|
+
assert.equal(r.status, 'fail');
|
|
749
|
+
assert.match(r.detail!, /noindex/);
|
|
750
|
+
assert.match(r.detail!, /out of search/);
|
|
751
|
+
});
|
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
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The Claude Code project settings a client repo carries, and the one list of what they deny.
|
|
3
|
+
*
|
|
4
|
+
* WHY A MODULE, like mcp.ts. Three places have to agree about these rules: the scaffold writes
|
|
5
|
+
* them into a new site, `webm sync` merges any that are missing into an existing site on every
|
|
6
|
+
* install, and the tests assert both against this list rather than a literal that falls one
|
|
7
|
+
* behind. `.claude/settings.json` is otherwise the site's own - a client adds allow rules to it -
|
|
8
|
+
* so it is MERGED, never replaced: the package's deny rules are added when absent and everything
|
|
9
|
+
* else in the file is left exactly as found.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THE DENY RULES ENFORCE. Two things a session in a client repo must never do, made
|
|
12
|
+
* mechanical rather than advisory:
|
|
13
|
+
*
|
|
14
|
+
* - read a secret. .env, .dev.vars, keys, the npm config.
|
|
15
|
+
* - edit the package. Rule 12 of the site's CLAUDE.md: a session in a client repo never edits
|
|
16
|
+
* the package, not in node_modules and not in its checkout. The deliverable for an upstream
|
|
17
|
+
* problem is a description of the fix, run later in the package repo.
|
|
18
|
+
*
|
|
19
|
+
* THE SYNTAX, verified against code.claude.com/docs/en/permissions rather than recalled:
|
|
20
|
+
*
|
|
21
|
+
* - Read and Edit rules take gitignore patterns. As a DENY rule, `Edit(**\/node_modules/**)`
|
|
22
|
+
* matches a node_modules directory at any depth under the working directory.
|
|
23
|
+
* - `Edit` rules apply to every built-in tool that edits files - Edit, Write, MultiEdit and
|
|
24
|
+
* NotebookEdit. A `Write(...)` rule is accepted, never consulted, and warned about at
|
|
25
|
+
* startup, which is why none appears here and why the two that 1.2.0 scaffolded are removed.
|
|
26
|
+
* - A `Read` deny also blocks Edit and Write on the same path. The Edit rules for the secret
|
|
27
|
+
* files are therefore redundant, and kept: they say what is meant.
|
|
28
|
+
* - THERE IS NO PATTERN FOR "ANY PATH OUTSIDE THE PROJECT". A rule names a path - `//absolute`,
|
|
29
|
+
* `~/home-relative`, `/project-relative`, or cwd-relative - and nothing expresses the
|
|
30
|
+
* complement of one. The package checkout sitting beside a client repo on the same machine
|
|
31
|
+
* cannot be denied by rule without hardcoding where it is, and a public package does not
|
|
32
|
+
* know. Rule 12 in CLAUDE.md carries that half in prose.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { MCP_NAMES } from './mcp.ts';
|
|
36
|
+
|
|
37
|
+
/** Every rule a site's settings must deny. Order is the order they are written. */
|
|
38
|
+
export const DENY_RULES: readonly string[] = [
|
|
39
|
+
'Read(**/.dev.vars)',
|
|
40
|
+
'Read(**/.dev.vars.*)',
|
|
41
|
+
'Read(**/.env)',
|
|
42
|
+
'Read(**/.env.*)',
|
|
43
|
+
'Read(**/*.pem)',
|
|
44
|
+
'Read(**/*.key)',
|
|
45
|
+
'Read(**/.npmrc)',
|
|
46
|
+
'Edit(**/.dev.vars)',
|
|
47
|
+
'Edit(**/.env)',
|
|
48
|
+
'Edit(**/node_modules/**)',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rules an earlier scaffold wrote that Claude Code never consults and warns about at startup.
|
|
53
|
+
* Removed on sync, by exact string, so a rule the site wrote itself is never touched.
|
|
54
|
+
*/
|
|
55
|
+
export const STALE_RULES: readonly string[] = ['Write(**/.dev.vars)', 'Write(**/.env)'];
|
|
56
|
+
|
|
57
|
+
const PERMISSIONS_NOTE =
|
|
58
|
+
'The deny list is package-managed: `webm sync` adds any rule that is missing on every ' +
|
|
59
|
+
'install and leaves everything else in this file alone. Edit(**/node_modules/**) is rule 12 ' +
|
|
60
|
+
'of CLAUDE.md made mechanical - a session in this repo never edits the package.';
|
|
61
|
+
|
|
62
|
+
/** The whole file, for a new site. */
|
|
63
|
+
export function projectSettings(repo: string): Record<string, unknown> {
|
|
64
|
+
return {
|
|
65
|
+
'//': `Project settings for ${repo}.`,
|
|
66
|
+
'//mcp':
|
|
67
|
+
'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.',
|
|
68
|
+
includeCoAuthoredBy: false,
|
|
69
|
+
enabledMcpjsonServers: [...MCP_NAMES],
|
|
70
|
+
'//permissions': PERMISSIONS_NOTE,
|
|
71
|
+
permissions: { deny: [...DENY_RULES] },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An existing settings file with the package's deny rules present and the stale ones gone.
|
|
77
|
+
*
|
|
78
|
+
* IDEMPOTENT, and additive everywhere else: a second pass reports nothing and changes nothing,
|
|
79
|
+
* and a rule the site added itself - an allow list, a deny of its own - survives untouched. The
|
|
80
|
+
* site's own rules keep their order; the package's are appended in DENY_RULES order.
|
|
81
|
+
*/
|
|
82
|
+
export function withDenyRules(settings: Record<string, unknown>): {
|
|
83
|
+
settings: Record<string, unknown>;
|
|
84
|
+
added: string[];
|
|
85
|
+
removed: string[];
|
|
86
|
+
} {
|
|
87
|
+
const permissions =
|
|
88
|
+
settings.permissions && typeof settings.permissions === 'object'
|
|
89
|
+
? { ...(settings.permissions as Record<string, unknown>) }
|
|
90
|
+
: {};
|
|
91
|
+
const current = Array.isArray(permissions.deny)
|
|
92
|
+
? (permissions.deny as unknown[]).filter((r): r is string => typeof r === 'string')
|
|
93
|
+
: [];
|
|
94
|
+
|
|
95
|
+
const removed = current.filter((r) => STALE_RULES.includes(r));
|
|
96
|
+
const kept = current.filter((r) => !STALE_RULES.includes(r));
|
|
97
|
+
const added = DENY_RULES.filter((r) => !kept.includes(r));
|
|
98
|
+
if (!added.length && !removed.length) return { settings, added, removed };
|
|
99
|
+
|
|
100
|
+
permissions.deny = [...kept, ...added];
|
|
101
|
+
return {
|
|
102
|
+
settings: { ...settings, '//permissions': PERMISSIONS_NOTE, permissions },
|
|
103
|
+
added,
|
|
104
|
+
removed,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { sync } from './sync.ts';
|
|
14
|
+
import { DENY_RULES, STALE_RULES } from './settings.ts';
|
|
14
15
|
|
|
15
16
|
const site = () => mkdtempSync(join(tmpdir(), 'webm-sync-'));
|
|
16
17
|
|
|
@@ -135,3 +136,63 @@ test('the migrations README rides along, so the --remote trap is documented in t
|
|
|
135
136
|
sync(dir);
|
|
136
137
|
assert.match(readFileSync(join(dir, 'migrations', 'README.md'), 'utf8'), /--remote/);
|
|
137
138
|
});
|
|
139
|
+
|
|
140
|
+
test('the package deny rules are MERGED into .claude/settings.json, never replacing it', () => {
|
|
141
|
+
const dir = site();
|
|
142
|
+
mkdirSync(join(dir, '.claude'), { recursive: true });
|
|
143
|
+
const path = join(dir, '.claude/settings.json');
|
|
144
|
+
writeFileSync(
|
|
145
|
+
path,
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
'//': 'theirs',
|
|
148
|
+
enabledMcpjsonServers: ['astro-docs'],
|
|
149
|
+
permissions: {
|
|
150
|
+
allow: ['Bash(npm run *)'],
|
|
151
|
+
deny: ['Read(**/.env)', 'Write(**/.env)', 'Edit(/secrets/**)'],
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const first = sync(dir);
|
|
157
|
+
const written = JSON.parse(readFileSync(path, 'utf8'));
|
|
158
|
+
assert.ok(first.settings.added.includes('Edit(**/node_modules/**)'), 'rule 12 lands');
|
|
159
|
+
assert.deepEqual(first.settings.removed, ['Write(**/.env)'], 'the rule Claude Code warns about');
|
|
160
|
+
assert.equal(written['//'], 'theirs');
|
|
161
|
+
assert.deepEqual(written.enabledMcpjsonServers, ['astro-docs'], 'not touched by this');
|
|
162
|
+
assert.deepEqual(
|
|
163
|
+
written.permissions.allow,
|
|
164
|
+
['Bash(npm run *)'],
|
|
165
|
+
"the site's allow list survives",
|
|
166
|
+
);
|
|
167
|
+
assert.ok(written.permissions.deny.includes('Edit(/secrets/**)'), "the site's own deny survives");
|
|
168
|
+
for (const rule of DENY_RULES) assert.ok(written.permissions.deny.includes(rule), rule);
|
|
169
|
+
for (const rule of STALE_RULES) assert.ok(!written.permissions.deny.includes(rule), rule);
|
|
170
|
+
assert.equal(
|
|
171
|
+
written.permissions.deny.filter((r: string) => r === 'Read(**/.env)').length,
|
|
172
|
+
1,
|
|
173
|
+
'a rule already there is not doubled',
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/* Idempotent: the second pass reports nothing and rewrites nothing. */
|
|
177
|
+
const before = readFileSync(path, 'utf8');
|
|
178
|
+
const second = sync(dir);
|
|
179
|
+
assert.deepEqual(second.settings, { added: [], removed: [], created: false, skipped: null });
|
|
180
|
+
assert.equal(readFileSync(path, 'utf8'), before);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a site with no settings file gets the scaffold defaults', () => {
|
|
184
|
+
const dir = site();
|
|
185
|
+
const result = sync(dir);
|
|
186
|
+
assert.equal(result.settings.created, true);
|
|
187
|
+
const written = JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'));
|
|
188
|
+
assert.deepEqual(written.permissions.deny, [...DENY_RULES]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('a settings file that will not parse is left alone and reported', () => {
|
|
192
|
+
const dir = site();
|
|
193
|
+
mkdirSync(join(dir, '.claude'), { recursive: true });
|
|
194
|
+
writeFileSync(join(dir, '.claude/settings.json'), '{ not json');
|
|
195
|
+
const result = sync(dir);
|
|
196
|
+
assert.ok(result.settings.skipped);
|
|
197
|
+
assert.equal(readFileSync(join(dir, '.claude/settings.json'), 'utf8'), '{ not json');
|
|
198
|
+
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
* a real D1 database must NEVER change - SQLite has already run
|
|
27
27
|
* it and wrangler tracks it by name. Later versions add 0002,
|
|
28
28
|
* they do not rewrite 0001.
|
|
29
|
+
* MERGE .claude/settings.json The package's deny rules are added when absent and the file
|
|
30
|
+
* is otherwise left as found - a client's allow list is theirs.
|
|
31
|
+
* See cli/settings.ts for the rules and why they are rules.
|
|
29
32
|
* SEED public/, content Written once by `webm new` and then the client's outright.
|
|
30
33
|
* Not handled here at all - see cli/scaffold.ts.
|
|
31
34
|
*
|
|
@@ -46,8 +49,9 @@ import {
|
|
|
46
49
|
writeFileSync,
|
|
47
50
|
readdirSync,
|
|
48
51
|
} from 'node:fs';
|
|
49
|
-
import { basename, join } from 'node:path';
|
|
52
|
+
import { basename, dirname, join } from 'node:path';
|
|
50
53
|
import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
|
|
54
|
+
import { DENY_RULES, projectSettings, withDenyRules } from './settings.ts';
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* The namespace. Rule 5's prefix: the CLI is `webm`, the tokens are --webm-*, the classes are
|
|
@@ -68,6 +72,11 @@ interface SyncResult {
|
|
|
68
72
|
workflows: string[];
|
|
69
73
|
/** Migrations copied because the site did not have them. Never includes an existing file. */
|
|
70
74
|
migrations: string[];
|
|
75
|
+
/**
|
|
76
|
+
* What changed in .claude/settings.json: deny rules added, stale ones removed, the file
|
|
77
|
+
* created when there was none, or why it was left alone.
|
|
78
|
+
*/
|
|
79
|
+
settings: { added: string[]; removed: string[]; created: boolean; skipped: string | null };
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
/**
|
|
@@ -119,6 +128,46 @@ function addMissing(source: string, target: string): string[] {
|
|
|
119
128
|
return added.sort();
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
/**
|
|
132
|
+
* MERGE. The package's deny rules into the site's settings, and nothing else.
|
|
133
|
+
*
|
|
134
|
+
* `.claude/settings.json` is the site's: a client adds allow rules to it, and a full replace would
|
|
135
|
+
* throw those away on the next install. So the file is read, the rules in cli/settings.ts are
|
|
136
|
+
* added where absent, the two rules Claude Code warns about at startup are dropped, and the file
|
|
137
|
+
* is written back only when that changed something - a second pass is a no-op. A file that will
|
|
138
|
+
* not parse is left exactly as it is and reported: Claude Code cannot read it either, and
|
|
139
|
+
* rewriting it would hide that.
|
|
140
|
+
*
|
|
141
|
+
* Created from the scaffold's defaults when there is none, so a site that predates the file gets
|
|
142
|
+
* the rules on its next install rather than never.
|
|
143
|
+
*/
|
|
144
|
+
function ensureSettings(siteRoot: string): SyncResult['settings'] {
|
|
145
|
+
const path = join(siteRoot, '.claude', 'settings.json');
|
|
146
|
+
if (!existsSync(path)) {
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
+
writeFileSync(path, JSON.stringify(projectSettings(basename(siteRoot)), null, 2) + '\n');
|
|
149
|
+
return { added: [...DENY_RULES], removed: [], created: true, skipped: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let parsed: Record<string, unknown>;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
155
|
+
} catch {
|
|
156
|
+
return {
|
|
157
|
+
added: [],
|
|
158
|
+
removed: [],
|
|
159
|
+
created: false,
|
|
160
|
+
skipped: '.claude/settings.json is not valid JSON, so it was left alone',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { settings, added, removed } = withDenyRules(parsed);
|
|
165
|
+
if (added.length || removed.length) {
|
|
166
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
|
|
167
|
+
}
|
|
168
|
+
return { added, removed, created: false, skipped: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
122
171
|
function listSkills(dir: string): string[] {
|
|
123
172
|
if (!existsSync(dir)) return [];
|
|
124
173
|
return readdirSync(dir, { withFileTypes: true })
|
|
@@ -207,6 +256,7 @@ export function sync(siteRoot: string): SyncResult {
|
|
|
207
256
|
*/
|
|
208
257
|
workflows: syncDir(join(template, 'workflows'), join(siteRoot, '.github/workflows')),
|
|
209
258
|
migrations: addMissing(join(template, 'migrations'), join(siteRoot, 'migrations')),
|
|
259
|
+
settings: ensureSettings(siteRoot),
|
|
210
260
|
};
|
|
211
261
|
}
|
|
212
262
|
|
|
@@ -245,6 +295,19 @@ export function run(argv: string[]): number {
|
|
|
245
295
|
for (const m of result.migrations) {
|
|
246
296
|
console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
|
|
247
297
|
}
|
|
298
|
+
if (result.settings.created) {
|
|
299
|
+
console.log(` + .claude/settings.json`);
|
|
300
|
+
} else if (result.settings.skipped) {
|
|
301
|
+
console.log(` ${result.settings.skipped}`);
|
|
302
|
+
} else if (result.settings.added.length || result.settings.removed.length) {
|
|
303
|
+
const parts = [
|
|
304
|
+
result.settings.added.length && `+${result.settings.added.length} deny`,
|
|
305
|
+
result.settings.removed.length && `-${result.settings.removed.length} stale`,
|
|
306
|
+
].filter(Boolean);
|
|
307
|
+
console.log(
|
|
308
|
+
` .claude/settings.json: ${parts.join(', ')} (package-managed rules; yours are kept)`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
248
311
|
|
|
249
312
|
if (ensureGitignored(siteRoot)) {
|
|
250
313
|
console.log(` gitignored .claude/skills/${NAMESPACE}/`);
|