@frontera-sdk/cli 1.52.28 → 1.52.30
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 +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- 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
|
+
}
|