@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
package/README.md CHANGED
@@ -9,13 +9,15 @@ bunx @frontera-sdk/cli --help
9
9
  ```bash
10
10
  frontera login
11
11
  frontera app init shipments-console
12
- cd shipments-console && bun install && bun run dev
12
+ cd shipments-console && bun install
13
+ frontera app dev
13
14
  ```
14
15
 
15
- Then open **`/dev-host.html`**, not `/`. An app served straight from Vite never
16
- mounts: the handshake refuses a parent that is not the platform, and renders a
17
- panel saying so. The dev host frames the app and plays the host side of the
18
- bridge, so it mounts and reads real data.
16
+ New Apps are Next.js static exports by default. `frontera app dev` starts Next
17
+ on loopback and a separate authenticated session broker. The stored workspace
18
+ key never enters browser JavaScript or `.env.local`; the browser receives only
19
+ a short-lived, App-scoped token. Existing Vite Apps remain supported with
20
+ `--framework react` and their legacy `dev-host.html` flow.
19
21
 
20
22
  ## The commands
21
23
 
@@ -24,12 +26,15 @@ project) and `frontera app init` (scaffold a new app) are different commands.
24
26
 
25
27
  | | |
26
28
  |---|---|
27
- | `frontera app init <name>` | scaffold a new app |
29
+ | `frontera app init <name>` | scaffold a Next.js static App |
30
+ | `frontera app init <name> --framework react` | scaffold a legacy Vite App |
31
+ | `frontera app dev` | run locally with authenticated Blueprint access |
28
32
  | `frontera app pull <slug>` | fetch an existing app's source |
29
33
  | `frontera app save` | package the working tree to storage |
30
34
  | `frontera app deploy` | build output → an immutable version |
31
35
  | `frontera app list` / `versions` | what exists, what is live |
32
36
  | `frontera blueprint list` / `get` | what data an app can read |
37
+ | `frontera blueprint generate-types` | generate committed App-local types from that data contract |
33
38
 
34
39
  `frontera help --json` returns the whole table as data.
35
40
 
@@ -38,13 +43,136 @@ project) and `frontera app init` (scaffold a new app) are different commands.
38
43
  `0` success · `1` transient · `2` usage · `3` conflict · `4` auth. Branch on
39
44
  them rather than on message text.
40
45
 
41
- ## Scaffolded apps carry the SDK
46
+ ## Project configuration
42
47
 
43
- `frontera app init` writes `@frontera-sdk/core` and `@frontera-sdk/blueprint` into
44
- `src/frontera/` and resolves the `@frontera-sdk/…` specifiers with tsconfig paths
45
- and a matching Vite alias, so a new app installs with nothing but public npm.
46
- `frontera app pull` repairs a tree that predates that — including one whose
47
- dependencies point at paths on the machine that published it.
48
+ `package.json` remains standard package metadata: package identity, scripts,
49
+ dependencies, development dependencies, and package-manager metadata.
50
+ Frontera's artifact contract lives in `frontera.config.json`:
51
+
52
+ ```json
53
+ {
54
+ "displayName": "shipments-console",
55
+ "outputDirectory": "out",
56
+ "routing": "filesystem",
57
+ "connectDomains": [],
58
+ "resourceDomains": []
59
+ }
60
+ ```
61
+
62
+ `runtime` is always static; `routing` describes the artifact rather than the
63
+ framework that produced it. The environment-specific App binding is written to
64
+ gitignored `.frontera/state.json`. Existing projects that already use
65
+ `package.json#frontera` remain readable, but new scaffolds do not add it.
66
+
67
+ Next Apps consume the published `@frontera-sdk/core` and
68
+ `@frontera-sdk/blueprint` packages. Existing Vite Apps retain vendored SDK
69
+ source; `frontera app sdk sync` refreshes only that generated tree.
70
+
71
+ ## What `app init` does
72
+
73
+ It refuses before it writes, then finishes the job:
74
+
75
+ 1. Rejects a name `package.json` would reject — with the name that would have
76
+ worked — and refuses to scaffold over a directory that already holds work.
77
+ 2. Writes the project, its `AGENTS.md`, and its pattern skills.
78
+ 3. Runs `bun install`. A machine with no network gets a reported skip and a
79
+ `bun install` line in the next steps, never a failed command.
80
+ 4. Adds the baseline shadcn components — `button`, `input`, `table`,
81
+ `skeleton`, `card`, `alert` — which the reference feature imports.
82
+ 5. Creates a repository and an initial commit, unless it is already inside one.
83
+ A repository that cannot be committed to is removed rather than left half
84
+ made.
85
+
86
+ `--no-install`, `--no-components` and `--no-git` opt out of the last three.
87
+ Progress goes to stderr, so `--json` still returns nothing but the result
88
+ document.
89
+
90
+ ## Components come from shadcn
91
+
92
+ Apps do not carry a component library this repository maintains. `components.json`
93
+ is configured for the project as scaffolded — Tailwind v4, `src/app/globals.css`,
94
+ the `@/` aliases, `cn` from `@/lib/utils` — so anything upstream publishes drops
95
+ in unmodified:
96
+
97
+ ```bash
98
+ bunx --bun shadcn@latest add dialog dropdown-menu tabs
99
+ ```
100
+
101
+ Two details make that work and are asserted by the scaffold tests. Every item in
102
+ the shadcn registry declares **no npm dependencies**, so the scaffold declares
103
+ what they import — `class-variance-authority`, `radix-ui`, `lucide-react`,
104
+ `tw-animate-css`. And the platform stylesheet already defines every token
105
+ upstream references, so `add` leaves it untouched; `shadcn init` would replace
106
+ it with the vanilla palette, which is why nothing runs it.
107
+
108
+ `frontera app add` remains for Frontera-specific compositions — `app-page`,
109
+ `page-header`, `stat-tile`, `filter-bar`, `data-table` — that upstream has no
110
+ equivalent for.
111
+
112
+ ## What a new App contains
113
+
114
+ `frontera app init` writes a project that already demonstrates the patterns its
115
+ skills describe, because an agent copies the nearest file rather than the
116
+ nearest paragraph:
117
+
118
+ ```text
119
+ src/app/ route adapters: layout, providers, one thin page
120
+ src/ui/app-shell/ the frame, aware of embedded vs standalone
121
+ src/ui/object-explorer/ reference feature — entry, components/, hooks/, utils, types
122
+ src/lib/blueprint/ the data layer, with tests over its pure helpers
123
+ src/lib/copy.ts user-visible strings
124
+ src/components/ui/ shadcn primitives, fetched at init
125
+ components.json shadcn CLI configuration for this project
126
+ AGENTS.md / CLAUDE.md the entry point that routes an agent to the skills
127
+ .agents/skills/ thirteen pattern skills, dispatcher first
128
+ ```
129
+
130
+ The reference feature reads any object type by name, so it works before a data
131
+ model is known: search debounced into a server-side filter, paged reads, a total
132
+ from a separate aggregate over the same object set, a content-shaped skeleton,
133
+ dimmed background refetches, and distinct empty and error states.
134
+
135
+ `bun run check` — typecheck, then `bun test`, then `next build` — is the gate a
136
+ scaffolded project ships with, and the one its skills tell an agent to run
137
+ before claiming a change works.
138
+
139
+ ## Blueprint types
140
+
141
+ From an authenticated App project, generate the active workspace-granted
142
+ Blueprint contract:
143
+
144
+ ```bash
145
+ bun run blueprint:types
146
+ git add src/generated/frontera-blueprint.ts
147
+ ```
148
+
149
+ The generated file is deterministic and contains API names and TypeScript
150
+ types, never server IDs, credentials, origins, or timestamps. Commit it so
151
+ ordinary installs and builds remain offline and reproducible. An authenticated
152
+ CI job may check freshness without writing:
153
+
154
+ ```bash
155
+ bun run blueprint:types:check
156
+ ```
157
+
158
+ Generation is intentionally not part of install, build, local development, or
159
+ deployment. Use `--output <relative.ts>` for a different committed location.
160
+
161
+ ## Build and release
162
+
163
+ Build on the FDE laptop or in customer CI, then upload the immutable result:
164
+
165
+ ```bash
166
+ bun run typecheck
167
+ bun run build
168
+ frontera app deploy --no-promote
169
+ frontera app versions
170
+ frontera app promote 0.1.0
171
+ ```
172
+
173
+ The CLI packages the configured output directory and records packaging
174
+ provenance. The service independently computes the authoritative artifact
175
+ digest, file count, byte count, and uploader.
48
176
 
49
177
  ## It runs on Bun, not Node
50
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.43.9",
3
+ "version": "1.44.0",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -37,8 +37,8 @@
37
37
  "build:release": "bun run scripts/build-release.ts"
38
38
  },
39
39
  "dependencies": {
40
- "@frontera-sdk/automation": "1.43.6",
41
- "@frontera-sdk/core": "1.43.6",
40
+ "@frontera-sdk/automation": "1.43.10",
41
+ "@frontera-sdk/core": "1.43.10",
42
42
  "gray-matter": "^4.0.3",
43
43
  "yaml": "^2.9.0"
44
44
  },
package/src/adopt.ts ADDED
@@ -0,0 +1,436 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { UsageError } from './errors'
5
+ import { nextSkillFiles } from './templates/next-skills'
6
+
7
+ /**
8
+ * Turn an existing Next.js project into a Frontera App.
9
+ *
10
+ * `frontera app init <name>` creates a project. This is the other half, and it
11
+ * is the half an FDE actually meets: the customer already has an app, or the
12
+ * work starts from `create-next-app`, and "scaffold a new one and copy your
13
+ * code across" is not an answer.
14
+ *
15
+ * The shape is borrowed from `shadcn init`, whose whole decision is one check —
16
+ * a `package.json` at the target means initialise in place, its absence means
17
+ * create — and whose preflight REFUSES rather than guesses: an undetected
18
+ * framework, a missing Tailwind entry or an existing `components.json` each
19
+ * stop the command with a specific message. A half-configured project is worse
20
+ * than an unconfigured one, because the author cannot tell which half is theirs.
21
+ *
22
+ * What follows from that here: this module writes files that did not exist and
23
+ * adds keys that were missing, and everything it cannot do SAFELY it reports as
24
+ * a manual step with the exact edit. It never rewrites a config file, a layout
25
+ * or a stylesheet someone else wrote — those are the three files where a clever
26
+ * codemod is indistinguishable from vandalism when it guesses wrong.
27
+ */
28
+
29
+ export interface AdoptionTarget {
30
+ root: string
31
+ /** Where the App Router lives, relative to the root: `src/app` or `app`. */
32
+ appDir: string
33
+ /** True when the project keeps sources under `src/`. */
34
+ srcDir: boolean
35
+ /** The Tailwind entry stylesheet, relative to the root. */
36
+ css: string
37
+ /** The `@/…` style import alias, read from tsconfig rather than assumed. */
38
+ alias: string
39
+ /** Path of the Next config file, relative to the root. */
40
+ nextConfig: string | null
41
+ packageName: string
42
+ /** Already a Frontera App — adoption is a no-op unless forced. */
43
+ alreadyAdopted: boolean
44
+ }
45
+
46
+ interface PackageJson {
47
+ name?: string
48
+ dependencies?: Record<string, string>
49
+ devDependencies?: Record<string, string>
50
+ scripts?: Record<string, string>
51
+ }
52
+
53
+ function readJson<T>(path: string): T | null {
54
+ try {
55
+ return JSON.parse(readFileSync(path, 'utf8')) as T
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Establish that this directory can become an App, or say precisely why not.
63
+ *
64
+ * Every refusal names the thing that is missing. "We could not detect a
65
+ * supported framework" is worth more than a stack trace three files later, and
66
+ * it is the reason shadcn's preflight is a list of specific checks rather than
67
+ * one try/catch.
68
+ */
69
+ export function detectAdoption(root: string): AdoptionTarget {
70
+ const pkg = readJson<PackageJson>(join(root, 'package.json'))
71
+ if (!pkg) {
72
+ throw new UsageError(
73
+ `no package.json in ${root}`,
74
+ 'run `frontera app init <name>` to create a project, or cd into an existing one',
75
+ )
76
+ }
77
+
78
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies }
79
+ if (!dependencies.next) {
80
+ throw new UsageError(
81
+ 'this project does not depend on Next.js',
82
+ 'Frontera Apps are Next.js projects — `frontera app init <name>` scaffolds one',
83
+ )
84
+ }
85
+
86
+ // A monorepo root is a real place to run this by accident, and adopting it
87
+ // would write an App manifest next to a workspace list.
88
+ const workspaces = (readJson<{ workspaces?: unknown }>(join(root, 'package.json')) ?? {}).workspaces
89
+ if (workspaces && !existsSync(join(root, 'app')) && !existsSync(join(root, 'src/app'))) {
90
+ throw new UsageError(
91
+ `${root} looks like a workspace root, not an App`,
92
+ 'cd into the package that holds the Next.js app and run this there',
93
+ )
94
+ }
95
+
96
+ const srcDir = existsSync(join(root, 'src/app'))
97
+ const appDir = srcDir ? 'src/app' : 'app'
98
+ if (!existsSync(join(root, appDir, 'layout.tsx')) && !existsSync(join(root, appDir, 'layout.jsx'))) {
99
+ throw new UsageError(
100
+ 'could not find an App Router layout',
101
+ 'Frontera Apps use the Next App Router — expected app/layout.tsx or src/app/layout.tsx',
102
+ )
103
+ }
104
+
105
+ const css = [`${appDir}/globals.css`, `${appDir}/global.css`, 'styles/globals.css']
106
+ .find((candidate) => existsSync(join(root, candidate)))
107
+ if (!css) {
108
+ throw new UsageError(
109
+ 'could not find a Tailwind entry stylesheet',
110
+ `expected ${appDir}/globals.css — Frontera Apps style with Tailwind v4`,
111
+ )
112
+ }
113
+
114
+ const tsconfig = readJson<{ compilerOptions?: { paths?: Record<string, string[]> } }>(
115
+ join(root, 'tsconfig.json'),
116
+ )
117
+ const paths = tsconfig?.compilerOptions?.paths ?? {}
118
+ // Read the alias rather than assuming `@/*`: a project with no `src/` maps it
119
+ // to `./*`, and writing imports against the wrong one produces a scaffold
120
+ // that resolves nothing.
121
+ const aliasEntry = Object.entries(paths).find(([, targets]) =>
122
+ targets.some((target) => target === (srcDir ? './src/*' : './*')),
123
+ )
124
+ if (!aliasEntry) {
125
+ throw new UsageError(
126
+ 'could not find an import alias in tsconfig.json',
127
+ `add "paths": { "@/*": ["./${srcDir ? 'src/' : ''}*"] } to compilerOptions, then run this again`,
128
+ )
129
+ }
130
+
131
+ const nextConfig = ['next.config.ts', 'next.config.mjs', 'next.config.js']
132
+ .find((candidate) => existsSync(join(root, candidate))) ?? null
133
+
134
+ return {
135
+ root,
136
+ appDir,
137
+ srcDir,
138
+ css,
139
+ alias: aliasEntry[0].replace(/\/\*$/, ''),
140
+ nextConfig,
141
+ packageName: pkg.name ?? 'app',
142
+ alreadyAdopted: existsSync(join(root, 'frontera.config.json')),
143
+ }
144
+ }
145
+
146
+ export interface AdoptionPlan {
147
+ /** New files, keyed by path relative to the root. Never overwrites. */
148
+ files: Record<string, string>
149
+ /** Dependencies to add, with the ranges the scaffold pins. */
150
+ dependencies: Record<string, string>
151
+ /** Development dependencies to add. */
152
+ devDependencies: Record<string, string>
153
+ /** Scripts to add. Existing scripts are never replaced. */
154
+ scripts: Record<string, string>
155
+ /** Edits this command will not make for you, with the exact change. */
156
+ manual: string[]
157
+ }
158
+
159
+ /**
160
+ * What a Frontera App needs that a plain Next.js project does not have.
161
+ *
162
+ * The shadcn packages are here for the same reason the scaffold declares them:
163
+ * this command writes `lib/utils.ts` and `components.json`, and every item in
164
+ * that registry declares NO dependencies of its own. Adopting a project and
165
+ * leaving it unable to resolve `clsx` is precisely the half-configured state
166
+ * the preflight exists to prevent — and it was caught by building an adopted
167
+ * project rather than by reading this list.
168
+ */
169
+ const REQUIRED_DEPENDENCIES: Record<string, string> = {
170
+ '@frontera-sdk/blueprint': '^1.43.10',
171
+ '@frontera-sdk/core': '^1.43.10',
172
+ '@tanstack/react-query': '^5.90.21',
173
+ 'class-variance-authority': '^0.7.1',
174
+ clsx: '^2.1.1',
175
+ 'lucide-react': '^1.31.0',
176
+ 'radix-ui': '^1.6.7',
177
+ 'tailwind-merge': '^3.3.1',
178
+ }
179
+
180
+ /** The Tailwind v4 animation utilities shadcn overlays style themselves with. */
181
+ const REQUIRED_DEV_DEPENDENCIES: Record<string, string> = {
182
+ 'tw-animate-css': '^1.4.0',
183
+ }
184
+
185
+ const REQUIRED_SCRIPTS: Record<string, string> = {
186
+ 'blueprint:types': 'frontera blueprint generate-types',
187
+ 'blueprint:types:check': 'frontera blueprint generate-types --check',
188
+ deploy: 'bun run build && frontera app deploy',
189
+ }
190
+
191
+ const NEXT_CONFIG_KEYS = [
192
+ ['output', "'export'"],
193
+ ['trailingSlash', 'true'],
194
+ ['images', '{ unoptimized: true }'],
195
+ ['transpilePackages', "['@frontera-sdk/core', '@frontera-sdk/blueprint']"],
196
+ ] as const
197
+
198
+ export function planAdoption(target: AdoptionTarget): AdoptionPlan {
199
+ const pkg = readJson<PackageJson>(join(target.root, 'package.json')) ?? {}
200
+ const declared = { ...pkg.devDependencies, ...pkg.dependencies }
201
+ const files: Record<string, string> = {}
202
+ const manual: string[] = []
203
+
204
+ const put = (path: string, content: string) => {
205
+ if (!existsSync(join(target.root, path))) files[path] = content
206
+ }
207
+
208
+ put(
209
+ 'frontera.config.json',
210
+ `${JSON.stringify(
211
+ {
212
+ displayName: target.packageName,
213
+ outputDirectory: 'out',
214
+ routing: 'filesystem',
215
+ connectDomains: [],
216
+ resourceDomains: [],
217
+ },
218
+ null,
219
+ 2,
220
+ )}\n`,
221
+ )
222
+
223
+ // The provider tree is a NEW file, so writing it is safe; mounting it is an
224
+ // edit to their layout, which is not.
225
+ put(
226
+ `${target.appDir}/providers.tsx`,
227
+ `'use client'
228
+
229
+ import type { ReactNode } from 'react'
230
+ import { blueprintProvider } from '@frontera-sdk/blueprint/provider'
231
+ import { FronteraAppProvider } from '@frontera-sdk/core/react'
232
+
233
+ const devSessionEndpoint = process.env.NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT
234
+ if (devSessionEndpoint) {
235
+ const runtime = globalThis as typeof globalThis & {
236
+ __FRONTERA_CONFIG__?: Record<string, unknown>
237
+ }
238
+ runtime.__FRONTERA_CONFIG__ = { ...runtime.__FRONTERA_CONFIG__, devSessionEndpoint }
239
+ }
240
+
241
+ export function Providers({ children }: { children: ReactNode }) {
242
+ return <FronteraAppProvider providers={[blueprintProvider]}>{children}</FronteraAppProvider>
243
+ }
244
+ `,
245
+ )
246
+
247
+ // The patterns, unchanged from a scaffolded App — an adopted project gets the
248
+ // same skills, or the guidance is only true for projects that started here.
249
+ for (const [path, content] of Object.entries(nextSkillFiles())) put(path, content)
250
+
251
+ /**
252
+ * The entry point that makes those skills findable.
253
+ *
254
+ * An adopted project usually HAS an AGENTS.md already — `create-next-app`
255
+ * writes one — and overwriting it would delete instructions someone wrote
256
+ * about their own codebase. So: write it when it is absent, and when it is
257
+ * not, say the one line that needs adding rather than claiming the skills are
258
+ * wired up when nothing points at them.
259
+ */
260
+ const agentsPath = join(target.root, 'AGENTS.md')
261
+ if (!existsSync(agentsPath)) {
262
+ files['AGENTS.md'] = adoptedAgentsMd(target)
263
+ } else if (!readFileSync(agentsPath, 'utf8').includes('using-frontera-app-patterns')) {
264
+ manual.push(
265
+ 'AGENTS.md: point agents at `.agents/skills/using-frontera-app-patterns/SKILL.md` before anything else',
266
+ )
267
+ }
268
+
269
+ put(
270
+ 'components.json',
271
+ `${JSON.stringify(
272
+ {
273
+ $schema: 'https://ui.shadcn.com/schema.json',
274
+ style: 'new-york',
275
+ rsc: false,
276
+ tsx: true,
277
+ tailwind: { config: '', css: target.css, baseColor: 'neutral', cssVariables: true, prefix: '' },
278
+ aliases: {
279
+ components: `${target.alias}/components`,
280
+ ui: `${target.alias}/components/ui`,
281
+ utils: `${target.alias}/lib/utils`,
282
+ lib: `${target.alias}/lib`,
283
+ hooks: `${target.alias}/hooks`,
284
+ },
285
+ iconLibrary: 'lucide',
286
+ },
287
+ null,
288
+ 2,
289
+ )}\n`,
290
+ )
291
+
292
+ put(
293
+ `${target.srcDir ? 'src/lib' : 'lib'}/utils.ts`,
294
+ `import { clsx, type ClassValue } from 'clsx'
295
+ import { twMerge } from 'tailwind-merge'
296
+
297
+ export function cn(...inputs: ClassValue[]) {
298
+ return twMerge(clsx(inputs))
299
+ }
300
+ `,
301
+ )
302
+
303
+ // Never rewritten, only extended — and only when the entry is missing.
304
+ const gitignore = existsSync(join(target.root, '.gitignore'))
305
+ ? readFileSync(join(target.root, '.gitignore'), 'utf8')
306
+ : ''
307
+ const missingIgnores = ['.frontera/', 'out/'].filter(
308
+ (entry) => !gitignore.split('\n').some((line) => line.trim() === entry.replace(/\/$/, '') || line.trim() === entry),
309
+ )
310
+ if (missingIgnores.length > 0) {
311
+ manual.push(`add to .gitignore: ${missingIgnores.join(', ')}`)
312
+ }
313
+
314
+ const nextConfigSource = target.nextConfig
315
+ ? readFileSync(join(target.root, target.nextConfig), 'utf8')
316
+ : null
317
+ if (!nextConfigSource) {
318
+ files['next.config.ts'] = `import type { NextConfig } from 'next'
319
+
320
+ const nextConfig: NextConfig = {
321
+ output: 'export',
322
+ trailingSlash: true,
323
+ images: { unoptimized: true },
324
+ transpilePackages: ['@frontera-sdk/core', '@frontera-sdk/blueprint'],
325
+ }
326
+
327
+ export default nextConfig
328
+ `
329
+ } else {
330
+ const missingKeys = NEXT_CONFIG_KEYS.filter(([key]) => !new RegExp(`\\b${key}\\s*:`).test(nextConfigSource))
331
+ if (missingKeys.length > 0) {
332
+ manual.push(
333
+ `${target.nextConfig}: add ${missingKeys.map(([key, value]) => `${key}: ${value}`).join(', ')}`,
334
+ )
335
+ }
336
+ }
337
+
338
+ const stylesheet = readFileSync(join(target.root, target.css), 'utf8')
339
+ if (!stylesheet.includes('tw-animate-css')) {
340
+ manual.push(`${target.css}: add \`@import "tw-animate-css";\` after the Tailwind import`)
341
+ }
342
+
343
+ const layoutPath = existsSync(join(target.root, target.appDir, 'layout.tsx'))
344
+ ? `${target.appDir}/layout.tsx`
345
+ : `${target.appDir}/layout.jsx`
346
+ const layout = readFileSync(join(target.root, layoutPath), 'utf8')
347
+ if (!layout.includes('Providers')) {
348
+ manual.push(
349
+ `${layoutPath}: import { Providers } from './providers' and wrap {children} in <Providers>`,
350
+ )
351
+ }
352
+
353
+ return {
354
+ files,
355
+ dependencies: Object.fromEntries(
356
+ Object.entries(REQUIRED_DEPENDENCIES).filter(([name]) => !declared[name]),
357
+ ),
358
+ devDependencies: Object.fromEntries(
359
+ Object.entries(REQUIRED_DEV_DEPENDENCIES).filter(([name]) => !declared[name]),
360
+ ),
361
+ scripts: Object.fromEntries(
362
+ Object.entries(REQUIRED_SCRIPTS).filter(([name]) => !pkg.scripts?.[name]),
363
+ ),
364
+ manual,
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Merge the plan's dependencies and scripts into an existing manifest.
370
+ *
371
+ * Returns the new text rather than writing, so the caller decides — and so the
372
+ * merge itself is testable without a directory. Existing entries always win:
373
+ * a project that pins its own React Query version has a reason.
374
+ */
375
+ export function mergeManifest(source: string, plan: AdoptionPlan): string {
376
+ const pkg = JSON.parse(source) as PackageJson & Record<string, unknown>
377
+ const indent = /\n(\s+)"/.exec(source)?.[1]?.length ?? 2
378
+
379
+ if (Object.keys(plan.dependencies).length > 0) {
380
+ pkg.dependencies = sortKeys({ ...pkg.dependencies, ...plan.dependencies })
381
+ }
382
+ if (Object.keys(plan.devDependencies).length > 0) {
383
+ pkg.devDependencies = sortKeys({ ...pkg.devDependencies, ...plan.devDependencies })
384
+ }
385
+ if (Object.keys(plan.scripts).length > 0) {
386
+ pkg.scripts = { ...pkg.scripts, ...plan.scripts }
387
+ }
388
+ return `${JSON.stringify(pkg, null, indent)}\n`
389
+ }
390
+
391
+ function sortKeys(record: Record<string, string>): Record<string, string> {
392
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)))
393
+ }
394
+
395
+ /** The agent entry point for a project that already had a life before Frontera. */
396
+ function adoptedAgentsMd(target: AdoptionTarget): string {
397
+ return `# ${target.packageName}
398
+
399
+ This project is a Frontera App: a Next.js App Router project whose deployable
400
+ result is static files, reading platform data through Blueprint.
401
+
402
+ ## Read this first
403
+
404
+ Load \`.agents/skills/using-frontera-app-patterns/SKILL.md\`. It is the
405
+ dispatcher — it routes you to the one skill that governs the files you are
406
+ about to change.
407
+
408
+ ## Where things go
409
+
410
+ | Path | What it is |
411
+ |---|---|
412
+ | \`${target.appDir}/\` | route adapters only — layout, providers, thin pages |
413
+ | \`${target.srcDir ? 'src/ui' : 'ui'}/<feature>/\` | feature folders; one component per file |
414
+ | \`${target.srcDir ? 'src/components' : 'components'}/ui/\` | shadcn primitives — \`bunx --bun shadcn@latest add <name>\` |
415
+ | \`${target.srcDir ? 'src/lib' : 'lib'}/blueprint/<domain>/\` | the data layer: Blueprint reads live here |
416
+
417
+ This project was adopted rather than scaffolded, so those directories may not
418
+ exist yet. Create them as you go; the skills describe what belongs in each.
419
+
420
+ ## Non-negotiables
421
+
422
+ 1. Bun runs everything: \`bun install\`, \`bun run <script>\`, \`bun test\`.
423
+ 2. Components never fetch. Hooks fetch; components take props.
424
+ 3. Filter, sort, page and aggregate on the server.
425
+ 4. No secrets in browser code — not in source, not in \`NEXT_PUBLIC_*\`, not in \`.env\`.
426
+ 5. Every route must exist as a static file after \`bun run build\`.
427
+
428
+ ## Verify before claiming done
429
+
430
+ \`\`\`bash
431
+ bun run build
432
+ \`\`\`
433
+
434
+ For anything touching data, run it against real data with \`frontera app dev\`.
435
+ `
436
+ }
@@ -10,6 +10,24 @@ export interface AppVersionSummary {
10
10
  uploadedBy: string
11
11
  createdAt: string
12
12
  deployed: boolean
13
+ runtime: 'static'
14
+ routing: 'spa' | 'filesystem'
15
+ provenance: {
16
+ schemaVersion?: number
17
+ cliVersion?: string
18
+ framework?: string
19
+ frameworkVersion?: string
20
+ sourceCommit?: string
21
+ sourceDirty?: boolean
22
+ lockfilePath?: string
23
+ lockfileDigest?: string
24
+ packagedAt?: string
25
+ } | null
26
+ }
27
+
28
+ export interface AppTokenResponse {
29
+ token: string
30
+ expiresAt: number
13
31
  }
14
32
 
15
33
  /**
@@ -98,6 +116,10 @@ export class AppsApi {
98
116
  return this.client.request<AppVersionSummary[]>(`/v1/platform-apps/${appId}/versions`)
99
117
  }
100
118
 
119
+ appToken(appId: string): Promise<AppTokenResponse> {
120
+ return this.client.request<AppTokenResponse>(`/v1/platform-apps/${appId}/token`)
121
+ }
122
+
101
123
  publish(appId: string, args: {
102
124
  version: string
103
125
  parentVersion?: string | null
@@ -107,6 +129,10 @@ export class AppsApi {
107
129
  promote: boolean
108
130
  /** §4.4 — whether this tree had been rendered since its last source change. */
109
131
  renderEvidence?: unknown
132
+ /** Recorded build/package metadata; never trusted for authorization. */
133
+ provenance?: unknown
134
+ /** PNG from that render; the platform keeps it as the app's thumbnail. */
135
+ thumbnail?: Uint8Array | null
110
136
  }) {
111
137
  const form = new FormData()
112
138
  form.set('version', args.version)
@@ -116,6 +142,10 @@ export class AppsApi {
116
142
  if (args.manifest) form.set('manifest', JSON.stringify(args.manifest))
117
143
  form.set('promote', args.promote ? 'true' : 'false')
118
144
  if (args.renderEvidence) form.set('renderEvidence', JSON.stringify(args.renderEvidence))
145
+ if (args.provenance) form.set('provenance', JSON.stringify(args.provenance))
146
+ if (args.thumbnail && args.thumbnail.byteLength > 0) {
147
+ form.set('thumbnail', new Blob([args.thumbnail], { type: 'image/png' }), 'thumbnail.png')
148
+ }
119
149
  return this.multipart<{
120
150
  version: string
121
151
  promoted: boolean