@frontera-sdk/cli 0.1.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/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
package/src/template.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import vendored from './vendor/sdk-sources.json'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Project scaffold.
|
|
8
|
+
*
|
|
9
|
+
* Everything the author should NOT have to write lives here: the Vite config,
|
|
10
|
+
* the entry point, the design tokens, and the SDK itself. What is left in
|
|
11
|
+
* `src/App.tsx` is application code, which is the whole point of the rewrite.
|
|
12
|
+
*
|
|
13
|
+
* The SDK arrives as VENDORED SOURCE under `src/frontera/`, not as a
|
|
14
|
+
* dependency — see `scripts/sync-sdk.ts` for why. The import specifiers still
|
|
15
|
+
* read `@frontera-sdk/core/…`; `tsconfig` paths and a matching Vite alias
|
|
16
|
+
* resolve them. So `package.json` needs nothing but public npm, and a project
|
|
17
|
+
* pulled onto a machine that has never seen this monorepo still builds.
|
|
18
|
+
*/
|
|
19
|
+
export function scaffold(target: string, name: string): void {
|
|
20
|
+
for (const [rel, content] of Object.entries(files(name))) {
|
|
21
|
+
const full = join(target, rel)
|
|
22
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
23
|
+
writeFileSync(full, content)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The scaffold's file map, exported so tests can assert its contents without
|
|
29
|
+
* writing to disk — in particular that every `frontera …` command named in a
|
|
30
|
+
* shipped SKILL.md actually exists, and that every `@frontera-sdk/…` import
|
|
31
|
+
* resolves to a file the scaffold actually writes.
|
|
32
|
+
*/
|
|
33
|
+
export function scaffoldFiles(name: string): Record<string, string> {
|
|
34
|
+
return files(name)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The SDK packages an APP carries as vendored source — the ones `tsconfig`
|
|
39
|
+
* paths and the Vite alias below actually resolve.
|
|
40
|
+
*
|
|
41
|
+
* The CLI vendors more than an app needs: `frontera/automation` is carried for
|
|
42
|
+
* automations, and its `manifest.ts` imports `cron-parser`, which is not a
|
|
43
|
+
* dependency of a React app. Writing it here would leave every scaffolded app
|
|
44
|
+
* with an unresolvable import and a failing `bun run typecheck`, for a module
|
|
45
|
+
* nothing in the app imports. So the scaffold takes what it can resolve, and
|
|
46
|
+
* this table moves when an alias does.
|
|
47
|
+
*
|
|
48
|
+
* `heal.ts` reads the same table to repair a PULLED app, so the two paths
|
|
49
|
+
* cannot drift into vendoring different sets of files under different names.
|
|
50
|
+
*/
|
|
51
|
+
export const APP_SDK_PACKAGES = {
|
|
52
|
+
'@frontera-sdk/core': { prefix: 'frontera/core/', dir: 'src/frontera/core' },
|
|
53
|
+
'@frontera-sdk/blueprint': { prefix: 'frontera/blueprint/', dir: 'src/frontera/blueprint' },
|
|
54
|
+
} as const
|
|
55
|
+
|
|
56
|
+
export type AppSdkPackage = keyof typeof APP_SDK_PACKAGES
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The names these packages had before the `@frontera-sdk` scope, mapped to what
|
|
60
|
+
* they are now.
|
|
61
|
+
*
|
|
62
|
+
* This table is the difference between `heal` working and `heal` being
|
|
63
|
+
* decorative. Every app already in storage was packaged under the old scope —
|
|
64
|
+
* that is what "legacy tree" MEANS here — so a healer that matches only
|
|
65
|
+
* `@frontera-sdk/*` repairs nothing that actually exists. The rename moved the
|
|
66
|
+
* code and the test fixture in one commit, so the suite followed the bug
|
|
67
|
+
* instead of catching it.
|
|
68
|
+
*
|
|
69
|
+
* Deleting an entry is not a cleanup. It is dropping support for trees that are
|
|
70
|
+
* still in storage, and they do not migrate themselves.
|
|
71
|
+
*/
|
|
72
|
+
export const LEGACY_SDK_ALIASES: Record<string, AppSdkPackage> = {
|
|
73
|
+
'@frontera/sdk-core': '@frontera-sdk/core',
|
|
74
|
+
'@frontera/blueprint': '@frontera-sdk/blueprint',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The current name for a dependency, whichever scope it was written in. */
|
|
78
|
+
export function currentSdkPackage(dep: string): AppSdkPackage | null {
|
|
79
|
+
if (dep in LEGACY_SDK_ALIASES) return LEGACY_SDK_ALIASES[dep]!
|
|
80
|
+
if (dep in APP_SDK_PACKAGES) return dep as AppSdkPackage
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const APP_SDK_DIRS = Object.values(APP_SDK_PACKAGES).map((p) => p.prefix)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The vendored source of ONE SDK package, keyed by its path relative to that
|
|
88
|
+
* package's own directory (`client.ts`, not `frontera/core/client.ts`).
|
|
89
|
+
*/
|
|
90
|
+
export function sdkPackageFiles(pkg: AppSdkPackage): Record<string, string> {
|
|
91
|
+
const { prefix } = APP_SDK_PACKAGES[pkg]
|
|
92
|
+
const out: Record<string, string> = {}
|
|
93
|
+
for (const [path, content] of Object.entries(vendored.files)) {
|
|
94
|
+
if (path.startsWith(prefix)) out[path.slice(prefix.length)] = content
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** SDK source and design tokens, written under `src/`. */
|
|
100
|
+
function vendoredFiles(): Record<string, string> {
|
|
101
|
+
const out: Record<string, string> = {}
|
|
102
|
+
for (const [path, content] of Object.entries(vendored.files)) {
|
|
103
|
+
const isSdk = path.startsWith('frontera/')
|
|
104
|
+
if (isSdk && !APP_SDK_DIRS.some((dir) => path.startsWith(dir))) continue
|
|
105
|
+
out[`src/${path}`] = content
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function files(name: string): Record<string, string> {
|
|
111
|
+
return {
|
|
112
|
+
...vendoredFiles(),
|
|
113
|
+
|
|
114
|
+
'package.json': `${JSON.stringify(
|
|
115
|
+
{
|
|
116
|
+
name,
|
|
117
|
+
private: true,
|
|
118
|
+
version: '0.1.0',
|
|
119
|
+
type: 'module',
|
|
120
|
+
frontera: {
|
|
121
|
+
// No slug: the platform owns it and can rename it, so a committed
|
|
122
|
+
// copy would go stale. `name` above seeds it on the first deploy, and
|
|
123
|
+
// `frontera.appId` is written back once the app exists.
|
|
124
|
+
displayName: name,
|
|
125
|
+
// Origins this app may reach at runtime, enforced as a CSP by the
|
|
126
|
+
// host. `frontera app deploy` turns these into the version's
|
|
127
|
+
// manifest, so an addition takes effect on the next deploy — add one
|
|
128
|
+
// deliberately rather than discovering the block in production.
|
|
129
|
+
connectDomains: [],
|
|
130
|
+
resourceDomains: [],
|
|
131
|
+
},
|
|
132
|
+
scripts: {
|
|
133
|
+
dev: 'vite',
|
|
134
|
+
build: 'vite build',
|
|
135
|
+
typecheck: 'tsc --noEmit',
|
|
136
|
+
deploy: 'bun run build && frontera app deploy',
|
|
137
|
+
},
|
|
138
|
+
dependencies: {
|
|
139
|
+
'@tanstack/react-query': '^5.90.21',
|
|
140
|
+
react: '^19.0.0',
|
|
141
|
+
'react-dom': '^19.0.0',
|
|
142
|
+
},
|
|
143
|
+
devDependencies: {
|
|
144
|
+
'@tailwindcss/vite': '^4.1.0',
|
|
145
|
+
// For `node:url` in vite.config.ts and the `process` guard in the
|
|
146
|
+
// SDK's config resolution — both compile without it, and neither
|
|
147
|
+
// type-checks.
|
|
148
|
+
'@types/node': '^22',
|
|
149
|
+
'@types/react': '^19',
|
|
150
|
+
'@types/react-dom': '^19',
|
|
151
|
+
'@vitejs/plugin-react': '^5.0.0',
|
|
152
|
+
tailwindcss: '^4.1.0',
|
|
153
|
+
typescript: '^5.9.3',
|
|
154
|
+
vite: '^7.0.0',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
)}\n`,
|
|
160
|
+
|
|
161
|
+
'vite.config.ts': `import { fileURLToPath, URL } from 'node:url'
|
|
162
|
+
import { defineConfig } from 'vite'
|
|
163
|
+
import react from '@vitejs/plugin-react'
|
|
164
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
165
|
+
|
|
166
|
+
const src = (path: string) => fileURLToPath(new URL(\`./src/\${path}\`, import.meta.url))
|
|
167
|
+
|
|
168
|
+
// The SDK is vendored into src/frontera/ so this project installs and builds
|
|
169
|
+
// with nothing but public npm. These aliases are what keep the import
|
|
170
|
+
// specifiers stable — app code, copied components and the docs all say
|
|
171
|
+
// \`@frontera-sdk/core/…\`, and only this file knows where that resolves.
|
|
172
|
+
// Keep them in step with the \`paths\` in tsconfig.json.
|
|
173
|
+
export default defineConfig({
|
|
174
|
+
plugins: [react(), tailwindcss()],
|
|
175
|
+
resolve: {
|
|
176
|
+
alias: [
|
|
177
|
+
{ find: /^@frontera-sdk\\/core\\/(.*)$/, replacement: src('frontera/core/$1') },
|
|
178
|
+
{ find: /^@frontera-sdk\\/blueprint\\/(.*)$/, replacement: src('frontera/blueprint/$1') },
|
|
179
|
+
{ find: /^@\\/(.*)$/, replacement: src('$1') },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
`,
|
|
184
|
+
|
|
185
|
+
'tsconfig.json': `${JSON.stringify(
|
|
186
|
+
{
|
|
187
|
+
compilerOptions: {
|
|
188
|
+
target: 'ESNext',
|
|
189
|
+
module: 'ESNext',
|
|
190
|
+
moduleResolution: 'bundler',
|
|
191
|
+
lib: ['ESNext', 'DOM'],
|
|
192
|
+
jsx: 'react-jsx',
|
|
193
|
+
strict: true,
|
|
194
|
+
skipLibCheck: true,
|
|
195
|
+
noEmit: true,
|
|
196
|
+
baseUrl: '.',
|
|
197
|
+
paths: {
|
|
198
|
+
'@/*': ['./src/*'],
|
|
199
|
+
// Mirrors resolve.alias in vite.config.ts — the editor and the
|
|
200
|
+
// bundler have to agree or one of them lies to you.
|
|
201
|
+
'@frontera-sdk/core/*': ['./src/frontera/core/*'],
|
|
202
|
+
'@frontera-sdk/blueprint/*': ['./src/frontera/blueprint/*'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
include: ['src', 'vite.config.ts'],
|
|
206
|
+
},
|
|
207
|
+
null,
|
|
208
|
+
2,
|
|
209
|
+
)}\n`,
|
|
210
|
+
|
|
211
|
+
'dev-host.html': devHostHtml(name),
|
|
212
|
+
|
|
213
|
+
'index.html': `<!doctype html>
|
|
214
|
+
<html lang="en">
|
|
215
|
+
<head>
|
|
216
|
+
<meta charset="UTF-8" />
|
|
217
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
218
|
+
<title>${name}</title>
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
<div id="root"></div>
|
|
222
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
223
|
+
</body>
|
|
224
|
+
</html>
|
|
225
|
+
`,
|
|
226
|
+
|
|
227
|
+
'src/main.tsx': `import './theme.css'
|
|
228
|
+
|
|
229
|
+
import { createFronteraApp } from '@frontera-sdk/core/create-frontera-app'
|
|
230
|
+
import { blueprintProvider } from '@frontera-sdk/blueprint/provider'
|
|
231
|
+
|
|
232
|
+
import App from './App'
|
|
233
|
+
|
|
234
|
+
// Completes the bridge handshake, adopts the host's palette, installs the
|
|
235
|
+
// error boundary that reports crashes back to the platform, and only THEN
|
|
236
|
+
// mounts — an app has no data scope until the host says who it is.
|
|
237
|
+
createFronteraApp(<App />, { providers: [blueprintProvider] })
|
|
238
|
+
`,
|
|
239
|
+
|
|
240
|
+
'src/App.tsx': `import { useObjects } from '@frontera-sdk/blueprint/hooks'
|
|
241
|
+
import { useFronteraApp } from '@frontera-sdk/core/create-frontera-app'
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The object type this page reads.
|
|
245
|
+
*
|
|
246
|
+
* \`frontera blueprint list\` shows what this workspace exposes; put an API name
|
|
247
|
+
* here — \`Shipment\`, \`LoanApplication\`, whatever the deployment models — and
|
|
248
|
+
* \`frontera blueprint get <apiName>\` lists its properties.
|
|
249
|
+
*
|
|
250
|
+
* Annotated \`: string\` on purpose. Without it TypeScript infers the literal
|
|
251
|
+
* type \`''\`, and the empty-check below becomes a "no overlap" error the moment
|
|
252
|
+
* you fill this in — a type error caused by doing exactly what the comment says.
|
|
253
|
+
*/
|
|
254
|
+
const OBJECT_TYPE: string = ''
|
|
255
|
+
|
|
256
|
+
export default function App() {
|
|
257
|
+
const { init } = useFronteraApp()
|
|
258
|
+
const { data, isLoading, error } = useObjects(OBJECT_TYPE, {
|
|
259
|
+
pageSize: 20,
|
|
260
|
+
enabled: OBJECT_TYPE.length > 0,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<main className="mx-auto flex max-w-5xl flex-col gap-6 p-8">
|
|
265
|
+
<header>
|
|
266
|
+
<h1 className="text-xl font-semibold text-foreground">${name}</h1>
|
|
267
|
+
<p className="text-sm text-muted-foreground">version {init.version}</p>
|
|
268
|
+
</header>
|
|
269
|
+
|
|
270
|
+
{OBJECT_TYPE === '' ? (
|
|
271
|
+
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
|
|
272
|
+
Set <code className="font-mono text-foreground">OBJECT_TYPE</code> in{' '}
|
|
273
|
+
<code className="font-mono text-foreground">src/App.tsx</code> to read live data. Run{' '}
|
|
274
|
+
<code className="font-mono text-foreground">frontera blueprint list</code> to see what
|
|
275
|
+
this workspace exposes.
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<section className="flex flex-col gap-2">
|
|
279
|
+
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
|
280
|
+
{error && (
|
|
281
|
+
<p role="alert" className="text-sm text-destructive">
|
|
282
|
+
{error.message}
|
|
283
|
+
</p>
|
|
284
|
+
)}
|
|
285
|
+
{data && (
|
|
286
|
+
<p className="text-sm text-muted-foreground">
|
|
287
|
+
{data.rows.length} of {OBJECT_TYPE}
|
|
288
|
+
</p>
|
|
289
|
+
)}
|
|
290
|
+
</section>
|
|
291
|
+
)}
|
|
292
|
+
</main>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
`,
|
|
296
|
+
|
|
297
|
+
'.gitignore': `node_modules/
|
|
298
|
+
dist/
|
|
299
|
+
.frontera/
|
|
300
|
+
*.log
|
|
301
|
+
`,
|
|
302
|
+
|
|
303
|
+
'.agents/skills/using-frontera-sdk/SKILL.md': `---
|
|
304
|
+
name: using-frontera-sdk
|
|
305
|
+
description: Use at the start of ANY task in a Frontera app project — a Vite project whose package.json carries a "frontera" key and that deploys via the frontera CLI. Establishes the project shape and routes to the right pattern.
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
# Frontera App Development
|
|
309
|
+
|
|
310
|
+
An app is ordinary React that runs in a sandboxed iframe inside Frontera and
|
|
311
|
+
reaches platform data through the SDK. You write application code; the scaffold
|
|
312
|
+
owns the runtime.
|
|
313
|
+
|
|
314
|
+
## The layout
|
|
315
|
+
|
|
316
|
+
| Path | What it is |
|
|
317
|
+
|---|---|
|
|
318
|
+
| \`src/App.tsx\` | yours — the page |
|
|
319
|
+
| \`src/components/ui/\` | yours — copied source from \`frontera app add\` |
|
|
320
|
+
| \`src/frontera/\` | **the SDK, vendored.** Read it freely; do not edit it |
|
|
321
|
+
| \`src/theme.css\` | design tokens + Tailwind entry, imported by \`main.tsx\` |
|
|
322
|
+
| \`src/main.tsx\`, \`vite.config.ts\` | the runtime wiring |
|
|
323
|
+
|
|
324
|
+
\`src/frontera/\` is a copy of \`@frontera-sdk/core\` and \`@frontera-sdk/blueprint\`,
|
|
325
|
+
written here at \`frontera app init\`. That is why this project installs with
|
|
326
|
+
nothing but public npm. Imports still read \`@frontera-sdk/core/…\` — the
|
|
327
|
+
tsconfig \`paths\` and the Vite alias resolve them — so **never rewrite an import
|
|
328
|
+
to a relative path into \`src/frontera/\`**, and never add \`@frontera-sdk/*\` to
|
|
329
|
+
\`package.json\`.
|
|
330
|
+
|
|
331
|
+
## Non-negotiables
|
|
332
|
+
|
|
333
|
+
1. **Never edit \`vite.config.ts\`, \`src/main.tsx\`, or \`src/frontera/\`.** They
|
|
334
|
+
wire the bridge and the module resolution. If you think you need to, you are
|
|
335
|
+
solving the wrong problem.
|
|
336
|
+
2. **Components are yours.** \`src/components/ui/\` is copied source, not a
|
|
337
|
+
dependency — edit it in place. \`frontera app add <name>\` fetches more.
|
|
338
|
+
3. **Data arrives as props.** Components never fetch; hooks do, at the page level.
|
|
339
|
+
4. **Filter on the server.** Pass \`where\` to \`useObjects\`; filtering the
|
|
340
|
+
returned array narrows one page and misreports every total.
|
|
341
|
+
5. **Never commit \`.env\`.** The CLI refuses to package it, and that refusal is
|
|
342
|
+
not overridable.
|
|
343
|
+
|
|
344
|
+
## Where to look
|
|
345
|
+
|
|
346
|
+
| Task | Source of truth |
|
|
347
|
+
|---|---|
|
|
348
|
+
| What data is available | \`frontera blueprint list\`, then \`frontera blueprint get <apiName>\` |
|
|
349
|
+
| What a component accepts | the file in \`src/components/ui/\` — read it, it is yours |
|
|
350
|
+
| What a hook returns | \`src/frontera/blueprint/hooks.ts\` |
|
|
351
|
+
| Deploy and versions | \`frontera app versions\`, \`frontera app deploy --help\` |
|
|
352
|
+
|
|
353
|
+
Read the source rather than trusting a summary: this file deliberately does not
|
|
354
|
+
restate prop tables or function signatures, because those change per release
|
|
355
|
+
and a stale skill is worse than no skill.
|
|
356
|
+
|
|
357
|
+
## Verify before claiming done
|
|
358
|
+
|
|
359
|
+
\`\`\`bash
|
|
360
|
+
bun run typecheck
|
|
361
|
+
\`\`\`
|
|
362
|
+
|
|
363
|
+
\`\`\`bash
|
|
364
|
+
bun run build
|
|
365
|
+
\`\`\`
|
|
366
|
+
|
|
367
|
+
\`\`\`bash
|
|
368
|
+
frontera app deploy --no-promote
|
|
369
|
+
\`\`\`
|
|
370
|
+
|
|
371
|
+
\`vite build\` does not type-check, so a build that succeeds proves nothing about
|
|
372
|
+
types — run both. \`bun run build\` is the deploy gate: \`frontera app deploy\`
|
|
373
|
+
refuses without \`dist/index.html\`. \`--no-promote\` publishes a version without
|
|
374
|
+
moving the live pointer, so you can preview it before customers see it.
|
|
375
|
+
|
|
376
|
+
## Running it locally
|
|
377
|
+
|
|
378
|
+
\`bun run dev\` serves the app. Opening \`/\` directly renders "This app runs
|
|
379
|
+
inside Frontera" instead of mounting — the handshake refusing to hand a
|
|
380
|
+
credential to an unknown parent, not a bug.
|
|
381
|
+
|
|
382
|
+
Open **\`/dev-host.html\`** to actually see it. That page frames the app and plays
|
|
383
|
+
the host side of the bridge (init, theme, navigate), so it mounts and reads real
|
|
384
|
+
data. Set \`VITE_FRONTERA_TOKEN\` and \`VITE_FRONTERA_API_URL\` in \`.env.local\` for
|
|
385
|
+
platform reads to succeed; without a token the app mounts and its queries 401.
|
|
386
|
+
|
|
387
|
+
The dev host lives at the project root, so it is served in dev and never built
|
|
388
|
+
into \`dist/\`. Do not move it into \`public/\` — a page that fakes a platform
|
|
389
|
+
handshake must not ship with the app.
|
|
390
|
+
`,
|
|
391
|
+
|
|
392
|
+
'.agents/skills/frontera-app-data/SKILL.md': `---
|
|
393
|
+
name: frontera-app-data
|
|
394
|
+
description: Use when reading platform data in a Frontera app — useObjects, useAggregate, useObjectInstance, where clauses, paging, and why filtering must happen on the server.
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
# Reading Blueprint data
|
|
398
|
+
|
|
399
|
+
\`\`\`tsx
|
|
400
|
+
import { useObjects, useAggregate } from '@frontera-sdk/blueprint/hooks'
|
|
401
|
+
import { objectsOf, type WhereNode } from '@frontera-sdk/blueprint/types'
|
|
402
|
+
\`\`\`
|
|
403
|
+
|
|
404
|
+
Every read is scoped by the credential the host handed over at handshake, so an
|
|
405
|
+
object type the workspace was not granted is simply absent — the query fails
|
|
406
|
+
with \`UNKNOWN_OBJECT_TYPE\` rather than an authorization error. Discover what
|
|
407
|
+
exists with \`frontera blueprint list\`, never by guessing an api name.
|
|
408
|
+
|
|
409
|
+
## Filter on the server
|
|
410
|
+
|
|
411
|
+
\`where\` compiles into the object set, so the server filters and pages:
|
|
412
|
+
|
|
413
|
+
\`\`\`tsx
|
|
414
|
+
const where: WhereNode | undefined = status
|
|
415
|
+
? { property: 'deliveryStatus', op: 'eq', value: status }
|
|
416
|
+
: undefined
|
|
417
|
+
|
|
418
|
+
const rows = useObjects<Shipment>('Shipment', { where, page: 1, pageSize: 20 })
|
|
419
|
+
\`\`\`
|
|
420
|
+
|
|
421
|
+
Filtering \`rows.data.rows\` in the component instead is the classic mistake: it
|
|
422
|
+
narrows only the page you happened to fetch, so a filter matching 8,961 records
|
|
423
|
+
renders 5 of them and the footer claims "Page 1 of 1".
|
|
424
|
+
|
|
425
|
+
For the same reason, a total is its own query — \`useAggregate\` with a count
|
|
426
|
+
over the SAME object set — not \`rows.length\`.
|
|
427
|
+
|
|
428
|
+
## Paging
|
|
429
|
+
|
|
430
|
+
\`page\` is 1-based. Server-driven tables need \`pageCount\` and \`rowCount\` from
|
|
431
|
+
the aggregate, or the table re-paginates one page of server data.
|
|
432
|
+
|
|
433
|
+
## Identity
|
|
434
|
+
|
|
435
|
+
\`useObjectInstance(objectType, pk)\` returns one record and is disabled while
|
|
436
|
+
\`pk\` is null, so a detail panel can mount before a row is selected.
|
|
437
|
+
`,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The dev host. `bun run dev` alone renders "This app runs inside Frontera"
|
|
443
|
+
* and nothing else: `connectToHost` rejects when `window.parent === window`,
|
|
444
|
+
* so an app served straight from Vite never mounts. That is correct behaviour
|
|
445
|
+
* — an app has no data scope until a host tells it who it is — but it leaves
|
|
446
|
+
* the author (and any agent verifying its work) looking at a refusal panel
|
|
447
|
+
* instead of the app.
|
|
448
|
+
*
|
|
449
|
+
* This page is the missing other half: it frames the app and plays the host
|
|
450
|
+
* side of the bridge. The SDK was built to accept exactly this —
|
|
451
|
+
* `connectToHost` takes an explicit `parentOrigin` and reads
|
|
452
|
+
* `__FRONTERA_CONFIG__` for the rest.
|
|
453
|
+
*
|
|
454
|
+
* It lives at the project ROOT, not in `public/`: Vite's build input is
|
|
455
|
+
* `index.html` alone, so a root-level HTML file is served in dev and never
|
|
456
|
+
* reaches `dist/`. That matters — a page that fakes a platform handshake must
|
|
457
|
+
* not ship inside the deployed bundle.
|
|
458
|
+
*
|
|
459
|
+
* Exported because `heal.ts` writes it into a PULLED app too. A project
|
|
460
|
+
* packaged before the dev host existed has none, and an agent that opens `/`
|
|
461
|
+
* because `/dev-host.html` 404s is looking at a refusal panel — which is how
|
|
462
|
+
* a session ends up hand-writing this file and then deleting it in cleanup.
|
|
463
|
+
*/
|
|
464
|
+
export function devHostHtml(name: string): string {
|
|
465
|
+
return `<!doctype html>
|
|
466
|
+
<html lang="en">
|
|
467
|
+
<head>
|
|
468
|
+
<meta charset="UTF-8" />
|
|
469
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
470
|
+
<title>${name} — dev host</title>
|
|
471
|
+
<style>
|
|
472
|
+
:root { color-scheme: dark; }
|
|
473
|
+
body { margin: 0; height: 100vh; display: flex; flex-direction: column;
|
|
474
|
+
font: 13px/1.4 system-ui, sans-serif; background: #0b0b0c; color: #e7e7ea; }
|
|
475
|
+
header { display: flex; align-items: center; gap: 12px; padding: 8px 12px;
|
|
476
|
+
background: #f5a623; color: #1a1a1a; font-weight: 600; }
|
|
477
|
+
header code { font-weight: 400; opacity: .8; }
|
|
478
|
+
iframe { flex: 1; width: 100%; border: 0; background: #fff; }
|
|
479
|
+
button { font: inherit; border: 0; border-radius: 4px; padding: 3px 8px; cursor: pointer; }
|
|
480
|
+
</style>
|
|
481
|
+
</head>
|
|
482
|
+
<body>
|
|
483
|
+
<!-- Deliberately loud. A screenshot of this page must never be mistaken
|
|
484
|
+
for the deployed app. -->
|
|
485
|
+
<header>
|
|
486
|
+
DEV HOST — simulated platform frame, not production
|
|
487
|
+
<code id="path"></code>
|
|
488
|
+
<button id="scheme" type="button">Toggle light/dark</button>
|
|
489
|
+
</header>
|
|
490
|
+
<iframe id="app" src="/" title="${name}"></iframe>
|
|
491
|
+
<script type="module">
|
|
492
|
+
const frame = document.getElementById('app')
|
|
493
|
+
const origin = window.location.origin
|
|
494
|
+
|
|
495
|
+
// Read from the environment so the dev host talks to whatever API the
|
|
496
|
+
// author is pointed at. Vite exposes VITE_* to the client; these are the
|
|
497
|
+
// same values \\\`frontera login\\\` stores.
|
|
498
|
+
const token = import.meta.env.VITE_FRONTERA_TOKEN ?? ''
|
|
499
|
+
const apiBaseUrl = import.meta.env.VITE_FRONTERA_API_URL ?? 'http://localhost:4000'
|
|
500
|
+
const orgId = import.meta.env.VITE_FRONTERA_ORG_ID ?? null
|
|
501
|
+
const workspaceId = import.meta.env.VITE_FRONTERA_WORKSPACE_ID ?? null
|
|
502
|
+
|
|
503
|
+
let colorScheme = 'dark'
|
|
504
|
+
const tokens = {
|
|
505
|
+
'--background': colorScheme === 'dark' ? '#0b0b0c' : '#ffffff',
|
|
506
|
+
'--foreground': colorScheme === 'dark' ? '#e7e7ea' : '#111111',
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const send = (message) => frame.contentWindow?.postMessage(message, origin)
|
|
510
|
+
|
|
511
|
+
window.addEventListener('message', (event) => {
|
|
512
|
+
// Same origin check the real host makes. The dev host is a
|
|
513
|
+
// convenience, not an excuse to model the protocol loosely.
|
|
514
|
+
if (event.origin !== origin) return
|
|
515
|
+
const message = event.data
|
|
516
|
+
if (!message || typeof message !== 'object') return
|
|
517
|
+
|
|
518
|
+
switch (message.type) {
|
|
519
|
+
case 'frontera:ready':
|
|
520
|
+
if (!token) {
|
|
521
|
+
console.warn(
|
|
522
|
+
'[dev-host] No VITE_FRONTERA_TOKEN — the app will mount but platform reads will 401.',
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
send({
|
|
526
|
+
type: 'frontera:init',
|
|
527
|
+
token,
|
|
528
|
+
apiBaseUrl,
|
|
529
|
+
appId: '${name}',
|
|
530
|
+
version: 'dev',
|
|
531
|
+
orgId,
|
|
532
|
+
workspaceId,
|
|
533
|
+
theme: { tokens, colorScheme },
|
|
534
|
+
state: {},
|
|
535
|
+
path: window.location.hash.slice(1) || undefined,
|
|
536
|
+
})
|
|
537
|
+
break
|
|
538
|
+
case 'frontera:navigate':
|
|
539
|
+
// The platform would change its URL here; showing it is enough to
|
|
540
|
+
// verify the app asked for the right one.
|
|
541
|
+
document.getElementById('path').textContent = message.path
|
|
542
|
+
window.location.hash = message.path
|
|
543
|
+
break
|
|
544
|
+
case 'frontera:error':
|
|
545
|
+
console.error('[dev-host] app error:', message.message, message.stack ?? '')
|
|
546
|
+
break
|
|
547
|
+
default:
|
|
548
|
+
break
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
document.getElementById('scheme').addEventListener('click', () => {
|
|
553
|
+
colorScheme = colorScheme === 'dark' ? 'light' : 'dark'
|
|
554
|
+
tokens['--background'] = colorScheme === 'dark' ? '#0b0b0c' : '#ffffff'
|
|
555
|
+
tokens['--foreground'] = colorScheme === 'dark' ? '#e7e7ea' : '#111111'
|
|
556
|
+
document.documentElement.style.colorScheme = colorScheme
|
|
557
|
+
// Exercises the path that was received-and-dropped before the SDK
|
|
558
|
+
// grew a theme handler: an app must follow the host's palette for the
|
|
559
|
+
// whole session, not only at mount.
|
|
560
|
+
send({ type: 'frontera:theme', tokens, colorScheme })
|
|
561
|
+
})
|
|
562
|
+
</script>
|
|
563
|
+
</body>
|
|
564
|
+
</html>
|
|
565
|
+
`
|
|
566
|
+
}
|