@frontera-sdk/cli 1.52.43 → 1.52.44

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.
@@ -16,8 +16,11 @@ import { lessonsPath, lessonsProposal } from './local-lessons'
16
16
  import { importedFiles } from './local-function-shared'
17
17
  import { isIgnoredName } from './local-dev-sync'
18
18
  import { comparableText, withoutSecrets } from './local-secrets'
19
+ import { projectModels } from './local-sync-models'
19
20
  import { deepEqual } from './local-sync-merge'
21
+ import { portableNotes, sameDocumentText, toLocal, toWorkspace } from './local-sync-portable'
20
22
  import { itemTitle } from './local-sync-names'
23
+ import { isScaffoldSample } from './local-scaffold'
21
24
  import { hash, itemHash, readLocal, safePath, writeLock, type LockEntry, type PlanItem, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
22
25
  import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync-snapshot'
23
26
 
@@ -55,8 +58,12 @@ const NOT_DELETED: Partial<Record<SyncKind, string>> = {
55
58
  lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
56
59
  }
57
60
 
58
- /** Why an item cannot be proposed now. */
59
- export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape'
61
+ /**
62
+ * Why an item cannot be proposed now. `model`: it names a model the workspace
63
+ * lacks and `sync.models` maps to none; `refused`: the workspace's checks
64
+ * would refuse it (`POST /v1/change-requests/check`), said before proposing.
65
+ */
66
+ export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape' | 'model' | 'refused'
60
67
 
61
68
  /** A skill's file that is not text (an image, a font, a template): uploaded as a skill asset when proposed. */
62
69
  export interface PushAsset {
@@ -96,6 +103,15 @@ export interface PushItem {
96
103
  /** Why not, when it is not. */
97
104
  blocked: BlockCode | null
98
105
  reason: string | null
106
+ /** What makes it proposable, when something here can: the command or the choice. */
107
+ fix?: string | null
108
+ /**
109
+ * Still exactly the sample setting up the project wrote (`isScaffoldSample`):
110
+ * proposable, but left out unless named or ticked.
111
+ */
112
+ sample?: boolean
113
+ /** The models it names that the workspace lacks and `sync.models` maps to none, by local slug. */
114
+ missingModels?: string[]
99
115
  /** The platform's id, from the lock (or the snapshot). */
100
116
  id: string | null
101
117
  /** The revision the item was changed from; for a Blueprint file, the Blueprint's. */
@@ -165,7 +181,7 @@ function functionImports(root: string, entry: string): string[] {
165
181
  * service). The lock's paths that still exist come along, so nothing the
166
182
  * last pull wrote is left behind.
167
183
  */
168
- export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
184
+ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean; upstream?: Record<string, string> } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
169
185
  const notes: string[] = []
170
186
  let paths: string[] = []
171
187
  const assets: string[] = []
@@ -228,6 +244,8 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
228
244
  // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
229
245
  if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
230
246
  const files: Record<string, string> = {}
247
+ const here: Record<string, string> = {}
248
+ const models = projectModels(root)
231
249
  for (const path of [...new Set(paths)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
232
250
  if (path.endsWith('.workspace') || assets.includes(path)) continue
233
251
  const text = readLocal(root, path)
@@ -236,8 +254,13 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
236
254
  const clean = withoutSecrets(text)
237
255
  files[path] = clean.text
238
256
  notes.push(...clean.notes)
239
- } else files[path] = text
257
+ } else {
258
+ // As the workspace will have it: a mapped model swapped, the multi-turn evaluation cases left here.
259
+ files[path] = toWorkspace(path, text, models, opts.upstream?.[path] ?? null).text
260
+ here[path] = text
261
+ }
240
262
  }
263
+ notes.push(...portableNotes(here, models, models.names, opts.upstream))
241
264
  return { files, notes, ...(kind === 'skill' ? { assets } : {}) }
242
265
  }
243
266
 
@@ -305,7 +328,7 @@ export interface CandidateInput {
305
328
  }
306
329
  }
307
330
 
308
- const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape'>, string> = {
331
+ const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape' | 'model' | 'refused'>, string> = {
309
332
  moved: 'Changed in the workspace too since your last pull: pull and merge it first',
310
333
  conflicts: 'Has conflicts from a pull: resolve them first',
311
334
  deleted: 'Deleted here',
@@ -389,7 +412,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
389
412
  if (status === 'new' || status === 'changed') continue
390
413
  const deleted = status === 'deleted-local'
391
414
  const bringDocuments = kind === 'knowledge' && !!input.documents?.names.has(name)
392
- const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments })
415
+ const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments, ...(theirs ? { upstream: theirs.files } : {}) })
393
416
  // What a file hash does not cover: a skill's assets, and a knowledge base's documents when they are brought.
394
417
  const assetState = kind === 'skill' && !deleted && (assetPaths?.length || theirs?.assets?.length) ? skillAssets(root, name, assetPaths ?? [], theirs) : null
395
418
  const docs = bringDocuments && !deleted && input.documents
@@ -422,6 +445,10 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
422
445
  base: baseFiles,
423
446
  notes,
424
447
  }
448
+ if (!deleted && isScaffoldSample(files)) {
449
+ item.sample = true
450
+ notes.push('A sample from setting up the project, not your work: it stays here unless you tick it (or name it to `frontera local sync push`)')
451
+ }
425
452
  if (assetState) {
426
453
  item.assets = assetState.assets
427
454
  for (const a of assetState.assets) if (a.change !== 'same') notes.push(`Asset ${a.path}: ${a.change === 'new' ? 'new' : 'changed'} (${kb(a.bytes)})`)
@@ -770,9 +797,20 @@ export function adoptApplied(root: string, workspace: string, refs: string[], re
770
797
  if (!item) continue
771
798
  for (const [path, text] of Object.entries(item.files)) {
772
799
  const here = readLocal(root, path)
773
- if (here !== null && here !== text && sameDocument(path, here, text)) {
800
+ if (here === null || here === text) continue
801
+ if (sameDocument(path, here, text)) {
774
802
  writeFileSync(safePath(root, path), text)
775
803
  rewritten.push(path)
804
+ continue
805
+ }
806
+ // What the workspace now holds, with this computer's parts kept (a mapped model, the multi-turn cases).
807
+ const portable = toWorkspace(path, here, projectModels(root), text).text
808
+ if (portable !== here && (sameDocument(path, portable, text) || sameDocumentText(path, portable, text))) {
809
+ const local = toLocal(path, text, here, projectModels(root))
810
+ if (local !== here) {
811
+ writeFileSync(safePath(root, path), local)
812
+ rewritten.push(path)
813
+ }
776
814
  }
777
815
  }
778
816
  next.items[ref] = {
@@ -164,7 +164,8 @@ async function listActions(org: SyncClient): Promise<ActionRow[]> {
164
164
  const d = ((row as { definition?: unknown } | null)?.definition ?? row) as Partial<ActionRow> | null
165
165
  return d && typeof d.id === 'string' && typeof d.apiName === 'string' ? [{ id: d.id, apiName: d.apiName }] : []
166
166
  }
167
- const draft = rows<unknown>(await org.get('/v1/blueprint/actions?view=draft')).flatMap(pick)
167
+ // An organization that has never published a Blueprint has no draft to read: no Actions (404).
168
+ const draft = rows<unknown>(await org.find('/v1/blueprint/actions?view=draft')).flatMap(pick)
168
169
  const published = rows<unknown>(await org.get('/v1/blueprint/actions').catch(() => null)).flatMap(pick)
169
170
  return [...new Map([...published, ...draft].map((a) => [a.id, a])).values()]
170
171
  }
@@ -191,8 +192,8 @@ export async function buildDirectory(source: SyncSource, warnings: string[] = []
191
192
  quietly(warnings, 'Blueprint object types', async () => rows<{ id: string; apiName: string }>(await ws.get('/v1/blueprint/object-types')), []),
192
193
  // The model catalog is organization-grain: a workspace key reads its names only, where the platform offers them.
193
194
  source.organization
194
- ? quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await source.organization!.get('/v1/config/models/catalog')), [])
195
- : quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await ws.find<unknown>('/v1/sync/models')), []),
195
+ ? quietly(warnings, 'Models', async () => rows<CatalogModelRow>(await source.organization!.get('/v1/config/models/catalog')), [])
196
+ : quietly(warnings, 'Models', async () => rows<CatalogModelRow>(await ws.find<unknown>('/v1/sync/models')), []),
196
197
  // So are governed Actions' definitions: without the organization key they are not listed.
197
198
  source.organization ? quietly<ActionRow[] | null>(warnings, 'Governed Actions', () => listActions(source.organization!), null) : Promise.resolve(null),
198
199
  // A platform without the route has none to name: absent, as for Actions.
@@ -221,7 +222,28 @@ export async function buildDirectory(source: SyncSource, warnings: string[] = []
221
222
  ...(objectSets ? { objectSet: new Map((objectSets ?? []).map((o) => [setNames.get(o.id)!, o.id])) } : {}),
222
223
  ...(objectDefinitions ? { object: new Map(objectDefinitions.map((o) => [o.slug, o.id])) } : {}),
223
224
  }
224
- return { directory, lists: { agents, workflows, functions, knowledge, apps, skills, objectSets: objectSets ?? [], objectDefinitions } }
225
+ const catalog = models.map((m) => ({ slug: m.slug, name: m.name ?? m.slug, kind: m.kind ?? 'text', ...(m.provider ? { provider: m.provider } : {}), ...(m.modelId ? { modelId: m.modelId } : {}) }))
226
+ return { directory, lists: { agents, workflows, functions, knowledge, apps, skills, objectSets: objectSets ?? [], objectDefinitions, models: catalog } }
227
+ }
228
+
229
+ /** A model of the organization's catalog as the workspace lists it; the provider's model id tells two slugs for one model apart. */
230
+ interface CatalogModelRow {
231
+ id: string
232
+ slug: string
233
+ name?: string
234
+ kind?: string
235
+ provider?: string
236
+ modelId?: string
237
+ }
238
+
239
+ /** A model of the workspace's catalog, kept with the snapshot: what a project's model can be matched against. */
240
+ export interface WorkspaceCatalogModel {
241
+ slug: string
242
+ name: string
243
+ kind: string
244
+ provider?: string
245
+ /** The provider's id for it (`deepseek/deepseek-v4.1-flash`): the same model under another slug has the same one. */
246
+ modelId?: string
225
247
  }
226
248
 
227
249
  export interface Lists {
@@ -235,6 +257,8 @@ export interface Lists {
235
257
  objectSets?: ObjectSetRow[]
236
258
  /** The object definitions an Agent's object tools work on; null when the platform doesn't list them. */
237
259
  objectDefinitions?: ObjectDefinitionRow[] | null
260
+ /** The organization's model catalog, as far as the key reads it. */
261
+ models?: WorkspaceCatalogModel[]
238
262
  }
239
263
 
240
264
  export interface ReadOptions {
@@ -1251,7 +1275,13 @@ async function readBlueprint(
1251
1275
  // The workspace's grants beyond read, from its slice (the organization's model has none of a workspace's).
1252
1276
  let grants: RemoteItem | null = null
1253
1277
  if (mode === 'organization' && source.organization) {
1254
- const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
1278
+ // None (404) for an organization that has never published or drafted a Blueprint: nothing to pull.
1279
+ const found = await source.organization.find<unknown>('/v1/blueprint/lifecycle')
1280
+ if (!found) {
1281
+ notes.push('The organization has no Blueprint yet; nothing was pulled into blueprint/')
1282
+ return []
1283
+ }
1284
+ const lifecycle = one<Lifecycle>(found)
1255
1285
  fromDraft = !!lifecycle.draft?.definition && (!!opts.draft || !lifecycle.activeRelease?.definition)
1256
1286
  answer = { definition: (fromDraft ? lifecycle.draft?.definition : lifecycle.activeRelease?.definition) ?? null, datasetNames: await datasetNames(source.organization) }
1257
1287
  if (fromDraft) notes.push(opts.draft ? 'The Blueprint was read from its unpublished draft, as asked' : 'The Blueprint has no published release; its draft was read')
@@ -1270,7 +1300,8 @@ async function readBlueprint(
1270
1300
  if (opts.draft && !source.organization) notes.push('Reading the Blueprint’s unpublished draft takes an organization key; its published release was read')
1271
1301
  if (opts.draft && source.organization && answer) {
1272
1302
  // The draft, cut to the object types the workspace's slice holds now.
1273
- const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
1303
+ const found = await source.organization.find<unknown>('/v1/blueprint/lifecycle')
1304
+ const lifecycle = found ? one<Lifecycle>(found) : ({} as Lifecycle)
1274
1305
  const granted = new Set(((answer.definition as { objects?: Array<{ apiName: string }> } | null)?.objects ?? []).map((o) => o.apiName))
1275
1306
  if (lifecycle.draft?.definition) {
1276
1307
  answer = { definition: sliceDraft(lifecycle.draft.definition, granted), datasetNames: { ...(answer.datasetNames ?? {}), ...(await datasetNames(source.organization)) } }
@@ -1606,7 +1637,7 @@ async function readEvaluations(source: SyncSource, lists: Lists, directory: Dire
1606
1637
  * collects what could not be read (a kind the key may not see, a platform
1607
1638
  * too old for an export), so the plan says so instead of failing.
1608
1639
  */
1609
- export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}): Promise<{ items: RemoteItem[]; warnings: string[]; notes: string[]; directory: Directory }> {
1640
+ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}): Promise<{ items: RemoteItem[]; warnings: string[]; notes: string[]; directory: Directory; models: WorkspaceCatalogModel[] }> {
1610
1641
  const warnings: string[] = []
1611
1642
  const notes: string[] = []
1612
1643
  const { directory, lists } = await buildDirectory(source, warnings)
@@ -1694,7 +1725,7 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1694
1725
  ? 'Governed Actions are named only with an organization key (they are organization-wide); an Action step shows as an id until you connect one'
1695
1726
  : 'Models and governed Actions are named only with an organization key (both are organization-wide); an LLM step’s model or an Action step shows as an id until you connect one')
1696
1727
  }
1697
- return { items, warnings, notes, directory }
1728
+ return { items, warnings, notes, directory, models: lists.models ?? [] }
1698
1729
  }
1699
1730
 
1700
1731
  /**
@@ -7,7 +7,7 @@ import { writeFileAtomic } from '../fs-atomic'
7
7
  import { hash, itemHash, safePath, SYNC_KINDS, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
8
8
  import { readOrgSettings, type OrgSettings } from './local-org-settings'
9
9
  import type { Portability } from './local-portability'
10
- import { readWorkspace, type SyncSource } from './local-sync-remote'
10
+ import { readWorkspace, type SyncSource, type WorkspaceCatalogModel } from './local-sync-remote'
11
11
  import { REF_KINDS, type Directory, type RefKind, type Unresolved } from './local-sync-refs'
12
12
 
13
13
  /**
@@ -86,6 +86,12 @@ export interface RemoteSnapshot {
86
86
  * now written `action/resetLine`) from a change.
87
87
  */
88
88
  names?: Partial<Record<RefKind, Record<string, string>>>
89
+ /**
90
+ * The organization's model catalog as of the last read, with each model's
91
+ * provider id, so a model this computer names under another slug is known
92
+ * as the workspace's (`local-sync-models.ts`).
93
+ */
94
+ models?: WorkspaceCatalogModel[]
89
95
  /**
90
96
  * Read with an organization key as well: it brings governed Actions (and,
91
97
  * for older platforms, the Blueprint). A snapshot read with other keys is
@@ -423,6 +429,7 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
423
429
  warnings: read.warnings,
424
430
  notes: read.notes,
425
431
  names: namesOf(read.directory),
432
+ ...(read.models.length ? { models: read.models } : {}),
426
433
  organizationKey: !!source.organization,
427
434
  reader: SNAPSHOT_READER,
428
435
  ...(settings ? { settings } : {}),
@@ -522,6 +529,7 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
522
529
  warnings: read?.warnings ?? [],
523
530
  notes: read ? read.notes : previous?.notes ?? [],
524
531
  ...(read ? { names: namesOf(read.directory) } : previous?.names ? { names: previous.names } : {}),
532
+ ...(read?.models.length ? { models: read.models } : previous?.models ? { models: previous.models } : {}),
525
533
  organizationKey: !!source.organization,
526
534
  reader: SNAPSHOT_READER,
527
535
  ...(settings ? { settings } : previous?.settings ? { settings: previous.settings } : {}),