@forumone/create-throughline 0.3.1 → 0.4.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 (27) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +4 -0
  4. package/dist/generator.js.map +1 -1
  5. package/dist/post-install.d.ts.map +1 -1
  6. package/dist/post-install.js +10 -3
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/.env.example +9 -13
  9. package/dist/templates/base/README.md +55 -2
  10. package/dist/templates/base/apps/web/package.json.template +11 -2
  11. package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
  12. package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
  13. package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
  14. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +8 -1
  15. package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
  16. package/dist/templates/base/apps/web/src/payload.config.ts.template +73 -16
  17. package/dist/templates/base/github/scripts/check-generated.sh +44 -0
  18. package/dist/templates/base/github/workflows/ci.yml +131 -0
  19. package/dist/templates/base/package.json.template +6 -1
  20. package/dist/templates/base/scripts/check-audit.ts +117 -0
  21. package/dist/templates/base/scripts/check-single-instance.ts +113 -0
  22. package/dist/templates/base/scripts/check-tested-packages.ts +165 -0
  23. package/dist/templates/base/turbo.json +16 -1
  24. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
  25. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
  26. package/dist/versions.json +1 -1
  27. package/package.json +2 -2
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ /*
4
+ One copy of each package that carries React context or is a singleton.
5
+
6
+ pnpm check:instances
7
+
8
+ **The failure this exists for.** pnpm keys a package's identity on its version
9
+ *and* its resolved peers. When two workspace packages resolve a peer differently
10
+ — one on `@types/node@^20`, another on `^24`, say — pnpm installs the same
11
+ version of `@payloadcms/ui` twice, in two directories. Two directories are two
12
+ module instances, two instances are two React contexts, and a component that
13
+ reads the context the provider did not populate gets `undefined`. In the admin
14
+ that is an intermittent 500 on one view while every other view works.
15
+
16
+ **Nothing else can see it.** `pnpm install` succeeds, typecheck, lint and every
17
+ unit test pass, because the two copies are byte-identical: the problem exists
18
+ only at module-resolution time, and only matters at render time. So this asserts
19
+ what no other gate can — that each package below resolves to exactly one
20
+ instance across the workspace. A version range is not the thing to check; the
21
+ instance is.
22
+
23
+ When it fails, compare the peers of the packages that pull in each copy and pin
24
+ the offender in `pnpm.overrides` in the root `package.json`.
25
+ */
26
+
27
+ /**
28
+ * Packages where a second copy is a bug rather than waste: each holds React
29
+ * context or is a singleton by construction, so two copies fail at runtime.
30
+ */
31
+ const SINGLETONS = ['@payloadcms/ui', 'payload', 'react', 'react-dom']
32
+
33
+ /**
34
+ * Copies that are real but cannot reach a bundle, by version, with the reason.
35
+ * An entry here is a claim that nothing you ship imports that copy.
36
+ */
37
+ const ACCEPTED: Record<string, string[]> = {}
38
+
39
+ interface Instance {
40
+ version: string
41
+ path: string
42
+ }
43
+
44
+ /** Every resolved copy of `name`, by reading pnpm's own resolution. */
45
+ function instances(name: string): Instance[] {
46
+ const stdout = execFileSync(
47
+ 'pnpm',
48
+ ['ls', name, '--recursive', '--depth', 'Infinity', '--json'],
49
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 },
50
+ )
51
+
52
+ const found = new Map<string, Instance>()
53
+ const visit = (deps: Record<string, unknown> | undefined): void => {
54
+ for (const [depName, raw] of Object.entries(deps ?? {})) {
55
+ const info = raw as {
56
+ version?: string
57
+ path?: string
58
+ dependencies?: Record<string, unknown>
59
+ }
60
+ if (!info || typeof info !== 'object') continue
61
+ if (depName === name && info.path) {
62
+ found.set(info.path, { version: info.version ?? '?', path: info.path })
63
+ }
64
+ visit(info.dependencies)
65
+ }
66
+ }
67
+
68
+ for (const pkg of JSON.parse(stdout) as Record<string, unknown>[]) {
69
+ visit(pkg['dependencies'] as Record<string, unknown> | undefined)
70
+ visit(pkg['devDependencies'] as Record<string, unknown> | undefined)
71
+ }
72
+ return [...found.values()]
73
+ }
74
+
75
+ function main(): void {
76
+ let failed = false
77
+
78
+ for (const name of SINGLETONS) {
79
+ const all = instances(name)
80
+ const accepted = ACCEPTED[name] ?? []
81
+ const live = all.filter((i) => !accepted.includes(i.version))
82
+
83
+ if (live.length <= 1) {
84
+ const note = all.length > live.length ? ` (+${all.length - live.length} accepted)` : ''
85
+ console.log(` = ${name.padEnd(20)} one instance${note}`)
86
+ continue
87
+ }
88
+
89
+ failed = true
90
+ console.log(`\n ! ${name} resolves to ${live.length} instances:\n`)
91
+ for (const instance of live) console.log(` ${instance.version} ${instance.path}`)
92
+ }
93
+
94
+ if (!failed) {
95
+ console.log('\nEvery singleton resolves once.')
96
+ return
97
+ }
98
+
99
+ console.log(
100
+ [
101
+ '',
102
+ 'Two copies of the same version are still two modules, and two modules holding',
103
+ 'React context is a runtime failure no type or test can see.',
104
+ '',
105
+ 'The usual cause is a peer that differs between workspace packages, which changes',
106
+ "pnpm's identity hash for the dependency. Compare the peers and pin the offender",
107
+ 'in `pnpm.overrides`.',
108
+ ].join('\n'),
109
+ )
110
+ process.exitCode = 1
111
+ }
112
+
113
+ main()
@@ -0,0 +1,165 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+
5
+ /*
6
+ Every workspace package that ships code has at least one test.
7
+
8
+ pnpm check:tested
9
+
10
+ Deliberately not a coverage percentage. A number invites a target, and a target
11
+ invites tests written to move it. What this asserts is the one bit a coverage
12
+ report hides: a package with source and **no test file at all**, which looks
13
+ exactly like a package whose tests pass. Defects in a code generator, a
14
+ serialiser or a set of access rules then surface downstream, one at a time, long
15
+ after the package that caused them.
16
+ */
17
+
18
+ /**
19
+ * Packages with source files and no tests, keyed by their path from the repo
20
+ * root, with the reason.
21
+ *
22
+ * This is the whole exception list. An entry is a claim that the package holds
23
+ * no behaviour worth asserting, not that testing it is inconvenient. An entry
24
+ * whose package has since gained a test fails the check, so each one is removed
25
+ * by the same change that makes it untrue.
26
+ */
27
+ const ACCEPTED: Record<string, string> = {
28
+ 'apps/web':
29
+ 'Freshly scaffolded: the config and routes are generated wiring. Delete this entry with your first test here — an access rule, a hook or a field validation is the place to start.',
30
+ {{#if useReferenceDs}}{{else}} 'design-system':
31
+ 'Placeholder until the design system is written. Delete this entry with its first component.',
32
+ {{/if}}}
33
+
34
+ /** Directories that never hold source worth testing. */
35
+ const IGNORED_DIRS = new Set([
36
+ 'node_modules',
37
+ 'dist',
38
+ 'build',
39
+ '.next',
40
+ '.turbo',
41
+ 'storybook-static',
42
+ 'coverage',
43
+ '__snapshots__',
44
+ ])
45
+
46
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
47
+
48
+ const isTest = (name: string): boolean => /\.(test|spec)\.[cm]?[jt]sx?$/.test(name)
49
+
50
+ const isSource = (name: string): boolean =>
51
+ SOURCE_EXTENSIONS.some((extension) => name.endsWith(extension)) &&
52
+ !isTest(name) &&
53
+ !name.endsWith('.d.ts')
54
+
55
+ interface Counts {
56
+ source: number
57
+ tests: number
58
+ }
59
+
60
+ /**
61
+ * Source and test files under a package's own `src`. `src` rather than the
62
+ * package root, so a package cannot pass on the strength of its own build
63
+ * tooling. A package with no `src` ships no code and is not asked.
64
+ */
65
+ function countUnder(packagePath: string): Counts | undefined {
66
+ const src = join(packagePath, 'src')
67
+ if (!existsSync(src)) return undefined
68
+
69
+ const counts: Counts = { source: 0, tests: 0 }
70
+ const walk = (dir: string): void => {
71
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
72
+ if (IGNORED_DIRS.has(entry.name)) continue
73
+ const full = join(dir, entry.name)
74
+ if (entry.isDirectory()) walk(full)
75
+ else if (isTest(entry.name)) counts.tests += 1
76
+ else if (isSource(entry.name)) counts.source += 1
77
+ }
78
+ }
79
+ walk(src)
80
+ return counts
81
+ }
82
+
83
+ interface WorkspacePackage {
84
+ name: string
85
+ path: string
86
+ }
87
+
88
+ function workspacePackages(): WorkspacePackage[] {
89
+ const stdout = execFileSync('pnpm', ['ls', '--recursive', '--depth', '-1', '--json'], {
90
+ encoding: 'utf8',
91
+ stdio: ['ignore', 'pipe', 'ignore'],
92
+ maxBuffer: 64 * 1024 * 1024,
93
+ })
94
+ return (JSON.parse(stdout) as WorkspacePackage[]).filter((pkg) => Boolean(pkg.name && pkg.path))
95
+ }
96
+
97
+ function main(): void {
98
+ const root = process.cwd()
99
+ const untested: string[] = []
100
+ const staleExceptions: string[] = []
101
+ let checked = 0
102
+
103
+ for (const pkg of workspacePackages()) {
104
+ const counts = countUnder(pkg.path)
105
+ if (!counts || counts.source === 0) continue
106
+
107
+ checked += 1
108
+ const where = relative(root, pkg.path) || '.'
109
+ const accepted = ACCEPTED[where]
110
+
111
+ if (counts.tests > 0) {
112
+ // An exception that has stopped applying reads, in review, as though
113
+ // somebody decided this package needs no tests.
114
+ if (accepted) staleExceptions.push(where)
115
+ console.log(
116
+ ` = ${where.padEnd(32)} ${String(counts.tests).padStart(3)} test files ` +
117
+ `${String(counts.source).padStart(4)} source`,
118
+ )
119
+ continue
120
+ }
121
+
122
+ if (accepted) {
123
+ console.log(` - ${where.padEnd(32)} no tests, accepted`)
124
+ continue
125
+ }
126
+
127
+ untested.push(where)
128
+ console.log(` ! ${where.padEnd(32)} 0 test files ${String(counts.source).padStart(4)} source`)
129
+ }
130
+
131
+ if (staleExceptions.length > 0) {
132
+ console.log(
133
+ [
134
+ '',
135
+ `${staleExceptions.length} package(s) are on the accepted list and now have tests:`,
136
+ '',
137
+ ...staleExceptions.map((where) => ` ${where}`),
138
+ '',
139
+ 'Delete the entry from ACCEPTED in scripts/check-tested-packages.ts.',
140
+ ].join('\n'),
141
+ )
142
+ process.exitCode = 1
143
+ }
144
+
145
+ if (untested.length === 0) {
146
+ if (!process.exitCode) console.log(`\nAll ${checked} packages that ship code have tests.`)
147
+ return
148
+ }
149
+
150
+ console.log(
151
+ [
152
+ '',
153
+ `${untested.length} package(s) ship code with no test file:`,
154
+ '',
155
+ ...untested.map((where) => ` ${where}`),
156
+ '',
157
+ 'One test is the bar, not a coverage number. If the package genuinely holds no',
158
+ 'runtime behaviour, add it to ACCEPTED in scripts/check-tested-packages.ts with',
159
+ 'the reason.',
160
+ ].join('\n'),
161
+ )
162
+ process.exitCode = 1
163
+ }
164
+
165
+ main()
@@ -5,7 +5,22 @@
5
5
  "build": {
6
6
  "dependsOn": ["^build"],
7
7
  "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
8
- "env": ["NODE_ENV"]
8
+ "env": [
9
+ "NODE_ENV",
10
+ "DATABASE_URI",
11
+ "DATABASE_URL",
12
+ "DATABASE_URL_UNPOOLED",
13
+ "POSTGRES_URL",
14
+ "POSTGRES_URL_NON_POOLING",
15
+ "PAYLOAD_SECRET",
16
+ "APPROVAL_TOKEN_SECRET",
17
+ "FORMS_IP_HASH_SECRET",
18
+ "RESEND_API_KEY",
19
+ "EMAIL_*",
20
+ "INNGEST_*",
21
+ "BLOB_READ_WRITE_TOKEN",
22
+ "NEXT_PUBLIC_*"
23
+ ]
9
24
  },
10
25
  "build-storybook": {
11
26
  "dependsOn": ["^build"],
@@ -10,42 +10,77 @@ import { getTokenList } from '../src/tokens/index.ts'
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
12
12
  const packageRoot = resolve(__dirname, '..')
13
- const componentsDir = resolve(packageRoot, 'src/components')
14
13
  const outputDir = resolve(packageRoot, 'dist')
15
14
  const outputFile = resolve(outputDir, 'manifest.json')
16
15
 
17
- async function readPackageVersion(): Promise<string> {
16
+ /**
17
+ * Every directory, relative to the package, whose subdirectories are
18
+ * components. A design system that splits components across layers (atoms and
19
+ * molecules, or a Gesso-style `02-layouts` / `03-components`) lists each one.
20
+ */
21
+ const CONTRACT_LAYERS = ['src/components']
22
+
23
+ /**
24
+ * The manifest names the design system from its own `package.json`. This file
25
+ * is vendored into every scaffolded project, and a hard-coded name made each of
26
+ * them publish a manifest claiming to be the reference design system.
27
+ */
28
+ async function readPackage(): Promise<{ name: string; version: string; description?: string }> {
18
29
  const raw = await readFile(resolve(packageRoot, 'package.json'), 'utf8')
19
- const parsed = JSON.parse(raw) as { version?: string }
20
- return parsed.version ?? '0.0.0'
30
+ const parsed = JSON.parse(raw) as { name?: string; version?: string; description?: string }
31
+ if (!parsed.name) throw new Error('package.json has no "name"; the manifest needs one.')
32
+ return {
33
+ name: parsed.name,
34
+ version: parsed.version ?? '0.0.0',
35
+ ...(parsed.description ? { description: parsed.description } : {}),
36
+ }
21
37
  }
22
38
 
23
39
  async function collectContracts(): Promise<Record<string, ComponentContract>> {
24
40
  const components: Record<string, ComponentContract> = {}
25
41
 
26
- let entries: string[]
27
- try {
28
- entries = await readdir(componentsDir)
29
- } catch {
30
- return components
31
- }
42
+ for (const layer of CONTRACT_LAYERS) {
43
+ let entries: string[]
44
+ try {
45
+ entries = await readdir(resolve(packageRoot, layer))
46
+ } catch {
47
+ continue
48
+ }
32
49
 
33
- for (const name of entries) {
34
- const dir = resolve(componentsDir, name)
35
- const dirStat = await stat(dir).catch(() => null)
36
- if (!dirStat?.isDirectory()) continue
50
+ for (const name of entries.sort()) {
51
+ // Underscore-prefixed directories are private implementation details.
52
+ if (name.startsWith('_') || name.startsWith('.')) continue
37
53
 
38
- const contractPath = join(dir, `${name}.contract.ts`)
39
- const contractStat = await stat(contractPath).catch(() => null)
40
- if (!contractStat?.isFile()) continue
54
+ const dir = resolve(packageRoot, layer, name)
55
+ const dirStat = await stat(dir).catch(() => null)
56
+ if (!dirStat?.isDirectory()) continue
41
57
 
42
- const module = (await import(pathToFileURL(contractPath).href)) as {
43
- contract?: ComponentContract
44
- }
45
- if (!module.contract) {
46
- throw new Error(`${name}.contract.ts does not export "contract"`)
58
+ /*
59
+ A component with no contract is refused, not skipped. Skipped, it is
60
+ absent from the manifest — and every other gate reads the manifest or the
61
+ contract, so nothing would notice that the CMS cannot offer it.
62
+ */
63
+ const contractPath = join(dir, `${name}.contract.ts`)
64
+ const contractStat = await stat(contractPath).catch(() => null)
65
+ if (!contractStat?.isFile()) {
66
+ throw new Error(
67
+ `${layer}/${name} has no ${name}.contract.ts, so the CMS cannot offer it. ` +
68
+ `Write one beside the component. If the directory is not a component, ` +
69
+ `prefix it with an underscore.`,
70
+ )
71
+ }
72
+
73
+ const module = (await import(pathToFileURL(contractPath).href)) as {
74
+ contract?: ComponentContract
75
+ }
76
+ if (!module.contract) {
77
+ throw new Error(`${name}.contract.ts does not export "contract"`)
78
+ }
79
+ if (components[name]) {
80
+ throw new Error(`Duplicate component contract for "${name}"`)
81
+ }
82
+ components[name] = module.contract
47
83
  }
48
- components[name] = module.contract
49
84
  }
50
85
 
51
86
  return components
@@ -56,12 +91,7 @@ async function main() {
56
91
 
57
92
  const manifest = {
58
93
  contractVersion: CONTRACT_VERSION,
59
- designSystem: {
60
- name: '@forumone/throughline-reference-ds',
61
- version: await readPackageVersion(),
62
- description:
63
- 'A brand-neutral reference design system demonstrating contract compliance for Throughline.',
64
- },
94
+ designSystem: await readPackage(),
65
95
  tokens: getTokenList(),
66
96
  components,
67
97
  build: {
@@ -32,6 +32,24 @@ async function collectStoryIds(): Promise<Set<string> | undefined> {
32
32
  return ids
33
33
  }
34
34
 
35
+ /*
36
+ Lint rules this design system treats as failures, though the shared linter
37
+ reports them as warnings.
38
+
39
+ `design-contract` sets both to `warning` because it serves every project, and a
40
+ project migrating an existing design system onto the contract should not be
41
+ blocked by prose. A design system written against the contract from the start is
42
+ never mid-migration, so it starts strict. Relax a rule here, deliberately, if
43
+ yours is.
44
+
45
+ Both are promoted because of who reads them. `intent` and `antiExamples` are the
46
+ two fields the composing agent reasons about hardest: `intent` is how it chooses
47
+ between components that could both fit, and `antiExamples` is how it rules one
48
+ out. A component with neither still renders, and still gets composed into the
49
+ wrong place, silently.
50
+ */
51
+ const REQUIRED_RULES = new Set(['antiExamples.empty', 'intent.brevity'])
52
+
35
53
  async function main() {
36
54
  const raw = await readJson<unknown>(manifestPath)
37
55
  const loaded = loadManifest(raw)
@@ -39,7 +57,14 @@ async function main() {
39
57
  const availableStoryIds = await collectStoryIds()
40
58
  const lintOptions = availableStoryIds ? { availableStoryIds } : {}
41
59
 
42
- const issues = lintManifest(loaded.raw, lintOptions)
60
+ /*
61
+ Severity is rewritten, not just re-bucketed: `formatLintIssues` groups by the
62
+ field itself, so a promoted rule left as a warning would print under
63
+ "Warnings" immediately before the command exits 1.
64
+ */
65
+ const issues = lintManifest(loaded.raw, lintOptions).map((issue) =>
66
+ REQUIRED_RULES.has(issue.rule) ? { ...issue, severity: 'error' as const } : issue,
67
+ )
43
68
  const errors = issues.filter((i) => i.severity === 'error')
44
69
  const warnings = issues.filter((i) => i.severity === 'warning')
45
70
 
@@ -9,6 +9,6 @@
9
9
  "@forumone/throughline-integrations": "0.8.5",
10
10
  "@forumone/throughline-plugin-contract": "0.4.1",
11
11
  "@forumone/throughline-publishing": "0.10.0",
12
- "@forumone/throughline-reference-ds": "0.3.5",
12
+ "@forumone/throughline-reference-ds": "0.3.6",
13
13
  "@forumone/throughline-workflows": "0.4.0"
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forumone/create-throughline",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffolder for new Throughline projects (Payload CMS + MCP servers + Inngest workflows).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@clack/prompts": "^0.8.0",
43
+ "@clack/prompts": "^0.11.0",
44
44
  "picocolors": "^1.1.0"
45
45
  },
46
46
  "devDependencies": {