@frontera-sdk/cli 1.52.27 → 1.52.29

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,465 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { blueprintApplyState, contentHash, loadBuild, type BuildEntry } from './local-applied'
7
+ import { actionFit, objectTypeFit, type ObjectTypeFit } from './local-portability'
8
+ import { referencesIn } from './local-sync-refs'
9
+
10
+ /**
11
+ * A project's Blueprint as people read it: one entry per object type, with
12
+ * its fields, the relations that touch it (from either end), the governed
13
+ * Actions whose subject it is and the metrics that measure it. Read from the
14
+ * `blueprint/` files only, so it answers whether or not a runtime is running.
15
+ *
16
+ * Forge's project home lists it and Studio's object definition page shows
17
+ * one entry; neither changes the files. `localAdaptation` is the rule
18
+ * `resolveBlueprint` applies when it makes the runtime's copy, kept here so
19
+ * what the home says about a type is what the runtime does with it.
20
+ */
21
+
22
+ type Doc = Record<string, any>
23
+
24
+ /** One parsed file, with its path from the project root. */
25
+ export interface BlueprintFile {
26
+ path: string
27
+ doc: Doc
28
+ }
29
+
30
+ export interface BlueprintFiles {
31
+ objectTypes: BlueprintFile[]
32
+ linkTypes: BlueprintFile[]
33
+ metrics: BlueprintFile[]
34
+ actions: BlueprintFile[]
35
+ }
36
+
37
+ const KIND_DIRS = { objectTypes: 'object-types', linkTypes: 'link-types', metrics: 'metrics', actions: 'actions' } as const
38
+
39
+ /** The `blueprint/` files of a project, parsed; a file that does not parse is skipped. */
40
+ export function readBlueprintFiles(root: string): BlueprintFiles {
41
+ const read = (dir: string): BlueprintFile[] => {
42
+ const at = join(root, 'blueprint', dir)
43
+ if (!existsSync(at)) return []
44
+ const out: BlueprintFile[] = []
45
+ for (const name of readdirSync(at).filter((n) => /\.ya?ml$/.test(n) && !n.startsWith('.')).sort()) {
46
+ try {
47
+ const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as unknown
48
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) out.push({ path: `blueprint/${dir}/${name}`, doc: doc as Doc })
49
+ } catch {
50
+ // A file being edited can be half-written; it shows again once it parses.
51
+ }
52
+ }
53
+ return out
54
+ }
55
+ return {
56
+ objectTypes: read(KIND_DIRS.objectTypes),
57
+ linkTypes: read(KIND_DIRS.linkTypes),
58
+ metrics: read(KIND_DIRS.metrics),
59
+ actions: read(KIND_DIRS.actions),
60
+ }
61
+ }
62
+
63
+ /** What the local runtime does with an object type: the rule lives with the other portability rules. */
64
+ export type LocalAdaptation = ObjectTypeFit
65
+
66
+ /**
67
+ * The rules `resolveBlueprint` applies to one object type. `hasLocalDataset`
68
+ * says whether the project has the dataset's local file (`datasets/<name>.json`).
69
+ */
70
+ export function localAdaptation(doc: Doc, hasLocalDataset: (dataset: string) => boolean): LocalAdaptation {
71
+ return objectTypeFit(doc, hasLocalDataset)
72
+ }
73
+
74
+ /** A workflow or Agent whose files name a governed Action. */
75
+ export interface ActionUser {
76
+ kind: 'workflow' | 'agent'
77
+ /** The workflow's slug or the Agent's folder. */
78
+ slug: string
79
+ name: string
80
+ }
81
+
82
+ function readable(slug: string): string {
83
+ const words = slug.replace(/[-_]+/g, ' ').trim()
84
+ return words ? words[0]!.toUpperCase() + words.slice(1) : slug
85
+ }
86
+
87
+ function readDoc(path: string): unknown {
88
+ try {
89
+ const text = readFileSync(path, 'utf8')
90
+ return /\.json$/.test(path) ? JSON.parse(text) : parseYaml(text)
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ function entries(dir: string): string[] {
97
+ try {
98
+ return readdirSync(dir).filter((n) => !n.startsWith('.')).sort()
99
+ } catch {
100
+ return []
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Which workflows and Agents name each governed Action (`action/<apiName>`,
106
+ * as a pulled workflow names one): `workflows/*.yaml`, and each Agent's
107
+ * `agent.json`, `bindings.json` and `operating.json`. Keyed by apiName.
108
+ */
109
+ export function actionUsage(root: string): Map<string, ActionUser[]> {
110
+ const usage = new Map<string, ActionUser[]>()
111
+ const note = (doc: unknown, user: ActionUser) => {
112
+ if (!doc) return
113
+ for (const ref of referencesIn(doc)) {
114
+ if (ref.kind !== 'action') continue
115
+ const users = usage.get(ref.name) ?? []
116
+ if (!users.some((u) => u.kind === user.kind && u.slug === user.slug)) users.push(user)
117
+ usage.set(ref.name, users)
118
+ }
119
+ }
120
+ for (const file of entries(join(root, 'workflows')).filter((f) => /\.ya?ml$/.test(f))) {
121
+ const slug = file.replace(/\.ya?ml$/, '')
122
+ note(readDoc(join(root, 'workflows', file)), { kind: 'workflow', slug, name: readable(slug) })
123
+ }
124
+ for (const slug of entries(join(root, 'agents'))) {
125
+ const dir = join(root, 'agents', slug)
126
+ const agent = readDoc(join(dir, 'agent.json')) as { config?: { name?: unknown } } | null
127
+ const name = typeof agent?.config?.name === 'string' && agent.config.name.trim() ? agent.config.name.trim() : readable(slug)
128
+ for (const file of ['agent.json', 'bindings.json', 'operating.json']) note(file === 'agent.json' ? agent : readDoc(join(dir, file)), { kind: 'agent', slug, name })
129
+ }
130
+ return usage
131
+ }
132
+
133
+ export interface OutlineField {
134
+ apiName: string
135
+ displayName: string
136
+ valueType: string
137
+ primaryKey: boolean
138
+ required: boolean
139
+ calculated: boolean
140
+ }
141
+
142
+ export type Cardinality = 'one to one' | 'one to many' | 'many to one' | 'many to many'
143
+
144
+ export interface OutlineRelation {
145
+ apiName: string
146
+ /** What this object calls the objects at the other end. */
147
+ name: string
148
+ /** `out`: this object is the link's `from`; `in`: its `to`. */
149
+ direction: 'out' | 'in'
150
+ other: { apiName: string; displayName: string }
151
+ /** Read from this object's side. */
152
+ cardinality: Cardinality | null
153
+ path: string
154
+ }
155
+
156
+ export type ActionLocalStatus = 'runs' | 'switched-off' | 'failed' | 'left-off' | 'not-deployed'
157
+
158
+ export interface OutlineAction {
159
+ apiName: string
160
+ displayName: string
161
+ /** `existing`: acts on one object of the type; `create`: creates one. */
162
+ mode: 'existing' | 'create'
163
+ /** Who approves, in a few words: "supervisor approves", "no approval". */
164
+ approval: string
165
+ description: string | null
166
+ /** Its parameters, by API name. */
167
+ parameters: string[]
168
+ /** Its parameters as the file declares them, for a reader that lists them. */
169
+ inputs: OutlineInput[]
170
+ /**
171
+ * The file's approval rule as written (`governance.approval`), for a reader
172
+ * that says it the platform's way ("Approval required · 2 steps"); `null`
173
+ * when the file has none.
174
+ */
175
+ approvalPolicy: Record<string, unknown> | null
176
+ usedBy: ActionUser[]
177
+ path: string
178
+ /** On the local runtime: "Runs locally", "Switched off here", "Didn’t deploy", "Not deployed yet". */
179
+ local: { status: ActionLocalStatus; label: string; reason: string | null }
180
+ }
181
+
182
+ /** One parameter of an Action, as its file declares it. */
183
+ export interface OutlineInput {
184
+ apiName: string
185
+ displayName: string
186
+ description: string | null
187
+ required: boolean
188
+ /** Its value type as written: `kind`, and `integer`, `enum`, `minimum`, `maximum` where the file sets them. */
189
+ valueType: Record<string, unknown> | null
190
+ /** The file's default; absent when it sets none. */
191
+ default?: unknown
192
+ }
193
+
194
+ const ACTION_LABEL: Record<ActionLocalStatus, string> = {
195
+ runs: 'Runs locally',
196
+ 'switched-off': 'Switched off here',
197
+ failed: 'Didn’t deploy',
198
+ 'left-off': 'Platform only',
199
+ 'not-deployed': 'Not deployed yet',
200
+ }
201
+
202
+ /**
203
+ * How the local runtime holds one Action: what the last Blueprint build
204
+ * recorded for the files as they are, or, before one, what it will do.
205
+ */
206
+ export function actionLocal(doc: Doc, built: BuildEntry['actions'] | null): OutlineAction['local'] {
207
+ const recorded = built?.[doc.apiName as string]
208
+ if (recorded) return { status: recorded.status, label: ACTION_LABEL[recorded.status], reason: recorded.reason ?? null }
209
+ const fit = actionFit(doc)
210
+ return fit.runsLocally ? { status: 'not-deployed', label: ACTION_LABEL['not-deployed'], reason: null } : { status: 'switched-off', label: ACTION_LABEL['switched-off'], reason: fit.reason ?? null }
211
+ }
212
+
213
+ export interface OutlineMetric {
214
+ apiName: string
215
+ displayName: string
216
+ description: string | null
217
+ path: string
218
+ }
219
+
220
+ export interface ObjectOutline {
221
+ apiName: string
222
+ displayName: string
223
+ pluralDisplayName: string
224
+ description: string | null
225
+ /** The object's glyph and colour, as the file sets them. */
226
+ icon: string | null
227
+ color: string | null
228
+ path: string
229
+ /** Backed by the platform's media files. */
230
+ media: boolean
231
+ fields: OutlineField[]
232
+ relations: OutlineRelation[]
233
+ actions: OutlineAction[]
234
+ metrics: OutlineMetric[]
235
+ local: LocalAdaptation
236
+ }
237
+
238
+ const CARDINALITY: Record<string, Cardinality> = {
239
+ one_to_one: 'one to one',
240
+ one_to_many: 'one to many',
241
+ many_to_one: 'many to one',
242
+ many_to_many: 'many to many',
243
+ }
244
+ const FLIPPED: Record<string, string> = { one_to_many: 'many_to_one', many_to_one: 'one_to_many' }
245
+
246
+ function text(value: unknown): string | null {
247
+ return typeof value === 'string' && value.trim() ? value.trim() : null
248
+ }
249
+
250
+ function record(value: unknown): Record<string, unknown> | null {
251
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
252
+ }
253
+
254
+ /** The approval rule of an Action file, as a reader says it. */
255
+ export function approvalPhrase(action: Doc): string {
256
+ const approval = action.governance?.approval as { mode?: string; threshold?: number; stages?: Array<{ approver?: unknown; when?: unknown }> } | undefined
257
+ const mode = approval?.mode
258
+ if (!mode || mode === 'none') return 'no approval'
259
+ const count = (n: number | undefined) => (n && n > 1 ? `${n} approvals` : 'approval')
260
+ if (mode === 'required') return `needs ${count(approval.threshold)}`
261
+ if (mode === 'conditional') return `sometimes needs ${count(approval.threshold)}`
262
+ const stages = Array.isArray(approval.stages) ? approval.stages : []
263
+ if (stages.length === 1) {
264
+ const approver = stages[0]!.approver as { role?: unknown } | undefined
265
+ const who = typeof approver?.role === 'string' ? readable(approver.role).toLowerCase() : 'a member'
266
+ return stages[0]!.when && stages[0]!.when !== 'always' ? `${who} approves when it applies` : `${who} approves`
267
+ }
268
+ return stages.length ? `${stages.length} approval steps` : 'needs approval'
269
+ }
270
+
271
+ /**
272
+ * Each object type with what belongs to it, alphabetical by display name.
273
+ * A relation shows on both of its objects, written from each one's side.
274
+ */
275
+ export function outlineBlueprint(files: BlueprintFiles, opts: { hasLocalDataset: (dataset: string) => boolean; usage: Map<string, ActionUser[]>; built?: BuildEntry['actions'] | null }): ObjectOutline[] {
276
+ const names = new Map<string, string>()
277
+ for (const f of files.objectTypes) if (text(f.doc.apiName)) names.set(f.doc.apiName, text(f.doc.displayName) ?? f.doc.apiName)
278
+ const displayOf = (apiName: string) => names.get(apiName) ?? apiName
279
+
280
+ const outlines = files.objectTypes
281
+ .filter((f) => text(f.doc.apiName))
282
+ .map((f): ObjectOutline => {
283
+ const doc = f.doc
284
+ const apiName = doc.apiName as string
285
+ const properties = (Array.isArray(doc.properties) ? doc.properties : []) as Doc[]
286
+ const fields = properties
287
+ .filter((p) => text(p.apiName))
288
+ .map((p) => ({
289
+ apiName: p.apiName as string,
290
+ displayName: text(p.displayName) ?? (p.apiName as string),
291
+ valueType: typeof p.valueType === 'string' ? p.valueType : (text(p.baseType?.kind) ?? 'unknown'),
292
+ primaryKey: doc.primaryKey === p.apiName,
293
+ required: p.required === true || doc.primaryKey === p.apiName,
294
+ calculated: !!p.calculation,
295
+ }))
296
+
297
+ const relations: OutlineRelation[] = []
298
+ for (const link of files.linkTypes) {
299
+ const l = link.doc
300
+ const from = text(l.from?.objectType)
301
+ const to = text(l.to?.objectType)
302
+ if (!from || !to || (from !== apiName && to !== apiName)) continue
303
+ const cardinality = typeof l.cardinality === 'string' ? l.cardinality : null
304
+ // Both ends on this object (a self link) shows twice: once each way.
305
+ for (const direction of ['out', 'in'] as const) {
306
+ if ((direction === 'out' ? from : to) !== apiName) continue
307
+ const end = direction === 'out' ? l.from : l.to
308
+ const other = direction === 'out' ? to : from
309
+ const read = cardinality ? (direction === 'out' ? cardinality : (FLIPPED[cardinality] ?? cardinality)) : null
310
+ relations.push({
311
+ apiName: text(l.apiName) ?? link.path,
312
+ name: text(end?.displayName) ?? displayOf(other),
313
+ direction,
314
+ other: { apiName: other, displayName: displayOf(other) },
315
+ cardinality: read ? (CARDINALITY[read] ?? null) : null,
316
+ path: link.path,
317
+ })
318
+ }
319
+ }
320
+
321
+ const actions = files.actions
322
+ .filter((a) => text(a.doc.apiName) && a.doc.subject?.objectType === apiName)
323
+ .map((a) => ({
324
+ apiName: a.doc.apiName as string,
325
+ displayName: text(a.doc.displayName) ?? (a.doc.apiName as string),
326
+ mode: a.doc.subject?.mode === 'create' ? ('create' as const) : ('existing' as const),
327
+ approval: approvalPhrase(a.doc),
328
+ description: text(a.doc.description),
329
+ parameters: (Array.isArray(a.doc.inputs) ? (a.doc.inputs as Doc[]) : []).map((input) => text(input?.apiName)).filter((name): name is string => !!name),
330
+ inputs: (Array.isArray(a.doc.inputs) ? (a.doc.inputs as Doc[]) : [])
331
+ .filter((input) => text(input?.apiName))
332
+ .map((input): OutlineInput => ({
333
+ apiName: input.apiName as string,
334
+ displayName: text(input.displayName) ?? (input.apiName as string),
335
+ description: text(input.description),
336
+ required: input.required === true,
337
+ valueType: record(input.valueType),
338
+ ...(Object.prototype.hasOwnProperty.call(input, 'default') ? { default: input.default } : {}),
339
+ })),
340
+ approvalPolicy: record(a.doc.governance?.approval),
341
+ usedBy: opts.usage.get(a.doc.apiName) ?? [],
342
+ path: a.path,
343
+ local: actionLocal(a.doc, opts.built ?? null),
344
+ }))
345
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
346
+
347
+ const metrics = files.metrics
348
+ .filter((m) => text(m.doc.apiName) && m.doc.objectType === apiName)
349
+ .map((m) => ({ apiName: m.doc.apiName as string, displayName: text(m.doc.displayName) ?? (m.doc.apiName as string), description: text(m.doc.description), path: m.path }))
350
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
351
+
352
+ const local = localAdaptation(doc, opts.hasLocalDataset)
353
+ return {
354
+ apiName,
355
+ displayName: displayOf(apiName),
356
+ pluralDisplayName: text(doc.pluralDisplayName) ?? displayOf(apiName),
357
+ description: text(doc.description),
358
+ icon: text(doc.icon),
359
+ color: text(doc.color),
360
+ path: f.path,
361
+ media: local.leftOff,
362
+ fields,
363
+ relations,
364
+ actions,
365
+ metrics,
366
+ local,
367
+ }
368
+ })
369
+ return outlines.sort((a, b) => a.displayName.localeCompare(b.displayName))
370
+ }
371
+
372
+ function plural(n: number, one: string, many: string): string {
373
+ return `${n} ${n === 1 ? one : many}`
374
+ }
375
+
376
+ /** "12 fields · 4 relations · 3 actions · 1 metric", leaving out what the object has none of. */
377
+ export function countsLine(o: Pick<ObjectOutline, 'fields' | 'relations' | 'actions' | 'metrics'>): string {
378
+ const parts = [
379
+ o.fields.length ? plural(o.fields.length, 'field', 'fields') : null,
380
+ o.relations.length ? plural(o.relations.length, 'relation', 'relations') : null,
381
+ o.actions.length ? plural(o.actions.length, 'action', 'actions') : null,
382
+ o.metrics.length ? plural(o.metrics.length, 'metric', 'metrics') : null,
383
+ ].filter(Boolean)
384
+ return parts.length ? parts.join(' · ') : 'No fields yet'
385
+ }
386
+
387
+ export type ObjectStatusKind = 'platform-only' | 'failed' | 'adapted' | 'stubbed' | 'not-applied' | 'built'
388
+
389
+ export interface ObjectStatus {
390
+ kind: ObjectStatusKind
391
+ label: string
392
+ /** The one line that says why, when the label does not: the error, or what is adapted. */
393
+ reason?: string
394
+ }
395
+
396
+ /** A field's name inside a sentence: "location", but "GPS position" keeps its capitals. */
397
+ function inline(name: string): string {
398
+ return /^[A-Z][A-Z]/.test(name) ? name : name.charAt(0).toLowerCase() + name.slice(1)
399
+ }
400
+
401
+ /** What the runtime holds of the Blueprint: whether it has the current files, and the last build's record. */
402
+ export interface BlueprintBuildState {
403
+ applied: boolean
404
+ /** The last Blueprint build failed for these files: its error. */
405
+ failed?: string | null
406
+ }
407
+
408
+ /**
409
+ * The one thing worth saying about an object type on this runtime, most
410
+ * important first: platform only > the Blueprint failed to build > not
411
+ * applied yet > adapted (an Action that does not run here, a calculated field
412
+ * off, every row read) > built. `state` (or `true`) says the runtime holds the
413
+ * project's current Blueprint.
414
+ */
415
+ export function objectStatus(o: Pick<ObjectOutline, 'fields' | 'actions' | 'local'>, state: boolean | BlueprintBuildState): ObjectStatus {
416
+ const s: BlueprintBuildState = typeof state === 'boolean' ? { applied: state } : state
417
+ if (o.local.leftOff) return { kind: 'platform-only', label: 'Platform only' }
418
+ if (s.failed) return { kind: 'failed', label: 'Failed', reason: s.failed }
419
+ if (!s.applied) return { kind: 'not-applied', label: 'Not applied yet' }
420
+ const idle = o.actions.filter((a) => a.local.status === 'switched-off' || a.local.status === 'failed')
421
+ if (idle.length) {
422
+ const failed = idle.filter((a) => a.local.status === 'failed')
423
+ const label = failed.length
424
+ ? failed.length === 1 ? 'An action didn’t deploy' : `${failed.length} actions didn’t deploy`
425
+ : idle.length === 1 ? '1 action switched off here' : `${idle.length} actions switched off here`
426
+ return { kind: 'adapted', label, reason: idle.map((a) => `${a.displayName}: ${a.local.reason ?? a.local.label}`).join('; ') }
427
+ }
428
+ if (o.local.mediaStub) return { kind: 'stubbed', label: 'Stubbed: no files here', reason: `Its files stay on the platform: an empty Media Set, ${o.local.mediaStub}, stands in for them here` }
429
+ if (o.local.calculated.length) {
430
+ const names = o.local.calculated.map((api) => o.fields.find((f) => f.apiName === api)?.displayName ?? api)
431
+ return { kind: 'adapted', label: names.length === 1 ? `Adapted: ${inline(names[0]!)} off` : `Adapted: ${names.length} fields off` }
432
+ }
433
+ if (o.local.readsEveryRow) return { kind: 'adapted', label: 'Reads every row' }
434
+ return { kind: 'built', label: 'Built' }
435
+ }
436
+
437
+ /** The local organization of a project's runtime, from its state; null before one started. */
438
+ export function localOrganizationId(root: string): string | null {
439
+ try {
440
+ const state = JSON.parse(readFileSync(join(root, '.frontera', 'local', 'state.json'), 'utf8')) as { credentials?: { organizationId?: unknown } }
441
+ return typeof state.credentials?.organizationId === 'string' ? state.credentials.organizationId : null
442
+ } catch {
443
+ return null
444
+ }
445
+ }
446
+
447
+ /** The Blueprint's build for the files as they are: applied or not, failed or not, and its Actions. */
448
+ export function blueprintBuild(root: string): BlueprintBuildState & { actions: BuildEntry['actions'] | null } {
449
+ const project = { root, id: '', stateDir: join(root, '.frontera', 'local') }
450
+ const org = localOrganizationId(root)
451
+ const entry = loadBuild(project, org).blueprint
452
+ // The record speaks only for the files it was made from.
453
+ const current = !!entry && entry.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' })
454
+ const failed = current && entry.status === 'failed' ? (entry.error ?? 'The Blueprint didn’t apply') : null
455
+ return { applied: blueprintApplyState(project, org) === 'applied', failed, actions: current && !failed ? (entry.actions ?? null) : null }
456
+ }
457
+
458
+ /** The outline of a project folder: its files, its local datasets and what names its Actions. */
459
+ export function projectBlueprintOutline(root: string, built: BuildEntry['actions'] | null = blueprintBuild(root).actions): ObjectOutline[] {
460
+ return outlineBlueprint(readBlueprintFiles(root), {
461
+ hasLocalDataset: (dataset) => existsSync(join(root, 'datasets', `${dataset}.json`)),
462
+ usage: actionUsage(root),
463
+ built,
464
+ })
465
+ }
@@ -0,0 +1,123 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { SyncClient } from './local-sync-client'
5
+ import type { RemoteItem } from './local-sync-plan'
6
+
7
+ /**
8
+ * The organization's brand: the colors it sets over the deployment's
9
+ * (light and dark primary) and its logo. A pull writes `brand/brand.json`
10
+ * with the logo beside it, and `local dev` gives the local runtime's
11
+ * organization the same, so the local Chat and Studio look like the
12
+ * workspace. Read-only: never proposed back (an organization's brand is an
13
+ * administrator's setting, not a project's change).
14
+ */
15
+
16
+ /** What `/v1/sync/brand` returns. */
17
+ export interface BrandRow {
18
+ colors?: { primary?: string | null; primaryDark?: string | null } | null
19
+ logo?: { file?: string | null } | null
20
+ }
21
+
22
+ /** The project's brand file. */
23
+ export interface BrandFile {
24
+ colors: { primary: string | null; primaryDark: string | null }
25
+ logo?: string
26
+ }
27
+
28
+ export const BRAND_PATH = 'brand/brand.json'
29
+
30
+ /** A logo larger than this stays on the platform, and the note says so. */
31
+ const LOGO_LIMIT = 5 * 1024 * 1024
32
+
33
+ /** The organization's brand as one project item, its logo an asset beside it. */
34
+ export async function readBrand(ws: SyncClient, row: BrandRow, warnings: string[]): Promise<RemoteItem[]> {
35
+ const colors = { primary: row.colors?.primary ?? null, primaryDark: row.colors?.primaryDark ?? null }
36
+ const it: RemoteItem = { ref: 'brand', kind: 'brand', name: 'brand', title: 'Organization brand', files: {}, unresolved: [], notes: ['The organization’s colors and logo, so the local Chat and Studio look like the workspace; changed on the platform, never proposed from here'], requires: [], assets: [] }
37
+ const file = row.logo?.file && /^logo\.[a-z]+$/.test(row.logo.file) ? row.logo.file : null
38
+ let logo: string | undefined
39
+ if (file) {
40
+ try {
41
+ const bytes = await ws.getBytes('/v1/sync/brand/logo')
42
+ if (bytes.byteLength > LOGO_LIMIT) throw new Error('larger than 5 MB')
43
+ it.assets!.push({ path: `brand/${file}`, bytes })
44
+ logo = file
45
+ } catch (err) {
46
+ warnings.push(`Organization brand: its logo: ${(err as Error).message}`)
47
+ it.notes.push('Its logo could not be read, so the brand comes without it')
48
+ }
49
+ }
50
+ if (!colors.primary && !colors.primaryDark && !logo) return []
51
+ const doc: BrandFile = { colors, ...(logo ? { logo } : {}) }
52
+ it.files[BRAND_PATH] = `${JSON.stringify(doc, null, 2)}\n`
53
+ if (!it.assets!.length) delete it.assets
54
+ return [it]
55
+ }
56
+
57
+ type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
58
+ type Upload = (path: string, form: FormData) => Promise<{ status: number; body: unknown }>
59
+
60
+ const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as Record<string, unknown> | null
61
+
62
+ const errorOf = (a: { status: number; body: unknown }) => {
63
+ const b = a.body as { message?: unknown } | null
64
+ return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
65
+ }
66
+
67
+ /** A multipart upload to the runtime with the owner's session, as Settings uploads a logo. */
68
+ export function brandUpload(serviceUrl: string, creds: { session?: string | null; organizationId: string; workspaceId: string }): Upload {
69
+ return async (path, form) => {
70
+ const res = await fetch(new URL(path, serviceUrl), { method: 'POST', headers: { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId }, body: form })
71
+ const text = await res.text()
72
+ let body: unknown = text
73
+ try {
74
+ body = text ? JSON.parse(text) : null
75
+ } catch {
76
+ // Keep the text.
77
+ }
78
+ return { status: res.status, body }
79
+ }
80
+ }
81
+
82
+ const MIME: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', webp: 'image/webp', gif: 'image/gif', ico: 'image/x-icon' }
83
+
84
+ /**
85
+ * Give the runtime's organization the project's brand: its colors (the
86
+ * owner's session, as an administrator sets them in Settings) and its logo,
87
+ * uploaded and set as the organization's.
88
+ */
89
+ export async function applyBrand(root: string, organizationId: string, call: Call, upload: Upload): Promise<{ ok: boolean; output: string }> {
90
+ const path = join(root, BRAND_PATH)
91
+ if (!existsSync(path)) return { ok: false, output: `error [BRAND]: ${BRAND_PATH} is missing` }
92
+ let doc: BrandFile
93
+ try {
94
+ doc = JSON.parse(readFileSync(path, 'utf8')) as BrandFile
95
+ } catch (err) {
96
+ return { ok: false, output: `error [USAGE]: ${BRAND_PATH} is not valid JSON: ${(err as Error).message}` }
97
+ }
98
+ const lines: string[] = []
99
+ const set = await call('PUT', '/v1/config/brand', { primary: doc.colors?.primary ?? null, primaryDark: doc.colors?.primaryDark ?? null })
100
+ if (set.status >= 400) return { ok: false, output: `error [BRAND]: ${errorOf(set)}` }
101
+ if (doc.logo) {
102
+ const file = join(root, 'brand', doc.logo)
103
+ if (!existsSync(file)) {
104
+ lines.push(` warning: Its logo (brand/${doc.logo}) is not in the project, so the runtime keeps its own`)
105
+ } else {
106
+ const ext = doc.logo.split('.').pop()?.toLowerCase() ?? ''
107
+ const form = new FormData()
108
+ form.append('file', new File([readFileSync(file)], doc.logo, { type: MIME[ext] ?? 'application/octet-stream' }))
109
+ form.append('purpose', 'org-logo')
110
+ form.append('orgId', organizationId)
111
+ const sent = await upload('/v1/upload', form)
112
+ const storagePath = String(dataOf(sent.body)?.storagePath ?? '')
113
+ if (sent.status >= 400 || !storagePath) lines.push(` warning: Its logo could not be uploaded here (${errorOf(sent)})`)
114
+ else {
115
+ const put = await call('PUT', '/v1/org/logo', { storagePath })
116
+ if (put.status >= 400) lines.push(` warning: Its logo could not be set here (${errorOf(put)})`)
117
+ }
118
+ }
119
+ }
120
+ const parts = [doc.colors?.primary || doc.colors?.primaryDark ? 'colors' : '', doc.logo ? 'logo' : ''].filter(Boolean)
121
+ lines.unshift(` set the organization’s ${parts.join(' and ') || 'brand'} as the workspace has ${parts.length > 1 ? 'them' : 'it'}`)
122
+ return { ok: true, output: lines.join('\n') }
123
+ }