@erclx/canon 5.79.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.
- package/CHANGELOG.md +17 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/tooling.ts +72 -0
- package/src/take/perform.ts +29 -4
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
package/src/commands/capture.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { relative, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import {
|
|
@@ -32,6 +32,7 @@ const DEFAULT_SOURCE = 'assets'
|
|
|
32
32
|
type Renderer = typeof import('@/capture/render')
|
|
33
33
|
type CaptureResult = Awaited<ReturnType<Renderer['captureSources']>>[number]
|
|
34
34
|
type ColorScheme = import('@/capture/render').ColorScheme
|
|
35
|
+
type SectionsResult = import('@/capture/render').SectionsResult
|
|
35
36
|
|
|
36
37
|
const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark']
|
|
37
38
|
|
|
@@ -56,7 +57,15 @@ export function register(program: Command): void {
|
|
|
56
57
|
'-o, --out <path>',
|
|
57
58
|
'Output directory for a file source, or the destination PNG for a URL source; defaults beside the source',
|
|
58
59
|
)
|
|
59
|
-
.option(
|
|
60
|
+
.option(
|
|
61
|
+
'-s, --selector <selector>',
|
|
62
|
+
'Element to capture, or with --sections the root to split, defaulting to main and then body',
|
|
63
|
+
)
|
|
64
|
+
.option(
|
|
65
|
+
'--sections',
|
|
66
|
+
'Write one PNG per section of one page into the --out folder, in page order',
|
|
67
|
+
)
|
|
68
|
+
.option('--json', 'With --sections, print the result as one JSON record')
|
|
60
69
|
.option(
|
|
61
70
|
'--width <px>',
|
|
62
71
|
'Viewport width in pixels, so a media query resolves at that width',
|
|
@@ -69,8 +78,8 @@ export function register(program: Command): void {
|
|
|
69
78
|
'after',
|
|
70
79
|
[
|
|
71
80
|
'',
|
|
72
|
-
'
|
|
73
|
-
'page. Each element holding text inside it must resolve its first font',
|
|
81
|
+
'Outside --sections the selector has no default, since the element to',
|
|
82
|
+
'crop to belongs to the page. Each element holding text inside it must resolve its first font',
|
|
74
83
|
'family, and a generic keyword such as monospace or system-ui fails,',
|
|
75
84
|
'since it resolves differently on each machine. The user agent sets',
|
|
76
85
|
'monospace on pre, code, kbd, and samp, so each needs its own',
|
|
@@ -81,6 +90,21 @@ export function register(program: Command): void {
|
|
|
81
90
|
'a light or dark class on the root, so a stylesheet keyed on any of the',
|
|
82
91
|
'three renders that theme. Run it once per scheme with its own --out.',
|
|
83
92
|
'',
|
|
93
|
+
'--sections takes one HTML file or URL and needs --out naming a folder.',
|
|
94
|
+
'It wheels down the page so lazy content loads, hides fixed and sticky',
|
|
95
|
+
'layers, splits any block taller than 1.6 viewports into its children,',
|
|
96
|
+
'and joins a block under 400px to the one above. Each shot is cut at',
|
|
97
|
+
'2400px and writes section-NN.png with its stamp. A generic or missing',
|
|
98
|
+
'font family is recorded rather than refused. --json prints',
|
|
99
|
+
'{ source, sections: [{ index, path, top, height }], failed: [{ index,',
|
|
100
|
+
'reason }], fonts: { generic, missing } }, or { source, reason } when',
|
|
101
|
+
'the page fails whole.',
|
|
102
|
+
'',
|
|
103
|
+
'Examples:',
|
|
104
|
+
' canon capture assets --selector .window',
|
|
105
|
+
' canon capture https://example.com --sections --out refs/example --width 1440',
|
|
106
|
+
' canon capture https://example.com --sections --out refs/example --json',
|
|
107
|
+
'',
|
|
84
108
|
].join('\n'),
|
|
85
109
|
)
|
|
86
110
|
.action(
|
|
@@ -91,8 +115,20 @@ export function register(program: Command): void {
|
|
|
91
115
|
selector?: string
|
|
92
116
|
width?: string
|
|
93
117
|
colorScheme?: string
|
|
118
|
+
sections?: boolean
|
|
119
|
+
json?: boolean
|
|
94
120
|
},
|
|
95
121
|
): Promise<void> => {
|
|
122
|
+
if (opts.json && !opts.sections) {
|
|
123
|
+
frameError('--json applies to --sections. See canon capture --help.')
|
|
124
|
+
process.exitCode = 1
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (opts.sections) {
|
|
128
|
+
await captureSectionsAction(source, opts)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
96
132
|
/**
|
|
97
133
|
* Refused rather than defaulted, and refused ahead of every other
|
|
98
134
|
* check, so the message names the invocation rather than whatever the
|
|
@@ -176,6 +212,115 @@ export function register(program: Command): void {
|
|
|
176
212
|
)
|
|
177
213
|
}
|
|
178
214
|
|
|
215
|
+
interface SectionFlags {
|
|
216
|
+
out?: string
|
|
217
|
+
selector?: string
|
|
218
|
+
width?: string
|
|
219
|
+
colorScheme?: string
|
|
220
|
+
json?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Section mode reads `--out` as a folder for every source kind, so it is
|
|
225
|
+
* required here even for a file source, and a directory source is refused,
|
|
226
|
+
* since a section run describes one page.
|
|
227
|
+
*/
|
|
228
|
+
async function captureSectionsAction(
|
|
229
|
+
source: string,
|
|
230
|
+
opts: SectionFlags,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
const width = parseWidth(opts.width)
|
|
233
|
+
if (Number.isNaN(width)) {
|
|
234
|
+
frameError(
|
|
235
|
+
`--width takes a whole number of pixels of 1 or more, got ${opts.width}.`,
|
|
236
|
+
)
|
|
237
|
+
process.exitCode = 1
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const colorScheme = parseColorScheme(opts.colorScheme)
|
|
241
|
+
if (colorScheme === null) {
|
|
242
|
+
frameError(
|
|
243
|
+
`--color-scheme takes ${COLOR_SCHEMES.join(' or ')}, got ${JSON.stringify(opts.colorScheme)}.`,
|
|
244
|
+
)
|
|
245
|
+
process.exitCode = 1
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (!opts.out) {
|
|
249
|
+
frameError('--sections needs --out naming the folder the shots go in.')
|
|
250
|
+
process.exitCode = 1
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const isUrl = isUrlSource(source)
|
|
255
|
+
const sourcePath = isUrl ? source : resolve(process.cwd(), source)
|
|
256
|
+
if (!isUrl && !existsSync(sourcePath)) {
|
|
257
|
+
frameError(`${source} not found`)
|
|
258
|
+
process.exitCode = 1
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (!isUrl && statSync(sourcePath).isDirectory()) {
|
|
262
|
+
frameError(
|
|
263
|
+
`--sections takes one HTML file or URL, and ${source} is a directory.`,
|
|
264
|
+
)
|
|
265
|
+
process.exitCode = 1
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const json = opts.json ?? false
|
|
270
|
+
if (!json) intro('Capture sections')
|
|
271
|
+
let result: SectionsResult
|
|
272
|
+
try {
|
|
273
|
+
const renderer = await import('@/capture/render')
|
|
274
|
+
result = await renderer.captureSections(sourcePath, {
|
|
275
|
+
outDir: resolve(process.cwd(), opts.out),
|
|
276
|
+
root: opts.selector,
|
|
277
|
+
width,
|
|
278
|
+
colorScheme,
|
|
279
|
+
})
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (json) {
|
|
282
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
283
|
+
} else {
|
|
284
|
+
reportInFrame(error)
|
|
285
|
+
}
|
|
286
|
+
process.exitCode = 1
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status === 'failed') {
|
|
291
|
+
if (json) {
|
|
292
|
+
process.stdout.write(
|
|
293
|
+
`${JSON.stringify({ source: result.source, reason: result.reason })}\n`,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
logError(`${displayPath(result.source)}: ${result.reason}`)
|
|
297
|
+
if (!json) outro()
|
|
298
|
+
process.exitCode = 1
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { source: page, sections, failed, fonts } = result
|
|
303
|
+
if (json) {
|
|
304
|
+
process.stdout.write(
|
|
305
|
+
`${JSON.stringify({ source: page, sections, failed, fonts })}\n`,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
for (const shot of json ? [] : sections) {
|
|
309
|
+
logInfo(`${displayPath(shot.path)} top ${shot.top} height ${shot.height}`)
|
|
310
|
+
}
|
|
311
|
+
for (const fault of failed) {
|
|
312
|
+
logError(`section ${fault.index}: ${fault.reason}`)
|
|
313
|
+
}
|
|
314
|
+
if (!json && fonts.generic.length) {
|
|
315
|
+
logWarn(`generic families drawn: ${fonts.generic.join(', ')}`)
|
|
316
|
+
}
|
|
317
|
+
if (!json && fonts.missing.length) {
|
|
318
|
+
logWarn(`families not installed: ${fonts.missing.join(', ')}`)
|
|
319
|
+
}
|
|
320
|
+
if (!json) outro()
|
|
321
|
+
if (failed.length || !sections.length) process.exitCode = 1
|
|
322
|
+
}
|
|
323
|
+
|
|
179
324
|
/**
|
|
180
325
|
* Undefined for an absent flag and `NaN` for a value that is not a positive
|
|
181
326
|
* integer, so the caller can tell no width from a bad one.
|
package/src/commands/tooling.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
38
38
|
import { recordToolingChain } from '@/tooling/stamp'
|
|
39
39
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
40
|
+
import { scaffoldStack } from '@/tooling/scaffold'
|
|
40
41
|
import { verifyStack } from '@/tooling/verify-stack'
|
|
41
42
|
import {
|
|
42
43
|
intro,
|
|
@@ -377,6 +378,77 @@ export function register(program: Command): void {
|
|
|
377
378
|
})
|
|
378
379
|
process.exitCode = outcome.exitCode
|
|
379
380
|
})
|
|
381
|
+
|
|
382
|
+
tooling
|
|
383
|
+
.command('scaffold')
|
|
384
|
+
.description("Run a stack's starter command into an empty folder")
|
|
385
|
+
.argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
|
|
386
|
+
.argument('[target]', 'Target directory, created when missing', '.')
|
|
387
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
388
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
389
|
+
.addHelpText(
|
|
390
|
+
'after',
|
|
391
|
+
[
|
|
392
|
+
'',
|
|
393
|
+
"Runs the stack's manifest scaffold in a scratch folder, with the",
|
|
394
|
+
"target's folder name as the project name, then moves the result into",
|
|
395
|
+
'the target. A target holding anything but .git is refused, and a',
|
|
396
|
+
'failed command moves nothing and leaves the scratch folder named.',
|
|
397
|
+
'Run `canon init` or the target-setup skill afterward to sync tooling.',
|
|
398
|
+
'',
|
|
399
|
+
'Exit codes:',
|
|
400
|
+
' 0 scaffolded',
|
|
401
|
+
' 1 refused: excluded, no-manifest, no-scaffold, not-empty, bad-name,',
|
|
402
|
+
' scaffold-failed, or no-output',
|
|
403
|
+
'',
|
|
404
|
+
'Examples:',
|
|
405
|
+
' canon tooling scaffold vite-react',
|
|
406
|
+
' canon tooling scaffold astro my-site --json',
|
|
407
|
+
'',
|
|
408
|
+
].join('\n'),
|
|
409
|
+
)
|
|
410
|
+
.action(async (stack: string, target: string, opts: ListOptions) => {
|
|
411
|
+
process.exitCode = await runScaffold(stack, target, opts)
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A `--json` run writes no frame, so a skill reads the record alone, and a
|
|
417
|
+
* refusal carries its reason on stdout beside the exit 1.
|
|
418
|
+
*/
|
|
419
|
+
async function runScaffold(
|
|
420
|
+
stack: string,
|
|
421
|
+
target: string,
|
|
422
|
+
opts: ListOptions,
|
|
423
|
+
): Promise<number> {
|
|
424
|
+
const json = opts.json === true
|
|
425
|
+
if (!json) intro(`canon tooling scaffold ${stack}`)
|
|
426
|
+
|
|
427
|
+
const result = await scaffoldStack({ root: PROJECT_ROOT, stack, target })
|
|
428
|
+
|
|
429
|
+
if (!result.ok) {
|
|
430
|
+
logError(result.message)
|
|
431
|
+
if (result.scratch !== undefined) {
|
|
432
|
+
if (result.output !== undefined && result.output !== '') {
|
|
433
|
+
process.stderr.write(
|
|
434
|
+
`${result.output.split('\n').slice(-20).join('\n')}\n`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
logInfo(`Kept ${result.scratch}`)
|
|
438
|
+
}
|
|
439
|
+
if (json) process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
440
|
+
else outro()
|
|
441
|
+
return 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (json) {
|
|
445
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
446
|
+
return 0
|
|
447
|
+
}
|
|
448
|
+
logStep(`Scaffolded ${result.target}`)
|
|
449
|
+
for (const file of result.files) logAdd(file)
|
|
450
|
+
outroSuccess('Scaffolded', `Next: canon init ${target}`)
|
|
451
|
+
return 0
|
|
380
452
|
}
|
|
381
453
|
|
|
382
454
|
/**
|
package/src/take/perform.ts
CHANGED
|
@@ -472,6 +472,12 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
|
|
|
472
472
|
* innermost element with a size whose whole text is those words, so a hidden
|
|
473
473
|
* copy earlier in the page cannot shadow a visible one, and anything else as a
|
|
474
474
|
* CSS selector.
|
|
475
|
+
* The document is searched first and every open shadow root after it in tree
|
|
476
|
+
* order, so a document-tree match wins, except that innermost reads containment
|
|
477
|
+
* through a shadow root to its host and a `text=` match holding a sized match
|
|
478
|
+
* inside its subtree's roots gives way to it. A selector matches within one
|
|
479
|
+
* root and never across a shadow boundary. A closed root and a frame stay
|
|
480
|
+
* unreachable.
|
|
475
481
|
* It runs inside the page so both routes resolve a target the same way, and a
|
|
476
482
|
* match with no size counts as missing, since a click on it would land on
|
|
477
483
|
* whatever sits behind.
|
|
@@ -479,19 +485,38 @@ async function scrollPoint(stage: Stage, target: string): Promise<Point> {
|
|
|
479
485
|
export function boxExpression(target: string): string {
|
|
480
486
|
return `(() => {
|
|
481
487
|
const target = ${JSON.stringify(target)}
|
|
488
|
+
const roots = []
|
|
489
|
+
const collect = (root) => {
|
|
490
|
+
roots.push(root)
|
|
491
|
+
for (const node of root.querySelectorAll('*')) {
|
|
492
|
+
if (node.shadowRoot) collect(node.shadowRoot)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
collect(document)
|
|
496
|
+
const within = (outer, inner) => {
|
|
497
|
+
for (let node = inner; node; node = node.parentNode ?? node.host ?? null) {
|
|
498
|
+
if (node === outer) return true
|
|
499
|
+
}
|
|
500
|
+
return false
|
|
501
|
+
}
|
|
482
502
|
let element = null
|
|
483
503
|
if (target.startsWith('text=')) {
|
|
484
504
|
const want = target.slice(5).trim()
|
|
485
|
-
const matches =
|
|
486
|
-
(
|
|
505
|
+
const matches = roots.flatMap((root) =>
|
|
506
|
+
[...root.querySelectorAll(root === document ? 'body *' : '*')].filter(
|
|
507
|
+
(node) => node.textContent.trim() === want,
|
|
508
|
+
),
|
|
487
509
|
)
|
|
488
|
-
const innermost = matches.filter((node) => !matches.some((other) => other !== node && node
|
|
510
|
+
const innermost = matches.filter((node) => !matches.some((other) => other !== node && within(node, other)))
|
|
489
511
|
element = innermost.find((node) => {
|
|
490
512
|
const rect = node.getBoundingClientRect()
|
|
491
513
|
return rect.width > 0 && rect.height > 0
|
|
492
514
|
}) ?? null
|
|
493
515
|
} else {
|
|
494
|
-
|
|
516
|
+
for (const root of roots) {
|
|
517
|
+
element = root.querySelector(target)
|
|
518
|
+
if (element) break
|
|
519
|
+
}
|
|
495
520
|
}
|
|
496
521
|
if (!element) return null
|
|
497
522
|
const rect = element.getBoundingClientRect()
|
package/src/tooling/list.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|