@frontera-sdk/cli 1.50.82 → 1.50.84

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.82",
3
+ "version": "1.50.84",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.50.82",
43
- "@frontera-sdk/core": "1.50.82",
42
+ "@frontera-sdk/functions": "1.50.84",
43
+ "@frontera-sdk/core": "1.50.84",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.50.82",
49
+ "@frontera-sdk/forge-contracts": "1.50.84",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -3,6 +3,7 @@ import { FronteraClient } from '@frontera-sdk/core/client'
3
3
  import { organizationHeaders, selectedOrganization } from '../organization'
4
4
 
5
5
  import { CliError } from '../errors'
6
+ import { BLUEPRINT_SCHEMA_CAPABILITIES } from '../blueprint-types'
6
7
 
7
8
  /**
8
9
  * The service's code for an HTTP status, for responses that carry no `code` of
@@ -108,7 +109,7 @@ export class PlatformApi {
108
109
  }
109
110
 
110
111
  blueprintSchema() {
111
- return this.get<unknown>('/v1/blueprint/schema')
112
+ return this.get<unknown>(`/v1/blueprint/schema?capabilities=${BLUEPRINT_SCHEMA_CAPABILITIES.join(',')}`)
112
113
  }
113
114
 
114
115
  blueprintObjectType(apiName: string) {
@@ -145,6 +146,8 @@ export class PlatformApi {
145
146
  orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>
146
147
  pageSize?: number
147
148
  pageToken?: string
149
+ /** Distance ordering from a location; checked by the service. */
150
+ nearest?: unknown
148
151
  }) {
149
152
  return this.client.request<{
150
153
  rows?: Array<Record<string, unknown>>
@@ -79,8 +79,11 @@ function propertyToFile(
79
79
  objectApiName: string,
80
80
  sharedApiNameById: Map<string, string>,
81
81
  semanticApiNameById: Map<string, string>,
82
+ siblingApiNameById: Map<string, string>,
82
83
  ): Record<string, unknown> {
83
84
  const body = rest(property, new Set(['id', 'sharedPropertyId', 'semanticTypeId']))
85
+ const location = locationToFile(property, objectApiName, siblingApiNameById)
86
+ if (location) body.calculation = location
84
87
  const semanticType = resolveName(
85
88
  property.semanticTypeId,
86
89
  semanticApiNameById,
@@ -127,6 +130,29 @@ function resolveName(
127
130
  return apiName
128
131
  }
129
132
 
133
+ /**
134
+ * A location calculation (ADR 0013) names its latitude and longitude fields, as
135
+ * `primaryKey` and `title` do, rather than carrying their ids. Its only reader is
136
+ * the differ, which compares it against the live draft projected the same way, so
137
+ * no inverse is needed. Other calculations pass through unchanged.
138
+ */
139
+ function locationToFile(
140
+ property: Record<string, unknown>,
141
+ objectApiName: string,
142
+ siblingApiNameById: Map<string, string>,
143
+ ): Record<string, unknown> | undefined {
144
+ const calculation = property.calculation as
145
+ | { kind?: unknown; latitudePropertyId?: unknown; longitudePropertyId?: unknown }
146
+ | undefined
147
+ if (calculation?.kind !== 'location') return undefined
148
+ const subject = `Location "${String(property.apiName)}" on ${objectApiName}`
149
+ return {
150
+ kind: 'location',
151
+ latitude: resolveName(calculation.latitudePropertyId, siblingApiNameById, 'latitude field', subject),
152
+ longitude: resolveName(calculation.longitudePropertyId, siblingApiNameById, 'longitude field', subject),
153
+ }
154
+ }
155
+
130
156
  /**
131
157
  * `governance.sourceMappings[0]` → the portable `backing:` a file writes.
132
158
  *
@@ -194,7 +220,7 @@ function objectToFile(
194
220
  primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
195
221
  title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
196
222
  properties: object.properties.map((property) =>
197
- propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById)),
223
+ propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById, byId)),
198
224
  }
199
225
  }
200
226
 
@@ -9,8 +9,19 @@ export const DEFAULT_BLUEPRINT_TYPES_OUTPUT = 'src/generated/frontera-blueprint.
9
9
  /** Mirrors the service's `DataType`. `media_reference` is first-class (spec
10
10
  * §4/§12): it generates a named struct rather than `unknown`, and the schema
11
11
  * publishes it as neither filterable nor sortable, so the generated
12
- * `BlueprintRegistry` cannot offer a predicate the query layer refuses. */
13
- export type BlueprintDataType = 'string' | 'number' | 'boolean' | 'date' | 'timestamp' | 'json' | 'media_reference'
12
+ * `BlueprintRegistry` cannot offer a predicate the query layer refuses.
13
+ * `geopoint` is a location (ADR 0013) and generates `BlueprintGeoPoint`. */
14
+ export const BLUEPRINT_DATA_TYPES = [
15
+ 'string', 'number', 'boolean', 'date', 'timestamp', 'json', 'media_reference', 'geopoint',
16
+ ] as const
17
+ export type BlueprintDataType = (typeof BLUEPRINT_DATA_TYPES)[number]
18
+
19
+ /**
20
+ * Value kinds this CLI declares to `GET /v1/blueprint/schema`. The service
21
+ * leaves out fields of any kind a client does not declare, so an older CLI
22
+ * keeps working when a newer kind is published.
23
+ */
24
+ export const BLUEPRINT_SCHEMA_CAPABILITIES = ['geopoint'] as const
14
25
  export type BlueprintPropertyType = 'attribute' | 'measure' | 'time'
15
26
 
16
27
  export interface BlueprintSchemaProperty {
@@ -37,7 +48,18 @@ export interface BlueprintSchemaResponse {
37
48
  objectTypes: BlueprintSchemaObject[]
38
49
  }
39
50
 
40
- const DATA_TYPES = new Set<BlueprintDataType>(['string', 'number', 'boolean', 'date', 'timestamp', 'json', 'media_reference'])
51
+ /** The schema as this CLI can use it: the fields it left out, as `Type.field`. */
52
+ export interface ParsedBlueprintSchema extends BlueprintSchemaResponse {
53
+ skipped: string[]
54
+ }
55
+
56
+ /** The stderr warning for fields a schema had that this CLI cannot type. */
57
+ export function skippedFieldsWarning(skipped: readonly string[]): string | null {
58
+ if (skipped.length === 0) return null
59
+ return `warning: skipped ${skipped.join(', ')}: their value type is newer than this CLI; upgrade the Frontera CLI to generate types for them`
60
+ }
61
+
62
+ const DATA_TYPES: ReadonlySet<string> = new Set(BLUEPRINT_DATA_TYPES)
41
63
  const PROPERTY_TYPES = new Set<BlueprintPropertyType>(['attribute', 'measure', 'time'])
42
64
 
43
65
  function schemaContractError(detail: string): never {
@@ -47,7 +69,16 @@ function schemaContractError(detail: string): never {
47
69
  })
48
70
  }
49
71
 
50
- export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
72
+ /**
73
+ * Validate the schema response.
74
+ *
75
+ * A property whose `dataType` this CLI does not know is left out and named in
76
+ * `skipped`, rather than failing the whole schema: one field of a newer kind
77
+ * anywhere in the organization would otherwise stop type generation for every
78
+ * App. It is still validated in every other respect, and anything malformed
79
+ * still fails. The input is not modified.
80
+ */
81
+ export function parseBlueprintSchema(input: unknown): ParsedBlueprintSchema {
51
82
  if (!input || typeof input !== 'object') return schemaContractError('expected an object')
52
83
  const schema = input as Record<string, unknown>
53
84
  if (schema.schemaVersion !== 1) {
@@ -58,7 +89,9 @@ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
58
89
  }
59
90
  if (!Array.isArray(schema.objectTypes)) return schemaContractError('objectTypes must be an array')
60
91
 
92
+ const skipped: string[] = []
61
93
  const objectNames = new Set<string>()
94
+ const objectTypes: BlueprintSchemaObject[] = []
62
95
  for (const [objectIndex, candidate] of schema.objectTypes.entries()) {
63
96
  if (!candidate || typeof candidate !== 'object') return schemaContractError(`objectTypes[${objectIndex}] must be an object`)
64
97
  const objectType = candidate as Record<string, unknown>
@@ -75,6 +108,7 @@ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
75
108
  if (objectNames.has(objectType.apiName)) return schemaContractError(`duplicate object apiName ${objectType.apiName}`)
76
109
  objectNames.add(objectType.apiName)
77
110
  const propertyNames = new Set<string>()
111
+ const properties: BlueprintSchemaProperty[] = []
78
112
  for (const [propertyIndex, propertyCandidate] of objectType.properties.entries()) {
79
113
  if (!propertyCandidate || typeof propertyCandidate !== 'object') {
80
114
  return schemaContractError(`objectTypes[${objectIndex}].properties[${propertyIndex}] must be an object`)
@@ -87,7 +121,7 @@ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
87
121
  typeof property.displayName !== 'string'
88
122
  || !(property.description === null || typeof property.description === 'string')
89
123
  || !PROPERTY_TYPES.has(property.propertyType as BlueprintPropertyType)
90
- || !DATA_TYPES.has(property.dataType as BlueprintDataType)
124
+ || typeof property.dataType !== 'string'
91
125
  || typeof property.nullable !== 'boolean'
92
126
  || typeof property.filterable !== 'boolean'
93
127
  || typeof property.sortable !== 'boolean'
@@ -98,9 +132,15 @@ export function parseBlueprintSchema(input: unknown): BlueprintSchemaResponse {
98
132
  return schemaContractError(`duplicate property apiName ${property.apiName} on ${objectType.apiName}`)
99
133
  }
100
134
  propertyNames.add(property.apiName)
135
+ if (!DATA_TYPES.has(property.dataType)) {
136
+ skipped.push(`${objectType.apiName}.${property.apiName}`)
137
+ continue
138
+ }
139
+ properties.push(property as unknown as BlueprintSchemaProperty)
101
140
  }
141
+ objectTypes.push({ ...(objectType as unknown as BlueprintSchemaObject), properties })
102
142
  }
103
- return input as BlueprintSchemaResponse
143
+ return { schemaVersion: 1, digest: schema.digest as `sha256:${string}`, objectTypes, skipped }
104
144
  }
105
145
 
106
146
  /**
@@ -126,6 +166,19 @@ const MEDIA_REFERENCE_TYPE_DECLARATION = [
126
166
  '}',
127
167
  ]
128
168
 
169
+ /** A WGS 84 location, always named — never a positional pair (ADR 0013). */
170
+ const GEO_POINT_TYPE_NAME = 'BlueprintGeoPoint'
171
+
172
+ const GEO_POINT_TYPE_DECLARATION = [
173
+ '/** A WGS 84 location in decimal degrees: latitude -90..90, longitude -180..180.',
174
+ ' * Null when the object has no valid location. It cannot be sorted or',
175
+ ' * compared — ask `!== null` to test presence. */',
176
+ `export interface ${GEO_POINT_TYPE_NAME} {`,
177
+ ' lat: number',
178
+ ' lon: number',
179
+ '}',
180
+ ]
181
+
129
182
  const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
130
183
  string: 'string',
131
184
  number: 'number',
@@ -134,6 +187,7 @@ const TYPE_BY_DATA_TYPE: Readonly<Record<BlueprintDataType, string>> = {
134
187
  timestamp: 'string',
135
188
  json: 'unknown',
136
189
  media_reference: MEDIA_REFERENCE_TYPE_NAME,
190
+ geopoint: GEO_POINT_TYPE_NAME,
137
191
  }
138
192
 
139
193
  function jsDoc(value: string | null): string[] {
@@ -232,7 +286,7 @@ export function writeBlueprintTypesFile(
232
286
  return { path, changed: true }
233
287
  }
234
288
 
235
- export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string {
289
+ export function generateBlueprintTypes(schema: BlueprintSchemaResponse & { skipped?: readonly string[] }): string {
236
290
  if (schema.schemaVersion !== 1) {
237
291
  throw new Error(`unsupported Blueprint schema version: ${String(schema.schemaVersion)}`)
238
292
  }
@@ -244,9 +298,13 @@ export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string
244
298
  properties: [...objectType.properties].sort(compareApiName),
245
299
  }))
246
300
 
301
+ const skipped = schema.skipped ?? []
247
302
  const lines = [
248
303
  BLUEPRINT_TYPES_MARKER,
249
304
  `// Blueprint schema digest: ${schema.digest}`,
305
+ // The digest covers the whole schema, so a field left out is named here:
306
+ // the file must not claim to type what it does not.
307
+ ...(skipped.length > 0 ? [`// Not generated (value type newer than this CLI): ${skipped.join(', ')}`] : []),
250
308
  '',
251
309
  "import type { BlueprintObjectSchema as __FronteraBlueprintObjectSchema } from '@frontera-sdk/blueprint/types'",
252
310
  '',
@@ -260,6 +318,11 @@ export function generateBlueprintTypes(schema: BlueprintSchemaResponse): string
260
318
  ))) {
261
319
  lines.push(...MEDIA_REFERENCE_TYPE_DECLARATION, '')
262
320
  }
321
+ if (objects.some((objectType) => objectType.properties.some(
322
+ (property) => property.dataType === 'geopoint',
323
+ ))) {
324
+ lines.push(...GEO_POINT_TYPE_DECLARATION, '')
325
+ }
263
326
 
264
327
  for (const objectType of objects) {
265
328
  lines.push(...jsDoc(objectType.description), `export interface ${objectType.apiName} {`)
@@ -600,6 +600,7 @@ export function assertApplicable(
600
600
  // omits, and refuse the run naming a field the plan never called changed.
601
601
  const unmappable = Object.keys(file.document).filter((field) =>
602
602
  !applicable.has(field) && !satisfies(file.document[field], live[field], field))
603
+ if (kind === 'object-type') unmappable.push(...unmappablePropertyFields(file.document, live))
603
604
  if (unmappable.length === 0) return
604
605
  throw new CliError(
605
606
  `${file.path}: ${unmappable.join(', ')} ${unmappable.length === 1 ? 'differs' : 'differ'} from the draft, `
@@ -615,6 +616,26 @@ export function assertApplicable(
615
616
  )
616
617
  }
617
618
 
619
+ /**
620
+ * Property fields the field commands do not carry. A property's `calculation`
621
+ * (a location included) and its own `sensitivity` have no route from a file:
622
+ * applying one would report a classification or a formula that never changed.
623
+ * Named as `properties.<apiName>.<field>`.
624
+ */
625
+ const UNAPPLICABLE_PROPERTY_FIELDS = ['calculation', 'sensitivity'] as const
626
+
627
+ function unmappablePropertyFields(document: Record<string, unknown>, live: Record<string, unknown>): string[] {
628
+ const declared = Array.isArray(document.properties) ? document.properties as Array<Record<string, unknown>> : []
629
+ const liveProperties = Array.isArray(live.properties) ? live.properties as Array<Record<string, unknown>> : []
630
+ const liveByName = new Map(liveProperties.map((property) => [String(property.apiName), property]))
631
+ return declared.flatMap((property) => {
632
+ const counterpart = liveByName.get(String(property.apiName)) ?? {}
633
+ return UNAPPLICABLE_PROPERTY_FIELDS
634
+ .filter((field) => property[field] !== undefined && !satisfies(property[field], counterpart[field], field))
635
+ .map((field) => `properties.${String(property.apiName)}.${field}`)
636
+ })
637
+ }
638
+
618
639
  /**
619
640
  * `primaryKey` and `title` — the two references a file states by property apiName.
620
641
  *
@@ -4,6 +4,7 @@ import { PlatformApi } from '../../api/platform-api'
4
4
  import {
5
5
  generateBlueprintTypes,
6
6
  parseBlueprintSchema,
7
+ skippedFieldsWarning,
7
8
  writeBlueprintTypesFile,
8
9
  } from '../../blueprint-types'
9
10
  import type { Command } from '../types'
@@ -31,6 +32,8 @@ export const blueprintGenerateTypes: Command = {
31
32
  const project = ctx.project!
32
33
  const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
33
34
  const schema = parseBlueprintSchema(await api.blueprintSchema())
35
+ const skippedWarning = skippedFieldsWarning(schema.skipped)
36
+ if (skippedWarning) ctx.output.note(skippedWarning)
34
37
  const contents = generateBlueprintTypes(schema)
35
38
  const written = writeBlueprintTypesFile(
36
39
  project.root,
@@ -50,6 +53,9 @@ export const blueprintGenerateTypes: Command = {
50
53
  propertyCount,
51
54
  changed: written.changed,
52
55
  checked,
56
+ // Fields left out because their value type is newer than this CLI,
57
+ // also named in the generated file's header.
58
+ skipped: schema.skipped,
53
59
  },
54
60
  text: checked
55
61
  ? `Blueprint types are current at ${path} (${schema.objectTypes.length} objects, ${propertyCount} properties).`
@@ -1,5 +1,5 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
- import { parseBlueprintSchema, type BlueprintSchemaObject } from '../../blueprint-types'
2
+ import { parseBlueprintSchema, skippedFieldsWarning, type BlueprintSchemaObject } from '../../blueprint-types'
3
3
  import { CliError, UsageError } from '../../errors'
4
4
  import type { Command } from '../types'
5
5
 
@@ -139,6 +139,8 @@ export const blueprintGet: Command = {
139
139
 
140
140
  const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
141
141
  const schema = parseBlueprintSchema(await api.blueprintSchema())
142
+ const skippedWarning = skippedFieldsWarning(schema.skipped)
143
+ if (skippedWarning) ctx.output.note(skippedWarning)
142
144
  const apiName = resolveApiName(schema.objectTypes, typed)
143
145
  const type = schema.objectTypes.find((objectType) => objectType.apiName === apiName) as BlueprintSchemaObject
144
146
  const grantedNames = new Set(schema.objectTypes.map((objectType) => objectType.apiName))
@@ -261,6 +263,8 @@ export const blueprintGet: Command = {
261
263
  ...(relations.ok ? {} : { relationsUnavailable: relations.unavailable }),
262
264
  metrics: own.ok ? own.rows : null,
263
265
  ...(own.ok ? {} : { metricsUnavailable: own.unavailable }),
266
+ // Fields of this type the CLI left out (a newer value type), as `Type.field`.
267
+ skipped: schema.skipped.filter((name) => name.startsWith(`${apiName}.`)),
264
268
  },
265
269
  text: lines.join('\n'),
266
270
  }
@@ -1,5 +1,5 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
- import { parseBlueprintSchema } from '../../blueprint-types'
2
+ import { parseBlueprintSchema, skippedFieldsWarning } from '../../blueprint-types'
3
3
  import { table } from '../../table'
4
4
  import type { Command } from '../types'
5
5
 
@@ -32,7 +32,12 @@ export const blueprintList: Command = {
32
32
 
33
33
  async run(ctx) {
34
34
  const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
35
- const types = parseBlueprintSchema(await api.blueprintSchema()).objectTypes
35
+ const schema = parseBlueprintSchema(await api.blueprintSchema())
36
+ // `data` stays the type list its JSON consumers read; a field left out
37
+ // is only a warning here, since a type list shows no fields.
38
+ const skippedWarning = skippedFieldsWarning(schema.skipped)
39
+ if (skippedWarning) ctx.output.note(skippedWarning)
40
+ const types = schema.objectTypes
36
41
 
37
42
  return {
38
43
  data: types,
@@ -323,6 +323,10 @@ const session: Skill = {
323
323
  ' for presentation (chrome density, external links), never for data access.',
324
324
  '- Treat `init.token` as opaque and short-lived. Do not cache it, put it in state, log it, send it',
325
325
  ' anywhere, or retry an authorization failure with a different credential.',
326
+ '- Who opened the App is `useViewer()` — `{ userId, name, email }` or `null`. Use it for "you",',
327
+ ' "assigned to me" and owner defaults; never ask a person to type who they are. It is for',
328
+ ' display only: never use it to decide what the person may do. It is `null` under a workspace',
329
+ ' key, for an App hosted outside Frontera, and on an older host — only then ask.',
326
330
  '- Local development is `frontera app dev`. It mints a short-lived App token, keeps the stored CLI',
327
331
  ' key in the broker, and owns `NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT` — never hand-write that',
328
332
  ' variable. `bun run dev` alone starts Next with no session: fine for layout, useless for data.',
@@ -330,13 +334,14 @@ const session: Skill = {
330
334
  '```tsx',
331
335
  "'use client'",
332
336
  '',
333
- "import { useFronteraApp } from '@frontera-sdk/core/react'",
337
+ "import { useFronteraApp, useViewer } from '@frontera-sdk/core/react'",
334
338
  '',
335
339
  'export function AppShell({ children }: { children: React.ReactNode }) {',
336
340
  ' const { mode, init } = useFronteraApp()',
341
+ ' const viewer = useViewer() // { userId, name, email } | null',
337
342
  ' // mode: "embedded" | "standalone" | "local"',
338
- ' // init: { appId, version, orgId, workspaceId, theme, state, path? }',
339
- ' return <section data-mode={mode}>{children}</section>',
343
+ ' // init: { appId, version, orgId, workspaceId, theme, state, path?, viewer? }',
344
+ ' return <section data-mode={mode} aria-label={viewer?.name ?? undefined}>{children}</section>',
340
345
  '}',
341
346
  '```',
342
347
  '',
@@ -353,6 +358,7 @@ const session: Skill = {
353
358
  '- Writing `NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT` by hand into `.env`.',
354
359
  '- Branching data access on `mode`.',
355
360
  '- Rendering the app before the session resolves, or adding a "not ready" guard inside a feature.',
361
+ '- An "enter your email" field when `useViewer()` already says who the person is.',
356
362
  '',
357
363
  '## Verification',
358
364
  '',