@frontera-sdk/cli 1.43.10 → 1.44.1

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
@@ -1,4 +1,5 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
+ import { parseBlueprintSchema, type BlueprintSchemaObject } from '../../blueprint-types'
2
3
  import { CliError, UsageError } from '../../errors'
3
4
  import type { Command } from '../types'
4
5
 
@@ -36,14 +37,15 @@ interface ObjectTypeRow {
36
37
  * error that names a different concept than the argument does, and does not
37
38
  * say the one thing that would fix it.
38
39
  *
39
- * The exact name is tried first, so the common path stays one request. Only a
40
- * miss pays for the list, and then it either resolves the case or names the
41
- * closest thing it found.
40
+ * Resolution happens only inside the granted schema. That both corrects case
41
+ * and prevents a detail lookup from becoming an existence oracle for an
42
+ * ungranted organization object type.
42
43
  */
43
- async function resolveApiName(api: PlatformApi, typed: string): Promise<string> {
44
- const types = (await api.blueprintObjectTypes()) as ObjectTypeRow[]
44
+ function resolveApiName(types: ObjectTypeRow[], typed: string): string {
45
45
  const names = types.map((t) => t.apiName).filter((n): n is string => Boolean(n))
46
46
 
47
+ const exact = names.find((n) => n === typed)
48
+ if (exact) return exact
47
49
  const insensitive = names.find((n) => n.toLowerCase() === typed.toLowerCase())
48
50
  if (insensitive) return insensitive
49
51
 
@@ -82,21 +84,10 @@ export const blueprintGet: Command = {
82
84
  }
83
85
 
84
86
  const api = new PlatformApi(ctx.apiUrl, ctx.token)
85
-
86
- // The response is `{ objectType, properties }`, NOT a flat object type.
87
- // Reading apiName off the top level silently yielded undefined and fell
88
- // back to whatever the caller typed — so the header echoed the input and
89
- // looked correct only when the case already matched.
90
- let payload: { objectType?: ObjectTypeRow & { description?: string | null }; properties?: Property[] }
91
- try {
92
- payload = (await api.blueprintObjectType(typed)) as typeof payload
93
- } catch {
94
- // Miss: resolve the case, or fail with the names that do exist.
95
- payload = (await api.blueprintObjectType(await resolveApiName(api, typed))) as typeof payload
96
- }
97
-
98
- const type = payload.objectType ?? {}
99
- const apiName = type.apiName ?? typed
87
+ const schema = parseBlueprintSchema(await api.blueprintSchema())
88
+ const apiName = resolveApiName(schema.objectTypes, typed)
89
+ const type = schema.objectTypes.find((objectType) => objectType.apiName === apiName) as BlueprintSchemaObject
90
+ const grantedNames = new Set(schema.objectTypes.map((objectType) => objectType.apiName))
100
91
 
101
92
  // Relations and metrics, not just columns. A property list alone says what
102
93
  // a table holds and nothing about how it joins or what is measured on it —
@@ -115,14 +106,21 @@ export const blueprintGet: Command = {
115
106
  for (const t of types as Array<{ id?: string; apiName?: string }>) {
116
107
  if (t.id && t.apiName) nameById.set(t.id, t.apiName)
117
108
  }
118
- const selfId = (type as { id?: string }).id ?? ''
119
-
120
- const related = (links as Link[]).filter(
121
- (l) => l.fromObjectTypeId === selfId || l.toObjectTypeId === selfId,
122
- )
123
- const own = (metrics as Metric[]).filter((m) => m.objectTypeId === selfId)
124
-
125
- const props = payload.properties ?? []
109
+ const selfId = (types as Array<{ id?: string; apiName?: string }>)
110
+ .find((candidate) => candidate.apiName === apiName)?.id ?? ''
111
+
112
+ const related = (links as Link[]).flatMap((link) => {
113
+ if (link.fromObjectTypeId !== selfId && link.toObjectTypeId !== selfId) return []
114
+ const outbound = link.fromObjectTypeId === selfId
115
+ const other = nameById.get((outbound ? link.toObjectTypeId : link.fromObjectTypeId) ?? '')
116
+ if (!other || !grantedNames.has(other)) return []
117
+ return [{ apiName: link.apiName, direction: outbound ? 'outbound' as const : 'inbound' as const, otherObjectType: other, cardinality: link.cardinality }]
118
+ })
119
+ const own = (metrics as Metric[])
120
+ .filter((metric) => metric.objectTypeId === selfId)
121
+ .map((metric) => ({ apiName: metric.apiName, displayName: metric.displayName }))
122
+
123
+ const props = type.properties as Property[]
126
124
  const heading = [apiName, type.displayName].filter(Boolean)
127
125
  const lines = [
128
126
  heading[1] && heading[1] !== heading[0] ? `${heading[0]} — ${heading[1]}` : String(heading[0]),
@@ -139,11 +137,8 @@ export const blueprintGet: Command = {
139
137
  `Relations (${related.length})`,
140
138
  ...(related.length === 0
141
139
  ? [' (none)']
142
- : related.map((l) => {
143
- const outbound = l.fromObjectTypeId === selfId
144
- const otherId = outbound ? l.toObjectTypeId : l.fromObjectTypeId
145
- const other = nameById.get(otherId ?? '') ?? otherId ?? ''
146
- return ` ${(l.apiName ?? '?').padEnd(26)}${outbound ? '→' : '←'} ${other.padEnd(18)}${l.cardinality ?? ''}`
140
+ : related.map((relation) => {
141
+ return ` ${(relation.apiName ?? '?').padEnd(26)}${relation.direction === 'outbound' ? '→' : '←'} ${relation.otherObjectType.padEnd(18)}${relation.cardinality ?? ''}`
147
142
  })),
148
143
  '',
149
144
  `Metrics (${own.length})`,
@@ -153,7 +148,7 @@ export const blueprintGet: Command = {
153
148
  ]
154
149
 
155
150
  return {
156
- data: { ...payload, relations: related, metrics: own },
151
+ data: { objectType: type, properties: props, relations: related, metrics: own },
157
152
  text: lines.join('\n'),
158
153
  }
159
154
  },
@@ -1,4 +1,5 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
+ import { parseBlueprintSchema } from '../../blueprint-types'
2
3
  import { table } from '../../table'
3
4
  import type { Command } from '../types'
4
5
 
@@ -28,7 +29,7 @@ export const blueprintList: Command = {
28
29
 
29
30
  async run(ctx) {
30
31
  const api = new PlatformApi(ctx.apiUrl, ctx.token)
31
- const types = (await api.blueprintObjectTypes()) as ObjectTypeRow[]
32
+ const types = parseBlueprintSchema(await api.blueprintSchema()).objectTypes
32
33
 
33
34
  return {
34
35
  data: types,
@@ -12,12 +12,17 @@ import { appSave } from './app/save'
12
12
  import { appDeploy } from './app/deploy'
13
13
  import { appPromote } from './app/promote'
14
14
  import { appVersions } from './app/versions'
15
+ import { appSdk } from './app/sdk'
16
+ import { appDev } from './app/dev'
15
17
  import { blueprintList } from './blueprint/list'
16
18
  import { blueprintGet } from './blueprint/get'
19
+ import { blueprintGenerateTypes } from './blueprint/generate-types'
17
20
  import { blueprintAuthoringCommands } from './blueprint/authoring'
18
21
  import { blueprintDeclarativeCommands } from './blueprint/declarative'
19
22
  import { blueprintBind } from './blueprint/bind'
23
+ import { blueprintEditable } from './blueprint/editable'
20
24
  import { blueprintGrant } from './blueprint/grants'
25
+ import { actionCommands } from './action/index-commands'
21
26
  import { agentCommands } from './agent/index-commands'
22
27
  import { skillCommands } from './skill/index-commands'
23
28
  import { pluginCommands } from './plugin/index-commands'
@@ -58,6 +63,8 @@ export const COMMANDS: readonly Command[] = [
58
63
  appDeploy,
59
64
  appPromote,
60
65
  appVersions,
66
+ appSdk,
67
+ appDev,
61
68
 
62
69
  ...agentCommands,
63
70
  ...skillCommands,
@@ -70,12 +77,16 @@ export const COMMANDS: readonly Command[] = [
70
77
 
71
78
  blueprintList,
72
79
  blueprintGet,
80
+ blueprintGenerateTypes,
73
81
  // Authoring is no longer reserved: the organization API key (`sk-org-`) reaches the
74
82
  // organization-level shared draft, which is what these verbs were waiting on.
75
83
  ...blueprintAuthoringCommands,
76
84
  ...blueprintDeclarativeCommands,
77
85
  blueprintBind,
86
+ blueprintEditable,
78
87
  blueprintGrant,
88
+
89
+ ...actionCommands,
79
90
  ]
80
91
 
81
92
  export function findCommand(noun: string, verb: string | undefined): Command | null {
@@ -106,6 +117,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
106
117
  secret: 'Workspace secrets — named here, never printed back',
107
118
  automation: 'Automations — TypeScript deployed here, run on a schedule',
108
119
  blueprint: 'The shared model of the organization — what an app can read',
120
+ action: 'Governed Actions — arm the write path a published Action runs',
109
121
  }
110
122
 
111
123
  export function nounSummary(noun: string): string {
package/src/context.ts CHANGED
@@ -20,14 +20,14 @@ export interface Context {
20
20
  /**
21
21
  * Is this directory the root of a Frontera app project?
22
22
  *
23
- * The marker is `package.json` carrying a `frontera` field, which the scaffold
24
- * writes at init (`frontera: { displayName }`) and `writeAppId` fills in after
25
- * the first deploy. A bare `package.json` deliberately does NOT match: without
26
- * the field, every JavaScript repository on the machine would look like an app.
23
+ * New projects are marked by `frontera.config.json`. Legacy projects remain
24
+ * marked by `package.json#frontera`. A bare package.json does not match, so an
25
+ * unrelated JavaScript repository never becomes an App accidentally.
27
26
  */
28
27
  function isProjectRoot(dir: string): boolean {
29
28
  const pkgPath = join(dir, 'package.json')
30
29
  if (!existsSync(pkgPath)) return false
30
+ if (existsSync(join(dir, 'frontera.config.json'))) return true
31
31
  try {
32
32
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { frontera?: unknown }
33
33
  return typeof pkg.frontera === 'object' && pkg.frontera !== null
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import type { BridgeInit } from '@frontera-sdk/core/bridge-protocol'
4
+
5
+ export interface DevSessionPayload {
6
+ init: BridgeInit
7
+ expiresAt: number
8
+ }
9
+
10
+ export interface DevSessionBroker {
11
+ endpoint: string
12
+ server: ReturnType<typeof Bun.serve>
13
+ }
14
+
15
+ /**
16
+ * Keep the durable CLI credential on the developer's machine while giving the
17
+ * browser only a short-lived App token. The random path is defence in depth;
18
+ * loopback binding and an exact Origin check are the actual trust boundary.
19
+ */
20
+ export function createDevSessionBroker(options: {
21
+ appOrigin: string
22
+ loadSession(): Promise<DevSessionPayload>
23
+ }): DevSessionBroker {
24
+ const allowedOrigin = new URL(options.appOrigin).origin
25
+ const path = `/.frontera/dev-session/${randomBytes(32).toString('base64url')}`
26
+
27
+ const fetch = async (request: Request): Promise<Response> => {
28
+ const url = new URL(request.url)
29
+ if (url.pathname !== path) return new Response('Not found', { status: 404 })
30
+
31
+ const origin = request.headers.get('origin')
32
+ if (origin !== allowedOrigin) return new Response('Forbidden', { status: 403 })
33
+
34
+ const corsHeaders = {
35
+ 'access-control-allow-origin': allowedOrigin,
36
+ 'access-control-allow-credentials': 'true',
37
+ 'access-control-allow-methods': 'GET, OPTIONS',
38
+ 'access-control-allow-headers': 'Accept',
39
+ 'cache-control': 'no-store',
40
+ vary: 'Origin',
41
+ }
42
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: corsHeaders })
43
+ if (request.method !== 'GET') return new Response('Method not allowed', { status: 405, headers: corsHeaders })
44
+
45
+ try {
46
+ return Response.json({ data: await options.loadSession() }, { headers: corsHeaders })
47
+ } catch (error) {
48
+ return Response.json(
49
+ { error: error instanceof Error ? error.message : 'Could not create a local App session' },
50
+ { status: 502, headers: corsHeaders },
51
+ )
52
+ }
53
+ }
54
+
55
+ // Bun 1.3 rejects port 0 instead of asking the kernel for an ephemeral
56
+ // port. Retry within the IANA dynamic range; binding itself is atomic, so a
57
+ // race can only make one attempt fail and move to the next candidate.
58
+ let server: ReturnType<typeof Bun.serve> | undefined
59
+ let lastError: unknown
60
+ for (let attempt = 0; attempt < 32 && !server; attempt += 1) {
61
+ try {
62
+ const port = 49_152 + Math.floor(Math.random() * (65_535 - 49_152 + 1))
63
+ server = Bun.serve({ hostname: '127.0.0.1', port, fetch })
64
+ } catch (error) {
65
+ lastError = error
66
+ }
67
+ }
68
+ if (!server) throw lastError instanceof Error ? lastError : new Error('Could not bind the local session broker')
69
+
70
+ return { endpoint: `http://127.0.0.1:${server.port}${path}`, server }
71
+ }
package/src/flag-help.ts CHANGED
@@ -19,10 +19,22 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
19
19
  plan: 'write a mapping skeleton to this path instead of binding',
20
20
  'accept-contract-change': 'confirm a rebind whose column contract differs from the pinned one',
21
21
  revoke: 'remove the grant instead of adding it',
22
+ // Editable properties. Comma-separated API names; `--set ''` clears the set,
23
+ // which is why an empty value is a valid one rather than a missing argument.
24
+ set: 'comma-separated property API names, replacing the whole set',
25
+ // Action deployment.
26
+ 'dry-run': 'derive and print the write path without building anything',
27
+ 'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
28
+ 'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
29
+ reason: 'reason recorded on the deployment transition',
30
+ 'no-activate': 'review the write path but leave it switched off',
31
+ role: 'organization role the capability is held by',
32
+ add: 'comma-separated property API names to add to the current set',
33
+ remove: 'comma-separated property API names to drop from the current set',
22
34
  // Blueprint authoring (organization API key). `notes` is described further down
23
35
  // — this map is keyed by flag name across every command, so one entry serves both.
24
36
  label: 'release label recorded on the published or rolled-back release',
25
- report: 'validation report id to publish against; defaults to a fresh validation',
37
+ report: 'validation report id to act on — the release publishes against it, the review is pinned to it',
26
38
  instruction: 'migration instruction applied to every change a rollback discards',
27
39
  // Global
28
40
  json: 'machine-readable output on stdout; stderr still carries commentary',
@@ -30,6 +42,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
30
42
  yes: 'assume yes for confirmations',
31
43
  help: 'show this help',
32
44
  'api-url': 'API origin to use, overriding FRONTERA_API_URL and the stored default',
45
+ output: 'project-relative .ts path for generated output',
46
+ check: 'verify generated output is current without writing it',
33
47
 
34
48
  // Project resolution
35
49
  dir: 'project directory to act on (default: walk up from the working directory)',
@@ -42,6 +56,12 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
42
56
  notes: 'note recorded against the published version',
43
57
 
44
58
  // Apps
59
+ framework: 'App scaffold to create: next (default) or react (legacy Vite)',
60
+ port: 'loopback port for the local App development server',
61
+ 'no-open': 'start local development without opening a browser window',
62
+ 'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
63
+ 'no-git': 'scaffold without creating a repository and an initial commit',
64
+ 'no-components': 'scaffold without fetching the baseline shadcn components',
45
65
  version: 'version to publish, overriding the one in package.json',
46
66
  'no-promote': 'publish the version without moving the live pointer',
47
67
  'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
@@ -83,11 +103,14 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
83
103
  */
84
104
  const PLACEHOLDER: Readonly<Record<string, string>> = {
85
105
  'api-url': 'origin',
106
+ output: 'path',
86
107
  dataset: 'name',
87
108
  plan: 'path',
88
109
  dir: 'path',
89
110
  file: 'path',
90
111
  version: 'semver',
112
+ framework: 'next|react',
113
+ port: 'number',
91
114
  'expect-revision': 'hash',
92
115
  notes: 'text',
93
116
  description: 'text',
package/src/heal.ts CHANGED
@@ -135,7 +135,7 @@ export function healProject(dir: string): HealResult {
135
135
  // Already pointing at the copy in this tree — that is the shape this
136
136
  // function produces, so re-pulling must not rewrite it back over
137
137
  // itself and report a repair that did nothing.
138
- if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` && vendorPresent(dir, pkg)) continue
138
+ if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` && vendorCurrent(dir, pkg)) continue
139
139
  vendorSdkPackage(dir, pkg)
140
140
  deps[dep] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
141
141
  const under = dep === pkg ? '' : ` (legacy name, now ${pkg})`
@@ -184,7 +184,10 @@ export function healProject(dir: string): HealResult {
184
184
  // Reacts.
185
185
  const imported = importedSdkPackages(join(dir, 'src'))
186
186
  for (const { specifier, pkg } of imported) {
187
- if (declares(manifest, specifier) || (specifier === pkg && vendorPresent(dir, pkg))) continue
187
+ // `declares` alone is not enough: a tree can declare the dependency and
188
+ // still carry a stale copy, which is the ordinary state of every app
189
+ // scaffolded before the SDK grew a module.
190
+ if (vendorCurrent(dir, pkg) && (declares(manifest, specifier) || specifier === pkg)) continue
188
191
  vendorSdkPackage(dir, pkg)
189
192
  manifest.dependencies ??= {}
190
193
  manifest.dependencies[specifier] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
@@ -252,6 +255,38 @@ function vendorPresent(dir: string, pkg: AppSdkPackage): boolean {
252
255
  return existsSync(join(dir, APP_SDK_PACKAGES[pkg].dir))
253
256
  }
254
257
 
258
+ /**
259
+ * Does it carry the CURRENT one — every file this CLI would write, as written?
260
+ *
261
+ * "The directory exists" was the wrong question. An app vendored before a
262
+ * module was added has the directory and not the module, so a heal that
263
+ * skipped on presence left it frozen at whatever the CLI shipped the day it
264
+ * was scaffolded — and `app pull`, which exists to hydrate a tree, silently
265
+ * refused to hydrate the one part it owns.
266
+ *
267
+ * CONTENT, not presence, for the same reason one step on: a file that exists
268
+ * but predates a fix is the ordinary state of every app already deployed, and
269
+ * a fix nothing can deliver is not shipped. The vendored tree is a copy of
270
+ * upstream rather than app code — `heal` owns it and rewrites it — so
271
+ * overwriting a divergent copy is the repair, not a loss.
272
+ *
273
+ * Re-vendoring is safe and idempotent: `vendorSdkPackage` writes into the SAME
274
+ * directory, so this refreshes a copy rather than creating a second one.
275
+ */
276
+ function vendorCurrent(dir: string, pkg: AppSdkPackage): boolean {
277
+ if (!vendorPresent(dir, pkg)) return false
278
+ const target = join(dir, APP_SDK_PACKAGES[pkg].dir)
279
+ return Object.entries(sdkPackageFiles(pkg)).every(([rel, content]) => {
280
+ const path = join(target, rel)
281
+ if (!existsSync(path)) return false
282
+ try {
283
+ return readFileSync(path, 'utf8') === content
284
+ } catch {
285
+ return false
286
+ }
287
+ })
288
+ }
289
+
255
290
  /** Does it carry any of them? */
256
291
  function vendored(dir: string): boolean {
257
292
  return SDK_PACKAGE_NAMES.some((pkg) => vendorPresent(dir, pkg))
package/src/manifest.ts CHANGED
@@ -15,11 +15,14 @@ import type { ProjectConfig } from './project'
15
15
  * `connectDomains` reaches the CSP for every app rather than only scaffolded
16
16
  * ones — which is the gap this closes.
17
17
  *
18
- * A build that DOES emit `dist/frontera.manifest.json` still wins, so a future
18
+ * A build that DOES emit `<outputDirectory>/frontera.manifest.json` still wins, so a future
19
19
  * plugin can add pages and an app-state schema without changing this.
20
20
  */
21
21
  export interface AppManifest {
22
- schemaVersion: 1
22
+ schemaVersion: 2
23
+ runtime: 'static'
24
+ routing: 'spa' | 'filesystem'
25
+ entrypoint: 'index.html'
23
26
  displayName: string
24
27
  connectDomains: string[]
25
28
  resourceDomains: string[]
@@ -50,7 +53,10 @@ function assertDomains(field: string, values: string[]): string[] {
50
53
 
51
54
  export function buildManifest(project: ProjectConfig): AppManifest {
52
55
  return {
53
- schemaVersion: 1,
56
+ schemaVersion: 2,
57
+ runtime: 'static',
58
+ routing: project.routing ?? 'spa',
59
+ entrypoint: 'index.html',
54
60
  displayName: project.displayName,
55
61
  connectDomains: assertDomains('connectDomains', project.connectDomains),
56
62
  resourceDomains: assertDomains('resourceDomains', project.resourceDomains),
@@ -60,8 +66,9 @@ export function buildManifest(project: ProjectConfig): AppManifest {
60
66
  /**
61
67
  * The manifest to publish with this build.
62
68
  *
63
- * Read from `dist/` when the build produced one; otherwise synthesised from
64
- * `package.json`. A malformed `dist/frontera.manifest.json` is an error rather
69
+ * Read from the configured output directory when the build produced one;
70
+ * otherwise synthesised from `frontera.config.json` (or the legacy
71
+ * package.json field). A malformed emitted manifest is an error rather
65
72
  * than a silent fallback: the build meant to say something, and quietly
66
73
  * publishing different rules than the author declared is worse than refusing.
67
74
  */
@@ -69,12 +76,86 @@ export function resolveManifest(distDir: string, project: ProjectConfig): AppMan
69
76
  const emitted = join(distDir, 'frontera.manifest.json')
70
77
  if (!existsSync(emitted)) return buildManifest(project)
71
78
 
79
+ let parsed: unknown
72
80
  try {
73
- return JSON.parse(readFileSync(emitted, 'utf8')) as AppManifest
81
+ parsed = JSON.parse(readFileSync(emitted, 'utf8'))
74
82
  } catch (err) {
75
- throw new CliError(`dist/frontera.manifest.json is not valid JSON: ${(err as Error).message}`, {
83
+ throw new CliError(`${emitted} is not valid JSON: ${(err as Error).message}`, {
76
84
  code: 'VALIDATION_ERROR',
77
- hint: 'delete it to fall back to the `frontera` key in package.json',
85
+ hint: 'fix the emitted JSON, or delete it to use frontera.config.json',
78
86
  })
79
87
  }
88
+ return normalizeManifest(parsed)
89
+ }
90
+
91
+ const MANIFEST_HINT = 'fix frontera.manifest.json, then run `frontera app deploy` again'
92
+
93
+ function stringArray(value: unknown, field: string): string[] {
94
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
95
+ throw new CliError(`${field} must be an array of strings`, {
96
+ code: 'VALIDATION_ERROR',
97
+ hint: MANIFEST_HINT,
98
+ })
99
+ }
100
+ return assertDomains(field, value as string[])
101
+ }
102
+
103
+ /** Validate emitted manifests before they cross the wire. */
104
+ export function normalizeManifest(value: unknown): AppManifest {
105
+ if (!value || typeof value !== 'object') {
106
+ throw new CliError('frontera manifest must be an object', {
107
+ code: 'VALIDATION_ERROR', hint: MANIFEST_HINT,
108
+ })
109
+ }
110
+ const raw = value as Record<string, unknown>
111
+ if (raw.schemaVersion !== 1 && raw.schemaVersion !== 2) {
112
+ throw new CliError('unsupported frontera manifest schemaVersion', {
113
+ code: 'VALIDATION_ERROR', hint: 'emit schemaVersion 2, then run `frontera app deploy` again',
114
+ })
115
+ }
116
+ if (typeof raw.displayName !== 'string' || raw.displayName.length === 0) {
117
+ throw new CliError('manifest displayName must be a non-empty string', {
118
+ code: 'VALIDATION_ERROR', hint: MANIFEST_HINT,
119
+ })
120
+ }
121
+ const connectDomains = stringArray(raw.connectDomains, 'connectDomains')
122
+ const resourceDomains = stringArray(raw.resourceDomains, 'resourceDomains')
123
+
124
+ if (raw.schemaVersion === 1) {
125
+ return {
126
+ schemaVersion: 2,
127
+ runtime: 'static',
128
+ routing: 'spa',
129
+ entrypoint: 'index.html',
130
+ displayName: raw.displayName,
131
+ connectDomains,
132
+ resourceDomains,
133
+ }
134
+ }
135
+ if (raw.runtime !== 'static') {
136
+ throw new CliError('manifest runtime must be "static"', {
137
+ code: 'VALIDATION_ERROR', hint: 'build a static artifact; server runtimes are not supported',
138
+ })
139
+ }
140
+ if (raw.routing !== 'spa' && raw.routing !== 'filesystem') {
141
+ throw new CliError('manifest routing must be "spa" or "filesystem"', {
142
+ code: 'VALIDATION_ERROR',
143
+ hint: MANIFEST_HINT,
144
+ })
145
+ }
146
+ if (raw.entrypoint !== 'index.html') {
147
+ throw new CliError('manifest entrypoint must be "index.html"', {
148
+ code: 'VALIDATION_ERROR',
149
+ hint: MANIFEST_HINT,
150
+ })
151
+ }
152
+ return {
153
+ schemaVersion: 2,
154
+ runtime: 'static',
155
+ routing: raw.routing,
156
+ entrypoint: 'index.html',
157
+ displayName: raw.displayName,
158
+ connectDomains,
159
+ resourceDomains,
160
+ }
80
161
  }
package/src/packaging.ts CHANGED
@@ -17,6 +17,7 @@ import { join, relative, sep } from 'node:path'
17
17
  export const HARD_EXCLUDED = Object.freeze([
18
18
  'node_modules',
19
19
  'dist',
20
+ 'out',
20
21
  'build',
21
22
  '.next',
22
23
  '.git',
@@ -39,6 +40,11 @@ export function isExcluded(rel: string): boolean {
39
40
  if (seg === '.DS_Store') return true
40
41
  if (seg.endsWith('.log')) return true
41
42
  }
43
+ // The render screenshot. It is written INSIDE the project so a deploy from a
44
+ // bare directory can find it, but it is a build-time by-product of this
45
+ // machine, not source: packaging it would push a few hundred KB of PNG into
46
+ // every `app pull` for a picture the platform already stores per version.
47
+ if (rel === '.frontera/last-render.png') return true
42
48
  return false
43
49
  }
44
50