@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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
-
import { join, relative } from 'node:path'
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { writeFileAtomic } from '../fs-atomic'
|
|
6
6
|
import { allTargets, describeTarget, datasetDocument, isIgnoredName, type SyncTarget } from './local-dev-sync'
|
|
7
7
|
import type { LocalProject } from './local-project'
|
|
8
|
+
import { standInTools } from './local-stand-ins'
|
|
9
|
+
import { importedFiles } from './local-function-shared'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* What `frontera local dev` has applied to this runtime, by content hash. It
|
|
@@ -71,6 +73,10 @@ function treeHash(dir: string): string | null {
|
|
|
71
73
|
export function contentHash(project: LocalProject, target: SyncTarget): string | null {
|
|
72
74
|
// The Blueprint is applied as one tree, so it changes when any file in it does.
|
|
73
75
|
if (target.kind === 'blueprint') return treeHash(join(project.root, 'blueprint'))
|
|
76
|
+
// A knowledge base, a skill and a slide theme are folders: a new document, reference or image changes them.
|
|
77
|
+
if (target.kind === 'knowledge' || target.kind === 'skill' || target.kind === 'theme' || target.kind === 'brand') return treeHash(join(project.root, dirname(target.file)))
|
|
78
|
+
// A stand-in lists the connection's tools and those the project's files name on it: either changing re-applies it.
|
|
79
|
+
if (target.kind === 'plugin' && target.file.endsWith('.stand-in.json')) return createHash('sha256').update(JSON.stringify(standInTools(project.root, target.name))).update(fileDigest(join(project.root, target.file)) ?? '').digest('hex')
|
|
74
80
|
const own = fileDigest(join(project.root, target.file))
|
|
75
81
|
if (!own) return null
|
|
76
82
|
const hash = createHash('sha256').update(own)
|
|
@@ -80,9 +86,15 @@ export function contentHash(project: LocalProject, target: SyncTarget): string |
|
|
|
80
86
|
const rows = csv ? fileDigest(join(project.root, csv)) : null
|
|
81
87
|
if (rows) hash.update(rows)
|
|
82
88
|
}
|
|
83
|
-
// A Function is bundled with what it imports: the other files in functions
|
|
89
|
+
// A Function is bundled with what it imports: the other files in functions/, the vendored SDK, and the
|
|
90
|
+
// shared files it imports from outside functions/.
|
|
84
91
|
if (target.kind === 'function') {
|
|
85
92
|
hash.update(treeHash(join(project.root, 'functions')) ?? '').update(treeHash(join(project.root, 'vendor')) ?? '')
|
|
93
|
+
for (const path of importedFiles(project.root, target.file).filter((p) => !p.startsWith('functions/')).sort()) hash.update(path).update('\0').update(fileDigest(join(project.root, path)) ?? '')
|
|
94
|
+
}
|
|
95
|
+
// Context and data the pull could not read is assumed on the project's object types: a new one changes it.
|
|
96
|
+
if (target.kind === 'bindings' && /"blueprintUnread"\s*:\s*true/.test(readFileSync(join(project.root, target.file), 'utf8'))) {
|
|
97
|
+
hash.update(treeHash(join(project.root, 'blueprint', 'object-types')) ?? '')
|
|
86
98
|
}
|
|
87
99
|
return hash.digest('hex')
|
|
88
100
|
}
|
|
@@ -119,6 +131,18 @@ export interface SolutionItem {
|
|
|
119
131
|
state: 'applied' | 'changed' | 'new'
|
|
120
132
|
}
|
|
121
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Whether the runtime has the Blueprint's current files: the tree hash `local
|
|
136
|
+
* dev` recorded after its last successful publish, against the files now. Null
|
|
137
|
+
* when the project has no Blueprint.
|
|
138
|
+
*/
|
|
139
|
+
export function blueprintApplyState(project: LocalProject, organizationId: string | null): SolutionItem['state'] | null {
|
|
140
|
+
const now = treeHash(join(project.root, 'blueprint'))
|
|
141
|
+
if (!now) return null
|
|
142
|
+
const was = organizationId ? loadHashes(project, organizationId).get('blueprint') : undefined
|
|
143
|
+
return was === undefined ? 'new' : was === now ? 'applied' : 'changed'
|
|
144
|
+
}
|
|
145
|
+
|
|
122
146
|
/** Each file `local dev` applies, and whether the runtime has its current content. */
|
|
123
147
|
export function solutionStatus(project: LocalProject, organizationId: string | null): SolutionItem[] {
|
|
124
148
|
const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
|
|
@@ -133,3 +157,310 @@ export function solutionStatus(project: LocalProject, organizationId: string | n
|
|
|
133
157
|
}
|
|
134
158
|
})
|
|
135
159
|
}
|
|
160
|
+
|
|
161
|
+
// ── The build: what each target's last apply came to (design §9) ─────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `local dev` records each target's outcome in `.frontera/local/build.json`,
|
|
165
|
+
* beside the hashes, so Forge and Studio say on the item itself whether it
|
|
166
|
+
* runs, and why not, without the runtime running and without a log:
|
|
167
|
+
*
|
|
168
|
+
* built the runtime has it as the files define it
|
|
169
|
+
* adapted it has it with listed adaptations (no computer, a model swapped, an Action switched off)
|
|
170
|
+
* stubbed it has it with a stand-in for what only the platform reaches (a built-in connection)
|
|
171
|
+
* blocked it waits for another item, named (`workflow/telco-fault-triage`); it is applied
|
|
172
|
+
* again once that one builds (the retry fixpoint in `local dev`)
|
|
173
|
+
* failed the runtime refused it; its error, cleaned to one line
|
|
174
|
+
*
|
|
175
|
+
* Like the hashes, it is kept per local organization.
|
|
176
|
+
*/
|
|
177
|
+
export type BuildStatus = 'built' | 'adapted' | 'stubbed' | 'blocked' | 'failed'
|
|
178
|
+
|
|
179
|
+
export interface BuildEntry {
|
|
180
|
+
/** What `local dev` calls it: "workflow telco-fault-triage". */
|
|
181
|
+
what: string
|
|
182
|
+
status: BuildStatus
|
|
183
|
+
/** adapted, stubbed: each adaptation in words. */
|
|
184
|
+
adaptations?: string[]
|
|
185
|
+
/** blocked: what it waits for, as references (`workflow/telco-fault-triage`, `action/resetLine`). */
|
|
186
|
+
needs?: string[]
|
|
187
|
+
/** blocked: it names governed Actions only by the platform's id. */
|
|
188
|
+
unnamedActions?: number
|
|
189
|
+
/** failed: the runtime's error, one line. */
|
|
190
|
+
error?: string
|
|
191
|
+
/** The content hash the outcome is for. */
|
|
192
|
+
hash: string | null
|
|
193
|
+
at: string
|
|
194
|
+
/** The Blueprint: each object type as the runtime holds it. */
|
|
195
|
+
objects?: Record<string, { status: 'built' | 'adapted' | 'stubbed' | 'left-off'; adaptations?: string[] }>
|
|
196
|
+
/** The Blueprint: each governed Action, deployed (`runs`) or not, with why. */
|
|
197
|
+
actions?: Record<string, { status: 'runs' | 'switched-off' | 'failed' | 'left-off'; reason?: string }>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface BuildFile {
|
|
201
|
+
version: 1
|
|
202
|
+
organizationId: string
|
|
203
|
+
updatedAt: string
|
|
204
|
+
items: Record<string, BuildEntry>
|
|
205
|
+
/** Where the runtime's organization settings differ from the workspace's, in words (`local-org-settings.ts`). */
|
|
206
|
+
settings?: { differences: string[]; at: string }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readBuildFile(project: LocalProject, organizationId: string): Partial<BuildFile> | null {
|
|
210
|
+
try {
|
|
211
|
+
const saved = JSON.parse(readFileSync(buildPath(project), 'utf8')) as Partial<BuildFile>
|
|
212
|
+
return saved.version === 1 && saved.organizationId === organizationId ? saved : null
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The settings the runtime holds differently from the workspace, as `local dev` last compared them. */
|
|
219
|
+
export function loadSettingsDifferences(project: LocalProject, organizationId: string | null): string[] {
|
|
220
|
+
return (organizationId ? readBuildFile(project, organizationId)?.settings?.differences : undefined) ?? []
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Record where the runtime's settings differ from the workspace's. Never throws. */
|
|
224
|
+
export function recordSettingsDifferences(project: LocalProject, organizationId: string, differences: string[]): void {
|
|
225
|
+
const saved = readBuildFile(project, organizationId)
|
|
226
|
+
try {
|
|
227
|
+
const file: BuildFile = { version: 1, organizationId, updatedAt: new Date().toISOString(), items: saved?.items ?? {}, settings: { differences, at: new Date().toISOString() } }
|
|
228
|
+
writeFileAtomic(buildPath(project), `${JSON.stringify(file, null, 2)}\n`, 0o644)
|
|
229
|
+
} catch {
|
|
230
|
+
// The summary only misses the line until the next \`local dev\`.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function buildPath(project: LocalProject): string {
|
|
235
|
+
return join(project.stateDir, 'build.json')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The last build's outcome per target key; empty for another organization (after a reset) or none. */
|
|
239
|
+
export function loadBuild(project: LocalProject, organizationId: string | null): Record<string, BuildEntry> {
|
|
240
|
+
if (!organizationId) return {}
|
|
241
|
+
try {
|
|
242
|
+
const saved = JSON.parse(readFileSync(buildPath(project), 'utf8')) as Partial<BuildFile>
|
|
243
|
+
if (saved.version === 1 && saved.organizationId === organizationId && saved.items) return saved.items
|
|
244
|
+
} catch {
|
|
245
|
+
// Missing or unreadable: nothing built yet.
|
|
246
|
+
}
|
|
247
|
+
return {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Record one target's outcome (or forget it, `null`). Never throws: the build must not stop on its record. */
|
|
251
|
+
export function recordBuild(project: LocalProject, organizationId: string, key: string, entry: BuildEntry | null): void {
|
|
252
|
+
const items = { ...loadBuild(project, organizationId) }
|
|
253
|
+
if (entry) items[key] = entry
|
|
254
|
+
else delete items[key]
|
|
255
|
+
const settings = readBuildFile(project, organizationId)?.settings
|
|
256
|
+
try {
|
|
257
|
+
const file: BuildFile = { version: 1, organizationId, updatedAt: new Date().toISOString(), items, ...(settings ? { settings } : {}) }
|
|
258
|
+
writeFileAtomic(buildPath(project), `${JSON.stringify(file, null, 2)}\n`, 0o644)
|
|
259
|
+
} catch {
|
|
260
|
+
// Losing it only means the home shows "not applied yet" until the next apply.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Forget the outcomes of targets the project no longer has. */
|
|
265
|
+
export function pruneBuild(project: LocalProject, organizationId: string, keep: ReadonlySet<string>): void {
|
|
266
|
+
const items = loadBuild(project, organizationId)
|
|
267
|
+
for (const key of Object.keys(items)) if (!keep.has(key)) recordBuild(project, organizationId, key, null)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The reference a target is: what other files name it by. Behavior settings, tools and lessons are their Agent. */
|
|
271
|
+
export function refOfTarget(target: SyncTarget): string {
|
|
272
|
+
switch (target.kind) {
|
|
273
|
+
case 'agent':
|
|
274
|
+
case 'operating':
|
|
275
|
+
case 'bindings':
|
|
276
|
+
case 'lessons':
|
|
277
|
+
return `agent/${target.slug}`
|
|
278
|
+
case 'workflow':
|
|
279
|
+
return `workflow/${target.slug}`
|
|
280
|
+
case 'function':
|
|
281
|
+
return `function/${target.key.endsWith('/') ? target.key.slice('function:'.length, -1) : basename(target.file).replace(/\.ts$/, '')}`
|
|
282
|
+
case 'dataset':
|
|
283
|
+
return `dataset/${target.apiName}`
|
|
284
|
+
case 'knowledge':
|
|
285
|
+
return `knowledge/${target.name}`
|
|
286
|
+
case 'skill':
|
|
287
|
+
return `skill/${target.name}`
|
|
288
|
+
case 'plugin':
|
|
289
|
+
return `plugin/${target.name}`
|
|
290
|
+
case 'theme':
|
|
291
|
+
return `theme/${target.name}`
|
|
292
|
+
case 'chat-app':
|
|
293
|
+
return `chat-app/${target.name}`
|
|
294
|
+
case 'app':
|
|
295
|
+
return `app/${target.name}`
|
|
296
|
+
case 'brand':
|
|
297
|
+
return 'brand'
|
|
298
|
+
case 'library':
|
|
299
|
+
return 'library'
|
|
300
|
+
case 'blueprint':
|
|
301
|
+
return 'blueprint'
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const KIND_WORDS: Record<string, string> = {
|
|
306
|
+
agent: 'Agent',
|
|
307
|
+
workflow: 'workflow',
|
|
308
|
+
function: 'Function',
|
|
309
|
+
action: 'governed Action',
|
|
310
|
+
objectType: 'object type',
|
|
311
|
+
knowledge: 'knowledge base',
|
|
312
|
+
skill: 'skill',
|
|
313
|
+
plugin: 'Plugin',
|
|
314
|
+
model: 'model',
|
|
315
|
+
dataset: 'dataset',
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `workflow/telco-fault-triage` → "workflow telco-fault-triage". */
|
|
319
|
+
export function refWords(ref: string): string {
|
|
320
|
+
const at = ref.indexOf('/')
|
|
321
|
+
return at < 0 ? ref : `${KIND_WORDS[ref.slice(0, at)] ?? ref.slice(0, at)} ${ref.slice(at + 1)}`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A refusal as one line, starting with a capital: the build's own record of it. */
|
|
325
|
+
export function oneLine(text: string): string {
|
|
326
|
+
const line = text.split('\n').map((l) => l.trim()).find((l) => l && !/^warning:/i.test(l)) ?? 'failed with no output'
|
|
327
|
+
const cleaned = line.replace(/^error(\s*\[[A-Z_]+\])?:\s*/i, '').replace(/\s+/g, ' ').trim()
|
|
328
|
+
const short = cleaned.length > 180 ? `${cleaned.slice(0, 177)}…` : cleaned
|
|
329
|
+
return short.charAt(0).toUpperCase() + short.slice(1)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** ` warning: agents/x/agent.json names the model y…` → "Names the model y…", for the list of adaptations. */
|
|
333
|
+
export function adaptationOf(warning: string): string {
|
|
334
|
+
const text = warning.replace(/^\s*warning:\s*/i, '').replace(/^(agents|workflows|functions)\/\S+\s+/, '').trim()
|
|
335
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export type ItemBuildState = BuildStatus | 'not-applied'
|
|
339
|
+
|
|
340
|
+
export interface ItemBuild {
|
|
341
|
+
state: ItemBuildState
|
|
342
|
+
/** A few words for the pill: "Built", "Adapted", "Blocked", "Failed", "Not applied yet". */
|
|
343
|
+
label: string
|
|
344
|
+
/** The one line that says why, when there is one: "Needs workflow telco-fault-triage". */
|
|
345
|
+
reason: string | null
|
|
346
|
+
adaptations: string[]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const BUILD_RANK: Record<ItemBuildState, number> = { built: 0, adapted: 1, stubbed: 2, 'not-applied': 3, blocked: 4, failed: 5 }
|
|
350
|
+
const BUILD_LABEL: Record<ItemBuildState, string> = { built: 'Built', adapted: 'Adapted', stubbed: 'Stubbed', 'not-applied': 'Not applied yet', blocked: 'Blocked', failed: 'Failed' }
|
|
351
|
+
|
|
352
|
+
function lower(text: string): string {
|
|
353
|
+
return text ? text.charAt(0).toLowerCase() + text.slice(1) : text
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Each target's build as the home shows it, read from the project alone:
|
|
358
|
+
* the recorded outcome when it is for the files as they are now, else "not
|
|
359
|
+
* applied yet". A blocked target's reason follows what it waits for, so a
|
|
360
|
+
* chain reads as one line: "Needs workflow telco-fault-triage, which is
|
|
361
|
+
* blocked: needs governed Action resetLine, which isn’t in the project’s
|
|
362
|
+
* Blueprint".
|
|
363
|
+
*/
|
|
364
|
+
export function buildView(project: LocalProject, organizationId: string | null): Map<string, ItemBuild> {
|
|
365
|
+
const build = loadBuild(project, organizationId)
|
|
366
|
+
const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
|
|
367
|
+
const targets = allTargets(project.root)
|
|
368
|
+
const byRef = new Map<string, SyncTarget[]>()
|
|
369
|
+
for (const t of targets) byRef.set(refOfTarget(t), [...(byRef.get(refOfTarget(t)) ?? []), t])
|
|
370
|
+
const hashes = new Map(targets.map((t) => [t.key, contentHash(project, t)]))
|
|
371
|
+
const blueprint = build.blueprint
|
|
372
|
+
|
|
373
|
+
const stateOf = (t: SyncTarget): { state: ItemBuildState; entry?: BuildEntry } => {
|
|
374
|
+
const entry = build[t.key]
|
|
375
|
+
const now = hashes.get(t.key) ?? null
|
|
376
|
+
if (entry && entry.hash === now) return { state: entry.status, entry }
|
|
377
|
+
// Applied before the build was recorded: it is built.
|
|
378
|
+
if (!entry && now && applied.get(t.key) === now) return { state: 'built' }
|
|
379
|
+
return { state: 'not-applied' }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Why a reference holds something up, in words, following at most two more links. */
|
|
383
|
+
const needWords = (ref: string, depth: number): string => {
|
|
384
|
+
const words = refWords(ref)
|
|
385
|
+
if (ref.startsWith('action/')) {
|
|
386
|
+
const action = blueprint?.actions?.[ref.slice('action/'.length)]
|
|
387
|
+
if (!action) return `${words}, which isn’t in the project’s Blueprint`
|
|
388
|
+
if (action.status === 'left-off') return `${words}, which the local runtime leaves off: ${action.reason}`
|
|
389
|
+
if (action.status === 'failed') return `${words}, which didn’t deploy: ${action.reason}`
|
|
390
|
+
return words
|
|
391
|
+
}
|
|
392
|
+
if (ref.startsWith('objectType/')) {
|
|
393
|
+
const obj = blueprint?.objects?.[ref.slice('objectType/'.length)]
|
|
394
|
+
// The Blueprint not built (failed, waiting, or never applied) holds every object type back.
|
|
395
|
+
if (blueprint && blueprint.status !== 'built' && blueprint.status !== 'adapted') return `${words}: the Blueprint ${blueprint.status === 'failed' ? 'didn’t build' : 'isn’t built yet'}`
|
|
396
|
+
if (!blueprint && existsSync(join(project.root, 'blueprint'))) return `${words}: the Blueprint isn’t built yet`
|
|
397
|
+
if (!obj) return `${words}, which isn’t in the project’s Blueprint`
|
|
398
|
+
return obj.status === 'left-off' ? `${words}, which the local runtime leaves off` : words
|
|
399
|
+
}
|
|
400
|
+
const held = byRef.get(ref)
|
|
401
|
+
if (!held?.length) return `${words}, which isn’t in this project`
|
|
402
|
+
if (depth >= 2) return words
|
|
403
|
+
const worst = held.map((t) => ({ t, ...stateOf(t) })).sort((a, b) => BUILD_RANK[b.state] - BUILD_RANK[a.state])[0]!
|
|
404
|
+
if (worst.state === 'blocked' && worst.entry) return `${words}, which is blocked: ${lower(reasonOf(worst.entry, depth + 1))}`
|
|
405
|
+
if (worst.state === 'failed') return `${words}, which failed`
|
|
406
|
+
if (worst.state === 'not-applied') return `${words}, which isn’t applied yet`
|
|
407
|
+
return words
|
|
408
|
+
}
|
|
409
|
+
const reasonOf = (entry: BuildEntry, depth: number): string => {
|
|
410
|
+
if (entry.status === 'failed') return entry.error ?? 'The runtime refused it'
|
|
411
|
+
if (entry.status === 'blocked') {
|
|
412
|
+
const needs = entry.needs ?? []
|
|
413
|
+
if (!needs.length) {
|
|
414
|
+
const n = entry.unnamedActions ?? 0
|
|
415
|
+
return `Runs ${n === 1 ? 'a governed Action' : `${n} governed Actions`} named only by the platform’s id: add an organization key and pull it again`
|
|
416
|
+
}
|
|
417
|
+
return `Needs ${needWords(needs[0]!, depth)}${needs.length > 1 ? ` (and ${needs.length - 1} more)` : ''}`
|
|
418
|
+
}
|
|
419
|
+
if (entry.status === 'adapted' || entry.status === 'stubbed') return entry.adaptations?.join('; ') ?? ''
|
|
420
|
+
return ''
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const out = new Map<string, ItemBuild>()
|
|
424
|
+
for (const t of targets) {
|
|
425
|
+
const found = stateOf(t)
|
|
426
|
+
const { entry } = found
|
|
427
|
+
// Adapted only by what the runtime is missing (no runner has polled yet): it is built as its files say, with a note.
|
|
428
|
+
const state = found.state === 'adapted' && entry?.adaptations?.length && entry.adaptations.every(isEnvironmentNote) ? 'built' : found.state
|
|
429
|
+
const reason = entry ? reasonOf(entry, 0) || null : null
|
|
430
|
+
out.set(t.key, { state, label: BUILD_LABEL[state], reason, adaptations: state === 'adapted' || state === 'stubbed' ? (entry?.adaptations ?? []) : [] })
|
|
431
|
+
}
|
|
432
|
+
return out
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** A warning about the runtime rather than the item: it says nothing about how the item runs differently here. */
|
|
436
|
+
export function isEnvironmentNote(text: string): boolean {
|
|
437
|
+
return /no automation runner has (ever )?polled/i.test(text)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Several targets as one item (an Agent: its file, its behavior settings and its tools): the worst one speaks. */
|
|
441
|
+
export function combineBuilds(builds: Array<ItemBuild | undefined>): ItemBuild {
|
|
442
|
+
const present = builds.filter((b): b is ItemBuild => !!b)
|
|
443
|
+
if (!present.length) return { state: 'not-applied', label: BUILD_LABEL['not-applied'], reason: null, adaptations: [] }
|
|
444
|
+
const worst = [...present].sort((a, b) => BUILD_RANK[b.state] - BUILD_RANK[a.state])[0]!
|
|
445
|
+
const adaptations = [...new Set(present.flatMap((b) => b.adaptations))]
|
|
446
|
+
return { ...worst, adaptations, reason: worst.reason ?? (worst.state === 'adapted' || worst.state === 'stubbed' ? adaptations.join('; ') || null : null) }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** How many items the home lists are in each state, for "49 built · 2 adapted · 3 blocked". */
|
|
450
|
+
export function buildCounts(states: ItemBuildState[]): Record<ItemBuildState, number> {
|
|
451
|
+
const counts: Record<ItemBuildState, number> = { built: 0, adapted: 0, stubbed: 0, 'not-applied': 0, blocked: 0, failed: 0 }
|
|
452
|
+
for (const s of states) counts[s] += 1
|
|
453
|
+
return counts
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The counts as one line, the problems first after what is built: "49 built · 2 adapted · 3 blocked". */
|
|
457
|
+
export function buildSummaryLine(counts: Record<ItemBuildState, number>): string {
|
|
458
|
+
return [
|
|
459
|
+
counts.built ? `${counts.built} built` : '',
|
|
460
|
+
counts.adapted ? `${counts.adapted} adapted` : '',
|
|
461
|
+
counts.stubbed ? `${counts.stubbed} stubbed` : '',
|
|
462
|
+
counts.blocked ? `${counts.blocked} blocked` : '',
|
|
463
|
+
counts.failed ? `${counts.failed} failed` : '',
|
|
464
|
+
counts['not-applied'] ? `${counts['not-applied']} not applied yet` : '',
|
|
465
|
+
].filter(Boolean).join(' · ')
|
|
466
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { RemoteItem } from './local-sync-plan'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Custom Apps: the workspace's code Apps (a Chat App is `chat-apps/`). A
|
|
8
|
+
* pull writes each as `apps/<slug>.json`: what it is (name, description,
|
|
9
|
+
* where it is hosted), its live version's number and manifest (routing and
|
|
10
|
+
* the hosts it may reach), and the Functions it may start, by name, each
|
|
11
|
+
* with its switch. `local dev` makes the App on the local runtime and binds
|
|
12
|
+
* those Functions, so the App's calls to them are allowed here as there.
|
|
13
|
+
*
|
|
14
|
+
* Its code is its own project (a folder `frontera app pull` fills and
|
|
15
|
+
* `frontera app deploy` ships), and is not brought: the App is saved here
|
|
16
|
+
* without it, which its portability says (Adapted). Who may open it, its
|
|
17
|
+
* sign-in (audiences, roles, keys) and its addresses are the platform's:
|
|
18
|
+
* people and deployment (sync v2 design §8). Never proposed back.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A Custom App as `/v1/sync/apps` lists it. */
|
|
22
|
+
export interface AppRow {
|
|
23
|
+
id: string
|
|
24
|
+
slug: string
|
|
25
|
+
name: string
|
|
26
|
+
description?: string | null
|
|
27
|
+
hosting?: 'platform' | 'external' | null
|
|
28
|
+
version?: string | null
|
|
29
|
+
manifest?: { routing?: unknown; connectDomains?: unknown; resourceDomains?: unknown } | null
|
|
30
|
+
functions?: Array<{ function: string; enabled?: boolean }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The project's file for one App. */
|
|
34
|
+
export interface AppFile {
|
|
35
|
+
name: string
|
|
36
|
+
description?: string
|
|
37
|
+
hosting?: 'external'
|
|
38
|
+
version?: string
|
|
39
|
+
routing?: string
|
|
40
|
+
connectDomains?: string[]
|
|
41
|
+
resourceDomains?: string[]
|
|
42
|
+
functions: Array<{ function: string; enabled: boolean }>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const appPath = (slug: string) => `apps/${slug}.json`
|
|
46
|
+
|
|
47
|
+
/** Why an App is Adapted here, as the build and the Pull dialog say it. */
|
|
48
|
+
export const APP_WITHOUT_CODE = 'Saved here with the Functions it may start, without its code: an App’s code is its own project, deployed to this runtime on its own'
|
|
49
|
+
|
|
50
|
+
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) && v.every((x) => typeof x === 'string') && v.length ? (v as string[]) : undefined)
|
|
51
|
+
|
|
52
|
+
export function appDocument(row: AppRow): AppFile {
|
|
53
|
+
const m = row.manifest ?? null
|
|
54
|
+
return {
|
|
55
|
+
name: row.name,
|
|
56
|
+
...(row.description ? { description: row.description } : {}),
|
|
57
|
+
...(row.hosting === 'external' ? { hosting: 'external' as const } : {}),
|
|
58
|
+
...(row.version ? { version: row.version } : {}),
|
|
59
|
+
...(m && typeof m.routing === 'string' && m.routing !== 'spa' ? { routing: m.routing } : {}),
|
|
60
|
+
...(strings(m?.connectDomains) ? { connectDomains: strings(m?.connectDomains) } : {}),
|
|
61
|
+
...(strings(m?.resourceDomains) ? { resourceDomains: strings(m?.resourceDomains) } : {}),
|
|
62
|
+
functions: (row.functions ?? []).map((b) => ({ function: `function/${b.function}`, enabled: b.enabled !== false })),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The workspace's Custom Apps as project items. */
|
|
67
|
+
export function readApps(rows: AppRow[], wanted: (ref: string) => boolean): RemoteItem[] {
|
|
68
|
+
const out: RemoteItem[] = []
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
const ref = `app/${row.slug}`
|
|
71
|
+
if (!wanted(ref)) continue
|
|
72
|
+
const doc = appDocument(row)
|
|
73
|
+
const notes = [
|
|
74
|
+
row.version ? `Its code (version ${row.version}) is its own project and stays on the platform; this file is what the App is and the Functions it may start` : 'Never deployed in the workspace; this file is what the App is and the Functions it may start',
|
|
75
|
+
row.hosting === 'external' ? 'Hosted on its own domain: its sign-in, roles and keys stay on the platform' : 'Who may open it and its addresses stay on the platform',
|
|
76
|
+
]
|
|
77
|
+
out.push({ ref, kind: 'app', name: row.slug, title: row.name, files: { [appPath(row.slug)]: `${JSON.stringify(doc, null, 2)}\n` }, unresolved: [], notes, requires: doc.functions.map((f) => f.function), id: row.id, ...(row.version ? { version: `v${row.version}` } : {}) })
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
83
|
+
|
|
84
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as Record<string, unknown> | null
|
|
85
|
+
|
|
86
|
+
const errorOf = (a: { status: number; body: unknown }) => {
|
|
87
|
+
const b = a.body as { message?: unknown } | null
|
|
88
|
+
return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The Functions a project holds, by name (a file or a folder of their own). */
|
|
92
|
+
function projectFunctions(root: string): string[] {
|
|
93
|
+
try {
|
|
94
|
+
return readdirSync(join(root, 'functions'))
|
|
95
|
+
.flatMap((f) => (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f) ? [f.replace(/\.ts$/, '')] : existsSync(join(root, 'functions', f, 'package.json')) ? [f] : []))
|
|
96
|
+
.sort()
|
|
97
|
+
} catch {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Save a project's App on the runtime: made (or found) by slug, named, and
|
|
104
|
+
* given the Functions its file names, each switched as the file says; a
|
|
105
|
+
* project Function the file no longer names is unbound from it. A Function
|
|
106
|
+
* the project holds but the runtime has not deployed yet holds the App back;
|
|
107
|
+
* one the project does not hold is said and left out.
|
|
108
|
+
*/
|
|
109
|
+
export async function applyApp(root: string, slug: string, call: Call, provides: (ref: string) => boolean): Promise<{ ok: boolean; output: string; missing?: string[] }> {
|
|
110
|
+
const path = join(root, appPath(slug))
|
|
111
|
+
if (!existsSync(path)) return { ok: false, output: `error [APP]: ${appPath(slug)} is missing` }
|
|
112
|
+
let doc: AppFile
|
|
113
|
+
try {
|
|
114
|
+
doc = JSON.parse(readFileSync(path, 'utf8')) as AppFile
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { ok: false, output: `error [USAGE]: ${appPath(slug)} is not valid JSON: ${(err as Error).message}` }
|
|
117
|
+
}
|
|
118
|
+
const name = typeof doc.name === 'string' && doc.name.trim() ? doc.name.trim() : slug
|
|
119
|
+
const description = typeof doc.description === 'string' ? doc.description : null
|
|
120
|
+
const ensured = await call('POST', '/v1/platform-apps/ensure', { slug, displayName: name, kind: 'code', ...(description ? { description } : {}) })
|
|
121
|
+
if (ensured.status >= 400) return { ok: false, output: `error [APP]: ${errorOf(ensured)}` }
|
|
122
|
+
const lines: string[] = []
|
|
123
|
+
if (!dataOf(ensured.body)?.created) {
|
|
124
|
+
const renamed = await call('PATCH', `/v1/platform-apps/${encodeURIComponent(String(dataOf(ensured.body)?.id ?? ''))}`, { displayName: name, description })
|
|
125
|
+
if (renamed.status >= 400) lines.push(` warning: Its name and description could not be set here (${errorOf(renamed)})`)
|
|
126
|
+
}
|
|
127
|
+
const bindings = Array.isArray(doc.functions) ? doc.functions : []
|
|
128
|
+
const named = new Set<string>()
|
|
129
|
+
const waiting: string[] = []
|
|
130
|
+
for (const b of bindings) {
|
|
131
|
+
const fn = String(b.function ?? '').replace(/^function\//, '')
|
|
132
|
+
if (!fn) continue
|
|
133
|
+
named.add(fn)
|
|
134
|
+
const put = await call('PUT', `/v1/functions/${encodeURIComponent(fn)}/app-bindings/${encodeURIComponent(slug)}`, { enabled: b.enabled !== false })
|
|
135
|
+
if (put.status === 404) {
|
|
136
|
+
if (provides(`function/${fn}`)) waiting.push(`function/${fn}`)
|
|
137
|
+
else lines.push(` warning: It may start Function ${fn}, which isn’t in this project: left out here`)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (put.status >= 400) return { ok: false, output: `error [APP]: Function ${fn}: ${errorOf(put)}` }
|
|
141
|
+
}
|
|
142
|
+
if (waiting.length) {
|
|
143
|
+
return { ok: false, missing: waiting, output: [`error [UNRESOLVED_REFERENCE]: ${appPath(slug)} names what this runtime does not have`, ...waiting.map((w) => ` ${w}: in the project, not applied yet`)].join('\n') }
|
|
144
|
+
}
|
|
145
|
+
// A project Function the file no longer names stops being startable by the App.
|
|
146
|
+
for (const fn of projectFunctions(root).filter((f) => !named.has(f))) {
|
|
147
|
+
const listed = await call('GET', `/v1/functions/${encodeURIComponent(fn)}/app-bindings`)
|
|
148
|
+
if (listed.status >= 400) continue
|
|
149
|
+
const rows = (dataOf(listed.body) as unknown as Array<{ appSlug?: string }> | null) ?? []
|
|
150
|
+
if (!Array.isArray(rows) || !rows.some((r) => r.appSlug === slug)) continue
|
|
151
|
+
const gone = await call('DELETE', `/v1/functions/${encodeURIComponent(fn)}/app-bindings/${encodeURIComponent(slug)}`)
|
|
152
|
+
if (gone.status >= 400 && gone.status !== 404) lines.push(` warning: Function ${fn} could not be unbound from it (${errorOf(gone)})`)
|
|
153
|
+
}
|
|
154
|
+
lines.unshift(` saved App ${name}${named.size ? ` (may start ${[...named].sort().join(', ')})` : ''}`)
|
|
155
|
+
return { ok: true, output: lines.join('\n') }
|
|
156
|
+
}
|