@erclx/canon 4.70.0 → 4.71.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/README.md CHANGED
@@ -8,7 +8,7 @@ canon is a CLI and Claude Code plugin that stops your AI conventions drifting ap
8
8
 
9
9
  ![The canon catalog, listing skills, governance rules, and standards with the count each ships, the workflow skills named, and a sample of the rule and standard names](assets/hero.png)
10
10
 
11
- The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today.
11
+ The counts above are read from the catalogs when the image is built, so they're what the repo actually ships today. See it live at [canon.erclx.dev](https://canon.erclx.dev).
12
12
 
13
13
  ## It runs on itself
14
14
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.70.0",
4
+ "version": "4.71.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -62,6 +62,10 @@ Run `canon tooling sync <stack> <target> --check` for the list resolved against
62
62
  - `commitlint.config.js`
63
63
  - `scripts/verify.sh`
64
64
 
65
+ ### cloudflare
66
+
67
+ - `.github/workflows/deploy.yml`
68
+
65
69
  ### nextjs
66
70
 
67
71
  - `eslint.config.js`
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: deploy-cloudflare
3
+ description: What the Cloudflare Pages setup gap is, which of the four manual steps this skill closes, and why the credential and the custom domain stay the operator's
4
+ ---
5
+
6
+ # Deploy Cloudflare requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a Cloudflare Pages setup is a sequence performed from memory. Three of its four repeated acts touch a service rather than the tree, so no workflow file can absorb them, and one of the acts has already cost a broken deploy: the account ID displayed in the Cloudflare dashboard is truncated, and a value copied from there is wrong in a way that only shows up at deploy time.
11
+
12
+ ## Must
13
+
14
+ - Create the Pages project through `wrangler pages project create`
15
+ - Fetch the account ID from `wrangler`'s own authenticated session rather than have it typed or pasted
16
+ - Stop for the operator to issue the API token and run `gh secret set CLOUDFLARE_API_TOKEN` themselves
17
+ - Verify both secrets are present with `gh secret list` before continuing
18
+ - Stop for the operator to attach the custom domain in the dashboard
19
+ - Call `wrangler` and `gh` directly for every step those tools already cover
20
+
21
+ ## Must not
22
+
23
+ - Accept a Cloudflare API token as input in any form. The toolkit verifies a secret exists, never what it contains.
24
+ - Reimplement `wrangler pages project create` or `gh secret set` as a hand-built API call
25
+ - Open a pull request or merge
26
+
27
+ ## Guards
28
+
29
+ - `wrangler` not authenticated: stop and name the login command
30
+ - `gh` not authenticated: stop and name the login command
31
+ - A secret missing after the token stop: stop and name which one
32
+
33
+ ## Out of scope
34
+
35
+ - Attaching a custom domain through the Cloudflare REST API instead of the dashboard. Measured absent from `wrangler pages` at plan time and the REST API was not read, so the dashboard stop stays for this pass.
36
+ - Running the deploy itself once secrets and the domain are set. That is the seeded `tooling/cloudflare/configs/.github/workflows/deploy.yml` workflow, triggered by a push to main.
37
+ - Reconfiguring an existing Pages project's build settings.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: deploy-cloudflare
3
+ description: Sets up a Cloudflare Pages deploy for the current project by creating the Pages project, fetching the account ID, and stopping twice for the operator to issue the API token and attach the custom domain. Calls `wrangler` and `gh` rather than reimplementing them. Use when asked to "set up Cloudflare deploy", "deploy this to Cloudflare Pages", "set up the Pages project", or "connect this repo to Cloudflare". Do NOT use to run an already-configured deploy, which the seeded workflow does on push, or to reconfigure an existing Pages project's build settings.
4
+ ---
5
+
6
+ # Deploy Cloudflare
7
+
8
+ Runs the one-time setup a Cloudflare Pages deploy needs before the seeded `deploy.yml` workflow can run, stopping twice for the two acts only the operator can take.
9
+
10
+ ## Guards
11
+
12
+ - If `wrangler whoami` fails, stop: `❌ wrangler is not authenticated. Run wrangler login, then re-invoke.`
13
+ - If `gh auth status` fails, stop: `❌ gh is not authenticated. Run gh auth login, then re-invoke.`
14
+ - Never accept a Cloudflare API token as input, in a prompt, an argument, or a file. Verify a secret's presence with `gh secret list` rather than asking for or reading its content.
15
+ - Never reimplement `wrangler pages project create` or `gh secret set` as a hand-built HTTP call. Call the tools directly.
16
+ - If the project name is not supplied, derive it from the repository's own name (`basename` of `git remote get-url origin`, stripped of a trailing `.git`) and confirm it in the preview rather than asking first.
17
+
18
+ ## Step 1: create the Pages project
19
+
20
+ ```bash
21
+ wrangler pages project create <project-name>
22
+ ```
23
+
24
+ Report the command's own failure output and stop rather than retrying on a name collision. A project already existing under that name is the operator's to resolve.
25
+
26
+ ## Step 2: fetch and set the account ID
27
+
28
+ Fetch the account ID from `wrangler`'s own authenticated session rather than asking the operator to copy it from the dashboard. The dashboard displays the account ID truncated, and a value copied from there fails the deploy silently:
29
+
30
+ ```bash
31
+ wrangler whoami
32
+ ```
33
+
34
+ Parse the account ID from that output and pipe it into the secret rather than printing it to the transcript first:
35
+
36
+ ```bash
37
+ echo "<account-id>" | gh secret set CLOUDFLARE_ACCOUNT_ID
38
+ ```
39
+
40
+ ## Step 3: stop for the API token
41
+
42
+ Stop: `⏸ Create a Cloudflare API token with Pages edit permission at the Cloudflare dashboard, then run: gh secret set CLOUDFLARE_API_TOKEN. Re-invoke this skill once that's done.`
43
+
44
+ This is the one credential the skill never touches. Resume only once the operator confirms the token is set.
45
+
46
+ ## Step 4: verify both secrets
47
+
48
+ ```bash
49
+ gh secret list
50
+ ```
51
+
52
+ - Both `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` present: continue.
53
+ - Either missing: stop and name which one, with the command that sets it.
54
+
55
+ ## Step 5: stop for the custom domain
56
+
57
+ Stop: `⏸ Attach the custom domain to the <project-name> Pages project in the Cloudflare dashboard, then confirm here. Whether this can run through the API instead of the dashboard is out of scope for this skill, per its REQUIREMENT.md.`
58
+
59
+ ## Step 6: hand off
60
+
61
+ Report that setup is complete and name the seeded workflow (`tooling/cloudflare/configs/.github/workflows/deploy.yml`, synced via `canon tooling sync cloudflare . --write`) as what deploys on the next push. Do not invoke `git-pr` or `git-ship` from here. The operator or the controlling session decides when to open that pull request.
@@ -1,9 +1,9 @@
1
1
  ---
2
- name: identity
2
+ name: draft-identity
3
3
  description: Why the mark and the card are one pick rather than two, and where the size sequence and the write folder come from before either is drafted
4
4
  ---
5
5
 
6
- # Identity requirement
6
+ # Draft identity requirement
7
7
 
8
8
  ## Gap
9
9
 
@@ -1,9 +1,9 @@
1
1
  ---
2
- name: identity
2
+ name: draft-identity
3
3
  description: Drafts a project's logo mark through draft-and-pick's render-and-pick loop, then composes the picked mark into an icon sequence and a 1200x630 social card. Use when asked to "make a logo", "design a logo mark", "create a favicon", "build the icon set", "generate a social card", "make an og:image", or "draft the logo and social card together". Do NOT use to mutate an existing logo file directly, which is a plain edit, or to record the mark's construction rules in DESIGN.md, which is out of scope.
4
4
  ---
5
5
 
6
- # Identity
6
+ # Draft identity
7
7
 
8
8
  One identity rendered twice: the same mark sized down to an icon sequence and composed with type into the social card sized up. Drafting both from one pick keeps the mark and its card composition from settling on shapes that do not match.
9
9
 
@@ -18,6 +18,7 @@ Read these in parallel from the project root, skipping any that do not exist:
18
18
  - `.claude/DESIGN.md`: visual intent, tokens, typography, spacing rules
19
19
  - `.claude/wireframes/index.md` + every `.claude/wireframes/<surface>.md`, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists: intended UI layout, UI copy, interaction rules. Surfaces are loaded one file at a time during Step 3. Per `${CLAUDE_SKILL_DIR}/../../standards/wireframes.md`, these carry layout and intent, not implementation detail.
20
20
  - `.claude/REQUIREMENTS.md`: feature scope and non-goals
21
+ - A committed capture beside the surface's own source, when the project has one. A rendered surface reads differently composed than it reads written, and a capture is the only artifact here that shows the composed result rather than the markup. Absent one, note that this audit reads source only, and keep going. This is a stated fallback, not a stop: a project with no capture yet, or a capture mid-rebuild, still gets a source-only audit rather than losing the skill entirely.
21
22
 
22
23
  ## Step 2: identify surfaces
23
24
 
@@ -34,6 +35,8 @@ For each surface, look for:
34
35
 
35
36
  Use `.claude/DESIGN.md` and the per-surface `.claude/wireframes/<surface>.md` files, each a flat file or a grouped surface's own `.claude/wireframes/<surface>/index.md` and the siblings it lists, as ground truth for intent. For each implementation surface, read the matching wireframe file before flagging drift. Observations only, no implementation suggestions or fixes.
36
37
 
38
+ Where Step 1 found a committed capture for this surface, judge composed output (spacing, overlap, contrast, wrapping) against it rather than inferring layout from markup alone, which reports what the surface renders rather than what its source implies. Where none exists, judge from source alone and say so in that surface's findings, since a source-only read cannot see what only a render would show.
39
+
37
40
  ## Step 4: report and persist
38
41
 
39
42
  ### Report format
@@ -305,7 +305,7 @@ This section is the corpus the coverage claim is measured against: every name `c
305
305
  | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
306
306
  | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
307
307
  | `canon:record-screencast` | For compiling and running a screencast draft into a recording and a still |
308
- | `canon:identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
308
+ | `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
309
309
 
310
310
  ### Answer a question at any point
311
311
 
@@ -40,7 +40,7 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
40
40
  - `canon:ux-audit` for UX gap detection on existing surfaces
41
41
  - `canon:ux-measure` for what a running surface costs to paint, read against published thresholds
42
42
  - `canon:draft-and-pick` for a call settled by looking, drafting several candidates onto one page and taking your pick
43
- - `canon:identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop
43
+ - `canon:draft-identity` to draft a project's logo mark and compose it into an icon sequence and a social card, through `draft-and-pick`'s own render-and-pick loop
44
44
  - Anthropic's `frontend-design` plugin optional for light visual steering
45
45
 
46
46
  ### When to pick
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.70.0",
4
+ "version": "4.71.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -15,7 +15,7 @@ export const MISC_CASES: readonly SkillCase[] = [
15
15
  {
16
16
  prompt:
17
17
  'This project has no logo yet. Draft one and give me a social card to go with it.',
18
- expect: 'identity',
18
+ expect: 'draft-identity',
19
19
  },
20
20
  {
21
21
  prompt: 'Fire up the dev server the way this project documents it.',
@@ -47,4 +47,8 @@ export const MISC_CASES: readonly SkillCase[] = [
47
47
  prompt: 'Is there a page anywhere in this repo that covers retries?',
48
48
  expect: 'index-lookup',
49
49
  },
50
+ {
51
+ prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
52
+ expect: 'deploy-cloudflare',
53
+ },
50
54
  ]
@@ -384,6 +384,7 @@ async function runStylesheet(
384
384
  root,
385
385
  slug: outcome.slug,
386
386
  path: outcome.path,
387
+ basePath: outcome.basePath,
387
388
  written: outcome.written,
388
389
  })}\n`,
389
390
  )
@@ -393,6 +394,7 @@ async function runStylesheet(
393
394
  intro('canon teach stylesheet')
394
395
  logStep(outcome.written ? 'Written' : 'Already present, left alone')
395
396
  logInfo(outcome.path)
397
+ logInfo(outcome.basePath)
396
398
  outro()
397
399
  return 0
398
400
  }
@@ -708,6 +708,72 @@ export const readmeCitations: Measure = async (ctx) => {
708
708
  }
709
709
  }
710
710
 
711
+ /**
712
+ * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
713
+ * copy of the eight path globs a landing-page change should trigger on, per
714
+ * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
715
+ * anchor cannot cross files here, so the two copies are read live and compared
716
+ * rather than one asserting a literal the other could drift behind unnoticed,
717
+ * mirroring how `web/e2e/home.spec.ts`'s rule-group test already reads live.
718
+ */
719
+ export const visualPathGlobs: Measure = async (ctx) => {
720
+ const deployPath = '.github/workflows/deploy-site.yml'
721
+ const visualPath = '.github/workflows/pr-visual-checks.yml'
722
+ const deployFile = join(ctx.root, deployPath)
723
+ const visualFile = join(ctx.root, visualPath)
724
+
725
+ if (!existsSync(deployFile) || !existsSync(visualFile)) {
726
+ return {
727
+ emissions: [],
728
+ unmeasured: `${deployPath} or ${visualPath} is absent, so no path list was compared.`,
729
+ }
730
+ }
731
+
732
+ const deploy = Bun.YAML.parse(readFileSync(deployFile, 'utf8')) as {
733
+ readonly on?: { readonly push?: { readonly paths?: readonly string[] } }
734
+ }
735
+ const visual = Bun.YAML.parse(readFileSync(visualFile, 'utf8')) as {
736
+ readonly on?: {
737
+ readonly pull_request?: { readonly paths?: readonly string[] }
738
+ }
739
+ }
740
+
741
+ const deployPaths = deploy.on?.push?.paths ?? []
742
+ const visualPaths = visual.on?.pull_request?.paths ?? []
743
+
744
+ if (deployPaths.length === 0 || visualPaths.length === 0) {
745
+ return {
746
+ emissions: [],
747
+ unmeasured: `${deployPath} or ${visualPath} carries no path list to compare.`,
748
+ }
749
+ }
750
+
751
+ const onlyDeploy = deployPaths.filter((path) => !visualPaths.includes(path))
752
+ const onlyVisual = visualPaths.filter((path) => !deployPaths.includes(path))
753
+
754
+ if (onlyDeploy.length === 0 && onlyVisual.length === 0) {
755
+ return {
756
+ emissions: [
757
+ info(
758
+ `${deployPaths.length} path glob(s) agree between ${deployPath} and ${visualPath}`,
759
+ ),
760
+ ],
761
+ }
762
+ }
763
+
764
+ return {
765
+ emissions: [
766
+ ...onlyDeploy.map((path) =>
767
+ warn(`${deployPath} carries ${path}, absent from ${visualPath}`),
768
+ ),
769
+ ...onlyVisual.map((path) =>
770
+ warn(`${visualPath} carries ${path}, absent from ${deployPath}`),
771
+ ),
772
+ ],
773
+ failure: `${deployPath} and ${visualPath} carry different path globs. Bring the two lists back into agreement.`,
774
+ }
775
+ }
776
+
711
777
  /**
712
778
  * `canon sandbox coverage` moves only when a person runs it, so a scenario added
713
779
  * with no expectation ships unnoticed.
@@ -12,6 +12,7 @@ import {
12
12
  shippedReferences,
13
13
  standardCriteria,
14
14
  unreferencedRules,
15
+ visualPathGlobs,
15
16
  } from '@/gate/measures'
16
17
  import { SHIPPED_CORPORA } from '@/shipped/references'
17
18
 
@@ -424,6 +425,16 @@ export const STAGES: readonly Stage[] = [
424
425
  skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
425
426
  checks: [{ kind: 'measure', measure: readmeCitations }],
426
427
  },
428
+ {
429
+ // Scoped to the two files carrying the literal path-glob copy, so an edit
430
+ // to either one runs the check.
431
+ id: 'visual-path-globs',
432
+ label: 'Visual check path globs',
433
+ scope: /^\.github\/workflows\/(deploy-site|pr-visual-checks)\.yml$/,
434
+ skipped:
435
+ 'Neither deploy-site.yml nor pr-visual-checks.yml changed, so no path list was compared',
436
+ checks: [{ kind: 'measure', measure: visualPathGlobs }],
437
+ },
427
438
  {
428
439
  id: 'seed-standards',
429
440
  label: 'Seed standards',
@@ -24,6 +24,16 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
24
24
  * Every name takes two words. Ten of these would have landed as a bare single
25
25
  * word under a plain strip, and a bare word such as `review` or `docs` is a
26
26
  * substring of ordinary prose with no token left for a later sweep to find.
27
+ *
28
+ * No bare single-word name may ever join this map, whatever prefix it would
29
+ * otherwise take. `wholeToken: true` below matches a standalone word rather
30
+ * than a namespaced compound, so a one-word key rewrites every unrelated use
31
+ * of that word too. `identity` proved it: a dry run against a fifth row
32
+ * reading `'identity': 'draft-identity'` reported 112 occurrences across 43
33
+ * files, most of them a variable, field, or type name spelling the bare word
34
+ * rather than the skill, including `readonly identity: SelfIdentity` at
35
+ * `src/sessions/resolve.ts:255`, which the rewrite would have turned into
36
+ * invalid TypeScript. The row was never added.
27
37
  */
28
38
  export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
29
39
  'claude-address-review': 'review-address',
@@ -42,9 +42,20 @@ export const TEACH_ASSETS = 'assets'
42
42
  * The one stylesheet every lesson in a workspace links. The name is fixed here
43
43
  * rather than chosen per lesson, because the second lesson has to reach the
44
44
  * file the first one wrote and a name composed twice is a name that can differ.
45
+ * This file is workspace-owned: `writeStylesheet` writes it once and leaves it
46
+ * alone, since a lesson adds its own rules here. It imports `TEACH_STYLESHEET_BASE`
47
+ * for the tokens and components, which is the half a resync may always replace.
45
48
  */
46
49
  export const TEACH_STYLESHEET = 'course.css'
47
50
 
51
+ /**
52
+ * The seeded half of a workspace's styling, imported by `TEACH_STYLESHEET`.
53
+ * `writeStylesheet` rewrites this file from the design source on every call,
54
+ * since nothing workspace-authored lives in it, which is what lets a design
55
+ * token move without a `--force` flag discarding a workspace's lesson rules.
56
+ */
57
+ export const TEACH_STYLESHEET_BASE = 'base.css'
58
+
48
59
  /**
49
60
  * The mission heading whose list a session reads as exit criteria. The writer,
50
61
  * the reader below, and the record validator all match this one spelling, so a
@@ -1001,31 +1012,40 @@ export interface StylesheetWritten {
1001
1012
  readonly slug: string
1002
1013
  /** Relative to the root, so a caller prints a path a reader can open. */
1003
1014
  readonly path: string
1004
- /** False when the workspace already held one and this call left it alone. */
1015
+ /** Relative to the root, the re-synced seed `path` imports. */
1016
+ readonly basePath: string
1017
+ /** False when the workspace already held `path` and this call left it alone. */
1005
1018
  readonly written: boolean
1006
1019
  }
1007
1020
 
1008
1021
  export type StylesheetOutcome = StylesheetWritten | TeachRefused
1009
1022
 
1010
- const STYLESHEET_BANNER = [
1023
+ const STYLESHEET_BASE_BANNER = [
1011
1024
  'Seeded by `canon teach stylesheet` from the design source in',
1012
- 'src/design/tokens.ts. The tokens and the two components below are the',
1013
- 'system this workspace renders in. Add lesson rules under them and read a',
1014
- 'value through its custom property rather than restating the hex, which is',
1015
- 'what let one workspace fork the palette from every other.',
1025
+ 'src/design/tokens.ts, and rewritten on every call. The tokens and the two',
1026
+ 'components below are the system this workspace renders in. Read a value',
1027
+ 'through its custom property rather than restating the hex, which is what',
1028
+ 'let one workspace fork the palette from every other. Add lesson rules to',
1029
+ `${TEACH_STYLESHEET} instead, which imports this file and is never rewritten.`,
1016
1030
  ].join('\n ')
1017
1031
 
1032
+ const stylesheetSeed = (): string =>
1033
+ `@import url('./${TEACH_STYLESHEET_BASE}');\n`
1034
+
1018
1035
  /**
1019
- * Writes a workspace's one stylesheet from the design source.
1036
+ * Writes a workspace's stylesheet pair from the design source.
1020
1037
  *
1021
- * Every workspace used to carry a hand-authored copy, which is how the course
1022
- * palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
1023
- * and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
1024
- * reach the file the first one wrote, and this is what puts the values in it.
1038
+ * Every workspace used to carry one hand-authored file, which is how the
1039
+ * course palette forked once per workspace. The names are fixed at
1040
+ * `TEACH_STYLESHEET` and `TEACH_STYLESHEET_BASE`, and the folder at
1041
+ * `TEACH_ASSETS`, for the same reason a second lesson has to reach the files
1042
+ * the first one wrote and names composed twice can differ.
1025
1043
  *
1026
- * An existing stylesheet is left alone rather than replaced. A workspace adds
1027
- * lesson rules to this file as it goes, so overwriting would discard them, and
1028
- * `--force` is the caller saying it wants the seed back.
1044
+ * `TEACH_STYLESHEET_BASE` carries the tokens and the components and is
1045
+ * rewritten every call, since nothing workspace-authored lives in it.
1046
+ * `TEACH_STYLESHEET` imports it, carries a workspace's own lesson rules, and
1047
+ * is written once and left alone, since overwriting it would discard them.
1048
+ * `--force` is the caller saying it wants that file's seed back too.
1029
1049
  */
1030
1050
  export async function writeStylesheet(
1031
1051
  root: string,
@@ -1036,22 +1056,39 @@ export async function writeStylesheet(
1036
1056
  if (!found.ok) return found
1037
1057
 
1038
1058
  const workspace = found.workspace
1039
- const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
1059
+ const assetsDir = join(workspace.path, TEACH_ASSETS)
1060
+ const baseRel = join(assetsDir, TEACH_STYLESHEET_BASE)
1061
+ const rel = join(assetsDir, TEACH_STYLESHEET)
1062
+ const basePath = join(root, baseRel)
1040
1063
  const path = join(root, rel)
1041
1064
 
1042
- if (existsSync(path) && !force) {
1043
- return { ok: true, slug: workspace.slug, path: rel, written: false }
1044
- }
1045
-
1046
- await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
1065
+ await mkdir(join(root, assetsDir), { recursive: true })
1047
1066
  await writeFile(
1048
- path,
1067
+ basePath,
1049
1068
  buildDesignCss(undefined, {
1050
- banner: STYLESHEET_BANNER,
1069
+ banner: STYLESHEET_BASE_BANNER,
1051
1070
  embedFonts: TEACH_FONT_FACES,
1052
1071
  components: TEACH_STYLESHEET_COMPONENTS,
1053
1072
  }),
1054
1073
  )
1055
1074
 
1056
- return { ok: true, slug: workspace.slug, path: rel, written: true }
1075
+ if (existsSync(path) && !force) {
1076
+ return {
1077
+ ok: true,
1078
+ slug: workspace.slug,
1079
+ path: rel,
1080
+ basePath: baseRel,
1081
+ written: false,
1082
+ }
1083
+ }
1084
+
1085
+ await writeFile(path, stylesheetSeed())
1086
+
1087
+ return {
1088
+ ok: true,
1089
+ slug: workspace.slug,
1090
+ path: rel,
1091
+ basePath: baseRel,
1092
+ written: true,
1093
+ }
1057
1094
  }
@@ -0,0 +1,103 @@
1
+ name: Deploy
2
+ # canon-no-seed: a stack-specific deploy workflow with no root counterpart by design, parameterized per project on sync rather than mirrored from one.
3
+
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+ paths:
9
+ # Fill in this project's own build inputs. A single glob covering the
10
+ # source tree is enough for most projects; widen it to cover every
11
+ # path the build reads when it draws on content living elsewhere.
12
+ - 'src/**'
13
+ workflow_dispatch:
14
+
15
+ # Fill in a concurrency group name unique to this project. A second push
16
+ # while a deploy is in flight publishes the newer commit rather than racing
17
+ # it, and the running job is left to finish so a half-uploaded site is never
18
+ # what a reader lands on.
19
+ concurrency:
20
+ group: deploy
21
+ cancel-in-progress: false
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ build:
28
+ name: 📦 Build
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - name: Checkout
32
+ uses: actions/checkout@v4
33
+
34
+ - name: Setup Bun
35
+ uses: oven-sh/setup-bun@v2
36
+ with:
37
+ bun-version: latest
38
+
39
+ - name: Install Dependencies
40
+ run: bun install --frozen-lockfile
41
+
42
+ - name: Build
43
+ run: bun run build
44
+
45
+ - name: Upload artifact
46
+ uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist
50
+ retention-days: 1
51
+
52
+ deploy:
53
+ name: 🚀 Deploy
54
+ needs: build
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - name: Check for Cloudflare secrets
58
+ id: secrets-check
59
+ env:
60
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
61
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
62
+ run: |
63
+ if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
64
+ echo "skip=true" >> "$GITHUB_OUTPUT"
65
+ else
66
+ echo "skip=false" >> "$GITHUB_OUTPUT"
67
+ fi
68
+
69
+ - name: Skip notice
70
+ if: steps.secrets-check.outputs.skip == 'true'
71
+ run: echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping deploy. Run the deploy-cloudflare skill to finish setup."
72
+
73
+ # This job builds nothing and still needs a toolchain. The checkout
74
+ # below brings bun.lock, wrangler-action reads it, picks bun on the
75
+ # strength of it, and installs wrangler with that, so a deploy with no
76
+ # bun on PATH fails before it reaches Cloudflare. Dropping these steps
77
+ # as dead weight breaks the deploy on every push.
78
+ - name: Checkout
79
+ if: steps.secrets-check.outputs.skip == 'false'
80
+ uses: actions/checkout@v4
81
+
82
+ - name: Setup Bun
83
+ if: steps.secrets-check.outputs.skip == 'false'
84
+ uses: oven-sh/setup-bun@v2
85
+ with:
86
+ bun-version: latest
87
+
88
+ - name: Download artifact
89
+ if: steps.secrets-check.outputs.skip == 'false'
90
+ uses: actions/download-artifact@v4
91
+ with:
92
+ name: dist
93
+ path: dist
94
+
95
+ - name: Publish
96
+ if: steps.secrets-check.outputs.skip == 'false'
97
+ uses: cloudflare/wrangler-action@v3
98
+ with:
99
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
100
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
101
+ # Fill in this project's Pages project name, set when the
102
+ # deploy-cloudflare skill runs wrangler pages project create.
103
+ command: pages deploy dist --project-name=REPLACE_WITH_PROJECT_NAME --branch=${{ github.ref_name }}
@@ -0,0 +1,5 @@
1
+ [stack]
2
+ name = "cloudflare"
3
+ extends = ""
4
+ runtime = "bun"
5
+ scaffold = ""
@@ -0,0 +1,24 @@
1
+ # Tooling cloudflare reference
2
+
3
+ ## Overview
4
+
5
+ The cloudflare stack ships one parameterized GitHub Actions workflow that deploys a static build to Cloudflare Pages. It is keyed on host rather than on framework and carries no dependencies, seeds, or scripts of its own, so it syncs onto a project already carrying `astro`, `vite-react`, or any other stack at the same root without colliding.
6
+
7
+ ## Deploy workflow
8
+
9
+ - Separate `build` and `deploy` jobs, connected by an uploaded artifact, converged on the shape already running in the closest prior art rather than invented fresh.
10
+ - The deploy job checks for `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` before doing anything else, and skips with a `::notice::` rather than failing when either is absent. A first merge lands ahead of the Pages project existing, and this guard is what keeps that push green.
11
+ - `--branch=${{ github.ref_name }}` on every deploy, which gives every branch a Cloudflare preview URL rather than only `main`.
12
+ - The deploy job checks out the repository and installs Bun even though it builds nothing. `wrangler-action` resolves its package manager from `bun.lock` in the checkout, and dropping that step breaks the deploy on every push once the toolchain it relies on silently goes missing.
13
+
14
+ ## Parameters a project fills in by hand
15
+
16
+ Three values in the seeded file have no generic default and are set once, right after sync:
17
+
18
+ - The `paths:` filter under the `push` trigger. The shipped default is a single `src/**` glob. Widen it to cover every path the build reads when the build draws on catalogs or generated content living outside `src/`.
19
+ - The `concurrency.group` name. The shipped default is the bare string `deploy`, which is enough for a single-deploy project. Give it a project-specific name only if the project runs more than one deploy workflow.
20
+ - The `--project-name` flag on the `wrangler pages deploy` command. Set it to the name the `deploy-cloudflare` skill creates the Pages project under.
21
+
22
+ ## Re-sync
23
+
24
+ `configs/` is golden and always overwrites. Re-running `canon tooling sync cloudflare . --write` after hand-editing any of the three parameters above discards that edit and restores the shipped defaults. Re-fill the parameters after any re-sync rather than expecting the file to preserve them.