@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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. package/src/vendor/sdk-sources.json +25 -0
@@ -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
+ }