@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.
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
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * The Next.js App scaffold: configuration, and a WORKING reference feature.
3
+ *
4
+ * The scaffold used to be four files — a layout, a providers tree, a page that
5
+ * rendered one paragraph, and a stack of prose skills describing patterns that
6
+ * appeared nowhere in the tree. Prose alone loses: an agent asked to "add a
7
+ * page listing shipments" copies the nearest file it can see, and the nearest
8
+ * file was a single-component page with inline query branching. So it produced
9
+ * single-component pages with inline query branching, no matter what the skill
10
+ * said.
11
+ *
12
+ * What ships here instead is one complete vertical slice — route adapter →
13
+ * shell → feature folder → sub-components → hooks → data layer → pure helpers
14
+ * → tests — small enough to read in a sitting and structured exactly the way
15
+ * every later feature must be. The skills name this slice by path, so "follow
16
+ * the pattern" is a file to copy rather than a paragraph to interpret.
17
+ *
18
+ * Everything here type-checks and builds with nothing installed but the public
19
+ * packages in `package.json`; no registry component is required for the first
20
+ * build to be green.
21
+ */
22
+
23
+ interface ScaffoldInput {
24
+ /** Project name — the package name, the display name and the page title. */
25
+ name: string
26
+ /** Design tokens + Tailwind entry, vendored from the platform stylesheet. */
27
+ themeCss: string
28
+ }
29
+
30
+ export function nextAppFiles({ name, themeCss }: ScaffoldInput): Record<string, string> {
31
+ return {
32
+ ...projectFiles(name),
33
+ ...sourceFiles(name, themeCss),
34
+ ...docFiles(name),
35
+ }
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ *
39
+ * Project configuration
40
+ * ------------------------------------------------------------------ */
41
+
42
+ function projectFiles(name: string): Record<string, string> {
43
+ return {
44
+ 'package.json': `${JSON.stringify(
45
+ {
46
+ name,
47
+ version: '0.1.0',
48
+ private: true,
49
+ packageManager: 'bun@1.3.14',
50
+ scripts: {
51
+ dev: 'next dev',
52
+ build: 'next build',
53
+ typecheck: 'tsc --noEmit',
54
+ test: 'bun test src',
55
+ // One command an agent can run before claiming done, in the order
56
+ // that fails fastest. `next build` does not type-check the whole
57
+ // project and `tsc` never sees the static-export constraints, so
58
+ // neither alone is a gate.
59
+ check: 'bun run typecheck && bun run test && bun run build',
60
+ 'blueprint:types': 'frontera blueprint generate-types',
61
+ 'blueprint:types:check': 'frontera blueprint generate-types --check',
62
+ deploy: 'bun run check && frontera app deploy',
63
+ },
64
+ dependencies: {
65
+ '@frontera-sdk/blueprint': '^1.43.10',
66
+ '@frontera-sdk/core': '^1.43.10',
67
+ '@tanstack/react-query': '^5.90.21',
68
+ // Everything from here to `tailwind-merge` is what a shadcn
69
+ // component imports. Every item in that registry declares NO
70
+ // dependencies of its own, so `shadcn add button` writes a file
71
+ // importing `class-variance-authority` and installs nothing —
72
+ // declaring them here is the difference between an added component
73
+ // compiling and a project that cannot resolve `cva`.
74
+ 'class-variance-authority': '^0.7.1',
75
+ clsx: '^2.1.1',
76
+ 'lucide-react': '^1.31.0',
77
+ next: '16.3.0',
78
+ 'radix-ui': '^1.6.7',
79
+ react: '19.2.7',
80
+ 'react-dom': '19.2.7',
81
+ 'tailwind-merge': '^3.3.1',
82
+ },
83
+ devDependencies: {
84
+ '@tailwindcss/postcss': '^4.1.0',
85
+ // The Tailwind v4 replacement for `tailwindcss-animate`. shadcn
86
+ // overlays (dialog, select, popover, sheet) style their transitions
87
+ // with `animate-in` / `fade-in-0` / `zoom-in-95`, and an unknown
88
+ // utility in Tailwind is not an error — it silently produces no CSS,
89
+ // so the overlay simply appears without motion and nothing says why.
90
+ 'tw-animate-css': '^1.4.0',
91
+ // For `bun:test` in `src/**/__tests__`. Without it `tsc` cannot
92
+ // resolve the module and `bun run typecheck` fails on the tests
93
+ // this scaffold ships.
94
+ '@types/bun': '^1.3.0',
95
+ '@types/node': '^22',
96
+ '@types/react': '^19',
97
+ '@types/react-dom': '^19',
98
+ tailwindcss: '^4.1.0',
99
+ typescript: '^5.9.3',
100
+ },
101
+ },
102
+ null,
103
+ 2,
104
+ )}\n`,
105
+
106
+ // Frontera hosting configuration is deliberately separate from npm's
107
+ // package manifest. package.json stays standard package metadata; this
108
+ // file describes how the already-built artifact is served.
109
+ 'frontera.config.json': `${JSON.stringify(
110
+ {
111
+ displayName: name,
112
+ outputDirectory: 'out',
113
+ routing: 'filesystem',
114
+ connectDomains: [],
115
+ resourceDomains: [],
116
+ },
117
+ null,
118
+ 2,
119
+ )}\n`,
120
+
121
+ 'next.config.ts': `import type { NextConfig } from 'next'
122
+
123
+ const nextConfig: NextConfig = {
124
+ output: 'export',
125
+ trailingSlash: true,
126
+ images: { unoptimized: true },
127
+ // Current Frontera SDK packages publish TypeScript source. This is Next's
128
+ // supported boundary for compiling those dependencies inside the App build.
129
+ transpilePackages: ['@frontera-sdk/core', '@frontera-sdk/blueprint'],
130
+ }
131
+
132
+ export default nextConfig
133
+ `,
134
+
135
+ 'tsconfig.json': `${JSON.stringify(
136
+ {
137
+ compilerOptions: {
138
+ target: 'ES2017',
139
+ lib: ['dom', 'dom.iterable', 'esnext'],
140
+ allowJs: false,
141
+ skipLibCheck: true,
142
+ strict: true,
143
+ noEmit: true,
144
+ esModuleInterop: true,
145
+ module: 'esnext',
146
+ moduleResolution: 'bundler',
147
+ resolveJsonModule: true,
148
+ isolatedModules: true,
149
+ jsx: 'react-jsx',
150
+ incremental: true,
151
+ plugins: [{ name: 'next' }],
152
+ paths: { '@/*': ['./src/*'] },
153
+ },
154
+ // `.next/dev/types` is not decoration: `next build` rewrites this file
155
+ // to add it on the first run, so omitting it means the very first
156
+ // build leaves the working tree dirty and a deploy records it as such.
157
+ include: [
158
+ 'next-env.d.ts',
159
+ '**/*.ts',
160
+ '**/*.tsx',
161
+ '.next/types/**/*.ts',
162
+ '.next/dev/types/**/*.ts',
163
+ ],
164
+ exclude: ['node_modules', 'out'],
165
+ },
166
+ null,
167
+ 2,
168
+ )}\n`,
169
+
170
+ 'next-env.d.ts': `/// <reference types="next" />
171
+ /// <reference types="next/image-types/global" />
172
+ `,
173
+
174
+ // Configures the shadcn CLI so `bunx shadcn@latest add <name>` writes into
175
+ // this project unmodified: Tailwind v4 (no config file), our stylesheet,
176
+ // our `@/` aliases, our `cn`.
177
+ //
178
+ // `cssVariables: true` with a stylesheet that already defines every
179
+ // variable upstream references is why `add` leaves `globals.css` alone.
180
+ // `shadcn init` is the command that would NOT: it rewrites the stylesheet
181
+ // with the vanilla palette and drops the platform surface scale.
182
+ 'components.json': `${JSON.stringify(
183
+ {
184
+ $schema: 'https://ui.shadcn.com/schema.json',
185
+ style: 'new-york',
186
+ // These components render in the browser under a static export, and
187
+ // the SDK session is a client boundary — so they carry 'use client'.
188
+ rsc: false,
189
+ tsx: true,
190
+ tailwind: {
191
+ config: '',
192
+ css: 'src/app/globals.css',
193
+ baseColor: 'neutral',
194
+ cssVariables: true,
195
+ prefix: '',
196
+ },
197
+ aliases: {
198
+ components: '@/components',
199
+ ui: '@/components/ui',
200
+ utils: '@/lib/utils',
201
+ lib: '@/lib',
202
+ hooks: '@/hooks',
203
+ },
204
+ iconLibrary: 'lucide',
205
+ },
206
+ null,
207
+ 2,
208
+ )}\n`,
209
+
210
+ 'postcss.config.mjs': `export default { plugins: { '@tailwindcss/postcss': {} } }
211
+ `,
212
+
213
+ '.gitignore': `node_modules/
214
+ .next/
215
+ out/
216
+ .frontera/
217
+ .env
218
+ .env.*
219
+ .env.local
220
+ !.env.example
221
+ *.log
222
+ `,
223
+
224
+ // Present so the answer to "where do I put the API key" is a file that
225
+ // says: nowhere. A static App has no secret storage — every value here
226
+ // ends up readable in the browser bundle.
227
+ '.env.example': `# Build-time, PUBLIC values only. Everything in a NEXT_PUBLIC_ variable is
228
+ # readable by anyone who opens the App.
229
+ #
230
+ # Do NOT put a Frontera workspace key, an App token, or any customer
231
+ # credential here. Local development gets real Blueprint access from
232
+ # \`frontera app dev\`, which owns NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT and
233
+ # keeps the stored CLI key out of the browser.
234
+
235
+ # NEXT_PUBLIC_SUPPORT_URL=https://example.com/support
236
+ `,
237
+ }
238
+ }
239
+
240
+ /* ------------------------------------------------------------------ *
241
+ * Application source — the reference vertical slice
242
+ * ------------------------------------------------------------------ */
243
+
244
+ function sourceFiles(name: string, themeCss: string): Record<string, string> {
245
+ return {
246
+ // The platform stylesheet, plus the animation utilities shadcn overlays
247
+ // expect. The import goes directly after Tailwind's because CSS `@import`
248
+ // rules must precede every other rule in the file.
249
+ 'src/app/globals.css': themeCss.includes('tw-animate-css')
250
+ ? themeCss
251
+ : themeCss.replace('@import "tailwindcss";', '@import "tailwindcss";\n@import "tw-animate-css";'),
252
+
253
+ 'src/app/layout.tsx': `import type { Metadata } from 'next'
254
+ import type { ReactNode } from 'react'
255
+
256
+ import './globals.css'
257
+ import { Providers } from './providers'
258
+
259
+ export const metadata: Metadata = { title: ${JSON.stringify(name)} }
260
+
261
+ /**
262
+ * Server root layout. Owns metadata and the document, nothing else.
263
+ *
264
+ * Never mark this file 'use client' — the client boundary belongs in
265
+ * providers.tsx so the document stays static-exportable.
266
+ */
267
+ export default function RootLayout({ children }: { children: ReactNode }) {
268
+ return (
269
+ <html lang="en">
270
+ <body className="min-h-dvh bg-background text-foreground antialiased">
271
+ <Providers>{children}</Providers>
272
+ </body>
273
+ </html>
274
+ )
275
+ }
276
+ `,
277
+
278
+ 'src/app/providers.tsx': `'use client'
279
+
280
+ import type { ReactNode } from 'react'
281
+ import { blueprintProvider } from '@frontera-sdk/blueprint/provider'
282
+ import { FronteraAppProvider } from '@frontera-sdk/core/react'
283
+
284
+ /**
285
+ * The one client boundary at the app root.
286
+ *
287
+ * FronteraAppProvider picks the session transport (embedded / standalone /
288
+ * local), completes the handshake, builds the credentialed client and only
289
+ * then renders children. Domain providers are contributed as a list, so a
290
+ * second data domain is one array entry, never a second bridge.
291
+ *
292
+ * Mount this ONCE. A per-route provider stack forks the session and the query
293
+ * cache, which shows up as a second handshake and rows that refuse to update.
294
+ */
295
+ const devSessionEndpoint = process.env.NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT
296
+ if (devSessionEndpoint) {
297
+ const runtime = globalThis as typeof globalThis & {
298
+ __FRONTERA_CONFIG__?: Record<string, unknown>
299
+ }
300
+ runtime.__FRONTERA_CONFIG__ = { ...runtime.__FRONTERA_CONFIG__, devSessionEndpoint }
301
+ }
302
+
303
+ export function Providers({ children }: { children: ReactNode }) {
304
+ return <FronteraAppProvider providers={[blueprintProvider]}>{children}</FronteraAppProvider>
305
+ }
306
+ `,
307
+
308
+ // Thin route adapter: reads configuration, renders ONE feature component.
309
+ // No JSX for the feature, no hooks, no data access.
310
+ 'src/app/page.tsx': `import { STARTER_OBJECT_TYPE, STARTER_SEARCH_PROPERTY } from '@/lib/blueprint/starter'
311
+ import { AppShell } from '@/ui/app-shell/app-shell'
312
+ import { ObjectExplorer } from '@/ui/object-explorer/object-explorer'
313
+
314
+ export default function HomePage() {
315
+ return (
316
+ <AppShell>
317
+ <ObjectExplorer
318
+ objectType={STARTER_OBJECT_TYPE}
319
+ searchProperty={STARTER_SEARCH_PROPERTY}
320
+ />
321
+ </AppShell>
322
+ )
323
+ }
324
+ `,
325
+
326
+ 'src/lib/utils.ts': `import { clsx, type ClassValue } from 'clsx'
327
+ import { twMerge } from 'tailwind-merge'
328
+
329
+ export function cn(...inputs: ClassValue[]) {
330
+ return twMerge(clsx(inputs))
331
+ }
332
+ `,
333
+
334
+ // One place for user-visible text. Not internationalisation — the point is
335
+ // that wording is reviewable in one file instead of hidden in twenty JSX
336
+ // branches, and that an empty state and its error twin cannot drift.
337
+ 'src/lib/copy.ts': `export const copy = {
338
+ app: {
339
+ title: ${JSON.stringify(name)},
340
+ embedded: 'Embedded',
341
+ standalone: 'Standalone',
342
+ local: 'Local development',
343
+ },
344
+ explorer: {
345
+ searchLabel: 'Search',
346
+ searchPlaceholder: 'Filter by the configured property…',
347
+ loading: 'Loading records…',
348
+ empty: 'No records match this view.',
349
+ emptyHint: 'Clear the search, or widen the filter.',
350
+ errorTitle: 'Could not load records',
351
+ retry: 'Try again',
352
+ unconfiguredTitle: 'Point this App at an object type',
353
+ unconfiguredBody:
354
+ 'Run \`frontera blueprint list\` to see what this workspace exposes, then set STARTER_OBJECT_TYPE in src/lib/blueprint/starter.ts.',
355
+ previous: 'Previous',
356
+ next: 'Next',
357
+ range: (from: number, to: number, total: number | null) =>
358
+ total === null ? \`\${from}–\${to}\` : \`\${from}–\${to} of \${total}\`,
359
+ },
360
+ } as const
361
+ `,
362
+
363
+ 'src/lib/blueprint/starter.ts': `/**
364
+ * The object type the starter page reads.
365
+ *
366
+ * \`frontera blueprint list\` shows what this workspace exposes; put an API name
367
+ * here — \`Shipment\`, \`LoanApplication\`, whatever the deployment models — and
368
+ * \`frontera blueprint get <apiName>\` lists its properties.
369
+ *
370
+ * Annotated \`: string\` on purpose. Without it TypeScript infers the literal
371
+ * type \`''\`, and the empty-check in the explorer becomes a "no overlap" error
372
+ * the moment you fill this in — a type error caused by doing exactly what the
373
+ * comment says.
374
+ *
375
+ * This starter reads any object type by name, which is what makes it useful
376
+ * before you know the data. Real features do the opposite: run
377
+ * \`bun run blueprint:types\` and pass a LITERAL api name to \`useObjects\`, so
378
+ * rows, filters and sorts are all typed. See the frontera-blueprint-data skill.
379
+ */
380
+ export const STARTER_OBJECT_TYPE: string = ''
381
+
382
+ /** A string property to search with \`contains\`. Undefined disables search. */
383
+ export const STARTER_SEARCH_PROPERTY: string | undefined = undefined
384
+ `,
385
+
386
+ // ---- data layer -------------------------------------------------
387
+ 'src/lib/blueprint/objects/objects-hooks.ts': `'use client'
388
+
389
+ import { keepPreviousData } from '@tanstack/react-query'
390
+ import { useAggregate, useObjectQuery } from '@frontera-sdk/blueprint/hooks'
391
+ import { objectsOf } from '@frontera-sdk/blueprint/types'
392
+ import type { AggregateResponse, WhereNode } from '@frontera-sdk/blueprint/types'
393
+
394
+ /**
395
+ * The data layer for the object explorer.
396
+ *
397
+ * Everything that talks to Blueprint lives in \`src/lib/blueprint/<domain>/\`,
398
+ * one folder per domain, and components receive rows as props. That separation
399
+ * is what keeps a filter change from turning into a fetch inside a table cell.
400
+ *
401
+ * This starter domain is deliberately generic — it takes the object type as an
402
+ * argument so it works before you know the data model. A real domain is named
403
+ * after its object type (\`src/lib/blueprint/shipment/shipment-hooks.ts\`), uses
404
+ * a literal api name, and gets full row/filter/sort inference from
405
+ * \`bun run blueprint:types\`.
406
+ */
407
+
408
+ export const PAGE_SIZE = 25
409
+
410
+ export interface ObjectPageParams {
411
+ objectType: string
412
+ /** Opaque cursor returned by the previous page. Omit for the first page. */
413
+ pageToken?: string
414
+ pageSize?: number
415
+ search?: string
416
+ searchProperty?: string
417
+ }
418
+
419
+ /**
420
+ * The server-side filter.
421
+ *
422
+ * Filtering the returned rows in the component instead narrows only the page
423
+ * you happened to fetch: a filter matching 8,961 records renders 5 of them and
424
+ * the footer claims "Page 1 of 1".
425
+ */
426
+ export function objectWhere(params: ObjectPageParams): WhereNode | undefined {
427
+ const search = params.search?.trim()
428
+ if (!search || !params.searchProperty) return undefined
429
+ return { property: params.searchProperty, op: 'contains', value: search }
430
+ }
431
+
432
+ /** One page of rows. Disabled until an object type is configured. */
433
+ export function useObjectPage(params: ObjectPageParams) {
434
+ const pageSize = params.pageSize ?? PAGE_SIZE
435
+ return useObjectQuery(
436
+ {
437
+ objectSet: objectsOf(params.objectType, objectWhere(params)),
438
+ pageToken: params.pageToken,
439
+ pageSize,
440
+ },
441
+ {
442
+ enabled: params.objectType.length > 0,
443
+ // Keep the previous page on screen while the next one loads, so paging
444
+ // dims instead of collapsing to a skeleton and losing scroll position.
445
+ placeholderData: keepPreviousData,
446
+ },
447
+ )
448
+ }
449
+
450
+ /**
451
+ * The total, as its own aggregate over the SAME object set.
452
+ *
453
+ * \`rows.length\` is the size of one page — never the count. The two queries
454
+ * must share \`objectWhere\`, or the number under the table describes a
455
+ * different filter than the table.
456
+ */
457
+ export function useObjectTotal(params: ObjectPageParams) {
458
+ return useAggregate(
459
+ {
460
+ objectSet: objectsOf(params.objectType, objectWhere(params)),
461
+ aggregations: [{ alias: 'total', fn: 'count' }],
462
+ // Required by the service. An empty array means "grand total".
463
+ groupBy: [],
464
+ },
465
+ { enabled: params.objectType.length > 0, placeholderData: keepPreviousData },
466
+ )
467
+ }
468
+
469
+ /** Read the count out of an aggregate response, or null when it is absent. */
470
+ export function totalFrom(response: AggregateResponse | undefined): number | null {
471
+ const value = response?.rows[0]?.total
472
+ if (value === null || value === undefined) return null
473
+ const total = Number(value)
474
+ return Number.isFinite(total) ? total : null
475
+ }
476
+
477
+ `,
478
+
479
+ 'src/lib/blueprint/objects/__tests__/objects-hooks.test.ts': `import { describe, expect, test } from 'bun:test'
480
+
481
+ import { objectWhere, totalFrom } from '../objects-hooks'
482
+
483
+ /**
484
+ * The parts of the data layer worth testing in a static App are the pure ones:
485
+ * filter construction and aggregate parsing. Hooks need a running
486
+ * session and are covered by \`frontera app dev\` against real data.
487
+ */
488
+ describe('objectWhere', () => {
489
+ const base = { objectType: 'Shipment' }
490
+
491
+ test('is undefined without a search property, so the query stays unfiltered', () => {
492
+ expect(objectWhere({ ...base, search: 'jakarta' })).toBeUndefined()
493
+ })
494
+
495
+ test('ignores whitespace-only input rather than filtering on an empty string', () => {
496
+ expect(objectWhere({ ...base, search: ' ', searchProperty: 'city' })).toBeUndefined()
497
+ })
498
+
499
+ test('builds a server-side contains filter', () => {
500
+ expect(objectWhere({ ...base, search: ' jakarta ', searchProperty: 'city' })).toEqual({
501
+ property: 'city',
502
+ op: 'contains',
503
+ value: 'jakarta',
504
+ })
505
+ })
506
+ })
507
+
508
+ describe('totalFrom', () => {
509
+ test('reads the aliased count', () => {
510
+ expect(totalFrom({ rows: [{ total: 8961 }] })).toBe(8961)
511
+ })
512
+
513
+ test('distinguishes "no answer yet" from zero', () => {
514
+ expect(totalFrom(undefined)).toBeNull()
515
+ expect(totalFrom({ rows: [] })).toBeNull()
516
+ expect(totalFrom({ rows: [{ total: 0 }] })).toBe(0)
517
+ })
518
+ })
519
+ `,
520
+
521
+ // ---- shell ------------------------------------------------------
522
+ 'src/ui/app-shell/app-shell.tsx': `'use client'
523
+
524
+ import type { ReactNode } from 'react'
525
+ import { useFronteraApp } from '@frontera-sdk/core/react'
526
+
527
+ import { copy } from '@/lib/copy'
528
+
529
+ /**
530
+ * The frame every page renders into.
531
+ *
532
+ * An App is shown in two containers — inside the platform in a constrained
533
+ * frame, and standalone at full viewport — so the shell adapts rather than
534
+ * assuming it owns the screen. It carries the customer's page identity, not
535
+ * Frontera branding: the platform chrome already says where you are.
536
+ */
537
+ export function AppShell({ children }: { children: ReactNode }) {
538
+ const { mode, init } = useFronteraApp()
539
+ const label =
540
+ mode === 'embedded' ? copy.app.embedded : mode === 'local' ? copy.app.local : copy.app.standalone
541
+
542
+ return (
543
+ <div className="flex min-h-dvh flex-col bg-background">
544
+ <header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-6">
545
+ <h1 className="text-base font-semibold text-foreground sm:text-lg">{copy.app.title}</h1>
546
+ <p className="text-xs text-muted-foreground">
547
+ {label} · {init.version}
548
+ </p>
549
+ </header>
550
+ {/* The App owns its own scrolling: embedded, the platform will not
551
+ scroll for it. */}
552
+ <main className="mx-auto w-full max-w-6xl flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
553
+ </div>
554
+ )
555
+ }
556
+ `,
557
+
558
+ // ---- feature: object explorer -----------------------------------
559
+ 'src/ui/object-explorer/types.ts': `/** A Blueprint property, as the query response describes it. */
560
+ export interface ObjectColumn {
561
+ apiName: string
562
+ displayName?: string
563
+ dataType?: string
564
+ }
565
+
566
+ export type ObjectRow = Record<string, unknown>
567
+ `,
568
+
569
+ 'src/ui/object-explorer/utils.ts': `/**
570
+ * Render one cell value.
571
+ *
572
+ * Blueprint returns JSON, so a cell can be null, a number, a boolean or a
573
+ * nested object. Formatting here keeps every table component free of
574
+ * defensive \`String(value ?? '')\` sprinkles that quietly print "null".
575
+ */
576
+ export function formatCell(value: unknown): string {
577
+ if (value === null || value === undefined) return '—'
578
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
579
+ if (typeof value === 'number') return Number.isFinite(value) ? value.toLocaleString() : '—'
580
+ if (typeof value === 'string') return value
581
+ return JSON.stringify(value)
582
+ }
583
+ `,
584
+
585
+ 'src/ui/object-explorer/hooks/use-debounced-value.ts': `'use client'
586
+
587
+ import { useEffect, useState } from 'react'
588
+
589
+ /**
590
+ * Trailing debounce for text input.
591
+ *
592
+ * A query key that changes on every keystroke issues one request per
593
+ * character; each one is a real Blueprint scan.
594
+ */
595
+ export function useDebouncedValue<T>(value: T, delayMs = 300): T {
596
+ const [debounced, setDebounced] = useState(value)
597
+
598
+ useEffect(() => {
599
+ const timer = setTimeout(() => setDebounced(value), delayMs)
600
+ return () => clearTimeout(timer)
601
+ }, [value, delayMs])
602
+
603
+ return debounced
604
+ }
605
+ `,
606
+
607
+ 'src/ui/object-explorer/object-explorer.tsx': `'use client'
608
+
609
+ import { useState } from 'react'
610
+
611
+ import {
612
+ PAGE_SIZE,
613
+ totalFrom,
614
+ useObjectPage,
615
+ useObjectTotal,
616
+ } from '@/lib/blueprint/objects/objects-hooks'
617
+ import { copy } from '@/lib/copy'
618
+
619
+ import { EmptyPanel } from './components/empty-panel'
620
+ import { ObjectPager } from './components/object-pager'
621
+ import { ObjectSearch } from './components/object-search'
622
+ import { ObjectTable } from './components/object-table'
623
+ import { ObjectTableSkeleton } from './components/object-table-skeleton'
624
+ import { QueryError } from './components/query-error'
625
+ import { useDebouncedValue } from './hooks/use-debounced-value'
626
+
627
+ /**
628
+ * The feature entry point. Owns view state, calls the data layer, and renders
629
+ * query states in one deliberate order — configuration, error, first load,
630
+ * empty, content. Sub-components below it receive data as props and fetch
631
+ * nothing.
632
+ *
633
+ * Copy this folder shape for the next feature: entry file at the root,
634
+ * sub-components in \`components/\`, hooks in \`hooks/\`, shared helpers in
635
+ * \`utils.ts\`, shared types in \`types.ts\`.
636
+ */
637
+ export function ObjectExplorer({
638
+ objectType,
639
+ searchProperty,
640
+ }: {
641
+ objectType: string
642
+ searchProperty?: string
643
+ }) {
644
+ const [search, setSearch] = useState('')
645
+ const [pageIndex, setPageIndex] = useState(0)
646
+ const [pageTokens, setPageTokens] = useState<Array<string | undefined>>([undefined])
647
+ const debouncedSearch = useDebouncedValue(search)
648
+
649
+ const params = {
650
+ objectType,
651
+ pageToken: pageTokens[pageIndex],
652
+ search: debouncedSearch,
653
+ searchProperty,
654
+ }
655
+ const rows = useObjectPage(params)
656
+ const total = useObjectTotal(params)
657
+
658
+ // A cursor belongs to one exact query shape. Changing the filter starts a
659
+ // new scan rather than sending a token minted for the previous search.
660
+ const handleSearch = (value: string) => {
661
+ setSearch(value)
662
+ setPageIndex(0)
663
+ setPageTokens([undefined])
664
+ }
665
+
666
+ const handlePrevious = () => setPageIndex((current) => Math.max(0, current - 1))
667
+ const handleNext = () => {
668
+ const nextPageToken = rows.data?.nextPageToken
669
+ if (!nextPageToken) return
670
+ setPageTokens((current) => [...current.slice(0, pageIndex + 1), nextPageToken])
671
+ setPageIndex((current) => current + 1)
672
+ }
673
+
674
+ if (objectType.length === 0) {
675
+ return (
676
+ <EmptyPanel title={copy.explorer.unconfiguredTitle} body={copy.explorer.unconfiguredBody} />
677
+ )
678
+ }
679
+
680
+ const records = rows.data?.rows ?? []
681
+ const columns = rows.data?.properties ?? []
682
+ const totalCount = totalFrom(total.data)
683
+
684
+ return (
685
+ <section className="flex flex-col gap-4">
686
+ <ObjectSearch
687
+ value={search}
688
+ onChange={handleSearch}
689
+ disabled={searchProperty === undefined}
690
+ />
691
+
692
+ {rows.isError ? (
693
+ <QueryError message={rows.error.message} onRetry={() => void rows.refetch()} />
694
+ ) : rows.isLoading ? (
695
+ <ObjectTableSkeleton />
696
+ ) : records.length === 0 ? (
697
+ <EmptyPanel title={copy.explorer.empty} body={copy.explorer.emptyHint} />
698
+ ) : (
699
+ <>
700
+ <ObjectTable columns={columns} rows={records} isRefreshing={rows.isFetching} />
701
+ <ObjectPager
702
+ page={pageIndex + 1}
703
+ pageSize={PAGE_SIZE}
704
+ rowsOnPage={records.length}
705
+ total={totalCount}
706
+ hasPrevious={pageIndex > 0}
707
+ hasNext={rows.data?.hasMore ?? false}
708
+ onPrevious={handlePrevious}
709
+ onNext={handleNext}
710
+ />
711
+ </>
712
+ )}
713
+ </section>
714
+ )
715
+ }
716
+ `,
717
+
718
+ 'src/ui/object-explorer/components/object-table.tsx': `'use client'
719
+
720
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
721
+ import { cn } from '@/lib/utils'
722
+
723
+ import type { ObjectColumn, ObjectRow } from '../types'
724
+ import { formatCell } from '../utils'
725
+
726
+ /**
727
+ * Presentational table. Receives rows, renders rows.
728
+ *
729
+ * The markup is the shadcn \`table\` primitive — do not hand-roll a \`<table>\`
730
+ * next to it. Wrapped in its own horizontal scroller so a wide object type
731
+ * stays readable on a phone without the page itself scrolling sideways.
732
+ */
733
+ export function ObjectTable({
734
+ columns,
735
+ rows,
736
+ isRefreshing = false,
737
+ }: {
738
+ columns: ObjectColumn[]
739
+ rows: ObjectRow[]
740
+ isRefreshing?: boolean
741
+ }) {
742
+ return (
743
+ <div
744
+ // A background refetch dims the stale rows instead of replacing them
745
+ // with a skeleton, so the table does not flash on every page change.
746
+ aria-busy={isRefreshing}
747
+ className={cn(
748
+ 'overflow-x-auto rounded-lg border transition-opacity',
749
+ isRefreshing && 'opacity-60',
750
+ )}
751
+ >
752
+ <Table>
753
+ <TableHeader>
754
+ <TableRow>
755
+ {columns.map((column) => (
756
+ <TableHead key={column.apiName}>{column.displayName ?? column.apiName}</TableHead>
757
+ ))}
758
+ </TableRow>
759
+ </TableHeader>
760
+ <TableBody>
761
+ {rows.map((row, index) => (
762
+ <TableRow key={index}>
763
+ {columns.map((column) => (
764
+ <TableCell key={column.apiName}>{formatCell(row[column.apiName])}</TableCell>
765
+ ))}
766
+ </TableRow>
767
+ ))}
768
+ </TableBody>
769
+ </Table>
770
+ </div>
771
+ )
772
+ }
773
+ `,
774
+
775
+ 'src/ui/object-explorer/components/object-table-skeleton.tsx': `import { Skeleton } from '@/components/ui/skeleton'
776
+
777
+ /**
778
+ * Shaped like the table it replaces — same border, same row rhythm — so the
779
+ * layout does not jump when data arrives. A centred spinner communicates
780
+ * nothing about what is loading and shifts everything below it.
781
+ */
782
+ export function ObjectTableSkeleton({ rows = 8, columns = 4 }: { rows?: number; columns?: number }) {
783
+ return (
784
+ <div className="overflow-hidden rounded-lg border">
785
+ {Array.from({ length: rows }).map((_, rowIndex) => (
786
+ <div key={rowIndex} className="flex gap-3 border-b px-3 py-2.5 last:border-0">
787
+ {Array.from({ length: columns }).map((__, cellIndex) => (
788
+ <Skeleton key={cellIndex} className="h-3.5 flex-1" />
789
+ ))}
790
+ </div>
791
+ ))}
792
+ </div>
793
+ )
794
+ }
795
+ `,
796
+
797
+ 'src/ui/object-explorer/components/object-search.tsx': `'use client'
798
+
799
+ import { Input } from '@/components/ui/input'
800
+ import { copy } from '@/lib/copy'
801
+
802
+ /** Controlled search input. The debounce belongs to the caller, not the field. */
803
+ export function ObjectSearch({
804
+ value,
805
+ onChange,
806
+ disabled = false,
807
+ }: {
808
+ value: string
809
+ onChange: (value: string) => void
810
+ disabled?: boolean
811
+ }) {
812
+ return (
813
+ <div className="flex flex-col gap-1.5">
814
+ <label htmlFor="object-search" className="text-xs font-medium text-muted-foreground">
815
+ {copy.explorer.searchLabel}
816
+ </label>
817
+ <Input
818
+ id="object-search"
819
+ type="search"
820
+ value={value}
821
+ disabled={disabled}
822
+ placeholder={copy.explorer.searchPlaceholder}
823
+ onChange={(event) => onChange(event.target.value)}
824
+ // h-11 keeps the target at 44px for touch; the primitive is denser by
825
+ // default, and a filter is a primary control on a phone.
826
+ className="h-11 sm:h-9 sm:max-w-sm"
827
+ />
828
+ </div>
829
+ )
830
+ }
831
+ `,
832
+
833
+ 'src/ui/object-explorer/components/object-pager.tsx': `'use client'
834
+
835
+ import { Button } from '@/components/ui/button'
836
+ import { copy } from '@/lib/copy'
837
+
838
+ /**
839
+ * Cursor-backed server-side pager. The numeric page is presentation only;
840
+ * Blueprint navigation uses opaque page tokens retained by the parent.
841
+ */
842
+ export function ObjectPager({
843
+ page,
844
+ pageSize,
845
+ rowsOnPage,
846
+ total,
847
+ hasPrevious,
848
+ hasNext,
849
+ onPrevious,
850
+ onNext,
851
+ }: {
852
+ page: number
853
+ pageSize: number
854
+ rowsOnPage: number
855
+ total: number | null
856
+ hasPrevious: boolean
857
+ hasNext: boolean
858
+ onPrevious: () => void
859
+ onNext: () => void
860
+ }) {
861
+ const from = (page - 1) * pageSize + 1
862
+ const to = from + Math.max(rowsOnPage, 1) - 1
863
+
864
+ return (
865
+ <div className="flex flex-wrap items-center justify-between gap-2">
866
+ <p className="text-xs text-muted-foreground">{copy.explorer.range(from, to, total)}</p>
867
+ <div className="flex items-center gap-2">
868
+ {/* h-11 on touch: the primitive's default height sits below the 44px
869
+ target a thumb needs, and paging is a primary control. */}
870
+ <Button variant="outline" className="h-11 sm:h-9" disabled={!hasPrevious} onClick={onPrevious}>
871
+ {copy.explorer.previous}
872
+ </Button>
873
+ <Button variant="outline" className="h-11 sm:h-9" disabled={!hasNext} onClick={onNext}>
874
+ {copy.explorer.next}
875
+ </Button>
876
+ </div>
877
+ </div>
878
+ )
879
+ }
880
+ `,
881
+
882
+ 'src/ui/object-explorer/components/empty-panel.tsx': `/** Success with nothing in it, and configuration gaps. Never used for errors. */
883
+ export function EmptyPanel({ title, body }: { title: string; body?: string }) {
884
+ return (
885
+ <div className="rounded-lg border border-dashed border-border bg-card p-8 text-center">
886
+ <p className="text-sm font-medium text-foreground">{title}</p>
887
+ {body ? <p className="mt-1 text-sm text-muted-foreground">{body}</p> : null}
888
+ </div>
889
+ )
890
+ }
891
+ `,
892
+
893
+ 'src/ui/object-explorer/components/query-error.tsx': `'use client'
894
+
895
+ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
896
+ import { Button } from '@/components/ui/button'
897
+ import { copy } from '@/lib/copy'
898
+
899
+ /**
900
+ * Recoverable failure, kept distinct from an empty result.
901
+ *
902
+ * Rendering a failed read as "no records" tells the customer their data is
903
+ * gone when the truth is that the request did not succeed. The primitive
904
+ * carries \`role="alert"\`, so a screen reader is told without being polled.
905
+ */
906
+ export function QueryError({ message, onRetry }: { message: string; onRetry?: () => void }) {
907
+ return (
908
+ <Alert variant="destructive">
909
+ <AlertTitle>{copy.explorer.errorTitle}</AlertTitle>
910
+ <AlertDescription>
911
+ <p>{message}</p>
912
+ {onRetry ? (
913
+ <Button variant="outline" className="mt-2 h-11 sm:h-9" onClick={onRetry}>
914
+ {copy.explorer.retry}
915
+ </Button>
916
+ ) : null}
917
+ </AlertDescription>
918
+ </Alert>
919
+ )
920
+ }
921
+ `,
922
+ }
923
+ }
924
+
925
+ /* ------------------------------------------------------------------ *
926
+ * Human and agent entry points
927
+ * ------------------------------------------------------------------ */
928
+
929
+ function docFiles(name: string): Record<string, string> {
930
+ return {
931
+ 'AGENTS.md': `# ${name}
932
+
933
+ A Frontera App: an ordinary Next.js App Router project whose deployable result
934
+ is static files, reading platform data through Blueprint.
935
+
936
+ ## Read this first
937
+
938
+ Load \`.agents/skills/using-frontera-app-patterns/SKILL.md\`. It is the
939
+ dispatcher — it routes you to the one skill that governs the files you are
940
+ about to change. The patterns there are the ones this project already follows;
941
+ match them rather than inventing a second style.
942
+
943
+ For the CLI itself — logging in, discovering data, deploying — load
944
+ \`.agents/skills/using-frontera-cli/SKILL.md\` if it is present. It is written
945
+ by \`frontera app init\` and covers the commands rather than the code.
946
+
947
+ ## The shape of the project
948
+
949
+ | Path | What it is |
950
+ |---|---|
951
+ | \`src/app/\` | route adapters only — layout, providers, thin pages |
952
+ | \`src/ui/<feature>/\` | feature folders; one component per file |
953
+ | \`src/components/ui/\` | shadcn primitives — copied source, yours to edit |
954
+ | \`src/lib/blueprint/<domain>/\` | the data layer: Blueprint reads live here |
955
+ | \`src/lib/copy.ts\` | user-visible strings |
956
+ | \`src/generated/\` | \`bun run blueprint:types\` output — never edited by hand |
957
+
958
+ \`src/ui/object-explorer/\` is the reference feature. It is a complete slice —
959
+ route adapter, feature entry, sub-components, hooks, data layer, tests — and
960
+ copying its shape is the fastest way to be right.
961
+
962
+ ## Non-negotiables
963
+
964
+ 1. Bun runs everything: \`bun install\`, \`bun run <script>\`, \`bun test\`.
965
+ 2. Components never fetch. Hooks in \`src/lib/blueprint/\` fetch; components take props.
966
+ 3. Filter, sort, cursor-page and aggregate on the server. Narrowing a fetched page misreports every total.
967
+ 4. No secrets in the browser. Not in source, not in \`NEXT_PUBLIC_*\`, not in \`.env\`.
968
+ 5. Colors come from tokens in \`src/app/globals.css\`. No hex literals in components.
969
+ 6. Every route must exist as a static file after \`bun run build\`. No server runtime exists.
970
+
971
+ ## Verify before claiming done
972
+
973
+ \`\`\`bash
974
+ bun run check
975
+ \`\`\`
976
+
977
+ That is \`typecheck\`, then \`bun test\`, then \`build\` — the build is the only
978
+ thing that proves the static export still works. For anything touching data,
979
+ also run it against real data with \`frontera app dev\`.
980
+ `,
981
+
982
+ 'CLAUDE.md': `./AGENTS.md
983
+ `,
984
+
985
+ 'README.md': `# ${name}
986
+
987
+ A Frontera App — a Next.js App Router project that builds to static files and
988
+ reads platform data through Blueprint.
989
+
990
+ ## Getting started
991
+
992
+ \`\`\`bash
993
+ bun install
994
+ \`\`\`
995
+
996
+ \`\`\`bash
997
+ frontera app dev
998
+ \`\`\`
999
+
1000
+ \`frontera app dev\` starts Next and a local session broker, so the App runs
1001
+ with real, short-lived Blueprint access. \`bun run dev\` alone starts Next
1002
+ without a session — useful for pure layout work, not for data.
1003
+
1004
+ ## Point it at your data
1005
+
1006
+ \`\`\`bash
1007
+ frontera blueprint list
1008
+ \`\`\`
1009
+
1010
+ Set \`STARTER_OBJECT_TYPE\` in \`src/lib/blueprint/starter.ts\` to one of the api
1011
+ names it prints. Then generate typed contracts for real features:
1012
+
1013
+ \`\`\`bash
1014
+ bun run blueprint:types
1015
+ \`\`\`
1016
+
1017
+ Commit \`src/generated/frontera-blueprint.ts\`. It is a projection of the
1018
+ Blueprint this workspace is granted; regenerate it after a publication or a
1019
+ grant change.
1020
+
1021
+ ## Ship it
1022
+
1023
+ \`\`\`bash
1024
+ bun run check
1025
+ \`\`\`
1026
+
1027
+ \`\`\`bash
1028
+ frontera app deploy --no-promote
1029
+ \`\`\`
1030
+
1031
+ Deploying without promoting publishes an immutable version and returns a
1032
+ preview URL. Promote it once it looks right:
1033
+
1034
+ \`\`\`bash
1035
+ frontera app promote <version>
1036
+ \`\`\`
1037
+
1038
+ ## Layout
1039
+
1040
+ \`\`\`text
1041
+ src/app/ route adapters: layout, providers, pages
1042
+ src/ui/<feature>/ feature folders — one component per file
1043
+ src/lib/blueprint/<domain>/ Blueprint reads (hooks + pure helpers)
1044
+ src/lib/copy.ts user-visible strings
1045
+ src/components/ui/ shadcn primitives (bunx shadcn@latest add <name>)
1046
+ \`\`\`
1047
+
1048
+ Agent instructions and the full pattern set live in \`AGENTS.md\` and
1049
+ \`.agents/skills/\`.
1050
+ `,
1051
+ }
1052
+ }