@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
package/src/template.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join, dirname } from 'node:path'
3
3
 
4
+ import { nextAppFiles } from './templates/next-app-files'
5
+ import { nextSkillFiles } from './templates/next-skills'
4
6
  import vendored from './vendor/sdk-sources.json'
5
7
 
6
8
  /**
7
9
  * Project scaffold.
8
10
  *
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.
11
+ * New projects are ordinary Next.js applications using published Frontera SDK
12
+ * packages. The explicit legacy React template keeps its vendored SDK tree so
13
+ * existing Vite applications remain reproducible and can opt into `sdk sync`.
14
+ * In both cases package.json stays standard package metadata; the static-host
15
+ * contract belongs to frontera.config.json.
18
16
  */
19
- export function scaffold(target: string, name: string): void {
20
- for (const [rel, content] of Object.entries(files(name))) {
17
+ export type AppFramework = 'next' | 'react'
18
+
19
+ export function scaffold(target: string, name: string, framework: AppFramework = 'next'): void {
20
+ for (const [rel, content] of Object.entries(scaffoldFiles(name, framework))) {
21
21
  const full = join(target, rel)
22
22
  mkdirSync(dirname(full), { recursive: true })
23
23
  writeFileSync(full, content)
@@ -30,8 +30,11 @@ export function scaffold(target: string, name: string): void {
30
30
  * shipped SKILL.md actually exists, and that every `@frontera-sdk/…` import
31
31
  * resolves to a file the scaffold actually writes.
32
32
  */
33
- export function scaffoldFiles(name: string): Record<string, string> {
34
- return files(name)
33
+ export function scaffoldFiles(
34
+ name: string,
35
+ framework: AppFramework = 'next',
36
+ ): Record<string, string> {
37
+ return framework === 'react' ? files(name) : nextFiles(name)
35
38
  }
36
39
 
37
40
  /**
@@ -117,18 +120,6 @@ function files(name: string): Record<string, string> {
117
120
  private: true,
118
121
  version: '0.1.0',
119
122
  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
123
  scripts: {
133
124
  dev: 'vite',
134
125
  build: 'vite build',
@@ -158,6 +149,21 @@ function files(name: string): Record<string, string> {
158
149
  2,
159
150
  )}\n`,
160
151
 
152
+ // Frontera deployment configuration is deliberately separate from npm's
153
+ // package manifest. package.json remains standard package metadata; this
154
+ // file describes how the already-built artifact is hosted.
155
+ 'frontera.config.json': `${JSON.stringify(
156
+ {
157
+ displayName: name,
158
+ outputDirectory: 'dist',
159
+ routing: 'spa',
160
+ connectDomains: [],
161
+ resourceDomains: [],
162
+ },
163
+ null,
164
+ 2,
165
+ )}\n`,
166
+
161
167
  'vite.config.ts': `import { fileURLToPath, URL } from 'node:url'
162
168
  import { defineConfig } from 'vite'
163
169
  import react from '@vitejs/plugin-react'
@@ -306,7 +312,7 @@ dist/
306
312
 
307
313
  '.agents/skills/using-frontera-sdk/SKILL.md': `---
308
314
  name: using-frontera-sdk
309
- 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.
315
+ description: Use at the start of ANY task in a Frontera Vite app project with frontera.config.json that deploys via the frontera CLI. Establishes the project shape and routes to the right pattern.
310
316
  ---
311
317
 
312
318
  # Frontera App Development
@@ -395,13 +401,13 @@ handshake must not ship with the app.
395
401
 
396
402
  '.agents/skills/frontera-app-data/SKILL.md': `---
397
403
  name: frontera-app-data
398
- description: Use when reading platform data in a Frontera app — useObjects, useAggregate, useObjectInstance, where clauses, paging, and why filtering must happen on the server.
404
+ description: Use when reading platform data in a Frontera app — useObjects, useAggregate, useMetric, useObjectInstance, where clauses, paging, and why filtering must happen on the server.
399
405
  ---
400
406
 
401
407
  # Reading Blueprint data
402
408
 
403
409
  \`\`\`tsx
404
- import { useObjects, useAggregate } from '@frontera-sdk/blueprint/hooks'
410
+ import { useObjects, useAggregate, useMetric } from '@frontera-sdk/blueprint/hooks'
405
411
  import { objectsOf, type WhereNode } from '@frontera-sdk/blueprint/types'
406
412
  \`\`\`
407
413
 
@@ -419,7 +425,7 @@ const where: WhereNode | undefined = status
419
425
  ? { property: 'deliveryStatus', op: 'eq', value: status }
420
426
  : undefined
421
427
 
422
- const rows = useObjects<Shipment>('Shipment', { where, page: 1, pageSize: 20 })
428
+ const rows = useObjects<Shipment>('Shipment', { where, pageToken, pageSize: 20 })
423
429
  \`\`\`
424
430
 
425
431
  Filtering \`rows.data.rows\` in the component instead is the classic mistake: it
@@ -429,15 +435,165 @@ renders 5 of them and the footer claims "Page 1 of 1".
429
435
  For the same reason, a total is its own query — \`useAggregate\` with a count
430
436
  over the SAME object set — not \`rows.length\`.
431
437
 
432
- ## Paging
438
+ ## Paging is a cursor
433
439
 
434
- \`page\` is 1-based. Server-driven tables need \`pageCount\` and \`rowCount\` from
435
- the aggregate, or the table re-paginates one page of server data.
440
+ Omit \`pageToken\` for the first request, then pass the response's opaque
441
+ \`nextPageToken\` to advance. Keep earlier tokens in UI state if Previous is
442
+ required, and reset them whenever filters, sort or projection change. Numeric
443
+ page labels and aggregate totals are presentation; they are not Blueprint
444
+ navigation inputs.
445
+ \`\`\`tsx
446
+ const [token, setToken] = useState<string | undefined>()
447
+ const page = useObjects<Ticket>('SupportTicket', { pageSize: 25, pageToken: token })
448
+ // next setToken(page.data?.nextPageToken)
449
+ // restart setToken(undefined)
450
+ \`\`\`
451
+
452
+ There is **no page number**. Offset paging was removed — it has no defined
453
+ meaning over an unordered scan — and the service refuses a request carrying
454
+ \`page\` outright. \`hasMore\` is false and \`nextPageToken\` absent on the last
455
+ page; its absence is how a scan learns it has finished.
456
+
457
+ There is also no total, so a "Page 3 of 12" footer cannot be built from this.
458
+ Show a count from \`useAggregate\` over the same object set if you need one.
436
459
 
437
460
  ## Identity
438
461
 
439
462
  \`useObjectInstance(objectType, pk)\` returns one record and is disabled while
440
463
  \`pk\` is null, so a detail panel can mount before a row is selected.
464
+
465
+ ## Metrics the organization already defined
466
+
467
+ \`\`\`tsx
468
+ const revenue = useMetric('codShare', { from, to })
469
+ \`\`\`
470
+
471
+ A metric exists so every reader computes it the same way. Prefer one over
472
+ \`useAggregate\` whenever it exists: re-deriving a defined figure from raw
473
+ columns is how two dashboards disagree about one number, and it is where the
474
+ arithmetic bugs live. \`frontera blueprint list\` shows what is defined.
475
+
476
+ Do NOT hand-roll a hook around \`useBlueprintClient\` for this — that escape
477
+ hatch is for calls with no hook, and a metric has one.
478
+
479
+ ## The escape hatch
480
+
481
+ \`useBlueprintClient()\` returns the raw client for anything the hooks do not
482
+ cover. Everything above is built on it, so reach for it last, not first.
483
+ `,
484
+
485
+ '.agents/skills/frontera-app-actions/SKILL.md': `---
486
+ name: frontera-app-actions
487
+ description: Use when an app must CHANGE something — governed Actions, useAction/useSubmitAction/useActionRequest, why a button may not exist for every user, and why submitting is not the same as done.
488
+ ---
489
+
490
+ # Changing data through a governed Action
491
+
492
+ \`\`\`tsx
493
+ import { useAction, useSubmitAction, useActionRequest } from '@frontera-sdk/blueprint/action-hooks'
494
+ \`\`\`
495
+
496
+ An app never writes to the Blueprint directly. It submits a **Request** against
497
+ a published Action, and the platform decides whether it runs, who must approve
498
+ it, and when it dispatches. There is no write hook, and adding one is not the
499
+ answer to "this is slow".
500
+
501
+ ## Render controls from discovery, never from a list you wrote
502
+
503
+ \`\`\`tsx
504
+ const { action } = useAction('escalateTicket') // null when this user may not
505
+ const submit = useSubmitAction(action)
506
+ \`\`\`
507
+
508
+ Authorization is per **person**, not per app: the signed-in user's role decides.
509
+ The same page must offer a control to one colleague and not another, and only
510
+ the server knows which. A hard-coded button that 403s on click is worse than one
511
+ that was never drawn — so gate on \`action\`, and render a disabled control with
512
+ a reason when you need the affordance to stay visible.
513
+
514
+ An empty result during development is far more often permissions than a bug.
515
+ An Action is hidden unless it is published, deployed, AND its invoke capability
516
+ is held by the caller's role.
517
+
518
+ ## Submitting is not applying
519
+
520
+ \`\`\`tsx
521
+ submit.mutate({
522
+ objectId: ticket.ticketId,
523
+ input: { priority: 'Critical', escalated: true },
524
+ expectedVersion: recordVersionOf(instance.data),
525
+ reason: 'Customer escalated.',
526
+ })
527
+
528
+ const request = useActionRequest(submit.data?.id) // polls until it settles
529
+ \`\`\`
530
+
531
+ \`mutate\` resolves when the Request is RECORDED. Dispatch happens on a
532
+ background worker, and approval may sit with someone else for hours. A success
533
+ toast fired on \`onSuccess\` is claiming something the app does not know.
534
+
535
+ **Ask \`actionEffectOf\`, never the lifecycle.**
536
+
537
+ \`\`\`tsx
538
+ import { actionEffectOf } from '@frontera-sdk/blueprint/action-types'
539
+
540
+ switch (actionEffectOf(request.data)) {
541
+ case 'applied': return <Done /> // the write committed. Close the dialog.
542
+ case 'refused': return <Refusal />
543
+ case 'uncertain': return <Checking /> // NOT failure — see below
544
+ case 'pending': return <Spinner />
545
+ }
546
+ \`\`\`
547
+
548
+ \`lifecycle\` says where the Request sits in the pipeline; \`effectCertainty\`
549
+ says whether the change landed. They settle at different times, and the gap is
550
+ not small. A Request reaches \`confirmed_applied\` the instant the write commits,
551
+ then sits in \`finalizing\` while the platform verifies its own promise —
552
+ re-reading the target, checking the properties the Action declared it would
553
+ change. **That verification never undoes the write.** Its worst outcome still
554
+ carries \`confirmed_applied\` and only means a human should look at why the proof
555
+ was inconclusive. So blocking a dialog on \`succeeded\` leaves someone watching a
556
+ spinner over a ticket that already exists.
557
+
558
+ \`uncertain\` is the one that must not be collapsed into either neighbour. A
559
+ dispatch was attempted and its outcome could not be established — the connection
560
+ died mid-commit, and the write may well have landed. Rendering it as failure
561
+ invites a duplicate; rendering it as success invites a lie. Say it is being
562
+ checked and leave it: the platform reconciles it against the target and the
563
+ answer arrives on its own.
564
+
565
+ ## Concurrency — and where the version actually comes from
566
+
567
+ \`\`\`tsx
568
+ import { recordVersionOf } from '@frontera-sdk/blueprint/action-types'
569
+
570
+ const instance = useObjectInstance('SupportTicket', selectedId)
571
+ const version = recordVersionOf(instance.data)
572
+ \`\`\`
573
+
574
+ **A list row does not carry the version.** It arrives as \`_meta.recordVersion\`
575
+ from the INSTANCE route only, and only for an editable type. So a control that
576
+ edits a row in a table fetches that row's instance first — selecting a row and
577
+ submitting straight from \`useObjects\` data sends no version at all.
578
+
579
+ That failure is silent: a missing version does not error, the compare-and-set
580
+ is simply skipped, and two people overwrite each other with no refusal and no
581
+ evidence. Use \`recordVersionOf\` rather than reaching into \`_meta\` yourself.
582
+
583
+ A stale value IS refused, and that refusal is the feature — report it as
584
+ "someone else changed this, reload" rather than retrying with a fresher version
585
+ behind the user's back.
586
+
587
+ Do not hand-build the invocation. One version travels as two fields of
588
+ different types, and a wrong shape is refused without naming the field —
589
+ \`useSubmitAction\` assembles it.
590
+
591
+ ## Idempotency
592
+
593
+ A key is minted per \`mutate\` call. A retried network failure reuses it and
594
+ cannot double-apply; a second click is a second intent and gets its own. Pass
595
+ \`idempotencyKey\` yourself only when you have a stronger notion of "the same
596
+ intended effect" than one click.
441
597
  `,
442
598
  }
443
599
  }
@@ -568,3 +724,26 @@ export function devHostHtml(name: string): string {
568
724
  </html>
569
725
  `
570
726
  }
727
+
728
+ /**
729
+ * Default public-package scaffold: an ordinary Next.js project with a static
730
+ * artifact contract, a working reference feature, and the pattern skills that
731
+ * describe it.
732
+ *
733
+ * The file map and the skill set live in `templates/` rather than here: they
734
+ * are the parts that change with the product, while this module owns the
735
+ * vendoring rules the legacy Vite tree still depends on. Keeping them apart
736
+ * means a scaffold edit cannot accidentally move a vendored SDK path.
737
+ */
738
+ function nextFiles(name: string): Record<string, string> {
739
+ return {
740
+ ...nextSkillFiles(),
741
+ ...nextAppFiles({
742
+ name,
743
+ // The platform stylesheet, vendored at CLI build time. It carries the
744
+ // token contract every shipped component and every skill refers to, so
745
+ // the scaffold must not hand-roll a second one.
746
+ themeCss: vendoredFiles()['src/theme.css'] ?? '@import "tailwindcss";\n',
747
+ }),
748
+ }
749
+ }