@erclx/canon 5.78.0 → 5.80.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/canon-cli/SKILL.md +13 -10
  12. package/claude/skills/design-pick/SKILL.md +8 -0
  13. package/claude/skills/design-pick/references/design-config.md +9 -6
  14. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  15. package/claude/skills/design-references/SKILL.md +120 -0
  16. package/claude/skills/design-references/references/films.md +90 -0
  17. package/claude/skills/design-references/references/sites.md +36 -0
  18. package/claude/skills/design-taste/SKILL.md +16 -4
  19. package/claude/skills/design-taste/references/adopted.md +5 -1
  20. package/claude/skills/design-taste/references/kinds.md +1 -0
  21. package/claude/skills/design-taste/references/preflight.md +27 -1
  22. package/claude/skills/design-taste/references/tells.md +2 -0
  23. package/claude/skills/git-followup/SKILL.md +1 -0
  24. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  25. package/claude/skills/git-pr/SKILL.md +2 -0
  26. package/claude/skills/git-pr/references/final-command.md +34 -2
  27. package/claude/skills/git-pr/references/pr.md +1 -1
  28. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  29. package/claude/skills/git-ship/SKILL.md +16 -11
  30. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  31. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  32. package/claude/skills/plan-feature/references/plan.md +2 -0
  33. package/claude/skills/review-address/SKILL.md +2 -1
  34. package/claude/skills/review-pr/references/key-changes.md +2 -1
  35. package/claude/skills/review-ui/references/driving.md +33 -1
  36. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  37. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  38. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  40. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  42. package/claude/skills/role-worker/SKILL.md +1 -1
  43. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  44. package/claude/skills/search-craft/SKILL.md +12 -1
  45. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  46. package/claude/skills/target-scaffold/SKILL.md +44 -0
  47. package/claude/skills/target-setup/SKILL.md +5 -1
  48. package/claude/skills/task-board/SKILL.md +1 -0
  49. package/claude/skills/task-board/references/tasks.md +1 -0
  50. package/claude/skills/ui-checklist/SKILL.md +5 -1
  51. package/claude/skills/ui-craft/SKILL.md +9 -9
  52. package/claude/skills/ui-craft/references/adopted.md +4 -2
  53. package/claude/skills/ui-craft/references/capture.md +8 -0
  54. package/claude/skills/ui-craft/references/controls.md +6 -0
  55. package/claude/skills/ui-craft/references/motion.md +6 -0
  56. package/claude/skills/ui-film/references/take-craft.md +2 -0
  57. package/docs/agents/output-shape.md +2 -1
  58. package/docs/target/projects.md +2 -2
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/parallel-features.md +2 -1
  61. package/docs/workflow/skill-map.md +7 -5
  62. package/docs/workflow/visual-design-workflow.md +4 -0
  63. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  64. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  65. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  66. package/governance/ledger/skills/design-taste/tells.md +22 -20
  67. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  69. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  70. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  71. package/package.json +3 -3
  72. package/src/capture/render.ts +154 -2
  73. package/src/capture/sections.ts +257 -0
  74. package/src/claude/cases/misc.ts +5 -0
  75. package/src/claude/cases/setup.ts +7 -0
  76. package/src/commands/capture.ts +149 -4
  77. package/src/commands/pr.ts +138 -15
  78. package/src/commands/sessions.ts +88 -6
  79. package/src/commands/tasks.ts +41 -2
  80. package/src/commands/tooling.ts +72 -0
  81. package/src/git/files.ts +5 -2
  82. package/src/indexes/frontmatter.ts +27 -6
  83. package/src/indexes/render.ts +21 -3
  84. package/src/markdown/colons.ts +14 -4
  85. package/src/markdown/heading-case.ts +9 -2
  86. package/src/pr/bijection.ts +8 -0
  87. package/src/pr/evidence.ts +38 -9
  88. package/src/pr/health.ts +63 -15
  89. package/src/pr/links.ts +160 -0
  90. package/src/pr/local.ts +17 -4
  91. package/src/pr/paths.ts +20 -2
  92. package/src/sessions/stop.ts +145 -0
  93. package/src/sessions/transfer/bundle.ts +1 -1
  94. package/src/take/encode.ts +16 -42
  95. package/src/take/frame-clock.ts +46 -0
  96. package/src/take/perform.ts +29 -4
  97. package/src/take/screencast.ts +114 -11
  98. package/src/tasks/holders.ts +198 -0
  99. package/src/tasks/validate.ts +148 -0
  100. package/src/tooling/list.ts +3 -0
  101. package/src/tooling/scaffold.ts +193 -0
  102. package/tooling/cloudflare/manifest.toml +1 -1
  103. package/tooling/cloudflare/reference.md +8 -0
  104. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -0,0 +1,198 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git/env'
3
+ import {
4
+ repositoryOf,
5
+ resolveSessions,
6
+ type SessionReport,
7
+ } from '@/sessions/resolve'
8
+
9
+ /**
10
+ * Bounds the one network read `canon tasks validate --live` takes, shorter than
11
+ * the archive's own, since the sweep waits on it and a slow answer reads the
12
+ * same as no answer.
13
+ */
14
+ const GH_TIMEOUT_MS = 5_000
15
+
16
+ /** `gh pr list` caps at 30 by default, and a truncated list would read as clean. */
17
+ const PULL_LIMIT = 1_000
18
+
19
+ /**
20
+ * Who holds each branch, named for a person reading the report. `undefined`
21
+ * means the source could not be read, which has to stay apart from an empty map:
22
+ * an unreachable source that degraded to "nobody holds it" would report a held
23
+ * row as clean.
24
+ */
25
+ export type HolderReader = () => Promise<
26
+ ReadonlyMap<string, string> | undefined
27
+ >
28
+
29
+ /** Whether a merged pull request names a branch, `undefined` when GitHub could not say. */
30
+ export type MergedReader = (branch: string) => Promise<boolean | undefined>
31
+
32
+ export interface LiveReaders {
33
+ readonly sessions: HolderReader
34
+ readonly pulls: HolderReader
35
+ readonly merged: MergedReader
36
+ }
37
+
38
+ export interface SessionHolderOptions {
39
+ readonly resolve?: () => Promise<SessionReport>
40
+ readonly repository?: (root: string) => Promise<string | null>
41
+ }
42
+
43
+ /**
44
+ * Reads the roster scoped to `root`'s repository, since a branch name says
45
+ * nothing across a machine and another project's worker on the same name holds
46
+ * nothing here. Two rows leave the liveness undecided, a foreign record whose
47
+ * socket is gone and a row resting only on a socket file a crash can leave, so
48
+ * either reads the whole roster as unread rather than as a hold or as clear.
49
+ */
50
+ export function sessionHolders(
51
+ root: string,
52
+ opts: SessionHolderOptions = {},
53
+ ): HolderReader {
54
+ const resolve = opts.resolve ?? resolveSessions
55
+ const repository = opts.repository ?? repositoryOf
56
+
57
+ return async () => {
58
+ try {
59
+ const [report, repo] = await Promise.all([resolve(), repository(root)])
60
+ if (
61
+ report.kind !== 'resolved' ||
62
+ report.foreignDead > 0 ||
63
+ report.sessions.some((session) => session.via === 'socket') ||
64
+ !repo
65
+ ) {
66
+ return undefined
67
+ }
68
+
69
+ return new Map(
70
+ report.sessions.flatMap((session) =>
71
+ session.repository === repo && session.branch
72
+ ? [[session.branch, `session ${session.name}`] as const]
73
+ : [],
74
+ ),
75
+ )
76
+ } catch {
77
+ return undefined
78
+ }
79
+ }
80
+ }
81
+
82
+ export type GhRunner = (
83
+ cwd: string,
84
+ args: readonly string[],
85
+ ) => Promise<string | undefined>
86
+
87
+ const runGh: GhRunner = async (cwd, args) => {
88
+ if (Bun.which('gh') === null) return undefined
89
+
90
+ try {
91
+ // `gh` resolves its repository through git, so a hook's resolution
92
+ // variables would point it at another repository than `cwd`.
93
+ const result = await execa('gh', [...args], {
94
+ cwd,
95
+ timeout: GH_TIMEOUT_MS,
96
+ env: gitEnv(),
97
+ extendEnv: false,
98
+ })
99
+ return result.stdout
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ interface ListedPull {
106
+ readonly number: number
107
+ readonly headRefName: string
108
+ readonly isCrossRepository: boolean
109
+ }
110
+
111
+ function isListedPull(value: unknown): value is ListedPull {
112
+ if (typeof value !== 'object' || value === null) return false
113
+ const pull = value as Record<string, unknown>
114
+
115
+ return (
116
+ typeof pull.number === 'number' &&
117
+ typeof pull.headRefName === 'string' &&
118
+ typeof pull.isCrossRepository === 'boolean'
119
+ )
120
+ }
121
+
122
+ function parsePulls(stdout: string): readonly ListedPull[] | undefined {
123
+ try {
124
+ const parsed: unknown = JSON.parse(stdout)
125
+ return Array.isArray(parsed) && parsed.every(isListedPull)
126
+ ? parsed
127
+ : undefined
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Reads open pull requests off GitHub. A list reaching the limit may have cut
135
+ * off the one that holds a row, so it reads as unread. A fork's head names a
136
+ * branch in another repository, which holds nothing here however it is spelled.
137
+ */
138
+ export function pullHolders(root: string, run: GhRunner = runGh): HolderReader {
139
+ return async () => {
140
+ const stdout = await run(root, [
141
+ 'pr',
142
+ 'list',
143
+ '--state',
144
+ 'open',
145
+ '--limit',
146
+ String(PULL_LIMIT),
147
+ '--json',
148
+ 'number,headRefName,isCrossRepository',
149
+ ])
150
+ if (stdout === undefined) return undefined
151
+
152
+ const pulls = parsePulls(stdout)
153
+ if (!pulls || pulls.length >= PULL_LIMIT) return undefined
154
+
155
+ return new Map(
156
+ pulls
157
+ .filter((pull) => !pull.isCrossRepository)
158
+ .map((pull) => [pull.headRefName, `pull request #${pull.number}`]),
159
+ )
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Asks about one branch at a time rather than listing merged pull requests,
165
+ * since a list long enough to reach an old merge costs every run, and only a
166
+ * branch a session already holds against a row takes this read.
167
+ */
168
+ export function mergedBranches(
169
+ root: string,
170
+ run: GhRunner = runGh,
171
+ ): MergedReader {
172
+ return async (branch) => {
173
+ const stdout = await run(root, [
174
+ 'pr',
175
+ 'list',
176
+ '--state',
177
+ 'merged',
178
+ '--head',
179
+ branch,
180
+ '--limit',
181
+ String(PULL_LIMIT),
182
+ '--json',
183
+ 'number,headRefName,isCrossRepository',
184
+ ])
185
+ if (stdout === undefined) return undefined
186
+
187
+ const pulls = parsePulls(stdout)
188
+ return pulls?.some((pull) => !pull.isCrossRepository)
189
+ }
190
+ }
191
+
192
+ export function liveReaders(root: string): LiveReaders {
193
+ return {
194
+ sessions: sessionHolders(root),
195
+ pulls: pullHolders(root),
196
+ merged: mergedBranches(root),
197
+ }
198
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
+ import { readFrontmatterSource } from '@/indexes/frontmatter'
4
5
  import { codeSpans, unbrokenRun } from '@/markdown/tables'
5
6
  import {
6
7
  archiveDir,
@@ -21,6 +22,8 @@ import {
21
22
  renderedLength,
22
23
  WAITING_LIMIT,
23
24
  } from '@/tasks/board'
25
+ import { branchOfPlan } from '@/tasks/branch'
26
+ import type { HolderReader, LiveReaders, MergedReader } from '@/tasks/holders'
24
27
  import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
25
28
 
26
29
  const ORDERING_FILE = 'priority.md'
@@ -61,6 +64,7 @@ export const FINDING_KINDS = [
61
64
  'row-untabled',
62
65
  'row-misordered',
63
66
  'row-unranked',
67
+ 'row-held',
64
68
  'touches-unstated',
65
69
  'touches-stale',
66
70
  'columns-mismatched',
@@ -69,6 +73,7 @@ export const FINDING_KINDS = [
69
73
  'blocker-unresolved',
70
74
  'blocker-declined',
71
75
  'sessions-misshapen',
76
+ 'frontmatter-invalid',
72
77
  ] as const
73
78
 
74
79
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -177,6 +182,18 @@ export interface BoardRow {
177
182
  readonly cells: readonly string[]
178
183
  }
179
184
 
185
+ /**
186
+ * What each source behind the held-row check answered. `unread` is a source the
187
+ * run asked and could not read, so a board carrying no `row-held` finding beside
188
+ * it is untested rather than clean. `not-asked` is a run without the live read.
189
+ */
190
+ export type LiveState = 'read' | 'unread' | 'not-asked'
191
+
192
+ export interface LiveReading {
193
+ readonly sessions: LiveState
194
+ readonly pulls: LiveState
195
+ }
196
+
180
197
  export interface ValidateReport {
181
198
  readonly ok: true
182
199
  readonly rows: number
@@ -189,6 +206,7 @@ export interface ValidateReport {
189
206
  readonly shared: readonly SharedFiles[]
190
207
  readonly wide: readonly WideToken[]
191
208
  readonly unplaced: readonly Unplaced[]
209
+ readonly live: LiveReading
192
210
  }
193
211
 
194
212
  export interface ValidateRefused {
@@ -877,6 +895,34 @@ async function checkSessions(
877
895
  return found.filter((finding): finding is Finding => finding !== undefined)
878
896
  }
879
897
 
898
+ /**
899
+ * Reports a task file whose frontmatter block will not parse, which the index
900
+ * regeneration would otherwise surface only after the row was placed. A file
901
+ * with no block is left alone, since the index walker owns that report.
902
+ */
903
+ async function checkFrontmatter(
904
+ stems: readonly string[],
905
+ dir: string,
906
+ ): Promise<Finding[]> {
907
+ const found = await Promise.all(
908
+ stems.map(async (stem): Promise<Finding | undefined> => {
909
+ const reading = readFrontmatterSource(
910
+ await readFile(join(dir, `${stem}.md`), 'utf8'),
911
+ )
912
+ if (reading.kind !== 'invalid') return undefined
913
+
914
+ return {
915
+ kind: 'frontmatter-invalid',
916
+ group: undefined,
917
+ subject: stem,
918
+ message: `frontmatter is not valid YAML: ${reading.message}. Double any apostrophe inside the single-quoted title.`,
919
+ }
920
+ }),
921
+ )
922
+
923
+ return found.filter((finding): finding is Finding => finding !== undefined)
924
+ }
925
+
880
926
  /**
881
927
  * Tests the claim a group name makes about a plan. `## Needs a plan` says none
882
928
  * is live and `## Up next` says one is written, and neither group has a `Plan`
@@ -1611,6 +1657,95 @@ export async function groupRuleFindings(
1611
1657
  ]
1612
1658
  }
1613
1659
 
1660
+ /**
1661
+ * Reports a row outside `## Run now` whose task a live session or an open pull
1662
+ * request holds, tied through the branch each live plan on its `Plan:` line
1663
+ * derives. A row whose worker renamed its branch has nothing to match, and an
1664
+ * archived plan's branch is finished work, so neither is reported.
1665
+ *
1666
+ * A session outlives its merge, so a session still on a branch a merged pull
1667
+ * request names is finished work too, until the merge reaches this clone and the
1668
+ * hook archives the row. A merged read that fails leaves the hold standing, since
1669
+ * the session is still there, and reads `pulls` as unread, since that hold may be
1670
+ * finished work the open list could not rule out.
1671
+ */
1672
+ async function checkHeld(
1673
+ rows: readonly BoardRow[],
1674
+ live: LiveRead,
1675
+ dir: string,
1676
+ root: string,
1677
+ ): Promise<{ readonly findings: Finding[]; readonly mergedUnread: boolean }> {
1678
+ let mergedUnread = false
1679
+
1680
+ const holdersOf = async (branch: string): Promise<string[]> => {
1681
+ const session = live.sessions?.get(branch)
1682
+ const merged = session ? await live.merged?.(branch) : false
1683
+ if (merged === undefined) mergedUnread = true
1684
+
1685
+ return [session && !merged ? session : undefined, live.pulls?.get(branch)]
1686
+ .filter((name): name is string => name !== undefined)
1687
+ .map((name) => `${name} on ${branch}`)
1688
+ }
1689
+
1690
+ const found = await Promise.all(
1691
+ rows.map(async (row): Promise<Finding | undefined> => {
1692
+ if (row.group === 'Run now' || !row.stem) return undefined
1693
+ const file = join(dir, `${row.stem}.md`)
1694
+ if (!existsSync(file)) return undefined
1695
+
1696
+ const targets = readPlanTargets(await readFile(file, 'utf8'))
1697
+ const branches = targets.flatMap((target) => {
1698
+ const path = target.split('#')[0] || target
1699
+ return resolveLivePlan(path, dir, root) === undefined
1700
+ ? []
1701
+ : [branchOfPlan(path)]
1702
+ })
1703
+ const held = (await Promise.all(branches.map(holdersOf))).flat()
1704
+ if (held.length === 0) return undefined
1705
+
1706
+ return {
1707
+ kind: 'row-held',
1708
+ group: row.group,
1709
+ subject: row.stem,
1710
+ message: `sits under ${row.group} and is held by ${held.join(' and ')}. A row with a worker belongs under Run now.`,
1711
+ }
1712
+ }),
1713
+ )
1714
+
1715
+ return {
1716
+ findings: found.filter(
1717
+ (finding): finding is Finding => finding !== undefined,
1718
+ ),
1719
+ mergedUnread,
1720
+ }
1721
+ }
1722
+
1723
+ /** What each source answered, `undefined` where it was not asked or failed. */
1724
+ interface LiveRead {
1725
+ readonly reading: LiveReading
1726
+ readonly sessions?: Awaited<ReturnType<HolderReader>>
1727
+ readonly pulls?: Awaited<ReturnType<HolderReader>>
1728
+ readonly merged?: MergedReader
1729
+ }
1730
+
1731
+ /** Takes each source once, since a failed read answers undefined rather than throws. */
1732
+ async function readLive(live: LiveReaders | undefined): Promise<LiveRead> {
1733
+ if (!live) {
1734
+ return { reading: { sessions: 'not-asked', pulls: 'not-asked' } }
1735
+ }
1736
+
1737
+ const stateOf = (read: Awaited<ReturnType<HolderReader>>): LiveState =>
1738
+ read ? 'read' : 'unread'
1739
+ const [sessions, pulls] = await Promise.all([live.sessions(), live.pulls()])
1740
+
1741
+ return {
1742
+ reading: { sessions: stateOf(sessions), pulls: stateOf(pulls) },
1743
+ sessions,
1744
+ pulls,
1745
+ merged: live.merged,
1746
+ }
1747
+ }
1748
+
1614
1749
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
1615
1750
  return { ok: false, reason, message }
1616
1751
  }
@@ -1618,6 +1753,12 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
1618
1753
  export interface ValidateOptions {
1619
1754
  /** Overridden by tests, which supply the trunk rather than reaching for git. */
1620
1755
  readonly trunk?: TrunkReader
1756
+ /**
1757
+ * The roster and GitHub reads behind the held-row check. Absent by default,
1758
+ * since the board's write hook runs this on every edit and a network read
1759
+ * there costs every write.
1760
+ */
1761
+ readonly live?: LiveReaders
1621
1762
  }
1622
1763
 
1623
1764
  /**
@@ -1668,6 +1809,8 @@ export async function validateBoard(
1668
1809
  ? await listTaskStems(declinedPath)
1669
1810
  : []
1670
1811
  const parked = await checkParked(rows, root, trunk)
1812
+ const live = await readLive(options.live)
1813
+ const held = await checkHeld(rows, live, dir, root)
1671
1814
 
1672
1815
  const findings = [
1673
1816
  ...shapeFindings,
@@ -1675,10 +1818,12 @@ export async function validateBoard(
1675
1818
  ...(await groupRuleFindings(rows, root)),
1676
1819
  ...(await checkPlanAgreement(rows, dir, root)),
1677
1820
  ...(await checkSessions(rows, dir)),
1821
+ ...(await checkFrontmatter(stems, dir)),
1678
1822
  ...(await checkStaleTouches(rows, root)),
1679
1823
  ...checkCellWidth(rows),
1680
1824
  ...checkOrdering(rows),
1681
1825
  ...parked.findings,
1826
+ ...held.findings,
1682
1827
  ]
1683
1828
 
1684
1829
  return {
@@ -1693,5 +1838,8 @@ export async function validateBoard(
1693
1838
  shared: checkSharedFiles(rows),
1694
1839
  wide: checkWideTokens(rows),
1695
1840
  unplaced: checkUnplaced(rows, backlog, stems),
1841
+ live: held.mergedUnread
1842
+ ? { ...live.reading, pulls: 'unread' }
1843
+ : live.reading,
1696
1844
  }
1697
1845
  }
@@ -6,6 +6,8 @@ export interface StackSummary {
6
6
  readonly devDeps: number
7
7
  readonly scripts: number
8
8
  readonly gitignoreGroups: number
9
+ /** Whether `canon tooling scaffold` can start a project on this stack. */
10
+ readonly scaffold: boolean
9
11
  }
10
12
 
11
13
  /**
@@ -26,6 +28,7 @@ export function buildStackSummaries(root: string): StackSummary[] {
26
28
  devDeps: manifest.devPackages.length,
27
29
  scripts: Object.keys(manifest.scripts).length,
28
30
  gitignoreGroups: manifest.gitignore.length,
31
+ scaffold: manifest.scaffold !== undefined,
29
32
  })
30
33
  }
31
34
 
@@ -0,0 +1,193 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { basename, join, resolve } from 'node:path'
12
+ import { execa } from 'execa'
13
+ import { isStackExcluded, loadManifest } from '@/tooling/manifest'
14
+
15
+ export interface ScaffoldOptions {
16
+ /** Root holding `tooling/<stack>/manifest.toml`. */
17
+ readonly root: string
18
+ readonly stack: string
19
+ readonly target: string
20
+ /** Parent of the scratch folder the command runs in. Defaults to the OS temp dir. */
21
+ readonly scratchBase?: string
22
+ }
23
+
24
+ export type ScaffoldRefusal =
25
+ | 'excluded'
26
+ | 'no-manifest'
27
+ | 'no-scaffold'
28
+ | 'not-empty'
29
+ | 'bad-name'
30
+ | 'scaffold-failed'
31
+ | 'no-output'
32
+
33
+ export type ScaffoldResult =
34
+ | {
35
+ readonly ok: true
36
+ readonly stack: string
37
+ readonly target: string
38
+ readonly name: string
39
+ readonly files: readonly string[]
40
+ }
41
+ | {
42
+ readonly ok: false
43
+ readonly reason: ScaffoldRefusal
44
+ readonly message: string
45
+ readonly suggested?: string
46
+ readonly scratch?: string
47
+ readonly output?: string
48
+ }
49
+
50
+ /**
51
+ * npm, create-next-app, and composer all refuse a package name with capitals,
52
+ * spaces, or a leading dot, and the target's basename becomes that name.
53
+ */
54
+ const PACKAGE_NAME = /^[a-z0-9][a-z0-9._-]*$/
55
+ const MAX_NAME = 214
56
+
57
+ /** A target holding only `.git` counts as empty, since `git init` first is common. */
58
+ const IGNORED_ENTRIES = new Set(['.git'])
59
+
60
+ /**
61
+ * Runs the stack's `manifest.scaffold` in a scratch folder with `{{name}}` set
62
+ * to the target's basename, then moves what it produced into the target. The
63
+ * scratch step is what lets a command that opens on `mkdir {{name}} && cd`
64
+ * land in a folder that already exists.
65
+ *
66
+ * Nothing reaches the target unless the command exits 0 and produced its
67
+ * folder. A failed run leaves the scratch folder in place and names it.
68
+ */
69
+ export async function scaffoldStack(
70
+ opts: ScaffoldOptions,
71
+ ): Promise<ScaffoldResult> {
72
+ const { root, stack } = opts
73
+ const target = resolve(opts.target)
74
+
75
+ if (isStackExcluded(stack)) {
76
+ return refuse('excluded', `Stack '${stack}' is excluded from tooling.`)
77
+ }
78
+ const manifest = loadManifest(root, stack)
79
+ if (manifest === undefined) {
80
+ return refuse(
81
+ 'no-manifest',
82
+ `No manifest at tooling/${stack}/manifest.toml`,
83
+ )
84
+ }
85
+ if (manifest.scaffold === undefined) {
86
+ return refuse(
87
+ 'no-scaffold',
88
+ `Stack '${stack}' has no scaffold command in its manifest.`,
89
+ )
90
+ }
91
+
92
+ const held = existsSync(target) ? occupiedEntries(target) : []
93
+ if (held.length > 0) {
94
+ return refuse(
95
+ 'not-empty',
96
+ `${target} is not empty (${held.slice(0, 3).join(', ')}${held.length > 3 ? ', ...' : ''}). Scaffold into an empty folder.`,
97
+ )
98
+ }
99
+
100
+ const name = basename(target)
101
+ if (!PACKAGE_NAME.test(name) || name.length > MAX_NAME) {
102
+ const suggested = suggestName(name)
103
+ return {
104
+ ok: false,
105
+ reason: 'bad-name',
106
+ message: `Folder name '${name}' is not a valid package name. Use lowercase letters, digits, '.', '_', and '-', such as '${suggested}'.`,
107
+ suggested,
108
+ }
109
+ }
110
+
111
+ const scratch = mkdtempSync(
112
+ join(opts.scratchBase ?? tmpdir(), 'canon-scaffold-'),
113
+ )
114
+ // Manifests carry shell syntax (`&&`, `cd`, quoted globs), so the command
115
+ // runs under a shell rather than as a split argv, as `tooling verify` runs it.
116
+ const command = manifest.scaffold.replaceAll('{{name}}', name)
117
+ const run = await execa(command, {
118
+ cwd: scratch,
119
+ shell: 'bash',
120
+ all: true,
121
+ reject: false,
122
+ stdin: 'ignore',
123
+ env: { CI: '1' },
124
+ })
125
+ const output = typeof run.all === 'string' ? run.all : ''
126
+
127
+ if (run.failed) {
128
+ return {
129
+ ok: false,
130
+ reason: 'scaffold-failed',
131
+ message: `Scaffold command failed: ${command}`,
132
+ scratch,
133
+ output,
134
+ }
135
+ }
136
+ const produced = join(scratch, name)
137
+ if (!existsSync(produced)) {
138
+ return {
139
+ ok: false,
140
+ reason: 'no-output',
141
+ message: `Scaffold command exited 0 but produced no ${name}/ folder.`,
142
+ scratch,
143
+ output,
144
+ }
145
+ }
146
+
147
+ mkdirSync(target, { recursive: true })
148
+ const keepTargetGit = existsSync(join(target, '.git'))
149
+ const files: string[] = []
150
+ for (const entry of readdirSync(produced).sort()) {
151
+ if (entry === '.git' && keepTargetGit) continue
152
+ move(join(produced, entry), join(target, entry))
153
+ files.push(entry)
154
+ }
155
+ rmSync(scratch, { recursive: true, force: true })
156
+
157
+ return { ok: true, stack, target, name, files }
158
+ }
159
+
160
+ function refuse(reason: ScaffoldRefusal, message: string): ScaffoldResult {
161
+ return { ok: false, reason, message }
162
+ }
163
+
164
+ function occupiedEntries(dir: string): string[] {
165
+ return readdirSync(dir)
166
+ .filter((entry) => !IGNORED_ENTRIES.has(entry))
167
+ .sort()
168
+ }
169
+
170
+ /**
171
+ * Lowercases the name and turns every run of other characters into one dash,
172
+ * falling back to `app` when nothing usable is left.
173
+ */
174
+ export function suggestName(name: string): string {
175
+ const cleaned = name
176
+ .toLowerCase()
177
+ .replace(/[^a-z0-9._-]+/g, '-')
178
+ .replace(/^[^a-z0-9]+/, '')
179
+ .replace(/-+$/, '')
180
+ .slice(0, MAX_NAME)
181
+ return cleaned === '' ? 'app' : cleaned
182
+ }
183
+
184
+ /** The OS temp dir often sits on another filesystem, where a rename fails. */
185
+ function move(from: string, to: string): void {
186
+ try {
187
+ renameSync(from, to)
188
+ } catch (error) {
189
+ if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error
190
+ cpSync(from, to, { recursive: true, preserveTimestamps: true })
191
+ rmSync(from, { recursive: true, force: true })
192
+ }
193
+ }
@@ -2,4 +2,4 @@
2
2
  name = "cloudflare"
3
3
  extends = ""
4
4
  runtime = "bun"
5
- scaffold = ""
5
+ scaffold = "npm create cloudflare@latest {{name}} -- --type hello-world --lang ts --no-deploy --no-git"
@@ -6,6 +6,14 @@ The cloudflare stack ships one parameterized GitHub Actions workflow that deploy
6
6
 
7
7
  It ships one script, `scripts/delete-previews.sh`, which the cleanup job calls and which overwrites a project's own file of that name on every sync.
8
8
 
9
+ ## Scaffold
10
+
11
+ The manifest's scaffold starts a new project as a TypeScript Worker from the `hello-world` template, through `npm create cloudflare@latest` with deploy and git both off. `canon tooling scaffold cloudflare` runs it in an empty folder.
12
+
13
+ - The starter installs its own dependencies, since `create-cloudflare` runs the install itself.
14
+ - A project already on `astro` or `vite-react` never takes this scaffold. It syncs the stack onto the framework it has.
15
+ - The stack extends nothing and ships no scripts, so `canon tooling verify cloudflare` passes Scaffold and Sync and stops at `lint:fix`. Run `canon init` on the starter to bring in the base scripts.
16
+
9
17
  ## Deploy workflow
10
18
 
11
19
  ### Job shape