@frontera-sdk/cli 1.52.28 → 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
@@ -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/ and the vendored SDK.
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
+ }