@frontera-sdk/cli 1.43.9 → 1.44.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 (42) hide show
  1. package/README.md +140 -12
  2. package/package.json +3 -3
  3. package/src/adopt.ts +436 -0
  4. package/src/api/apps-api.ts +30 -0
  5. package/src/api/blueprint-authoring-api.ts +13 -2
  6. package/src/api/governed-action-api.ts +192 -0
  7. package/src/api/platform-api.ts +4 -0
  8. package/src/blueprint/ontology-edit-plan.ts +195 -0
  9. package/src/blueprint-types.ts +252 -0
  10. package/src/commands/action/deploy.ts +135 -0
  11. package/src/commands/action/grant.ts +68 -0
  12. package/src/commands/action/index-commands.ts +29 -0
  13. package/src/commands/action/list.ts +49 -0
  14. package/src/commands/action/prepare.ts +48 -0
  15. package/src/commands/action/review.ts +94 -0
  16. package/src/commands/app/deploy.ts +16 -5
  17. package/src/commands/app/dev.ts +173 -0
  18. package/src/commands/app/init.ts +270 -28
  19. package/src/commands/app/sdk.ts +31 -0
  20. package/src/commands/app/versions.ts +8 -1
  21. package/src/commands/blueprint/editable.ts +151 -0
  22. package/src/commands/blueprint/generate-types.ts +58 -0
  23. package/src/commands/blueprint/get.ts +29 -34
  24. package/src/commands/blueprint/list.ts +2 -1
  25. package/src/commands/registry.ts +12 -0
  26. package/src/context.ts +4 -4
  27. package/src/dev-broker.ts +71 -0
  28. package/src/flag-help.ts +24 -1
  29. package/src/heal.ts +37 -2
  30. package/src/manifest.ts +89 -8
  31. package/src/packaging.ts +6 -0
  32. package/src/project-bootstrap.ts +176 -0
  33. package/src/project.ts +68 -35
  34. package/src/provenance.ts +89 -0
  35. package/src/render-evidence.ts +28 -0
  36. package/src/sdk-sync.ts +41 -0
  37. package/src/shadcn-components.ts +106 -0
  38. package/src/static-app-validation.ts +67 -0
  39. package/src/template.ts +211 -32
  40. package/src/templates/next-app-files.ts +1052 -0
  41. package/src/templates/next-skills.ts +1216 -0
  42. package/src/vendor/sdk-sources.json +21 -15
@@ -0,0 +1,176 @@
1
+ import { existsSync, readdirSync, rmSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { UsageError } from './errors'
5
+
6
+ /**
7
+ * The steps between "files written" and "a project someone can work in".
8
+ *
9
+ * `frontera app init` used to stop at writing files, and everything after that
10
+ * was a list the author had to retype: install, init a repository, discover
11
+ * that the name they chose produced an invalid `package.json`, discover that
12
+ * they had just written a scaffold on top of an existing project. Every one of
13
+ * those is knowable at scaffold time, so the command does them.
14
+ *
15
+ * They live here rather than in the command because each one is a decision
16
+ * with an edge case — an occupied directory, a machine with no git identity, a
17
+ * sandbox with no network — and those are worth testing directly rather than
18
+ * through a command that also writes forty files.
19
+ */
20
+
21
+ /**
22
+ * Names that survive being a directory, an npm package name and a slug.
23
+ *
24
+ * The scaffold writes `name` straight into `package.json`, so a capital letter
25
+ * or a space produced a project where `bun install` fails on the manifest —
26
+ * after the files were written, which is the least useful moment to find out.
27
+ */
28
+ export function validateAppName(name: string): void {
29
+ if (name.length > 100) {
30
+ throw new UsageError('app name is too long', 'use 100 characters or fewer')
31
+ }
32
+ if (/^[a-z0-9][a-z0-9-]*$/.test(name) && !name.endsWith('-')) return
33
+
34
+ const suggestion = name
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9-]+/g, '-')
37
+ .replace(/^-+|-+$/g, '')
38
+
39
+ throw new UsageError(
40
+ `invalid app name: ${name}`,
41
+ suggestion.length > 0 && /^[a-z0-9]/.test(suggestion)
42
+ ? `use lowercase letters, digits and hyphens — try \`frontera app init ${suggestion}\``
43
+ : 'use lowercase letters, digits and hyphens, starting with a letter or digit',
44
+ )
45
+ }
46
+
47
+ /** Entries that do not make a directory "occupied". */
48
+ const IGNORED_ENTRIES = new Set(['.git', '.DS_Store', '.idea', '.vscode', 'Thumbs.db'])
49
+
50
+ /**
51
+ * Refuse to scaffold over someone's work.
52
+ *
53
+ * Writing into a non-empty directory is silent damage: the scaffold overwrites
54
+ * `package.json`, `tsconfig.json` and `src/app/page.tsx` without asking, and
55
+ * the author finds out when their project stops building.
56
+ */
57
+ export function assertTargetAvailable(target: string): void {
58
+ if (!existsSync(target)) return
59
+
60
+ // Sorted so the message is the same on every machine — readdir order is not.
61
+ const occupied = readdirSync(target)
62
+ .filter((entry) => !IGNORED_ENTRIES.has(entry))
63
+ .sort()
64
+ if (occupied.length === 0) return
65
+
66
+ const shown = occupied.slice(0, 3).join(', ')
67
+ throw new UsageError(
68
+ `${target} already exists and is not empty (${shown}${occupied.length > 3 ? ', …' : ''})`,
69
+ 'choose another name, or remove that directory first',
70
+ )
71
+ }
72
+
73
+ export interface InstallResult {
74
+ installed: boolean
75
+ /** The last line bun printed — its own package count and timing. */
76
+ summary: string
77
+ }
78
+
79
+ /**
80
+ * Install dependencies, best effort.
81
+ *
82
+ * `app init` is `offline: true` and must keep working in a sandbox with no
83
+ * network, so a failed install is reported and the project is still usable —
84
+ * the next-steps block grows a `bun install` line instead. Output is captured
85
+ * rather than inherited because stdout carries command data and nothing else;
86
+ * everything here is progress, which belongs on stderr.
87
+ */
88
+ export async function installDependencies(target: string): Promise<InstallResult> {
89
+ try {
90
+ const child = Bun.spawn(['bun', 'install'], {
91
+ cwd: target,
92
+ stdin: 'ignore',
93
+ stdout: 'pipe',
94
+ stderr: 'pipe',
95
+ })
96
+ const [stdout, stderr, exitCode] = await Promise.all([
97
+ new Response(child.stdout).text(),
98
+ new Response(child.stderr).text(),
99
+ child.exited,
100
+ ])
101
+ const lines = `${stdout}\n${stderr}`
102
+ .split('\n')
103
+ .map((line) => line.trim())
104
+ .filter((line) => line.length > 0)
105
+
106
+ if (exitCode !== 0) {
107
+ return { installed: false, summary: lines.at(-1) ?? `bun install exited with ${exitCode}` }
108
+ }
109
+ const installed = lines.filter((line) => /packages installed|Checked \d+ install/.test(line))
110
+ return { installed: true, summary: installed.at(-1) ?? lines.at(-1) ?? 'dependencies installed' }
111
+ } catch (error) {
112
+ return { installed: false, summary: error instanceof Error ? error.message : 'bun install failed' }
113
+ }
114
+ }
115
+
116
+ export interface GitResult {
117
+ initialized: boolean
118
+ reason?: 'already-in-repository' | 'unavailable'
119
+ }
120
+
121
+ /**
122
+ * Start the project on a commit.
123
+ *
124
+ * Skipped inside an existing repository: a nested repo the author did not ask
125
+ * for is worse than no repo, and a monorepo is a normal place to scaffold an
126
+ * App.
127
+ *
128
+ * The rollback below only ever removes a repository THIS call created, and the
129
+ * probe asks about `target` as well as its parent. Both are load-bearing, and
130
+ * the earlier version of this function had neither:
131
+ *
132
+ * mkdir myapp && cd myapp && git init && git commit …
133
+ * cd .. && frontera app init myapp
134
+ *
135
+ * `.git` is deliberately ignored when deciding whether a directory is occupied
136
+ * (`git clone --no-checkout` then scaffold is a real workflow), the parent-only
137
+ * probe never noticed that `myapp` was itself a worktree, and `git commit` then
138
+ * failed the way it does on a machine with no `user.email` — or with a
139
+ * `pre-commit` hook that exits non-zero, or `commit.gpgsign` and no key. The
140
+ * cleanup deleted history, branches, remotes and stashes that this command had
141
+ * not created. That was reproduced end to end before this was rewritten.
142
+ */
143
+ export async function initGitRepository(target: string): Promise<GitResult> {
144
+ const run = async (args: string[], cwd: string): Promise<boolean> => {
145
+ try {
146
+ const child = Bun.spawn(args, { cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
147
+ await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()])
148
+ return (await child.exited) === 0
149
+ } catch {
150
+ return false
151
+ }
152
+ }
153
+
154
+ // `target` first: it is the directory whose repository we could destroy.
155
+ if (
156
+ existsSync(join(target, '.git')) ||
157
+ (await run(['git', 'rev-parse', '--is-inside-work-tree'], target)) ||
158
+ (await run(['git', 'rev-parse', '--is-inside-work-tree'], dirname(target)))
159
+ ) {
160
+ return { initialized: false, reason: 'already-in-repository' }
161
+ }
162
+
163
+ const steps =
164
+ (await run(['git', 'init', '-b', 'main'], target)) &&
165
+ (await run(['git', 'add', '-A'], target)) &&
166
+ (await run(['git', 'commit', '-m', 'Initial commit from frontera app init'], target))
167
+
168
+ if (!steps) {
169
+ // Safe because of the guard above: nothing here existed a moment ago. A
170
+ // repository with no first commit reports the whole scaffold as untracked
171
+ // while looking initialised, which is worse than no repository at all.
172
+ rmSync(join(target, '.git'), { recursive: true, force: true })
173
+ return { initialized: false, reason: 'unavailable' }
174
+ }
175
+ return { initialized: true }
176
+ }
package/src/project.ts CHANGED
@@ -16,6 +16,10 @@ export interface ProjectConfig {
16
16
  /** Origins the app may reach at runtime; become the served CSP. */
17
17
  connectDomains: string[]
18
18
  resourceDomains: string[]
19
+ /** Directory containing the already-built static artifact. */
20
+ outputDirectory: string
21
+ /** Hosting behavior, deliberately independent of the build framework. */
22
+ routing: 'spa' | 'filesystem'
19
23
  }
20
24
 
21
25
  const STATE_FILE = '.frontera/state.json'
@@ -23,11 +27,9 @@ const STATE_FILE = '.frontera/state.json'
23
27
  /**
24
28
  * Read app identity and runtime configuration.
25
29
  *
26
- * All of it lives in `package.json` under a `frontera` key — the same key that
27
- * marks a directory as an app project. A separate `frontera.config.ts` was
28
- * specified originally and is deliberately not used: it would force the CLI to
29
- * evaluate TypeScript to learn a slug, and it would put an app's identity in
30
- * two files that can disagree. JSON is also what an agent can patch safely.
30
+ * New projects keep deployment settings in `frontera.config.json`, leaving
31
+ * package.json to standard package metadata. The legacy `package.json#frontera`
32
+ * object remains readable so existing projects do not need a migration.
31
33
  */
32
34
  export function readProject(dir: string): ProjectConfig {
33
35
  const pkgPath = join(dir, 'package.json')
@@ -44,8 +46,27 @@ export function readProject(dir: string): ProjectConfig {
44
46
  description?: string
45
47
  connectDomains?: unknown
46
48
  resourceDomains?: unknown
49
+ outputDirectory?: string
50
+ routing?: unknown
47
51
  }
48
52
  }
53
+ const configPath = join(dir, 'frontera.config.json')
54
+ let config: {
55
+ displayName?: string
56
+ description?: string
57
+ connectDomains?: unknown
58
+ resourceDomains?: unknown
59
+ outputDirectory?: string
60
+ routing?: unknown
61
+ } = {}
62
+ if (existsSync(configPath)) {
63
+ try {
64
+ config = JSON.parse(readFileSync(configPath, 'utf8')) as typeof config
65
+ } catch (err) {
66
+ throw new Error(`frontera.config.json is not valid JSON: ${(err as Error).message}`)
67
+ }
68
+ }
69
+ const state = readState(dir)
49
70
 
50
71
  // Bootstrap only: the name to CREATE the app under, on the one deploy where
51
72
  // it does not exist yet. Never written back, because the platform owns the
@@ -56,45 +77,57 @@ export function readProject(dir: string): ProjectConfig {
56
77
  if (!slug) throw new Error('package.json needs a "name"')
57
78
 
58
79
  return {
59
- // The real identifier. In package.json rather than `.frontera/state.json`
60
- // because state is gitignored, so a colleague who clones the repo would
61
- // otherwise have nothing durable and would deploy to a new app.
62
- appId: pkg.frontera?.appId ?? readState(dir).appId ?? null,
80
+ // The real identifier is local environment state. A source tree remains
81
+ // portable across Frontera organizations; first authenticated use binds a
82
+ // clone to the App in that environment and subsequent calls use the id.
83
+ appId: state.appId ?? pkg.frontera?.appId ?? null,
63
84
  slug,
64
- displayName: pkg.frontera?.displayName ?? slug,
65
- description: pkg.frontera?.description,
66
- parentVersion: readState(dir).parentVersion ?? null,
67
- connectDomains: stringList(pkg.frontera?.connectDomains),
68
- resourceDomains: stringList(pkg.frontera?.resourceDomains),
85
+ displayName: config.displayName ?? pkg.frontera?.displayName ?? slug,
86
+ description: config.description ?? pkg.frontera?.description,
87
+ parentVersion: state.parentVersion ?? null,
88
+ connectDomains: stringList(config.connectDomains ?? pkg.frontera?.connectDomains, 'connectDomains'),
89
+ resourceDomains: stringList(config.resourceDomains ?? pkg.frontera?.resourceDomains, 'resourceDomains'),
90
+ outputDirectory: safeOutputDirectory(
91
+ config.outputDirectory ?? pkg.frontera?.outputDirectory ?? 'dist',
92
+ ),
93
+ routing: routing(config.routing ?? pkg.frontera?.routing),
69
94
  }
70
95
  }
71
96
 
72
- /** Tolerate a missing or malformed list rather than failing a whole read. */
73
- function stringList(value: unknown): string[] {
74
- return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
97
+ function safeOutputDirectory(value: string): string {
98
+ if (
99
+ !value ||
100
+ value === '.' ||
101
+ value.startsWith('/') ||
102
+ /^[a-z]:[\\/]/i.test(value) ||
103
+ value.split(/[\\/]/).includes('..')
104
+ ) {
105
+ throw new Error('outputDirectory must be a project-relative directory without `..`')
106
+ }
107
+ return value
108
+ }
109
+
110
+ function routing(value: unknown): 'spa' | 'filesystem' {
111
+ if (value === undefined) return 'spa'
112
+ if (value === 'spa' || value === 'filesystem') return value
113
+ throw new Error('routing must be either "spa" or "filesystem"')
114
+ }
115
+
116
+ function stringList(value: unknown, field: string): string[] {
117
+ if (value === undefined) return []
118
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
119
+ throw new Error(`${field} must be an array of strings`)
120
+ }
121
+ return value as string[]
75
122
  }
76
123
 
77
124
  /**
78
- * Record the app's id in package.json, so it is committed alongside the code.
79
- *
80
- * Also DROPS any `frontera.slug`. Once the id is known the slug is dead weight
81
- * — the platform owns it and can rename it, so a committed copy only survives
82
- * to go stale and mislead the next person who reads the file.
83
- *
84
- * Idempotent: when nothing would change, the file is left untouched rather
85
- * than rewritten, so this never shows up as a spurious diff.
125
+ * Record the environment-specific app binding locally. Source remains portable
126
+ * across organizations and package.json remains ordinary package metadata.
86
127
  */
87
128
  export function writeAppId(dir: string, appId: string): void {
88
- const pkgPath = join(dir, 'package.json')
89
- const raw = readFileSync(pkgPath, 'utf8')
90
- const pkg = JSON.parse(raw) as { frontera?: Record<string, unknown> }
91
- const frontera = pkg.frontera ?? {}
92
- if (frontera.appId === appId && frontera.slug === undefined) return
93
-
94
- const { slug: _dropped, ...rest } = frontera
95
- pkg.frontera = { appId, ...rest }
96
- const indent = /^\{\n(\s+)"/.exec(raw)?.[1]?.length ?? 2
97
- writeFileSync(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`)
129
+ if (readState(dir).appId === appId) return
130
+ writeState(dir, { appId })
98
131
  }
99
132
 
100
133
  export function readPackageVersion(dir: string): string {
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ export interface AppBuildProvenance {
7
+ schemaVersion: 1
8
+ cliVersion: string
9
+ framework: 'next' | 'vite' | 'unknown'
10
+ frameworkVersion?: string
11
+ sourceCommit?: string
12
+ sourceDirty?: boolean
13
+ lockfilePath?: string
14
+ lockfileDigest?: string
15
+ packagedAt: string
16
+ }
17
+
18
+ const LOCKFILES = ['bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'] as const
19
+
20
+ export function detectAppFramework(root: string): AppBuildProvenance['framework'] {
21
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
22
+ dependencies?: Record<string, string>
23
+ devDependencies?: Record<string, string>
24
+ }
25
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies }
26
+ return dependencies.next ? 'next' : dependencies.vite ? 'vite' : 'unknown'
27
+ }
28
+
29
+ function installedFrameworkVersion(root: string, framework: AppBuildProvenance['framework']): string | undefined {
30
+ const packageName = framework === 'next' ? 'next' : framework === 'vite' ? 'vite' : null
31
+ if (!packageName) return undefined
32
+ try {
33
+ const installed = JSON.parse(
34
+ readFileSync(join(root, 'node_modules', packageName, 'package.json'), 'utf8'),
35
+ ) as { version?: string }
36
+ return installed.version
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ function ownVersion(): string {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, '../package.json'), 'utf8')) as {
45
+ version?: string
46
+ }
47
+ return pkg.version ?? 'unknown'
48
+ } catch {
49
+ return 'unknown'
50
+ }
51
+ }
52
+
53
+ export function collectBuildProvenance(root: string): AppBuildProvenance {
54
+ const framework = detectAppFramework(root)
55
+ const frameworkVersion = installedFrameworkVersion(root, framework)
56
+ const provenance: AppBuildProvenance = {
57
+ schemaVersion: 1,
58
+ cliVersion: ownVersion(),
59
+ framework,
60
+ ...(frameworkVersion ? { frameworkVersion } : {}),
61
+ packagedAt: new Date().toISOString(),
62
+ }
63
+
64
+ const lockfilePath = LOCKFILES.find((name) => existsSync(join(root, name)))
65
+ if (lockfilePath) {
66
+ provenance.lockfilePath = lockfilePath
67
+ provenance.lockfileDigest = `sha256:${createHash('sha256')
68
+ .update(readFileSync(join(root, lockfilePath)))
69
+ .digest('hex')}`
70
+ }
71
+
72
+ try {
73
+ provenance.sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
74
+ cwd: root,
75
+ encoding: 'utf8',
76
+ stdio: ['ignore', 'pipe', 'ignore'],
77
+ }).trim()
78
+ provenance.sourceDirty =
79
+ execFileSync('git', ['status', '--porcelain'], {
80
+ cwd: root,
81
+ encoding: 'utf8',
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ }).trim().length > 0
84
+ } catch {
85
+ // Source provenance is optional for projects outside Git.
86
+ }
87
+
88
+ return provenance
89
+ }
@@ -39,6 +39,12 @@ import { isExcluded } from './packaging'
39
39
  /** Written by `render_app`; see RENDER_EVIDENCE_RELATIVE_PATH in the service. */
40
40
  export const RENDER_EVIDENCE_PATH = '.frontera/last-render.json'
41
41
 
42
+ /** The screenshot from that same render; RENDER_THUMBNAIL_RELATIVE_PATH. */
43
+ export const RENDER_THUMBNAIL_PATH = '.frontera/last-render.png'
44
+
45
+ /** Bigger than any viewport screenshot; a file over this is not one of ours. */
46
+ const MAX_THUMBNAIL_BYTES = 4 * 1024 * 1024
47
+
42
48
  export interface RenderEvidence {
43
49
  /** True only when a render happened AFTER the newest source change. */
44
50
  rendered: boolean
@@ -130,6 +136,28 @@ export function readRenderEvidence(root: string): RenderEvidence {
130
136
  }
131
137
  }
132
138
 
139
+ /**
140
+ * The screenshot from the last render, when this tree has one.
141
+ *
142
+ * Read whatever `readRenderEvidence` concluded: a `stale` picture is still a
143
+ * picture of THIS app, and the version row records that the source moved on
144
+ * afterwards, so a reviewer is never misled by it. Refusing to upload one
145
+ * would leave the gallery blank for exactly the apps people iterate on most.
146
+ *
147
+ * Never throws. This is the decorative half of the deploy payload, and a
148
+ * deploy that dies on an unreadable PNG would be a bad trade.
149
+ */
150
+ export function readRenderThumbnail(root: string): Uint8Array | null {
151
+ const path = join(root, RENDER_THUMBNAIL_PATH)
152
+ try {
153
+ if (!existsSync(path)) return null
154
+ if (statSync(path).size > MAX_THUMBNAIL_BYTES) return null
155
+ return new Uint8Array(readFileSync(path))
156
+ } catch {
157
+ return null
158
+ }
159
+ }
160
+
133
161
  /**
134
162
  * The line deploy prints, and the one the agent reads.
135
163
  *
@@ -0,0 +1,41 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import vendored from './vendor/sdk-sources.json'
5
+ import { APP_SDK_PACKAGES, sdkPackageFiles } from './template'
6
+
7
+ const VERSION_FILE = 'src/frontera/.frontera-sdk.json'
8
+
9
+ export type SdkSyncResult =
10
+ | { kind: 'published' }
11
+ | { kind: 'vendored'; before: string; after: string; fileCount: number }
12
+
13
+ export function syncVendoredAppSdk(root: string): SdkSyncResult {
14
+ const generatedRoot = join(root, 'src/frontera')
15
+ if (!existsSync(generatedRoot)) return { kind: 'published' }
16
+
17
+ let before = 'unknown'
18
+ try {
19
+ before = (JSON.parse(readFileSync(join(root, VERSION_FILE), 'utf8')) as { version?: string })
20
+ .version ?? 'unknown'
21
+ } catch {
22
+ // Trees scaffolded before version metadata are still syncable.
23
+ }
24
+
25
+ let fileCount = 0
26
+ for (const [pkg, spec] of Object.entries(APP_SDK_PACKAGES)) {
27
+ const target = join(root, spec.dir)
28
+ rmSync(target, { recursive: true, force: true })
29
+ for (const [rel, content] of Object.entries(sdkPackageFiles(pkg as keyof typeof APP_SDK_PACKAGES))) {
30
+ const full = join(target, rel)
31
+ mkdirSync(dirname(full), { recursive: true })
32
+ writeFileSync(full, content)
33
+ fileCount++
34
+ }
35
+ }
36
+ const after = vendored.sdkVersion
37
+ const versionPath = join(root, VERSION_FILE)
38
+ mkdirSync(dirname(versionPath), { recursive: true })
39
+ writeFileSync(versionPath, `${JSON.stringify({ version: after }, null, 2)}\n`)
40
+ return { kind: 'vendored', before, after, fileCount }
41
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared primitives come from the shadcn registry, not from us.
3
+ *
4
+ * The alternative was a curated Frontera component library, and the cost of
5
+ * that is continuous: every upstream fix has to be re-applied by hand, and an
6
+ * FDE waiting on a component they could have had in ten seconds writes their
7
+ * own instead. So the scaffold configures the ecosystem's registry and gets
8
+ * out of the way.
9
+ *
10
+ * What the scaffold DOES own is the wiring that makes upstream components work
11
+ * unmodified:
12
+ *
13
+ * - `components.json` — paths, aliases, Tailwind v4 CSS entry, icon library.
14
+ * - The npm packages. Every item in the shadcn registry declares NO
15
+ * dependencies of its own, so `shadcn add button` writes a file importing
16
+ * `class-variance-authority` and installs nothing. Verified against the
17
+ * live registry: 63 items, zero declared dependencies. The scaffold
18
+ * declares them up front so an added component compiles immediately.
19
+ * - The token contract. `src/app/globals.css` already defines every variable
20
+ * upstream components reference, which is why `add` leaves it untouched.
21
+ *
22
+ * `shadcn init` would rewrite that stylesheet with the vanilla palette and
23
+ * flatten the platform surfaces. Nothing here ever runs it, and the UI skill
24
+ * lists it as a red flag.
25
+ */
26
+
27
+ /**
28
+ * The components the scaffold's own reference feature imports.
29
+ *
30
+ * This is the contract between three places: `app init` adds exactly these,
31
+ * the reference feature may import exactly these, and the scaffold test
32
+ * asserts the second never exceeds the first. A feature that reaches for a
33
+ * seventh component would otherwise produce a project whose first build fails
34
+ * on a file nobody added.
35
+ */
36
+ export const BASELINE_COMPONENTS = ['button', 'input', 'table', 'skeleton', 'card', 'alert'] as const
37
+
38
+ /** Paths those components occupy, for tests and for import resolution. */
39
+ export const BASELINE_COMPONENT_PATHS = BASELINE_COMPONENTS.map(
40
+ (name) => `src/components/ui/${name}.tsx`,
41
+ )
42
+
43
+ /**
44
+ * The command that adds these by hand.
45
+ *
46
+ * Shared with the caller so a project that did not get them — offline, or
47
+ * `--no-components` — is told exactly what completes it. The reference feature
48
+ * imports these files, so "figure it out" here means a first build that fails
49
+ * on a missing module.
50
+ */
51
+ export function shadcnAddCommand(names: readonly string[] = BASELINE_COMPONENTS): string {
52
+ return `bunx --bun shadcn@latest add ${names.join(' ')}`
53
+ }
54
+
55
+ export interface AddComponentsResult {
56
+ added: boolean
57
+ /** What to show the caller: a file count, or why nothing was written. */
58
+ summary: string
59
+ /** The command to run by hand when this did not work. */
60
+ command: string
61
+ }
62
+
63
+ /**
64
+ * Fetch the baseline into a freshly scaffolded project.
65
+ *
66
+ * Best effort, and reported rather than thrown: this needs the network, and so
67
+ * does the `bun install` that precedes it, so a machine without one already
68
+ * ends up with a project it must finish by hand. The failure has to name the
69
+ * command that finishes it, or the caller is left with a tree that type-checks
70
+ * against files that were never written.
71
+ */
72
+ export async function addShadcnComponents(
73
+ target: string,
74
+ names: readonly string[] = BASELINE_COMPONENTS,
75
+ ): Promise<AddComponentsResult> {
76
+ const command = shadcnAddCommand(names)
77
+ try {
78
+ const child = Bun.spawn(['bunx', '--bun', 'shadcn@latest', 'add', ...names, '--yes'], {
79
+ cwd: target,
80
+ stdin: 'ignore',
81
+ stdout: 'pipe',
82
+ stderr: 'pipe',
83
+ })
84
+ const [stdout, stderr, exitCode] = await Promise.all([
85
+ new Response(child.stdout).text(),
86
+ new Response(child.stderr).text(),
87
+ child.exited,
88
+ ])
89
+
90
+ if (exitCode !== 0) {
91
+ const reason = `${stdout}\n${stderr}`
92
+ .split('\n')
93
+ .map((line) => line.trim())
94
+ .filter((line) => line.length > 0)
95
+ .at(-1)
96
+ return { added: false, summary: reason ?? `shadcn exited with ${exitCode}`, command }
97
+ }
98
+ return { added: true, summary: `${names.length} components in src/components/ui/`, command }
99
+ } catch (error) {
100
+ return {
101
+ added: false,
102
+ summary: error instanceof Error ? error.message : 'shadcn add failed',
103
+ command,
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+
4
+ import { CliError } from './errors'
5
+
6
+ function sourceFiles(root: string): string[] {
7
+ const files: string[] = []
8
+ const walk = (dir: string) => {
9
+ if (!existsSync(dir)) return
10
+ for (const entry of readdirSync(dir)) {
11
+ const full = join(dir, entry)
12
+ const stat = statSync(full)
13
+ if (stat.isDirectory()) walk(full)
14
+ else if (/\.(?:ts|tsx|js|jsx)$/.test(entry)) files.push(full)
15
+ }
16
+ }
17
+ walk(join(root, 'app'))
18
+ walk(join(root, 'src', 'app'))
19
+ return files
20
+ }
21
+
22
+ /** Actionable preflight; Next build remains the final compatibility authority. */
23
+ export function validateStaticAppProject(
24
+ root: string,
25
+ routing: 'spa' | 'filesystem',
26
+ ): void {
27
+ if (routing !== 'filesystem') return
28
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as {
29
+ dependencies?: Record<string, string>
30
+ devDependencies?: Record<string, string>
31
+ }
32
+ if (!pkg.dependencies?.next && !pkg.devDependencies?.next) return
33
+
34
+ const configName = ['next.config.ts', 'next.config.mjs', 'next.config.js'].find((name) =>
35
+ existsSync(join(root, name)),
36
+ )
37
+ const issues: string[] = []
38
+ if (!configName) {
39
+ issues.push('next.config is missing')
40
+ } else {
41
+ const config = readFileSync(join(root, configName), 'utf8')
42
+ if (!/output\s*:\s*['"]export['"]/.test(config)) issues.push('next.config needs output: "export"')
43
+ }
44
+
45
+ for (const name of ['middleware.ts', 'middleware.js', 'proxy.ts', 'proxy.js']) {
46
+ if (existsSync(join(root, name)) || existsSync(join(root, 'src', name))) {
47
+ issues.push(`${name} requires a request-time runtime`)
48
+ }
49
+ }
50
+ for (const file of sourceFiles(root)) {
51
+ const rel = relative(root, file).split('\\').join('/')
52
+ if (rel.split('/').some((segment) => segment.startsWith('[') && segment.endsWith(']'))) {
53
+ issues.push(`${rel} is a dynamic route; use search parameters or client state`)
54
+ }
55
+ if (/\/route\.(?:ts|js)$/.test(rel)) issues.push(`${rel} is a runtime Route Handler`)
56
+ if (/['"]use server['"]/.test(readFileSync(file, 'utf8'))) {
57
+ issues.push(`${rel} contains a Server Action`)
58
+ }
59
+ }
60
+
61
+ if (issues.length > 0) {
62
+ throw new CliError(`App is not compatible with static hosting:\n- ${issues.join('\n- ')}`, {
63
+ code: 'VALIDATION_ERROR',
64
+ hint: 'remove request-time features, then run `bun run build` again',
65
+ })
66
+ }
67
+ }