@cparkerwebm/webmonterey 1.0.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 +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- package/template/workflows/ci.yml +52 -0
package/src/cli/slug.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Turning a domain into the names everything else uses.
|
|
3
|
+
*
|
|
4
|
+
* TWO NAMES, DELIBERATELY DIFFERENT.
|
|
5
|
+
*
|
|
6
|
+
* GitHub repo <domain_underscored> webmonterey/autire_com
|
|
7
|
+
* Cloudflare stuff webm-<slug> webm-autire, webm-autire-db, webm-autire-media
|
|
8
|
+
*
|
|
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
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Public suffixes that take two labels, so `example.co.uk` slugs to `example`. */
|
|
16
|
+
const TWO_PART_SUFFIXES = new Set([
|
|
17
|
+
'co.uk',
|
|
18
|
+
'org.uk',
|
|
19
|
+
'ac.uk',
|
|
20
|
+
'gov.uk',
|
|
21
|
+
'me.uk',
|
|
22
|
+
'net.uk',
|
|
23
|
+
'sch.uk',
|
|
24
|
+
'com.au',
|
|
25
|
+
'net.au',
|
|
26
|
+
'org.au',
|
|
27
|
+
'edu.au',
|
|
28
|
+
'gov.au',
|
|
29
|
+
'co.nz',
|
|
30
|
+
'net.nz',
|
|
31
|
+
'org.nz',
|
|
32
|
+
'com.br',
|
|
33
|
+
'com.mx',
|
|
34
|
+
'co.za',
|
|
35
|
+
'co.jp',
|
|
36
|
+
'co.in',
|
|
37
|
+
'com.sg',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export class DomainError extends Error {
|
|
41
|
+
constructor(message: string) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'DomainError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Strip scheme, www, path and trailing dots. `https://www.Example.com/` -> `example.com` */
|
|
48
|
+
export function normalizeDomain(input: string): string {
|
|
49
|
+
const cleaned = input
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/^[a-z]+:\/\//, '')
|
|
53
|
+
.replace(/\/.*$/, '')
|
|
54
|
+
.replace(/^www\./, '')
|
|
55
|
+
.replace(/\.+$/, '');
|
|
56
|
+
|
|
57
|
+
if (!cleaned || !cleaned.includes('.')) {
|
|
58
|
+
throw new DomainError(`"${input}" is not a domain. Expected something like example.com.`);
|
|
59
|
+
}
|
|
60
|
+
if (!/^[a-z0-9.-]+$/.test(cleaned)) {
|
|
61
|
+
throw new DomainError(`"${input}" contains characters a hostname cannot.`);
|
|
62
|
+
}
|
|
63
|
+
return cleaned;
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
+
/**
|
|
84
|
+
* The client slug - the domain with its public suffix removed.
|
|
85
|
+
*
|
|
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.
|
|
88
|
+
*/
|
|
89
|
+
export function slugFor(domain: string): string {
|
|
90
|
+
const clean = normalizeDomain(domain);
|
|
91
|
+
const parts = clean.split('.');
|
|
92
|
+
const lastTwo = parts.slice(-2).join('.');
|
|
93
|
+
const drop = TWO_PART_SUFFIXES.has(lastTwo) ? 2 : 1;
|
|
94
|
+
const kept = parts.slice(0, Math.max(1, parts.length - drop));
|
|
95
|
+
return kept.join('-');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every Cloudflare resource name for a site, from one domain. */
|
|
99
|
+
export function resourceNames(domain: string) {
|
|
100
|
+
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
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { sync } from './sync.ts';
|
|
14
|
+
|
|
15
|
+
const site = () => mkdtempSync(join(tmpdir(), 'webm-sync-'));
|
|
16
|
+
|
|
17
|
+
test('materializes a skills-directory plugin at .claude/skills/webm/', () => {
|
|
18
|
+
const root = site();
|
|
19
|
+
sync(root);
|
|
20
|
+
const target = join(root, '.claude/skills/webm');
|
|
21
|
+
assert.ok(existsSync(join(target, '.claude-plugin/plugin.json')));
|
|
22
|
+
const manifest = JSON.parse(readFileSync(join(target, '.claude-plugin/plugin.json'), 'utf8'));
|
|
23
|
+
// The folder name IS the namespace - this is what makes skills /webm:go-live.
|
|
24
|
+
assert.equal(manifest.name, 'webm');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('writes a version marker the doctor reads', () => {
|
|
28
|
+
const root = site();
|
|
29
|
+
const result = sync(root);
|
|
30
|
+
const marker = JSON.parse(
|
|
31
|
+
readFileSync(join(root, '.claude/skills/webm/.webm-sync.json'), 'utf8'),
|
|
32
|
+
);
|
|
33
|
+
assert.equal(marker.version, result.version);
|
|
34
|
+
assert.ok(marker.skills.includes('launch'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the package ships exactly the skills the release claims', () => {
|
|
38
|
+
const root = site();
|
|
39
|
+
const result = sync(root);
|
|
40
|
+
assert.deepEqual([...result.added].sort(), [
|
|
41
|
+
'launch',
|
|
42
|
+
'new-component',
|
|
43
|
+
'start',
|
|
44
|
+
'traps',
|
|
45
|
+
'upgrade',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('agents/ and hooks/ ride along when the package ships any, and never as empty dirs', () => {
|
|
50
|
+
// Empty in the package today. The mechanism is what is asserted: a .gitkeep is not an agent.
|
|
51
|
+
const root = site();
|
|
52
|
+
const result = sync(root);
|
|
53
|
+
assert.deepEqual(result.agents, []);
|
|
54
|
+
assert.deepEqual(result.hooks, []);
|
|
55
|
+
assert.ok(!existsSync(join(root, '.claude/skills/webm/agents')), 'no empty agents/ in a site');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('dotfiles are not copied - .DS_Store would otherwise reach every client repo', () => {
|
|
59
|
+
const root = site();
|
|
60
|
+
sync(root);
|
|
61
|
+
const copied = readdirSync(join(root, '.claude/skills/webm/skills'));
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
copied.filter((f) => f.startsWith('.')),
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a full replace removes a skill that no longer ships', () => {
|
|
69
|
+
const root = site();
|
|
70
|
+
sync(root);
|
|
71
|
+
// Simulate a skill that existed in an older version.
|
|
72
|
+
mkdirSync(join(root, '.claude/skills/webm/skills/retired'), { recursive: true });
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(root, '.claude/skills/webm/skills/retired/SKILL.md'),
|
|
75
|
+
'---\nname: retired\n---\n',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const second = sync(root);
|
|
79
|
+
assert.ok(second.removed.includes('retired'), 'a deleted skill must disappear, not linger');
|
|
80
|
+
assert.ok(!existsSync(join(root, '.claude/skills/webm/skills/retired')));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a client skill placed BESIDE the plugin survives a sync', () => {
|
|
84
|
+
const root = site();
|
|
85
|
+
mkdirSync(join(root, '.claude/skills/add-event'), { recursive: true });
|
|
86
|
+
writeFileSync(join(root, '.claude/skills/add-event/SKILL.md'), '---\nname: add-event\n---\n');
|
|
87
|
+
|
|
88
|
+
sync(root);
|
|
89
|
+
// Siblings are untouched. Inside .claude/skills/webm/ they would be destroyed - which is why
|
|
90
|
+
// the gitignore comment says client skills go beside it.
|
|
91
|
+
assert.ok(existsSync(join(root, '.claude/skills/add-event/SKILL.md')));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('scripts/ is REPLACED, because a fix to the Node guard must reach every site', () => {
|
|
95
|
+
const dir = site();
|
|
96
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
97
|
+
writeFileSync(join(dir, 'scripts', 'check-node.mjs'), '// stale, from v1\n');
|
|
98
|
+
const result = sync(dir);
|
|
99
|
+
|
|
100
|
+
assert.ok(result.scripts.includes('check-node.mjs'), 'check-node.mjs is package-owned');
|
|
101
|
+
assert.ok(result.scripts.includes('test-hooks.mjs'), 'test-hooks.mjs is package-owned');
|
|
102
|
+
const written = readFileSync(join(dir, 'scripts', 'check-node.mjs'), 'utf8');
|
|
103
|
+
assert.ok(!written.includes('stale, from v1'), 'the stale copy was overwritten');
|
|
104
|
+
assert.match(written, /npm_config_user_agent/, 'the real guard landed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a migration is copied when absent and NEVER rewritten once it exists', () => {
|
|
108
|
+
/*
|
|
109
|
+
* The one that would be a data bug rather than a cosmetic one. D1 records an applied
|
|
110
|
+
* migration by filename; rewriting the file does not re-run it, so an "upgrade" that edits
|
|
111
|
+
* 0001 changes local and leaves production on the old schema, with nothing to show for it.
|
|
112
|
+
*/
|
|
113
|
+
const dir = site();
|
|
114
|
+
const first = sync(dir);
|
|
115
|
+
assert.ok(first.migrations.includes('0001_create_submissions.sql'), 'seeded on a fresh site');
|
|
116
|
+
assert.match(
|
|
117
|
+
readFileSync(join(dir, 'migrations', '0001_create_submissions.sql'), 'utf8'),
|
|
118
|
+
/CREATE TABLE/,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const applied = '-- already applied to production, hands off\n';
|
|
122
|
+
writeFileSync(join(dir, 'migrations', '0001_create_submissions.sql'), applied);
|
|
123
|
+
const second = sync(dir);
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(second.migrations, [], 'nothing re-copied on the second sync');
|
|
126
|
+
assert.equal(
|
|
127
|
+
readFileSync(join(dir, 'migrations', '0001_create_submissions.sql'), 'utf8'),
|
|
128
|
+
applied,
|
|
129
|
+
'the applied migration is untouched',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the migrations README rides along, so the --remote trap is documented in the repo', () => {
|
|
134
|
+
const dir = site();
|
|
135
|
+
sync(dir);
|
|
136
|
+
assert.match(readFileSync(join(dir, 'migrations', 'README.md'), 'utf8'), /--remote/);
|
|
137
|
+
});
|
package/src/cli/sync.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `webm sync` - materialize the fleet skills into .claude/skills/webm/.
|
|
3
|
+
*
|
|
4
|
+
* CLAUDE CODE DOES NOT READ SKILLS OUT OF node_modules. Discovery is directory-based - personal,
|
|
5
|
+
* project, nested, plugin - and an npm package is none of those. A skills/ folder inside the
|
|
6
|
+
* installed package is inert: Claude never looks, the skill never appears, and nothing errors.
|
|
7
|
+
*
|
|
8
|
+
* The destination is a SKILLS-DIRECTORY PLUGIN. Any folder under .claude/skills/ containing
|
|
9
|
+
* .claude-plugin/plugin.json loads as `<folder>@skills-dir` on the next session, with no
|
|
10
|
+
* marketplace and no install step, discovered in place rather than copied to a cache. Naming the
|
|
11
|
+
* folder `webm` is what makes these `/webm:launch` - namespaced, so they can never collide with
|
|
12
|
+
* a client's own skills, which sit BESIDE this directory at .claude/skills/<name>/. The same
|
|
13
|
+
* folder carries the package's agents/ and hooks/ when it ships any; a client's own agents go in
|
|
14
|
+
* .claude/agents/, untouched by this.
|
|
15
|
+
*
|
|
16
|
+
* FULL REPLACE, NEVER A MERGE. A skill deleted in a later version has to disappear here too,
|
|
17
|
+
* which is why this directory is gitignored and exclusively package-owned. A client skill placed
|
|
18
|
+
* inside it would be destroyed on the next install.
|
|
19
|
+
*
|
|
20
|
+
* THREE KINDS OF PACKAGE-OWNED FILE ON DISK, and the difference matters:
|
|
21
|
+
*
|
|
22
|
+
* REPLACE skills/, agents/, hooks/, scripts/, the CI workflow
|
|
23
|
+
* Regenerated every install. Pure infrastructure that a client
|
|
24
|
+
* never edits, so overwriting is free and a fix propagates.
|
|
25
|
+
* ADD-ONLY migrations/ Copied only when absent. A migration that has been applied to
|
|
26
|
+
* a real D1 database must NEVER change - SQLite has already run
|
|
27
|
+
* it and wrangler tracks it by name. Later versions add 0002,
|
|
28
|
+
* they do not rewrite 0001.
|
|
29
|
+
* SEED public/, content Written once by `webm new` and then the client's outright.
|
|
30
|
+
* Not handled here at all - see cli/scaffold.ts.
|
|
31
|
+
*
|
|
32
|
+
* Anything that is a starting point a client will edit belongs in SEED. Putting it in REPLACE
|
|
33
|
+
* throws their work away on the next `npm update`, silently.
|
|
34
|
+
*
|
|
35
|
+
* Wired from the CLIENT's package.json, so it is visible in the repo rather than a dependency
|
|
36
|
+
* writing outside its own tree behind your back:
|
|
37
|
+
*
|
|
38
|
+
* "scripts": { "postinstall": "webm sync" }
|
|
39
|
+
*/
|
|
40
|
+
import {
|
|
41
|
+
cpSync,
|
|
42
|
+
existsSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
rmSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
readdirSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import { basename, join } from 'node:path';
|
|
50
|
+
import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
|
|
51
|
+
|
|
52
|
+
/**
|
|
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`.
|
|
55
|
+
*/
|
|
56
|
+
const NAMESPACE = 'webm';
|
|
57
|
+
|
|
58
|
+
interface SyncResult {
|
|
59
|
+
added: string[];
|
|
60
|
+
removed: string[];
|
|
61
|
+
unchanged: string[];
|
|
62
|
+
version: string;
|
|
63
|
+
/** Agent definitions and hook files copied into the plugin, when the package ships any. */
|
|
64
|
+
agents: string[];
|
|
65
|
+
hooks: string[];
|
|
66
|
+
/** Files refreshed under REPLACE, relative to the site root. */
|
|
67
|
+
scripts: string[];
|
|
68
|
+
workflows: string[];
|
|
69
|
+
/** Migrations copied because the site did not have them. Never includes an existing file. */
|
|
70
|
+
migrations: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* REPLACE. Overwrite unconditionally from the package.
|
|
75
|
+
*
|
|
76
|
+
* `scripts/` holds two files that cannot live in node_modules:
|
|
77
|
+
*
|
|
78
|
+
* check-node.mjs runs as `preinstall`, which is BEFORE node_modules exists. A bin from an
|
|
79
|
+
* uninstalled package is not on PATH yet, so this one has to be on disk.
|
|
80
|
+
* test-hooks.mjs loaded with `node --import`, which resolves against the site, and is the
|
|
81
|
+
* thing that lets a client's own component tests import `./thing` without an
|
|
82
|
+
* extension the way the rest of the toolchain does.
|
|
83
|
+
*
|
|
84
|
+
* Both are dependency-free and neither has a client-specific line in it, so a full replace is
|
|
85
|
+
* the whole propagation story: fix a bug here and every site gets it on `npm update`.
|
|
86
|
+
*/
|
|
87
|
+
function syncDir(source: string, target: string): string[] {
|
|
88
|
+
if (!existsSync(source)) return [];
|
|
89
|
+
mkdirSync(target, { recursive: true });
|
|
90
|
+
const written: string[] = [];
|
|
91
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
92
|
+
if (entry.name.startsWith('.')) continue;
|
|
93
|
+
if (!entry.isFile()) continue;
|
|
94
|
+
cpSync(join(source, entry.name), join(target, entry.name));
|
|
95
|
+
written.push(entry.name);
|
|
96
|
+
}
|
|
97
|
+
return written.sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ADD-ONLY. Copy what is missing and never touch what is there.
|
|
102
|
+
*
|
|
103
|
+
* A D1 migration is applied once and recorded by filename in the database's own
|
|
104
|
+
* d1_migrations table. Rewriting an applied file does not re-run it - the change simply never
|
|
105
|
+
* reaches production, and local and remote drift apart with nothing to show for it. So a later
|
|
106
|
+
* package version ships 0002 alongside, and this leaves 0001 exactly as the client has it.
|
|
107
|
+
*/
|
|
108
|
+
function addMissing(source: string, target: string): string[] {
|
|
109
|
+
if (!existsSync(source)) return [];
|
|
110
|
+
mkdirSync(target, { recursive: true });
|
|
111
|
+
const added: string[] = [];
|
|
112
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
113
|
+
if (entry.name.startsWith('.') || !entry.isFile()) continue;
|
|
114
|
+
const dest = join(target, entry.name);
|
|
115
|
+
if (existsSync(dest)) continue;
|
|
116
|
+
cpSync(join(source, entry.name), dest);
|
|
117
|
+
added.push(entry.name);
|
|
118
|
+
}
|
|
119
|
+
return added.sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function listSkills(dir: string): string[] {
|
|
123
|
+
if (!existsSync(dir)) return [];
|
|
124
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
125
|
+
.filter((e) => e.isDirectory() && existsSync(join(dir, e.name, 'SKILL.md')))
|
|
126
|
+
.map((e) => e.name)
|
|
127
|
+
.sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function sync(siteRoot: string): SyncResult {
|
|
131
|
+
const version = packageVersion();
|
|
132
|
+
const source = join(PACKAGE_ROOT, 'skills');
|
|
133
|
+
const target = join(siteRoot, '.claude', 'skills', NAMESPACE);
|
|
134
|
+
|
|
135
|
+
const before = listSkills(join(target, 'skills'));
|
|
136
|
+
const after = listSkills(source);
|
|
137
|
+
|
|
138
|
+
rmSync(target, { recursive: true, force: true });
|
|
139
|
+
mkdirSync(join(target, '.claude-plugin'), { recursive: true });
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* Filter dotfiles. On macOS `.DS_Store` is created in any directory Finder has opened, so
|
|
143
|
+
* without this it copies into every client repo AND ships in the npm tarball. Gitignored and
|
|
144
|
+
* harmless, but it is junk in someone else's repo with our name on it. The same filter is
|
|
145
|
+
* what lets agents/ and hooks/ hold a .gitkeep in the package and arrive empty in a site.
|
|
146
|
+
*/
|
|
147
|
+
const noDotfiles = (src: string) => !basename(src).startsWith('.');
|
|
148
|
+
if (existsSync(source)) {
|
|
149
|
+
cpSync(source, join(target, 'skills'), { recursive: true, filter: noDotfiles });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/*
|
|
153
|
+
* THE PLUGIN'S OTHER COMPONENTS. A skills-directory plugin can carry agents/ (subagent
|
|
154
|
+
* definitions, invoked as webm:<name>) and hooks/hooks.json (event handlers that make a trap
|
|
155
|
+
* mechanical rather than advisory). Both ride along under the same REPLACE rule as skills/, and
|
|
156
|
+
* both need /reload-plugins in a running session - SKILL.md edits are picked up live, these are
|
|
157
|
+
* not. Empty in the package today; the mechanism is here so shipping one is a file, not a
|
|
158
|
+
* release of the sync command.
|
|
159
|
+
*/
|
|
160
|
+
const plugin = (dir: string): string[] => {
|
|
161
|
+
const from = join(PACKAGE_ROOT, dir);
|
|
162
|
+
if (!existsSync(from)) return [];
|
|
163
|
+
const names = readdirSync(from).filter(noDotfiles).sort();
|
|
164
|
+
if (names.length) cpSync(from, join(target, dir), { recursive: true, filter: noDotfiles });
|
|
165
|
+
return names;
|
|
166
|
+
};
|
|
167
|
+
const agents = plugin('agents');
|
|
168
|
+
const hooks = plugin('hooks');
|
|
169
|
+
|
|
170
|
+
writeFileSync(
|
|
171
|
+
join(target, '.claude-plugin', 'plugin.json'),
|
|
172
|
+
JSON.stringify(
|
|
173
|
+
{
|
|
174
|
+
name: NAMESPACE,
|
|
175
|
+
description: 'WebMonterey fleet skills. Materialized by `webm sync` - do not edit here.',
|
|
176
|
+
version: version,
|
|
177
|
+
},
|
|
178
|
+
null,
|
|
179
|
+
2,
|
|
180
|
+
) + '\n',
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* A marker the doctor reads to tell whether the sync ran against the installed version.
|
|
185
|
+
* `npm install --ignore-scripts` skips postinstall silently, and the failure mode is a session
|
|
186
|
+
* with no fleet skills and no error to explain why.
|
|
187
|
+
*/
|
|
188
|
+
writeFileSync(
|
|
189
|
+
join(target, '.webm-sync.json'),
|
|
190
|
+
JSON.stringify({ version: version, namespace: NAMESPACE, skills: after }, null, 2) + '\n',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const template = join(PACKAGE_ROOT, 'template');
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
added: after.filter((s) => !before.includes(s)),
|
|
197
|
+
removed: before.filter((s) => !after.includes(s)),
|
|
198
|
+
unchanged: after.filter((s) => before.includes(s)),
|
|
199
|
+
version: version,
|
|
200
|
+
agents,
|
|
201
|
+
hooks,
|
|
202
|
+
scripts: syncDir(join(template, 'scripts'), join(siteRoot, 'scripts')),
|
|
203
|
+
/*
|
|
204
|
+
* CI, REPLACE-managed like scripts/. A site never edits its own workflow - if a leg is wrong
|
|
205
|
+
* it is wrong on every site, and it gets fixed here. Five sites once had no CI whatsoever,
|
|
206
|
+
* which is how code depending on an unpublished package reached main without a word.
|
|
207
|
+
*/
|
|
208
|
+
workflows: syncDir(join(template, 'workflows'), join(siteRoot, '.github/workflows')),
|
|
209
|
+
migrations: addMissing(join(template, 'migrations'), join(siteRoot, 'migrations')),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Make sure the materialized directory is ignored.
|
|
215
|
+
*
|
|
216
|
+
* It is regenerated on every install, so committing it guarantees a conflict on every upgrade
|
|
217
|
+
* and a stale copy on every checkout.
|
|
218
|
+
*/
|
|
219
|
+
function ensureGitignored(siteRoot: string): boolean {
|
|
220
|
+
const path = join(siteRoot, '.gitignore');
|
|
221
|
+
const entry = `.claude/skills/${NAMESPACE}/`;
|
|
222
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
223
|
+
if (current.includes(entry)) return false;
|
|
224
|
+
const addition =
|
|
225
|
+
`\n# Materialized by \`webm sync\` on every install. Package-owned; never commit.\n` +
|
|
226
|
+
`# Client skills go BESIDE it, at .claude/skills/<name>/.\n${entry}\n`;
|
|
227
|
+
writeFileSync(path, current.replace(/\n*$/, '\n') + addition);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function run(argv: string[]): number {
|
|
232
|
+
const siteRoot = argv[0] ?? process.cwd();
|
|
233
|
+
const result = sync(siteRoot);
|
|
234
|
+
|
|
235
|
+
const total = result.added.length + result.unchanged.length;
|
|
236
|
+
console.log(`webm sync: ${total} skills at .claude/skills/${NAMESPACE}/ (v${result.version})`);
|
|
237
|
+
for (const s of result.added) console.log(` + /${NAMESPACE}:${s}`);
|
|
238
|
+
for (const s of result.removed) console.log(` - /${NAMESPACE}:${s}`);
|
|
239
|
+
|
|
240
|
+
if (result.agents.length) console.log(` agents: ${result.agents.join(', ')}`);
|
|
241
|
+
if (result.hooks.length) console.log(` hooks: ${result.hooks.join(', ')}`);
|
|
242
|
+
if (result.scripts.length) {
|
|
243
|
+
console.log(` scripts/ refreshed: ${result.scripts.join(', ')}`);
|
|
244
|
+
}
|
|
245
|
+
for (const m of result.migrations) {
|
|
246
|
+
console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (ensureGitignored(siteRoot)) {
|
|
250
|
+
console.log(` gitignored .claude/skills/${NAMESPACE}/`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/*
|
|
254
|
+
* A new or removed SKILL.md is picked up live, within the session - Claude Code watches skill
|
|
255
|
+
* directories. Only the plugin's other components (agents/, hooks/, .mcp.json) need a reload,
|
|
256
|
+
* and the first-ever sync needs a restart because a watcher cannot watch a directory that did
|
|
257
|
+
* not exist when the session started.
|
|
258
|
+
*/
|
|
259
|
+
if (result.added.length || result.removed.length) {
|
|
260
|
+
console.log(
|
|
261
|
+
`\nSkill changes are picked up live. If this was the first sync in this repo, restart ` +
|
|
262
|
+
`Claude Code so it can watch the new directory.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `webm upgrade` - move a site to a newer framework version.
|
|
3
|
+
*
|
|
4
|
+
* THE STEPS LIVE HERE, NOT IN THE SKILL. An invoked skill's rendered text enters the conversation
|
|
5
|
+
* once and is not re-read on later turns, so /webm:upgrade runs npm install, overwrites its own
|
|
6
|
+
* SKILL.md with the new version's, and keeps executing the OLD instructions to completion. The
|
|
7
|
+
* binary on disk is the new version the moment the install finishes; the markdown is frozen for
|
|
8
|
+
* the session. Anything version-specific therefore has to be here.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { codemodsBetween } from './codemods.ts';
|
|
14
|
+
import { sync } from './sync.ts';
|
|
15
|
+
import { packageName } from './package-root.ts';
|
|
16
|
+
|
|
17
|
+
const PACKAGE = packageName();
|
|
18
|
+
|
|
19
|
+
function git(siteRoot: string, args: string[]): string {
|
|
20
|
+
return execFileSync('git', args, { cwd: siteRoot, encoding: 'utf8' }).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function installedVersion(siteRoot: string): string | null {
|
|
24
|
+
const path = join(siteRoot, 'node_modules', PACKAGE, 'package.json');
|
|
25
|
+
if (!existsSync(path)) return null;
|
|
26
|
+
return (JSON.parse(readFileSync(path, 'utf8')) as { version: string }).version;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function run(argv: string[]): number {
|
|
30
|
+
const siteRoot = process.cwd();
|
|
31
|
+
const target = argv.find((a) => !a.startsWith('-')) ?? 'latest';
|
|
32
|
+
const dryRun = argv.includes('--dry-run');
|
|
33
|
+
|
|
34
|
+
if (!existsSync(join(siteRoot, 'webmonterey.json'))) {
|
|
35
|
+
console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* A dirty tree turns a failed upgrade into a mess with no clean revert. Refuse rather than
|
|
41
|
+
* stash - an automatic stash is a surprise nobody wants to discover later.
|
|
42
|
+
*/
|
|
43
|
+
if (git(siteRoot, ['status', '--porcelain'])) {
|
|
44
|
+
console.error('webm upgrade: working tree is not clean. Commit or stash first.');
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const from = installedVersion(siteRoot);
|
|
49
|
+
console.log(`Installed: ${from ?? 'nothing'} -> ${target}`);
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
console.log('--dry-run: stopping before any change.');
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const branch = `upgrade/${PACKAGE.split('/')[1]}-${target}`;
|
|
56
|
+
if (git(siteRoot, ['rev-parse', '--abbrev-ref', 'HEAD']) === 'main') {
|
|
57
|
+
git(siteRoot, ['checkout', '-b', branch]);
|
|
58
|
+
console.log(`Branched to ${branch}. Never upgrade on main.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
execFileSync('npm', ['install', `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: 'inherit' });
|
|
62
|
+
const to = installedVersion(siteRoot);
|
|
63
|
+
if (!to) {
|
|
64
|
+
console.error('webm upgrade: install did not produce a version. Check the npm output above.');
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mods = codemodsBetween(from ?? '0.0.0', to);
|
|
69
|
+
if (mods.length) {
|
|
70
|
+
console.log(`\nRunning ${mods.length} codemod${mods.length === 1 ? '' : 's'}:`);
|
|
71
|
+
for (const mod of mods) {
|
|
72
|
+
const changes = mod.run(siteRoot);
|
|
73
|
+
console.log(` ${mod.version} ${mod.title}`);
|
|
74
|
+
for (const c of changes) console.log(` ${c}`);
|
|
75
|
+
if (!changes.length) console.log(` nothing to change`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const synced = sync(siteRoot);
|
|
80
|
+
console.log(`\nSkills re-materialized (v${synced.version})`);
|
|
81
|
+
for (const s of synced.added) console.log(` + /webm:${s}`);
|
|
82
|
+
for (const s of synced.removed) console.log(` - /webm:${s}`);
|
|
83
|
+
|
|
84
|
+
console.log(`\nNow, in order:`);
|
|
85
|
+
console.log(` npx webm doctor`);
|
|
86
|
+
console.log(` npm run check && npm run build`);
|
|
87
|
+
console.log(` git push -u origin HEAD # review the preview URL before merging`);
|
|
88
|
+
if (synced.added.length || synced.removed.length) {
|
|
89
|
+
console.log(`\nSkill changes are live already. Run /reload-plugins if anything outside`);
|
|
90
|
+
console.log(`skills/ changed - agents, hooks and .mcp.json are not picked up live.`);
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|