@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
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
|
|
12
|
+
cd shipments-console && bun install
|
|
13
|
+
frontera app dev
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
##
|
|
46
|
+
## Project configuration
|
|
42
47
|
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
41
|
-
"@frontera-sdk/core": "1.43.
|
|
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
|
+
}
|
package/src/api/apps-api.ts
CHANGED
|
@@ -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
|