@frontera-sdk/cli 1.52.43 → 1.52.45

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.43",
3
+ "version": "1.52.45",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.52.43",
44
- "@frontera-sdk/functions": "1.52.43",
43
+ "@frontera-sdk/core": "1.52.45",
44
+ "@frontera-sdk/functions": "1.52.45",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.52.43",
52
+ "@frontera-sdk/forge-contracts": "1.52.45",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -44,55 +44,8 @@ export const kitVendor: Command = {
44
44
  requireCompatible()
45
45
 
46
46
  const root = kitRoot(ctx)
47
- const rendered = renderKit()
48
- const lock = readLock(root)
49
47
  const force = flagBool(ctx, 'force')
50
-
51
- const report = writeGenerated(root, rendered.generated, lock, { force })
52
-
53
- const edited: string[] = []
54
- const repaired: string[] = []
55
-
56
- if (rendered.agentsMd !== null) {
57
- const path = join(root, 'AGENTS.md')
58
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
59
- const next = applyManagedBlock(existing, rendered.agentsMd)
60
- if (next !== existing) {
61
- writeFileSync(path, next)
62
- edited.push('AGENTS.md')
63
- }
64
- }
65
-
66
- if (rendered.claudeMd !== null) {
67
- const path = join(root, 'CLAUDE.md')
68
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
69
- const result = applyClaudeImport(existing, rendered.claudeMd)
70
- if (result.content !== existing) {
71
- writeFileSync(path, result.content)
72
- edited.push('CLAUDE.md')
73
- }
74
- if (result.repaired) repaired.push('CLAUDE.md')
75
- }
76
-
77
- // The lock records what WAS written. A conflicted file keeps whatever digest
78
- // it had, so the next run still reports it rather than adopting the local
79
- // edit as ours.
80
- const locked = Object.fromEntries(
81
- Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
82
- )
83
- writeLock(root, locked, cliVersion())
84
-
85
- const data = {
86
- root,
87
- kitVersion: KIT.kitVersion,
88
- hosts: KIT.hosts,
89
- written: report.written,
90
- unchanged: report.unchanged,
91
- conflicts: report.conflicts,
92
- edited,
93
- repaired,
94
- lock: LOCK_FILE,
95
- }
48
+ const { data, report, edited, repaired } = vendorKit(root, { force })
96
49
 
97
50
  const lines = [
98
51
  ...report.written.map((f) => ` + ${f}`),
@@ -117,4 +70,65 @@ export const kitVendor: Command = {
117
70
  },
118
71
  }
119
72
 
73
+ /**
74
+ * Write the kit into `root`: the skills in both host directories, the managed
75
+ * AGENTS.md block, the CLAUDE.md import and the lock.
76
+ *
77
+ * Shared with `frontera local init`, so a project folder Forge opens carries the
78
+ * whole kit, the solution skills included, rather than only the CLI skill.
79
+ */
80
+ export function vendorKit(root: string, opts: { force?: boolean } = {}) {
81
+ const rendered = renderKit()
82
+ const lock = readLock(root)
83
+ const force = opts.force === true
84
+
85
+ const report = writeGenerated(root, rendered.generated, lock, { force })
86
+
87
+ const edited: string[] = []
88
+ const repaired: string[] = []
89
+
90
+ if (rendered.agentsMd !== null) {
91
+ const path = join(root, 'AGENTS.md')
92
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
93
+ const next = applyManagedBlock(existing, rendered.agentsMd)
94
+ if (next !== existing) {
95
+ writeFileSync(path, next)
96
+ edited.push('AGENTS.md')
97
+ }
98
+ }
99
+
100
+ if (rendered.claudeMd !== null) {
101
+ const path = join(root, 'CLAUDE.md')
102
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
103
+ const result = applyClaudeImport(existing, rendered.claudeMd)
104
+ if (result.content !== existing) {
105
+ writeFileSync(path, result.content)
106
+ edited.push('CLAUDE.md')
107
+ }
108
+ if (result.repaired) repaired.push('CLAUDE.md')
109
+ }
110
+
111
+ // The lock records what WAS written. A conflicted file keeps whatever digest
112
+ // it had, so the next run still reports it rather than adopting the local
113
+ // edit as ours.
114
+ const locked = Object.fromEntries(
115
+ Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
116
+ )
117
+ writeLock(root, locked, cliVersion())
118
+
119
+ const data = {
120
+ root,
121
+ kitVersion: KIT.kitVersion,
122
+ hosts: KIT.hosts,
123
+ written: report.written,
124
+ unchanged: report.unchanged,
125
+ conflicts: report.conflicts,
126
+ edited,
127
+ repaired,
128
+ lock: LOCK_FILE,
129
+ }
130
+
131
+ return { data, report, edited, repaired }
132
+ }
133
+
120
134
  export { CliError }
@@ -26,6 +26,7 @@ import { agentFilesProblem, caseConcurrency, findCaseFiles, runCases, selectCase
26
26
  import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState } from '../../local/local-workflow-tests'
27
27
  import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
28
28
  import { withFix } from '../../local/fix-words'
29
+ import { runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
29
30
  import { ensureAppPackages } from '../../project-bootstrap'
30
31
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
31
32
 
@@ -53,8 +54,10 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
53
54
  * case with `realActions` or a `decision`), so after the apply the database
54
55
  * is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
55
56
  * is applied changed), and after the tests it is restored: each check starts
56
- * from the data the project's files make. `--keep-state` keeps what the tests
57
- * wrote, to look at it.
57
+ * from the data the project's files make. The run history (conversations,
58
+ * runs, evaluations) is kept across it (`local-history-carry.ts`), so one
59
+ * check's results can be compared with the last. `--keep-state` keeps what the
60
+ * tests wrote, to look at it.
58
61
  *
59
62
  * Knowledge documents are read by a background job whose work lands in the
60
63
  * database: before the baseline and the tests, the check waits (bounded)
@@ -373,8 +376,13 @@ async function check(ctx: CommandContext) {
373
376
  continue
374
377
  }
375
378
  if (step === 'start') {
376
- if (running(project)) record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
377
- else await command(step, root, self(['local', 'start']), 'frontera local start')
379
+ if (running(project)) {
380
+ // Running, with keys it can't read: every later step would fail on a secret or an Action record.
381
+ const state = readState(project)!
382
+ const keyProblem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`)
383
+ if (keyProblem) record({ step, status: 'failed', note: keyProblem, ms: 0 })
384
+ else record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
385
+ } else await command(step, root, self(['local', 'start']), 'frontera local start')
378
386
  continue
379
387
  }
380
388
  if (step === 'apply') {
@@ -560,10 +568,9 @@ async function check(ctx: CommandContext) {
560
568
  })
561
569
  recordRun(project, results)
562
570
  const bad = results.filter((r) => !r.passed && !r.notRun)
563
- // Each failing case's conversation, last turn's trace and reply, read before the rollback takes the conversation.
571
+ // Each failing case's conversation, last turn's trace and reply, read before the rollback (which keeps the conversation).
564
572
  const evidence = await failingCaseEvidence(project, results)
565
- const rollsBack = baselineReady && !!testData && !testData.kept
566
- for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e, { rolledBack: rollsBack })}`)
573
+ for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e)}`)
567
574
  const notRun = notRunGroups(results)
568
575
  for (const g of notRun) {
569
576
  if (g.skipped) ctx.output.note(` – ${g.skipped} more case${g.skipped === 1 ? '' : 's'} not sent: ${g.what}`)
@@ -604,9 +611,10 @@ async function check(ctx: CommandContext) {
604
611
  const state = readState(project)
605
612
  if (state?.credentials && running(project)) {
606
613
  const restored = await restoreBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
607
- // Traces live outside the platform database: a failing case's trace outlives the rollback, its conversation does not.
614
+ // The run history is kept across the rollback; when it couldn't be, a failing case's conversation went with it (its trace stays: traces live outside the database).
608
615
  const traced = steps.some((st) => st.failures?.some((f) => f.traceId))
609
- testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
616
+ const historyLost = restored.history?.mode === 'keep' && !restored.history.ok
617
+ testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced && historyLost ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
610
618
  ctx.output.note(`── test data: ${testData.note}${restored.ok ? ' (`--keep-state` keeps them)' : ''}`)
611
619
  // The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
612
620
  const ingest = restored.ok ? localIngestApi(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
@@ -59,6 +59,7 @@ import { localSchema } from './schema'
59
59
  import { localSync } from './sync'
60
60
  import { keylessEmbeddingsStatus, KEYLESS_RETRIEVAL_NOTE } from '../../local/local-org-settings'
61
61
  import { withFix } from '../../local/fix-words'
62
+ import { isKeyCustodianProblem, KEY_CUSTODIAN_FIX, runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
62
63
 
63
64
  /**
64
65
  * `frontera local …`: Frontera's runtimes on this machine, for FDEs and the coding agents working with them.
@@ -302,7 +303,8 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
302
303
  if (state.phase !== 'ready') {
303
304
  throw new CliError(`the local runtime did not start: ${state.error ?? state.phase}`, {
304
305
  code: 'LOCAL_START',
305
- hint: 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
306
+ // Keys a restart can't bring back: the error carries its fix, a reset.
307
+ hint: isKeyCustodianProblem(state.error) ? KEY_CUSTODIAN_FIX : 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
306
308
  })
307
309
  }
308
310
  const profile = await registerLocalProfile(project, state)
@@ -411,6 +413,8 @@ async function status(ctx: CommandContext) {
411
413
  const stuck = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await stuckKnowledge(`http://127.0.0.1:${state.ports.service}`, state.credentials, readBaseline(project, state.credentials.organizationId)?.restoredAt) : null) ?? []
412
414
  // What the runtime holds with no file in the project (an Agent whose folder was deleted): said with its fix.
413
415
  const orphans = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await projectOrphans(project, `http://127.0.0.1:${state.ports.service}`, state.credentials).catch(() => null) : null) ?? []
416
+ // Encryption keys the runtime can't read (data keys a hosted OpenBao locked): every secret and governed Action record fails until a reset.
417
+ const keyProblem = state.phase === 'ready' && isAlive(state.supervisorPid) ? await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`) : null
414
418
  return {
415
419
  data: {
416
420
  ...rest,
@@ -431,9 +435,11 @@ async function status(ctx: CommandContext) {
431
435
  ...(stuck.length ? { knowledgeStuck: stuck } : {}),
432
436
  // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
433
437
  ...(orphans.length ? { notInProject: orphans } : {}),
438
+ ...(keyProblem ? { keyCustodian: { problem: keyProblem, fix: KEY_CUSTODIAN_FIX } } : {}),
434
439
  },
435
440
  text: [
436
441
  renderStatus({ ...state, components }, project, undefined, live, setIds, personas, secrets, liveApps ?? null, down),
442
+ ...(keyProblem ? ['', 'Encryption keys', ` ✕ ${keyProblem}`] : []),
437
443
  ...(stuck.length ? ['', 'Knowledge', ...stuck.map((k) => ` warning: ${k.problem}`)] : []),
438
444
  ...(orphans.length ? ['', 'On the runtime, not in the project', ...orphans.map((o) => ` warning: ${orphanLine(o)}`)] : []),
439
445
  ...(devServers.length ? ['', 'App dev servers', ...devServers.map((s) => ` ${devServerLine(s)}`)] : []),
@@ -10,6 +10,9 @@ import { readLink } from '../../local/local-sync-link'
10
10
  import { generateSyntheticData, addMissingBackings, loadScenario, SCENARIO_TEMPLATE } from '../../local/local-synthetic-data'
11
11
  import { isoDay, writeSampleOptions } from '../../local/local-sample-remake'
12
12
  import { migrateMcpConfigs, writeSolutionScaffold } from '../../local/local-scaffold'
13
+ import { checkCompatibility } from '../../kit'
14
+ import { cliVersion } from '../kit/shared'
15
+ import { vendorKit } from '../kit/vendor'
13
16
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
14
17
  import { localSync } from './sync'
15
18
 
@@ -39,6 +42,7 @@ export const localInit: Command = {
39
42
  kinds: 'string',
40
43
  'no-sample-data': 'boolean',
41
44
  'no-app-code': 'boolean',
45
+ 'no-kit': 'boolean',
42
46
  rows: 'string',
43
47
  seed: 'string',
44
48
  },
@@ -80,9 +84,25 @@ export const localInit: Command = {
80
84
  const at = scaffold.skipped.indexOf(file)
81
85
  if (at >= 0) scaffold.skipped.splice(at, 1)
82
86
  }
87
+ // The whole authoring kit, after the scaffold so its managed block lands in
88
+ // the project's AGENTS.md: the solution skills (delivery, Agents, workflows,
89
+ // Functions, Apps, embeds, review) reach every coding agent working here,
90
+ // not only the CLI skill. `--no-kit` leaves it out, for a machine that uses
91
+ // the installed plugin instead (`frontera kit doctor` finds duplicates).
92
+ const kit = { written: [] as string[], skipped: [] as string[] }
93
+ if (!flagBool(ctx, 'no-kit')) {
94
+ const compat = checkCompatibility(cliVersion())
95
+ if (compat.compatible) {
96
+ const vendored = vendorKit(target, { force })
97
+ kit.written.push(...vendored.report.written, ...vendored.edited)
98
+ kit.skipped.push(...vendored.report.conflicts)
99
+ } else {
100
+ ctx.output.note(` the Frontera skills were not added: ${compat.reason}; run \`frontera kit vendor\` after updating the CLI`)
101
+ }
102
+ }
83
103
  const listed = (files: string[]) => files.filter((f) => !f.startsWith('vendor/'))
84
- const written = [...new Set(listed([...scaffold.written, ...harness.written]))]
85
- const skipped = listed([...scaffold.skipped, ...harness.skipped]).filter((f) => !written.includes(f))
104
+ const written = [...new Set(listed([...scaffold.written, ...harness.written, ...kit.written]))]
105
+ const skipped = listed([...scaffold.skipped, ...harness.skipped, ...kit.skipped]).filter((f) => !written.includes(f))
86
106
 
87
107
  let installed: boolean | null = null
88
108
  if (!flagBool(ctx, 'no-install') && scaffold.written.includes('package.json')) {
@@ -1,6 +1,7 @@
1
1
  import { CliError, UsageError } from '../../errors'
2
2
  import { appsLostWords } from '../../local/local-app-restore'
3
3
  import { BASELINE_DB, readBaseline, restoreBaseline, saveBaseline, secretsWords } from '../../local/local-db-snapshot'
4
+ import { historyWords } from '../../local/local-history-carry'
4
5
  import { localIngestApi, settleKnowledge } from '../../local/local-knowledge-ingestion'
5
6
  import { localProject } from '../../local/local-project'
6
7
  import { projectSecretLines } from '../../local/local-project-secrets'
@@ -15,7 +16,8 @@ import { flagBool, type Command } from '../types'
15
16
  * same, by hand: save after setting up data to come back to, restore to throw
16
17
  * away what was written since. The catalog of managed datasets, object
17
18
  * storage and local traces are not part of it. Workspace secrets are
18
- * configuration, not data: a restore keeps them as they are now, unless
19
+ * configuration, not data, and the run history (conversations, runs,
20
+ * evaluations) is what happened: a restore keeps both as they are now, unless
19
21
  * `--exact` puts the baseline's back too.
20
22
  */
21
23
 
@@ -31,6 +33,7 @@ export const localSnapshot: Command = {
31
33
  details: [
32
34
  'save keeps the database as it is now as the baseline, replacing the one before. restore puts the database back as the baseline holds it; the service, job engine and runner keep running and reconnect, and the command returns once the service reaches the database again (it waits up to 30 seconds).',
33
35
  'Workspace secrets are configuration, not data: restore keeps them as they are now (set, changed or deleted since the baseline), and `local check` keeps them across its rollback too. `--exact` puts the baseline\'s secrets back as well.',
36
+ 'The run history is kept the same way: conversations (an Agent test case\'s included), Agent sessions and runs, workflow and Function runs and evaluation runs are as they were before the restore, so results can be compared across runs; the records those runs wrote are rolled back. A history row that names a record the restore took away is left out and counted. `--exact` puts the baseline\'s history back as well.',
34
37
  'Items applied after the baseline count as not applied once it is restored: the next `frontera local dev` applies them again (a running one does so at once).',
35
38
  'Knowledge documents are read by a background job. save waits (up to two minutes) until no document is still being read, so the baseline never holds one half-read; restore queues any document the baseline holds unread to be read again, and waits for it.',
36
39
  ],
@@ -64,7 +67,7 @@ export const localSnapshot: Command = {
64
67
  }
65
68
  const exact = flagBool(ctx, 'exact')
66
69
  const restored = await ctx.output.task('Restoring the database from the baseline', () =>
67
- restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep' }),
70
+ restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep', history: exact ? 'baseline' : 'keep' }),
68
71
  )
69
72
  if (!restored.ok) throw new CliError(restored.note, { code: 'LOCAL_SNAPSHOT_FAILED', hint: 'see `frontera local logs postgres` and `frontera local logs service`' })
70
73
  // The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
@@ -85,6 +88,8 @@ export const localSnapshot: Command = {
85
88
  ...(lost.length ? { appsLost: lost.map((a) => ({ app: a.name, dir: a.dir, deployment: a.lost, baselineDeployment: a.back })) } : {}),
86
89
  // Names and counts only: what became of the workspace secrets.
87
90
  workspaceSecrets: restored.secrets?.mode === 'keep' ? { kept: restored.secrets.ok, count: restored.secrets.kept, setAgain: restored.secrets.notKept, ...(restored.secrets.problem ? { problem: restored.secrets.problem } : {}) } : { kept: false, exact: exact },
91
+ // Counts only: what became of the run history.
92
+ runHistory: restored.history?.mode === 'keep' ? { kept: restored.history.ok, rows: restored.history.rows, notKept: restored.history.notKept, ...(restored.history.problem ? { problem: restored.history.problem } : {}) } : { kept: false, exact },
88
93
  ...(secrets.length ? { secrets } : {}),
89
94
  ...(knowledge.length ? { knowledge: knowledge.map((l) => l.replace(/^warning:\s*/, '')) } : {}),
90
95
  },
@@ -92,6 +97,7 @@ export const localSnapshot: Command = {
92
97
  `Restored the database from the baseline: what was written since is gone (${restored.ms} ms).${items ? ` ${items} item${items === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`.` : ''}`,
93
98
  ...(lost.length ? [` ${appsLostWords(lost).replace(/^./, (c) => c.toUpperCase())}.`] : []),
94
99
  ...(restored.secrets ? [` ${secretsWords(restored.secrets).replace(/^./, (c) => c.toUpperCase())}${restored.secrets.mode === 'keep' && restored.secrets.ok && !restored.secrets.notKept.length ? ': secrets are configuration, not test data (`--exact` restores the baseline’s).' : '.'}`] : []),
100
+ ...(restored.history ? [` ${historyWords(restored.history).replace(/^./, (c) => c.toUpperCase())}.`] : []),
95
101
  ...secrets.map((l) => ` warning: ${l}`),
96
102
  ...knowledge.map((l) => ` ${l}`),
97
103
  ].join('\n'),
@@ -12,8 +12,10 @@ import { adoptProposedLessons } from '../../local/local-lessons'
12
12
  import { localProject, type LocalProject } from '../../local/local-project'
13
13
  import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
14
14
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
15
- import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
15
+ import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
16
16
  import { itemTitle } from '../../local/local-sync-names'
17
+ import { checkBody, markMissingModels, markRefused, type WorkspaceModel } from '../../local/local-sync-precheck'
18
+ import { rows } from '../../local/local-sync-remote'
17
19
  import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
18
20
  import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
19
21
  import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
@@ -330,6 +332,9 @@ function itemJson(item: PushItem) {
330
332
  proposable: item.proposable,
331
333
  blocked: item.blocked,
332
334
  reason: item.reason,
335
+ ...(item.fix ? { fix: item.fix } : {}),
336
+ ...(item.sample ? { sample: true } : {}),
337
+ ...(item.missingModels?.length ? { missingModels: item.missingModels } : {}),
333
338
  files: item.diff,
334
339
  notes: item.notes,
335
340
  ...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes, change: a.change })) } : {}),
@@ -404,8 +409,41 @@ async function deletionUsers(project: LocalProject, link: SyncLink, items: PushI
404
409
  }
405
410
  }
406
411
 
412
+ /**
413
+ * The workspace's checks for what would be proposed, run before anything is
414
+ * sent (`POST /v1/change-requests/check`); each item they refuse is blocked
415
+ * with its reason and fix. A platform without the check leaves them to
416
+ * Propose: `checked` says which.
417
+ */
418
+ async function precheck(api: Platform, items: PushItem[]): Promise<{ checked: boolean; refused: string[]; problem?: string }> {
419
+ const body = checkBody(items)
420
+ if (!body.length) return { checked: true, refused: [] }
421
+ try {
422
+ const answer = await api.call<{ checks?: unknown[] }>('POST', '/v1/change-requests/check', { items: body }, { timeoutMs: 120_000 })
423
+ return { checked: true, refused: markRefused(items, checksOf(answer)) }
424
+ } catch (err) {
425
+ if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
426
+ // An older platform has no check; a refusal of the check itself names the items as the push would.
427
+ if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)) }
428
+ return { checked: false, refused: [] }
429
+ }
430
+ return { checked: false, refused: [], problem: (err as Error).message.split('\n')[0] }
431
+ }
432
+ }
433
+
434
+ /** The workspace's chat models, offered in place of one only this computer has; empty when the platform can't list them. */
435
+ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
436
+ try {
437
+ const answer = await api.call<unknown>('GET', '/v1/sync/models')
438
+ return rows<{ slug?: string; name?: string; kind?: string }>(answer).flatMap((m) => (m.slug && (m.kind ?? 'text') === 'text' ? [{ slug: m.slug, name: m.name ?? m.slug, kind: m.kind ?? 'text' }] : []))
439
+ } catch {
440
+ return []
441
+ }
442
+ }
443
+
407
444
  function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
408
- const ok = items.filter((i) => i.proposable)
445
+ const ok = items.filter((i) => i.proposable && !i.sample)
446
+ const samples = items.filter((i) => i.proposable && i.sample)
409
447
  const not = items.filter((i) => !i.proposable)
410
448
  const name = link.workspaceName ?? link.workspace
411
449
  const lines = [
@@ -413,7 +451,8 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
413
451
  // Each item's notes under it.
414
452
  ...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
415
453
  ]
416
- if (not.length) lines.push('', 'Not proposed:', ...not.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`))
454
+ if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
455
+ if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
417
456
  if (orgNote) lines.push('', orgNote)
418
457
  if (!available) lines.push('', 'This platform does not take change requests yet.')
419
458
  if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
@@ -443,6 +482,36 @@ async function releaseItems(api: Platform, asked: Array<{ slug: string; deployme
443
482
  return out
444
483
  }
445
484
 
485
+ /**
486
+ * `frontera local sync map-model <local model> <workspace model>`: the
487
+ * workspace's model a model only this computer has goes as when proposed
488
+ * (`sync.models` in frontera.yaml); the files keep naming the local one.
489
+ * `--remove` stops mapping it; with no model, the mappings are listed.
490
+ */
491
+ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
492
+ const project = localProject(ctx.cwd)
493
+ const link = needLink(project)
494
+ const [local, workspace] = ctx.positional.slice(1)
495
+ const name = link.workspaceName ?? link.workspace
496
+ if (!local) {
497
+ const models = link.models ?? {}
498
+ const lines = Object.entries(models).map(([from, to]) => ` ${from} → ${to}`)
499
+ return { data: { models }, text: lines.length ? [`Models proposed to ${name} as the workspace’s:`, ...lines].join('\n') : `No models mapped. \`frontera local sync push --preview\` names any model ${name} doesn’t have.` }
500
+ }
501
+ if (flagBool(ctx, 'remove')) {
502
+ setModelMapping(project.root, local, null)
503
+ return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
504
+ }
505
+ if (!workspace) throw new UsageError('name the workspace model it goes as', `frontera local sync map-model ${local} <workspace model>; \`push --preview\` lists ${name}’s models`)
506
+ const offered = await workspaceModels(platformFor(project, link))
507
+ if (offered.length && !offered.some((m) => m.slug === workspace)) {
508
+ throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
509
+ }
510
+ setModelMapping(project.root, local, workspace)
511
+ const label = offered.find((m) => m.slug === workspace)?.name ?? workspace
512
+ return { data: { local, workspace }, text: `${local} goes to ${name} as ${label} when proposed; the project keeps using ${local} here (sync.models in frontera.yaml).` }
513
+ }
514
+
446
515
  export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
447
516
  const project = localProject(ctx.cwd)
448
517
  const link = needLink(project)
@@ -462,6 +531,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
462
531
  ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
463
532
  : undefined
464
533
  const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
534
+ // Before anything is sent: models only this computer has, then the workspace's own checks.
535
+ const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
536
+ const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
537
+ const api = platformFor(project, link)
538
+ const checked = available ? await precheck(api, all) : { checked: false, refused: [] as string[] }
465
539
  const kept = currentRequest(readRequests(project.root, link.workspace))
466
540
  const updating = kept && isOpen(kept.state) ? kept : null
467
541
  const orgStored = !!getSyncKey(project, 'organization')
@@ -474,6 +548,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
474
548
  if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, 'list what can be proposed with `frontera local sync push --preview`')
475
549
  chosen = named.map((r) => byRef.get(r)!)
476
550
  } else chosen = all
551
+ // Samples from setting up the project go only when named.
552
+ const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
477
553
  const orgNote = needsOrganizationKey(chosen) ? (orgStored ? 'Blueprint changes are applied with the organization key stored for this project.' : 'Blueprint changes need the organization key to apply: connect again with `--org-key -` before applying.') : null
478
554
 
479
555
  if (preview) {
@@ -484,7 +560,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
484
560
  available,
485
561
  request: updating ? { id: updating.id, title: updating.title, state: updating.state, items: updating.refs.length, refs: updating.refs } : null,
486
562
  items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
487
- counts: { proposable: chosen.filter((i) => i.proposable).length, blocked: chosen.filter((i) => !i.proposable).length },
563
+ counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
564
+ // Whether the workspace's checks ran before proposing; a platform without them checks at Propose.
565
+ checked: checked.checked,
566
+ ...(('problem' in checked && checked.problem) ? { checkProblem: checked.problem } : {}),
567
+ models: {
568
+ missing: [...missing].map(([slug, refs]) => ({ slug, refs })),
569
+ workspace: missing.size ? await workspaceModels(api) : [],
570
+ mapped: link.models ?? {},
571
+ },
488
572
  organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
489
573
  fetchedAt: snapshot.fetchedAt,
490
574
  },
@@ -503,12 +587,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
503
587
  details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
504
588
  })
505
589
  }
506
- const going = chosen.filter((i) => i.proposable)
590
+ const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
507
591
  const releaseAsks = parseReleases(flagString(ctx, 'release'))
508
592
  if (!going.length && !releaseAsks.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
509
593
  const title = flagString(ctx, 'title')?.trim()
510
594
  const description = flagString(ctx, 'description')
511
- const api = platformFor(project, link)
512
595
  // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
513
596
  let target = updating
514
597
  if (target) {
@@ -554,14 +637,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
554
637
  request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
555
638
  pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
556
639
  releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
557
- notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
640
+ notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
641
+ samples: leftSamples.map((i) => i.ref),
558
642
  organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
559
643
  },
560
644
  text: [
561
645
  `${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
562
646
  ...going.map((i) => ` ${pushItemLine(i)}`),
563
647
  ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
564
- ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)] : []),
648
+ ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
649
+ ...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
565
650
  ...(orgNote ? ['', orgNote] : []),
566
651
  '',
567
652
  'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
@@ -812,7 +897,8 @@ async function apply(ctx: CommandContext) {
812
897
  if (force && !reason) throw new UsageError('--force needs --reason', 'frontera local sync request apply --force --reason "why it applies without its required checks"')
813
898
  let answer: View
814
899
  try {
815
- answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
900
+ // The key in the body: the platform's edge strips `x-frontera-*` headers a client sends. The header stays for an older platform.
901
+ answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
816
902
  } catch (err) {
817
903
  if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
818
904
  const gate = (err.details as { gate?: Gate } | undefined)?.gate