@frontera-sdk/cli 1.43.9 → 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
@@ -162,13 +162,24 @@ export class BlueprintAuthoringApi {
162
162
  async getObjectTypeDetail(apiName: string): Promise<{
163
163
  id?: string
164
164
  properties: Array<{ id?: string; apiName?: string }>
165
+ /**
166
+ * Property API NAMES — the draft stores ids, and the catalog resolves them
167
+ * before it answers. Carried here rather than read back through a second
168
+ * call because `editable` has to show the current set before it can add to
169
+ * or remove from it.
170
+ */
171
+ editableProperties: string[]
165
172
  } | null> {
166
173
  const payload = await this.call<{
167
- objectType?: { id?: string }
174
+ objectType?: { id?: string; editableProperties?: string[] }
168
175
  properties?: Array<{ id?: string; apiName?: string }>
169
176
  }>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}?view=draft`)
170
177
  if (!payload?.objectType?.id) return null
171
- return { id: payload.objectType.id, properties: payload.properties ?? [] }
178
+ return {
179
+ id: payload.objectType.id,
180
+ properties: payload.properties ?? [],
181
+ editableProperties: payload.objectType.editableProperties ?? [],
182
+ }
172
183
  }
173
184
 
174
185
  /** The draft catalog — what an organization key can actually see. */
@@ -0,0 +1,192 @@
1
+ import { CliError } from '../errors'
2
+ import type { ObjectTypeShape, PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
3
+
4
+ /**
5
+ * The Action deployment plane.
6
+ *
7
+ * Separate from `BlueprintAuthoringApi` because it is a separate plane: the
8
+ * Blueprint draft-and-release lifecycle publishes the CONTRACT, and these
9
+ * routes arm the WRITE. They carry their own capability family
10
+ * (`actionMutationPlan`, `actionBinding`), their own revisions, and their own
11
+ * state machine, and a credential holding one says nothing about the other.
12
+ */
13
+
14
+ interface Envelope<T> { error?: boolean; data?: T }
15
+
16
+ export class GovernedActionApi {
17
+ constructor(private readonly apiUrl: string, private readonly token: string) {}
18
+
19
+ private async call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
20
+ const response = await fetch(`${this.apiUrl}${path}`, {
21
+ method: init.method ?? 'GET',
22
+ headers: {
23
+ authorization: `Bearer ${this.token}`,
24
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
25
+ },
26
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
27
+ })
28
+ const text = await response.text()
29
+ let payload: unknown
30
+ try { payload = text ? JSON.parse(text) : null } catch { payload = null }
31
+
32
+ if (!response.ok) {
33
+ const body = payload as { message?: string; code?: string; details?: unknown } | null
34
+ throw new CliError(body?.message ?? `${response.status} from ${path}`, {
35
+ code: body?.code ?? 'FAILURE',
36
+ ...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
37
+ })
38
+ }
39
+ const envelope = payload as Envelope<T>
40
+ return (envelope && envelope.error === false && 'data' in envelope
41
+ ? envelope.data
42
+ : payload) as T
43
+ }
44
+
45
+ /** The published Action: its definition and the digest a Binding pins. */
46
+ publishedAction(apiName: string): Promise<{
47
+ definition: PublishedActionDefinition
48
+ contractDigest: string
49
+ availability?: string
50
+ deploymentStatus?: string
51
+ reason?: string
52
+ }> {
53
+ return this.call(`/v1/blueprint/actions/${encodeURIComponent(apiName)}`)
54
+ }
55
+
56
+ listPublishedActions(): Promise<Array<{
57
+ definition: { apiName: string; displayName?: string }
58
+ deploymentStatus?: string
59
+ availability?: string
60
+ reason?: string
61
+ }>> {
62
+ return this.call('/v1/blueprint/actions')
63
+ }
64
+
65
+ /**
66
+ * The ACTIVE view, never the draft: a deployment is checked against the
67
+ * release the write path will actually read, and a draft-only property would
68
+ * validate here and fail at dispatch.
69
+ */
70
+ async activeObjectType(apiName: string): Promise<ObjectTypeShape | null> {
71
+ const payload = await this.call<{
72
+ objectType?: { id?: string; apiName?: string; primaryKeyPropertyId?: string | null }
73
+ properties?: Array<{ id?: string; apiName?: string }>
74
+ }>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`)
75
+ if (!payload?.objectType?.id) return null
76
+ return {
77
+ id: payload.objectType.id,
78
+ apiName: payload.objectType.apiName ?? apiName,
79
+ primaryKeyPropertyId: payload.objectType.primaryKeyPropertyId ?? null,
80
+ properties: payload.properties ?? [],
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The first deployment decision, taken against the DRAFT — the only place an
86
+ * Action lives before it has ever published.
87
+ */
88
+ recordDeploymentDecision(actionApiName: string, reason: string): Promise<{
89
+ stateId: string
90
+ bindingId: string
91
+ actionDefinitionId: string
92
+ actionContractDigest: string
93
+ state: string
94
+ generation: number
95
+ }> {
96
+ return this.call('/v1/blueprint/governed-actions/admin/deployments', {
97
+ method: 'POST',
98
+ body: { actionApiName, reason },
99
+ })
100
+ }
101
+
102
+ /**
103
+ * One Action's invoke capability on one role. Not role editing: the service
104
+ * refuses a platform resource, and refuses a capability no published Action
105
+ * declares.
106
+ */
107
+ setCapabilityGrant(capability: string, role: string, revoke: boolean): Promise<{
108
+ role: string
109
+ capability: string
110
+ actions: string[]
111
+ granted: boolean
112
+ roleMaySubmit: boolean
113
+ }> {
114
+ return this.call('/v1/blueprint/governed-actions/admin/capability-grants', {
115
+ method: 'POST',
116
+ body: { capability, role, revoke },
117
+ })
118
+ }
119
+
120
+ /** The active catalog, for turning the subject's id into the name routes take. */
121
+ listActiveObjectTypes(): Promise<Array<{ id?: string; apiName?: string }>> {
122
+ return this.call('/v1/blueprint/object-types')
123
+ }
124
+
125
+ createMutationPlanRevision(planId: string, plan: unknown): Promise<{ id: string }> {
126
+ return this.call(`/v1/blueprint/governed-actions/admin/mutation-plans/${planId}/revisions`, {
127
+ method: 'POST',
128
+ body: { expectedRevision: 0, parentRevisionId: null, plan },
129
+ })
130
+ }
131
+
132
+ /**
133
+ * No `connectorRevisionId`: an ontology edit writes through an internal
134
+ * identity the deployment mints for itself, and the service provisions it
135
+ * precisely when the field is absent.
136
+ */
137
+ createBindingRevision(bindingId: string, input: {
138
+ actionDefinitionId: string
139
+ actionContractDigest: string
140
+ mutationPlanRevisionId: string
141
+ }): Promise<{ id: string; stateId: string }> {
142
+ return this.call(`/v1/blueprint/governed-actions/admin/bindings/${bindingId}/revisions`, {
143
+ method: 'POST',
144
+ body: { expectedRevision: 0, parentRevisionId: null, ...input },
145
+ })
146
+ }
147
+
148
+ validateBindingRevision(revisionId: string, expectedStateGeneration: number): Promise<{
149
+ id: string
150
+ status: string
151
+ findings?: unknown
152
+ }> {
153
+ return this.call(
154
+ `/v1/blueprint/governed-actions/admin/binding-revisions/${revisionId}/validate`,
155
+ { method: 'POST', body: { expectedStateGeneration } },
156
+ )
157
+ }
158
+
159
+ reviewBindingRevision(revisionId: string, input: {
160
+ validationReportId: string
161
+ expectedStateGeneration: number
162
+ }): Promise<unknown> {
163
+ return this.call(
164
+ `/v1/blueprint/governed-actions/admin/binding-revisions/${revisionId}/review`,
165
+ { method: 'POST', body: input },
166
+ )
167
+ }
168
+
169
+ activateDeployment(stateId: string, input: {
170
+ expectedGeneration: number
171
+ expectedCurrentStateId: string
172
+ expectedCurrentGeneration: number
173
+ reason: string
174
+ }): Promise<unknown> {
175
+ return this.call(
176
+ `/v1/blueprint/governed-actions/admin/deployments/${stateId}/activate`,
177
+ { method: 'POST', body: input },
178
+ )
179
+ }
180
+
181
+ listBindings(): Promise<{
182
+ entries: Array<{
183
+ bindingId: string
184
+ revisionId: string
185
+ actionDefinitionId: string
186
+ deployment?: { stateId?: string; state?: string; generation?: number }
187
+ }>
188
+ hasMore?: boolean
189
+ }> {
190
+ return this.call('/v1/blueprint/governed-actions/admin/bindings')
191
+ }
192
+ }
@@ -79,6 +79,10 @@ export class PlatformApi {
79
79
  return this.getList<unknown>('/v1/blueprint/object-types')
80
80
  }
81
81
 
82
+ blueprintSchema() {
83
+ return this.get<unknown>('/v1/blueprint/schema')
84
+ }
85
+
82
86
  blueprintObjectType(apiName: string) {
83
87
  return this.get<unknown>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`)
84
88
  }
@@ -0,0 +1,195 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * Derive an `ontology_edit` mutation plan from a published Action.
5
+ *
6
+ * The plan is not authored: everything in it is already stated by the Action
7
+ * and the object type it acts on. The Action says which properties it sets and
8
+ * which parameter feeds each one (`businessOutcomes[].expectedChanges`); the
9
+ * object type says which property is the key. Asking an operator to restate
10
+ * that in a hand-written JSON file would be asking them to reproduce, by hand,
11
+ * a mapping the platform can already read — and a typo there binds the write
12
+ * path to the wrong column.
13
+ *
14
+ * Pure and separately tested, because this is the one part of `action deploy`
15
+ * whose output is a contract rather than a call.
16
+ */
17
+
18
+ export interface PublishedActionDefinition {
19
+ id: string
20
+ apiName: string
21
+ subject: { objectTypeId: string; mode: 'existing' | 'create' }
22
+ effect: { kind: 'create' | 'update' | 'delete' | 'transition' | 'command' }
23
+ inputs: Array<{ id: string; apiName: string }>
24
+ concurrency: { strategy: string }
25
+ businessOutcomes: Array<{
26
+ expectedChanges?: Array<Record<string, unknown>>
27
+ }>
28
+ }
29
+
30
+ export interface ObjectTypeShape {
31
+ id: string
32
+ apiName: string
33
+ primaryKeyPropertyId: string | null
34
+ properties: Array<{ id?: string; apiName?: string }>
35
+ }
36
+
37
+ export interface OntologyEditPlan {
38
+ schemaVersion: 1
39
+ kind: 'ontology_edit'
40
+ targetCompatibility: { kind: 'ontology_edit'; expectedFingerprint: string }
41
+ objectTypeId: string
42
+ objectTypeApiName: string
43
+ op: 'patch' | 'create' | 'delete'
44
+ /** property apiName → parameter apiName. */
45
+ set: Record<string, string>
46
+ pkParameter: string
47
+ expectedVersionParameter?: string
48
+ impactBounds: { maxAffectedRows: number; maxTouchedRows: number }
49
+ capabilities: Record<string, unknown>
50
+ }
51
+
52
+ export class PlanDerivationError extends Error {
53
+ constructor(message: string, readonly hint: string) {
54
+ super(message)
55
+ this.name = 'PlanDerivationError'
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The service's `ontologyEditCompatibilityFingerprint`, reproduced.
61
+ *
62
+ * Duplicated rather than imported because the CLI ships as a standalone binary
63
+ * and cannot reach into the service. The shape is two fields — a string and a
64
+ * sorted unique string array — so canonical JSON here is ordinary
65
+ * `JSON.stringify` with the keys already in lexicographic order. A fixture in
66
+ * both test suites pins the same literal digest, so a change to either side
67
+ * fails a test rather than silently deploying a binding the inspector will
68
+ * refuse.
69
+ */
70
+ export function ontologyEditCompatibilityFingerprint(
71
+ objectTypeId: string,
72
+ usedProperties: readonly string[],
73
+ ): string {
74
+ const canonical = JSON.stringify({
75
+ objectTypeId,
76
+ usedProperties: [...new Set(usedProperties)].sort(),
77
+ })
78
+ return `sha256:${createHash('sha256').update(canonical).digest('hex')}`
79
+ }
80
+
81
+ const OP_FOR_EFFECT: Readonly<Record<string, OntologyEditPlan['op']>> = {
82
+ update: 'patch',
83
+ create: 'create',
84
+ delete: 'delete',
85
+ }
86
+
87
+ export function deriveOntologyEditPlan(
88
+ action: PublishedActionDefinition,
89
+ objectType: ObjectTypeShape,
90
+ ): OntologyEditPlan {
91
+ const op = OP_FOR_EFFECT[action.effect.kind]
92
+ if (!op) {
93
+ throw new PlanDerivationError(
94
+ `Action "${action.apiName}" has effect kind "${action.effect.kind}", which is not an ontology edit.`,
95
+ 'Only create, update and delete write to the Blueprint. transition and command are external effects.',
96
+ )
97
+ }
98
+ if (action.subject.objectTypeId !== objectType.id) {
99
+ throw new PlanDerivationError(
100
+ `Action "${action.apiName}" acts on a different object type than "${objectType.apiName}".`,
101
+ 'Re-read the Action; its subject moved.',
102
+ )
103
+ }
104
+
105
+ const propertyApiNameById = new Map(
106
+ objectType.properties.flatMap((p) => (p.id && p.apiName ? [[p.id, p.apiName] as const] : [])),
107
+ )
108
+ const inputApiNameById = new Map(action.inputs.map((i) => [i.id, i.apiName] as const))
109
+
110
+ // Every property this Action declares it sets, and the parameter that feeds
111
+ // it. Read from the Action rather than from a flag: this IS the rule, and a
112
+ // deployment that wrote a different set would be executing something other
113
+ // than what was published and reviewed.
114
+ const set: Record<string, string> = {}
115
+ for (const outcome of action.businessOutcomes) {
116
+ for (const change of outcome.expectedChanges ?? []) {
117
+ if (change.kind !== 'property_set') continue
118
+ const value = change.value as { kind?: string; fieldId?: string } | undefined
119
+ if (value?.kind !== 'input' || !value.fieldId) continue
120
+ const property = propertyApiNameById.get(String(change.propertyId))
121
+ const parameter = inputApiNameById.get(value.fieldId)
122
+ if (!property || !parameter) continue
123
+ set[property] = parameter
124
+ }
125
+ }
126
+
127
+ if (op !== 'delete' && Object.keys(set).length === 0) {
128
+ throw new PlanDerivationError(
129
+ `Action "${action.apiName}" sets no properties, so there is nothing to deploy.`,
130
+ 'Open the Action and tick at least one property under "What it changes".',
131
+ )
132
+ }
133
+
134
+ // The key is addressed, never set — an editable key could move a row out from
135
+ // under its own identity, and the service refuses one.
136
+ const pkApiName = objectType.primaryKeyPropertyId
137
+ ? propertyApiNameById.get(objectType.primaryKeyPropertyId)
138
+ : undefined
139
+ if (!pkApiName) {
140
+ throw new PlanDerivationError(
141
+ `Object type "${objectType.apiName}" has no primary key.`,
142
+ 'Set one with `frontera blueprint update object-type`, then publish.',
143
+ )
144
+ }
145
+ const pkParameter = action.inputs.find((i) => i.apiName === pkApiName)?.apiName
146
+ if (!pkParameter) {
147
+ throw new PlanDerivationError(
148
+ `Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
149
+ `Add a required "${pkApiName}" parameter to the Action, then publish.`,
150
+ )
151
+ }
152
+
153
+ // Only when the Action asked for it. Compare-and-set is the Action's
154
+ // concurrency contract, not a deployment preference.
155
+ const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
156
+ ? action.inputs.find((i) => i.apiName === 'expectedVersion')?.apiName
157
+ : undefined
158
+ if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
159
+ throw new PlanDerivationError(
160
+ `Action "${action.apiName}" declares expected_version concurrency but takes no "expectedVersion" parameter.`,
161
+ 'Add it to the Action, or change the concurrency strategy.',
162
+ )
163
+ }
164
+
165
+ return {
166
+ schemaVersion: 1,
167
+ kind: 'ontology_edit',
168
+ targetCompatibility: {
169
+ kind: 'ontology_edit',
170
+ expectedFingerprint: ontologyEditCompatibilityFingerprint(
171
+ objectType.id,
172
+ Object.keys(set),
173
+ ),
174
+ },
175
+ objectTypeId: objectType.id,
176
+ objectTypeApiName: objectType.apiName,
177
+ op,
178
+ set,
179
+ pkParameter,
180
+ ...(expectedVersionParameter ? { expectedVersionParameter } : {}),
181
+ // One subject, because the Action itself declares `impact.maxSubjects: 1`
182
+ // and the schema admits no other value.
183
+ impactBounds: { maxAffectedRows: 1, maxTouchedRows: 1 },
184
+ // Properties of the in-process edit path, not choices: it runs in one
185
+ // transaction against our own store, so every one of these is true by
186
+ // construction. They are stated because the inspector checks them.
187
+ capabilities: {
188
+ conditionalMutation: true,
189
+ targetIdempotency: 'transactional_ledger',
190
+ authoritativeReconciliation: true,
191
+ atomicEffect: true,
192
+ readYourWrites: true,
193
+ },
194
+ }
195
+ }
@@ -0,0 +1,252 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
3
+
4
+ import { CliError, UsageError } from './errors'
5
+
6
+ export const BLUEPRINT_TYPES_MARKER = '// Generated by `frontera blueprint generate-types`. Do not edit.'
7
+ export const DEFAULT_BLUEPRINT_TYPES_OUTPUT = 'src/generated/frontera-blueprint.ts'
8
+
9
+ export type BlueprintDataType = 'string' | 'number' | 'boolean' | 'date' | 'timestamp' | 'json'
10
+ export type BlueprintPropertyType = 'attribute' | 'measure' | 'time'
11
+
12
+ export interface BlueprintSchemaProperty {
13
+ apiName: string
14
+ displayName: string
15
+ description: string | null
16
+ propertyType: BlueprintPropertyType
17
+ dataType: BlueprintDataType
18
+ nullable: boolean
19
+ filterable: boolean
20
+ sortable: boolean
21
+ }
22
+
23
+ export interface BlueprintSchemaObject {
24
+ apiName: string
25
+ displayName: string
26
+ description: string | null
27
+ properties: BlueprintSchemaProperty[]
28
+ }
29
+
30
+ export interface BlueprintSchemaResponse {
31
+ schemaVersion: 1
32
+ digest: `sha256:${string}`
33
+ objectTypes: BlueprintSchemaObject[]
34
+ }
35
+
36
+ const DATA_TYPES = new Set<BlueprintDataType>(['string', 'number', 'boolean', 'date', 'timestamp', 'json'])
37
+ const PROPERTY_TYPES = new Set<BlueprintPropertyType>(['attribute', 'measure', 'time'])
38
+
39
+ function schemaContractError(detail: string): never {
40
+ throw new CliError(`invalid Blueprint schema response: ${detail}`, {
41
+ code: 'BAD_REQUEST',
42
+ hint: 'upgrade the Frontera CLI or confirm the API origin is running a compatible service',
43
+ })
44
+ }
45
+
46
+ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
47
+ if (!input || typeof input !== 'object') return schemaContractError('expected an object')
48
+ const schema = input as Record<string, unknown>
49
+ if (schema.schemaVersion !== 1) {
50
+ return schemaContractError(`unsupported schema version ${String(schema.schemaVersion)}`)
51
+ }
52
+ if (typeof schema.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(schema.digest)) {
53
+ return schemaContractError('digest must be a sha256 value')
54
+ }
55
+ if (!Array.isArray(schema.objectTypes)) return schemaContractError('objectTypes must be an array')
56
+
57
+ const objectNames = new Set<string>()
58
+ for (const [objectIndex, candidate] of schema.objectTypes.entries()) {
59
+ if (!candidate || typeof candidate !== 'object') return schemaContractError(`objectTypes[${objectIndex}] must be an object`)
60
+ const objectType = candidate as Record<string, unknown>
61
+ if (typeof objectType.apiName !== 'string' || !/^[A-Z][A-Za-z0-9]{0,99}$/.test(objectType.apiName)) {
62
+ return schemaContractError(`objectTypes[${objectIndex}].apiName is invalid`)
63
+ }
64
+ if (
65
+ typeof objectType.displayName !== 'string'
66
+ || !(objectType.description === null || typeof objectType.description === 'string')
67
+ || !Array.isArray(objectType.properties)
68
+ ) {
69
+ return schemaContractError(`objectTypes[${objectIndex}] is malformed`)
70
+ }
71
+ if (objectNames.has(objectType.apiName)) return schemaContractError(`duplicate object apiName ${objectType.apiName}`)
72
+ objectNames.add(objectType.apiName)
73
+ const propertyNames = new Set<string>()
74
+ for (const [propertyIndex, propertyCandidate] of objectType.properties.entries()) {
75
+ if (!propertyCandidate || typeof propertyCandidate !== 'object') {
76
+ return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}] must be an object`)
77
+ }
78
+ const property = propertyCandidate as Record<string, unknown>
79
+ if (typeof property.apiName !== 'string' || !/^[a-z][A-Za-z0-9]{0,99}$/.test(property.apiName)) {
80
+ return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}].apiName is invalid`)
81
+ }
82
+ if (
83
+ typeof property.displayName !== 'string'
84
+ || !(property.description === null || typeof property.description === 'string')
85
+ || !PROPERTY_TYPES.has(property.propertyType as BlueprintPropertyType)
86
+ || !DATA_TYPES.has(property.dataType as BlueprintDataType)
87
+ || typeof property.nullable !== 'boolean'
88
+ || typeof property.filterable !== 'boolean'
89
+ || typeof property.sortable !== 'boolean'
90
+ ) {
91
+ return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}] is malformed`)
92
+ }
93
+ if (propertyNames.has(property.apiName)) {
94
+ return schemaContractError(`duplicate property apiName ${property.apiName} on ${objectType.apiName}`)
95
+ }
96
+ propertyNames.add(property.apiName)
97
+ }
98
+ }
99
+ return input as BlueprintSchemaResponse
100
+ }
101
+
102
+ const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
103
+ string: 'string',
104
+ number: 'number',
105
+ boolean: 'boolean',
106
+ date: 'string',
107
+ timestamp: 'string',
108
+ json: 'unknown',
109
+ }
110
+
111
+ function jsDoc(value: string | null): string[] {
112
+ if (!value) return []
113
+ const safe = value.replace(/\*\//g, '*\\/').replace(/\r?\n/g, ' ')
114
+ return [`/** ${safe} */`]
115
+ }
116
+
117
+ function stringUnion(values: string[]): string {
118
+ return values.length === 0 ? 'never' : values.map((value) => JSON.stringify(value).replace(/^"|"$/g, "'")).join(' | ')
119
+ }
120
+
121
+ function compareApiName(left: { apiName: string }, right: { apiName: string }): number {
122
+ return left.apiName < right.apiName ? -1 : left.apiName > right.apiName ? 1 : 0
123
+ }
124
+
125
+ export function resolveBlueprintTypesOutputPath(
126
+ projectRoot: string,
127
+ output = DEFAULT_BLUEPRINT_TYPES_OUTPUT,
128
+ ): string {
129
+ const target = resolve(projectRoot, output)
130
+ const fromRoot = relative(resolve(projectRoot), target)
131
+ if (
132
+ !output
133
+ || isAbsolute(output)
134
+ || output.split(/[\\/]/).includes('..')
135
+ || fromRoot === '..'
136
+ || fromRoot.startsWith(`..${sep}`)
137
+ ) {
138
+ throw new UsageError(
139
+ 'Blueprint types output must be a project-relative path without `..`',
140
+ 'rerun with a path such as `--output src/generated/frontera-blueprint.ts`',
141
+ )
142
+ }
143
+ if (!output.endsWith('.ts') || output.endsWith('.d.ts')) {
144
+ throw new UsageError(
145
+ 'Blueprint types output must be a .ts file',
146
+ 'rerun with a path such as `--output src/generated/frontera-blueprint.ts`',
147
+ )
148
+ }
149
+ const realRoot = realpathSync(projectRoot)
150
+ let existingAncestor = target
151
+ while (!existsSync(existingAncestor)) {
152
+ const parent = dirname(existingAncestor)
153
+ if (parent === existingAncestor) break
154
+ existingAncestor = parent
155
+ }
156
+ const realAncestor = realpathSync(existingAncestor)
157
+ const fromRealRoot = relative(realRoot, realAncestor)
158
+ if (isAbsolute(fromRealRoot) || fromRealRoot === '..' || fromRealRoot.startsWith(`..${sep}`)) {
159
+ throw new UsageError(
160
+ 'Blueprint types output resolves outside the App project',
161
+ 'choose an output directory inside the current App project and rerun generation',
162
+ )
163
+ }
164
+ return target
165
+ }
166
+
167
+ export interface BlueprintTypesWriteResult {
168
+ path: string
169
+ changed: boolean
170
+ }
171
+
172
+ export function writeBlueprintTypesFile(
173
+ projectRoot: string,
174
+ output: string | undefined,
175
+ contents: string,
176
+ check: boolean,
177
+ ): BlueprintTypesWriteResult {
178
+ const path = resolveBlueprintTypesOutputPath(projectRoot, output)
179
+ const exists = existsSync(path)
180
+ const current = exists ? readFileSync(path, 'utf8') : null
181
+
182
+ if (current === contents) return { path, changed: false }
183
+ if (check) {
184
+ throw new CliError(
185
+ exists ? `Blueprint types are stale at ${path}` : `Blueprint types are missing at ${path}`,
186
+ { code: 'CONFLICT', hint: 'run `frontera blueprint generate-types` and commit the result' },
187
+ )
188
+ }
189
+ if (current !== null && !current.startsWith(`${BLUEPRINT_TYPES_MARKER}\n`)) {
190
+ throw new CliError(`refusing to overwrite ${path}: the file is not generated by Frontera`, {
191
+ code: 'BAD_REQUEST',
192
+ hint: 'choose another --output path or move the authored file yourself',
193
+ })
194
+ }
195
+
196
+ mkdirSync(dirname(path), { recursive: true })
197
+ const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
198
+ try {
199
+ writeFileSync(temporary, contents)
200
+ renameSync(temporary, path)
201
+ } finally {
202
+ if (existsSync(temporary)) unlinkSync(temporary)
203
+ }
204
+ return { path, changed: true }
205
+ }
206
+
207
+ export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string {
208
+ if (schema.schemaVersion !== 1) {
209
+ throw new Error(`unsupported Blueprint schema version: ${String(schema.schemaVersion)}`)
210
+ }
211
+
212
+ const objects = [...schema.objectTypes]
213
+ .sort(compareApiName)
214
+ .map((objectType) => ({
215
+ ...objectType,
216
+ properties: [...objectType.properties].sort(compareApiName),
217
+ }))
218
+
219
+ const lines = [
220
+ BLUEPRINT_TYPES_MARKER,
221
+ `// Blueprint schema digest: ${schema.digest}`,
222
+ '',
223
+ "import type { BlueprintObjectSchema as __FronteraBlueprintObjectSchema } from '@frontera-sdk/blueprint/types'",
224
+ '',
225
+ ]
226
+
227
+ for (const objectType of objects) {
228
+ lines.push(...jsDoc(objectType.description), `export interface ${objectType.apiName} {`)
229
+ for (const property of objectType.properties) {
230
+ lines.push(...jsDoc(property.description).map((line) => ` ${line}`))
231
+ const base = TYPE_BY_DATA_TYPE[property.dataType]
232
+ lines.push(` ${property.apiName}: ${base}${property.nullable ? ' | null' : ''}`)
233
+ }
234
+ lines.push('}', '')
235
+ }
236
+
237
+ lines.push("declare module '@frontera-sdk/blueprint/types' {", ' interface BlueprintRegistry {')
238
+ for (const objectType of objects) {
239
+ const filterable = stringUnion(objectType.properties.filter((property) => property.filterable).map((property) => property.apiName))
240
+ const sortable = stringUnion(objectType.properties.filter((property) => property.sortable).map((property) => property.apiName))
241
+ lines.push(
242
+ ` ${objectType.apiName}: __FronteraBlueprintObjectSchema<`,
243
+ ` ${objectType.apiName},`,
244
+ ` ${filterable},`,
245
+ ` ${sortable}`,
246
+ ' >',
247
+ )
248
+ }
249
+ lines.push(' }', '}', '')
250
+
251
+ return `${lines.join('\n')}\n`
252
+ }