@frontera-sdk/cli 1.43.10 → 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.
- package/README.md +140 -12
- package/package.json +3 -3
- package/src/adopt.ts +436 -0
- package/src/api/apps-api.ts +30 -0
- package/src/api/blueprint-authoring-api.ts +13 -2
- package/src/api/governed-action-api.ts +192 -0
- package/src/api/platform-api.ts +4 -0
- package/src/blueprint/ontology-edit-plan.ts +195 -0
- package/src/blueprint-types.ts +252 -0
- package/src/commands/action/deploy.ts +135 -0
- package/src/commands/action/grant.ts +68 -0
- package/src/commands/action/index-commands.ts +29 -0
- package/src/commands/action/list.ts +49 -0
- package/src/commands/action/prepare.ts +48 -0
- package/src/commands/action/review.ts +94 -0
- package/src/commands/app/deploy.ts +16 -5
- package/src/commands/app/dev.ts +173 -0
- package/src/commands/app/init.ts +270 -28
- package/src/commands/app/sdk.ts +31 -0
- package/src/commands/app/versions.ts +8 -1
- package/src/commands/blueprint/editable.ts +151 -0
- package/src/commands/blueprint/generate-types.ts +58 -0
- package/src/commands/blueprint/get.ts +29 -34
- package/src/commands/blueprint/list.ts +2 -1
- package/src/commands/registry.ts +12 -0
- package/src/context.ts +4 -4
- package/src/dev-broker.ts +71 -0
- package/src/flag-help.ts +24 -1
- package/src/heal.ts +37 -2
- package/src/manifest.ts +89 -8
- package/src/packaging.ts +6 -0
- package/src/project-bootstrap.ts +176 -0
- package/src/project.ts +68 -35
- package/src/provenance.ts +89 -0
- package/src/render-evidence.ts +28 -0
- package/src/sdk-sync.ts +41 -0
- package/src/shadcn-components.ts +106 -0
- package/src/static-app-validation.ts +67 -0
- package/src/template.ts +211 -32
- package/src/templates/next-app-files.ts +1052 -0
- package/src/templates/next-skills.ts +1216 -0
- 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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
appId:
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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
|
+
}
|
package/src/render-evidence.ts
CHANGED
|
@@ -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
|
*
|
package/src/sdk-sync.ts
ADDED
|
@@ -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
|
+
}
|