@frontera-sdk/cli 1.53.5 → 1.53.7

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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
@@ -12,7 +12,7 @@ import { appPulledAsIs, keepBuildWrittenFiles } from '../../local/local-app-pull
12
12
  import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
13
13
  import { repullRefFor } from '../../local/local-app-code'
14
14
  import { customAppFolders } from '../../local/local-apps'
15
- import { readBaseline, restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
15
+ import { endCheckBaseline, readBaseline, restoreBaseline, restoreCutOffCheck, saveBaseline } from '../../local/local-db-snapshot'
16
16
  import { localIngestApi, settleKnowledge, type KnowledgeSettle } from '../../local/local-knowledge-ingestion'
17
17
  import { localProject, type LocalProject } from '../../local/local-project'
18
18
  import { projectSecretLines } from '../../local/local-project-secrets'
@@ -53,12 +53,12 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
53
53
  *
54
54
  * The tests write for real (a workflow test always does, as does an Agent
55
55
  * case with `realActions` or a `decision`), so after the apply the database
56
- * is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
57
- * is applied changed), and after the tests it is restored: each check starts
58
- * from the data the project's files make. The run history (conversations,
59
- * runs, evaluations) is kept across it (`local-history-carry.ts`), so one
60
- * check's results can be compared with the last. `--keep-state` keeps what the
61
- * tests wrote, to look at it.
56
+ * is kept as a baseline (`local-db-snapshot.ts`, a new copy every check), and
57
+ * after the tests it is restored: the tests' writes are rolled back, and what
58
+ * was written before the check (a person's sign-up, an App's records) stays.
59
+ * The run history (conversations, runs, evaluations) is kept across it
60
+ * (`local-history-carry.ts`), so one check's results can be compared with the
61
+ * last. `--keep-state` keeps what the tests wrote, to look at it.
62
62
  *
63
63
  * Knowledge documents are read by a background job whose work lands in the
64
64
  * database: before the baseline and the tests, the check waits (bounded)
@@ -104,10 +104,8 @@ export function blockedBy(step: CheckStepName, failed: ReadonlySet<CheckStepName
104
104
  /** What `--skip` may leave out: the runtime and the apply are the gate itself. */
105
105
  const SKIPPABLE: readonly CheckStepName[] = ['typecheck', 'unit', 'apps', 'agents', 'workflows']
106
106
 
107
- /** What became of the tests' writes: rolled back to the applied baseline, kept, or not rolled back and why. */
107
+ /** What became of the tests' writes: rolled back to the baseline taken before them, kept, or not rolled back and why. */
108
108
  export interface CheckTestData {
109
- /** taken: a new baseline after this apply; reused: the stored one is of this build. */
110
- baseline?: 'taken' | 'reused'
111
109
  rolledBack: boolean
112
110
  /** --keep-state */
113
111
  kept?: boolean
@@ -406,6 +404,13 @@ async function check(ctx: CommandContext) {
406
404
  continue
407
405
  }
408
406
  if (step === 'apply') {
407
+ // A check cut off before its rollback left its tests' writes: its baseline goes back before this apply (within the hour).
408
+ const before = readState(project)
409
+ if (before?.credentials && running(project)) {
410
+ const cut = await restoreCutOffCheck(project, before.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${before.ports.service}`) })
411
+ if (cut?.left) ctx.output.note(`── test data: ${cut.note}`)
412
+ else if (cut) ctx.output.note(`── test data: the last \`frontera local check\` stopped before it rolled back its tests’ writes, ${cut.ok ? 'so that is done first' : 'and they couldn’t be rolled back now'}: ${cut.note}`)
413
+ }
409
414
  say(step, 'frontera local dev --once')
410
415
  // Which Agents serve customers: an operating.json leaves an Agent out of the built-in Chat (X4).
411
416
  if (customerFacing.length) ctx.output.note(` note: ${customerFacingWords(customerFacing)}`)
@@ -448,9 +453,9 @@ async function check(ctx: CommandContext) {
448
453
  if (state?.credentials && running(project) && testsWillRun()) {
449
454
  if (keepState) testData = { rolledBack: false, kept: true, note: 'kept what the tests write (--keep-state)' }
450
455
  else {
451
- const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
456
+ const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, 'check')
452
457
  baselineReady = saved.ok
453
- if (saved.ok) testData = { baseline: saved.took ? 'taken' : 'reused', rolledBack: false, note: saved.note }
458
+ if (saved.ok) testData = { rolledBack: false, note: saved.note }
454
459
  else testData = { rolledBack: false, note: `not rolled back: ${saved.note}` }
455
460
  ctx.output.note(` test data: ${saved.ok ? saved.note : `couldn’t keep a baseline, so what the tests write stays: ${saved.note}`}`)
456
461
  }
@@ -666,7 +671,10 @@ async function check(ctx: CommandContext) {
666
671
  secretNotes.push(...lost)
667
672
  }
668
673
  }
669
- } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); the next `frontera local check` rolls them back')
674
+ } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); they stay: the next `frontera local check` rolls back only its own tests’ writes')
675
+ // A baseline taken for tests that then did not run holds nothing of theirs to put back.
676
+ const ended = readState(project)
677
+ if (baselineReady && !testsRan && ended?.credentials) endCheckBaseline(project, ended.credentials.organizationId)
670
678
  const testDataField = { ...(testData && testsRan ? { testData } : {}), ...(customerFacing.length ? { customerFacing } : {}) }
671
679
 
672
680
  if (flagBool(ctx, 'stop')) {
@@ -114,7 +114,7 @@ async function loadCommand(ctx: Parameters<Command['run']>[0]) {
114
114
  const organizationId = state.credentials.organizationId
115
115
  const snapshot = !flagBool(ctx, 'no-snapshot')
116
116
  if (snapshot) {
117
- const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, true))
117
+ const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, 'data'))
118
118
  if (!saved.ok) throw new CliError(`the records could not be saved first, so nothing was loaded: ${saved.note}`, { code: 'LOCAL_SNAPSHOT_FAILED', hint: '`--no-snapshot` loads without saving them' })
119
119
  }
120
120
  const persona = await actAsLocalPersona({ meta: { noun: 'action', verb: 'submit' }, cwd: ctx.cwd, apiUrl: serviceUrl, wanted: 'submitter' })
@@ -29,7 +29,7 @@ import {
29
29
  type SyncProfiles,
30
30
  type SyncTarget,
31
31
  } from '../../local/local-dev-sync'
32
- import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
32
+ import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
33
33
  import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
34
34
  import { applyTheme, themeCalls } from '../../local/local-themes'
35
35
  import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
@@ -421,8 +421,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
421
421
  if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
422
422
  }
423
423
  let authoredAgain: string[] = []
424
- // Object types whose editable fields were declared for their Actions below.
425
- const editableDone = new Set<string>()
426
424
  if (actionCalls && blueprint) {
427
425
  const names = blueprint.actions.map((a) => a.apiName)
428
426
  // A decision pins the Action's contract, its object type's fields included, and a first
@@ -431,14 +429,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
431
429
  const again = await runCli(project, command, opts.auth)
432
430
  if (!again.ok) return again
433
431
  }
434
- // What each Action may write (`backing.editable`): `blueprint apply` leaves backings to `bind`.
435
- const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
436
- const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
437
- for (const [type, props] of editableSets(appliedDir, subjects)) {
438
- editableDone.add(type)
439
- const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
440
- if (!set.ok && !/no change|already|unchanged/i.test(set.output)) notes = [notes, ` warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}`].filter(Boolean).join('\n')
441
- }
442
432
  // An Action whose contract moved after its decision here blocks the publish, and a decision
443
433
  // cannot follow a new contract. On this runtime it is authored again under a new id: out of
444
434
  // the draft (`--prune` over a copy without it), then back from the files.
@@ -462,16 +452,6 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
462
452
  const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
463
453
  if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
464
454
  }
465
- // Every other type's `backing.editable` too, before the publish: an editable type's rows carry
466
- // their record version, which an Action on it (one to come, or an App's) submits.
467
- if (blueprint && opts.publish) {
468
- const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
469
- for (const [type, props] of editableSets(appliedDir, null)) {
470
- if (editableDone.has(type)) continue
471
- const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
472
- if (!set.ok && !/no change|already|unchanged/i.test(set.output)) result = { ...result, output: `${result.output}\n warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}` }
473
- }
474
- }
475
455
  const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
476
456
  // The workspace's grants beyond read (`blueprint/workspace-grants.yaml`); a file that is wrong fails the Blueprint, said by line.
477
457
  const workspaceGrants = target.kind === 'blueprint' ? readWorkspaceGrants(project.root) : null
@@ -489,7 +489,7 @@ async function status(ctx: CommandContext) {
489
489
  docs: docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource }),
490
490
  notices: projectNotices(project.root),
491
491
  // The App dev servers running against this runtime, with their ports: `frontera local stop` stops them.
492
- appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner ? { ownerPid: s.owner.pid } : {}) })),
492
+ appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner && 'pid' in s.owner ? { ownerPid: s.owner.pid } : {}) })),
493
493
  // Knowledge documents pending or processing with no ingestion behind them, each with its fix.
494
494
  ...(stuck.length ? { knowledgeStuck: stuck } : {}),
495
495
  // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
@@ -31,7 +31,7 @@ export const localSnapshot: Command = {
31
31
  flags: { exact: 'boolean' },
32
32
  summary: 'Save the local database as a baseline, or restore it, throwing away what was written since (local check does this around its tests)',
33
33
  details: [
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).',
34
+ 'save keeps the database as it is now as the baseline, replacing the one before. `local check` replaces it the same way just before its tests, so a restore after a check goes back to the database as it was before that check’s tests. 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).',
35
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
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.',
37
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).',
@@ -57,7 +57,7 @@ export const localSnapshot: Command = {
57
57
  if (action === 'save') {
58
58
  // A document mid-read would be kept unread, its reading lost on a restore: the save waits (bounded) for it.
59
59
  const settled = ingest ? await settleKnowledge(ingest, state.credentials.workspaceId, { restoredAt: readBaseline(project, organizationId)?.restoredAt }) : null
60
- const saved = await ctx.output.task('Saving the database as the baseline', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, true))
60
+ const saved = await ctx.output.task('Saving the database as the baseline', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, 'snapshot'))
61
61
  if (!saved.ok) throw new CliError(saved.note, { code: 'LOCAL_SNAPSHOT_FAILED', hint: 'see `frontera local logs postgres`' })
62
62
  const knowledge = settled?.lines ?? []
63
63
  return {
@@ -1,6 +1,6 @@
1
1
  import { UsageError } from '../../errors'
2
2
  import { FRONTERA_READ_ONLY_COMMANDS } from '../../forge/permission-rules'
3
- import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, type PersonStep } from '../../local/local-person-steps'
3
+ import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, currentTarget, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, staleProblem, type PersonStep, type StepTarget } from '../../local/local-person-steps'
4
4
  import { runSelf } from '../../mcp/mcp-cli'
5
5
  import { localProject } from '../../local/local-project'
6
6
  import { flagBool, flagString, type Command } from '../types'
@@ -15,11 +15,15 @@ import { flagBool, flagString, type Command } from '../types'
15
15
 
16
16
  const ACTIONS = ['list', 'add', 'done', 'dismiss', 'reopen', 'check', 'clear'] as const
17
17
 
18
- function stepLines(steps: PersonStep[]): string[] {
19
- return steps.flatMap((s, n) => [
20
- ` ${n + 1}. ${s.command ?? `${s.changes} (in the product)`}${s.check ? ' · checks itself' : ''}`,
21
- ` ${s.why} Changes: ${s.changes} Needs ${CREDENTIAL_WORDS[s.credential] ?? s.credential}. (${s.id})`,
22
- ])
18
+ function stepLines(steps: PersonStep[], current: StepTarget | null): string[] {
19
+ return steps.flatMap((s, n) => {
20
+ const stale = staleProblem(s, current)
21
+ return [
22
+ ` ${n + 1}. ${s.command ?? `${s.changes} (in the product)`}${s.check ? ' · checks itself' : ''}`,
23
+ ` ${s.why} Changes: ${s.changes} Needs ${CREDENTIAL_WORDS[s.credential] ?? s.credential}. (${s.id})`,
24
+ ...(stale ? [` ! Don't run it: ${stale}.`] : []),
25
+ ]
26
+ })
23
27
  }
24
28
 
25
29
  export const localSteps: Command = {
@@ -97,7 +101,7 @@ export const localSteps: Command = {
97
101
  const groups = deliveryGroups(steps)
98
102
  const settled = steps.filter((s) => s.status !== 'pending')
99
103
  const lines = groups.length
100
- ? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps), ''])
104
+ ? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps, currentTarget(project.root)), ''])
101
105
  : ['No steps wait on the person.', '']
102
106
  if (flagBool(ctx, 'all') && settled.length) lines.push('Settled:', ...settled.map((s) => ` ${s.status === 'done' ? '✓' : '–'} ${s.command ?? s.changes} (${s.id})`))
103
107
  return { data: { pending: groups, ...(flagBool(ctx, 'all') ? { settled } : {}) }, text: lines.join('\n').trimEnd() }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
+ import { signInStep } from '../../device-login'
4
5
  import { CliError, UsageError } from '../../errors'
5
6
  import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
6
7
  import { assetFilename } from '../skill/bundle-commands'
@@ -8,10 +9,11 @@ import { packFunctionSource } from '../../pack'
8
9
  import { createTarGz } from '../../tar'
9
10
  import { functionEntry } from '../../local/local-dev-sync'
10
11
  import { adoptProposedLessons } from '../../local/local-lessons'
12
+ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
11
13
  import { localProject, type LocalProject } from '../../local/local-project'
12
14
  import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
13
15
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
14
- import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
16
+ import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, type SyncLink } from '../../local/local-sync-link'
15
17
  import { itemTitle } from '../../local/local-sync-names'
16
18
  import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
17
19
  import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
@@ -27,6 +29,7 @@ import {
27
29
  checksOf,
28
30
  currentRequest,
29
31
  describeOutcome,
32
+ failedName,
30
33
  functionEntryOf,
31
34
  isOpen,
32
35
  itemLine,
@@ -463,13 +466,15 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
463
466
  }
464
467
  }
465
468
 
466
- function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = []): string {
469
+ function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = [], notCarried: string | null = null): string {
467
470
  const ok = items.filter((i) => i.proposable && !i.sample)
468
471
  const samples = items.filter((i) => i.proposable && i.sample)
469
472
  const not = items.filter((i) => !i.proposable)
470
473
  const name = link.workspaceName ?? link.workspace
471
474
  const lines = [
472
- ok.length ? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:` : `Nothing to propose to ${name}.`,
475
+ ok.length
476
+ ? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:`
477
+ : not.length ? `Nothing to propose to ${name}: ${plural(not.length, 'change')} here cannot go yet, each for the reason below.` : `Nothing to propose to ${name}.`,
473
478
  // Each item's notes under it.
474
479
  ...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}`)]),
475
480
  ]
@@ -480,6 +485,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
480
485
  if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
481
486
  if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
482
487
  if (orgNote) lines.push('', orgNote)
488
+ if (notCarried) lines.push('', notCarried)
483
489
  if (!available) lines.push('', 'This platform does not take change requests yet.')
484
490
  if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
485
491
  return lines.join('\n')
@@ -567,11 +573,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
567
573
  const updating = kept && isOpen(kept.state) ? kept : null
568
574
  const orgStored = !!getSyncKey(project, 'organization')
569
575
  const titles = Object.fromEntries(all.map((i) => [i.ref, i.title]))
576
+ const mcp = mcpServersNotCarried(project.root)
577
+ const notCarried = mcp ? { mcpServers: mcp.files, save: mcp.command } : null
570
578
 
571
579
  let chosen: PushItem[]
572
580
  if (named.length) {
573
581
  const byRef = new Map(all.map((i) => [i.ref, i]))
574
582
  const unknown = named.filter((r) => !byRef.has(r))
583
+ const servers = unknown.filter((r) => r.startsWith('mcp-server'))
584
+ if (mcp && servers.length) throw new UsageError(`a change request does not carry ${servers.join(', ')}`, mcp.line)
575
585
  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`')
576
586
  chosen = named.map((r) => byRef.get(r)!)
577
587
  } else chosen = all
@@ -600,9 +610,10 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
600
610
  // Apply's steps in order, and the ones the project's credentials can't take (N3).
601
611
  applyPlan: checked.plan,
602
612
  needsPerson: person,
613
+ notCarried,
603
614
  fetchedAt: snapshot.fetchedAt,
604
615
  },
605
- text: previewText(link, chosen, updating, orgNote, available, usedBy, person),
616
+ text: previewText(link, chosen, updating, orgNote, available, usedBy, person, mcp?.line),
606
617
  }
607
618
  }
608
619
 
@@ -619,7 +630,21 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
619
630
  }
620
631
  const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
621
632
  const releaseAsks = parseReleases(flagString(ctx, 'release'))
622
- 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' })
633
+ if (!going.length && !releaseAsks.length) {
634
+ // Changes the checks refused are changes still: say why each can't go, not that nothing changed.
635
+ const held = chosen.filter((i) => !i.proposable && !i.sample)
636
+ if (held.length) {
637
+ throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
638
+ code: 'SYNC_PUSH_BLOCKED',
639
+ hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`frontera local sync push --preview\``,
640
+ details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
641
+ })
642
+ }
643
+ throw new CliError(`nothing to propose: nothing ${mcp ? 'a change request carries ' : ''}changed here since the last pull`, {
644
+ code: 'SYNC_NOTHING_TO_PUSH',
645
+ hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
646
+ })
647
+ }
623
648
  const title = flagString(ctx, 'title')?.trim()
624
649
  const description = flagString(ctx, 'description')
625
650
  // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
@@ -671,6 +696,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
671
696
  notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
672
697
  samples: leftSamples.map((i) => i.ref),
673
698
  organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
699
+ notCarried,
674
700
  },
675
701
  text: [
676
702
  `${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
@@ -679,6 +705,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
679
705
  ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
680
706
  ...(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`] : []),
681
707
  ...(orgNote ? ['', orgNote] : []),
708
+ ...(mcp ? ['', mcp.line] : []),
682
709
  '',
683
710
  'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
684
711
  ].join('\n'),
@@ -957,7 +984,7 @@ async function apply(ctx: CommandContext) {
957
984
  throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
958
985
  code: 'SYNC_APPLY_FAILED',
959
986
  hint: /migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : 'once what failed is fixed, apply again (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)',
960
- details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: allTitles[outcome.failure.ref] ?? outcome.failure.ref, message: outcome.failure.message } : null },
987
+ details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
961
988
  })
962
989
  }
963
990
  // The workspace now has the applied versions: fetch, and move each applied item's base to them.
@@ -980,7 +1007,13 @@ async function apply(ctx: CommandContext) {
980
1007
  // The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
981
1008
  recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
982
1009
  // What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
983
- const pending = recordAfterApply(project.root, answer.request.title, afterApplyOf(answer.outcome))
1010
+ const after = afterApplyOf(answer.outcome)
1011
+ // The steps change the workspace the request went to, so they name it: the folder is bound to its local runtime.
1012
+ const { target, targetError } = after.length
1013
+ ? await registerTargetProfile(project, link).then((name) => ({ target: name, targetError: null }), (err: Error) => ({ target: null, targetError: err.message }))
1014
+ : { target: null, targetError: null }
1015
+ const pending = recordAfterApply(project.root, answer.request.title, after, target ? { profile: target, target: targetOf(link) } : null)
1016
+ const signIn = target && pending.length ? await signInStep({ profile: target, apiUrl: link.platform, named: true }) : null
984
1017
  return {
985
1018
  data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
986
1019
  text: [
@@ -989,7 +1022,11 @@ async function apply(ctx: CommandContext) {
989
1022
  '',
990
1023
  fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
991
1024
  ...(pending.length
992
- ? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`), 'Forge lists them on the project’s home; `frontera local steps` here.']
1025
+ ? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
1026
+ target
1027
+ ? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (this project's keys), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
1028
+ : `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${targetError ? ` (${targetError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
1029
+ 'Forge lists them on the project’s home; `frontera local steps` here.']
993
1030
  : []),
994
1031
  ].join('\n'),
995
1032
  }
@@ -11,7 +11,7 @@ import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/l
11
11
  import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
12
12
  import { localProject, type LocalProject } from '../../local/local-project'
13
13
  import { normalizeOrigin, syncClient, type SyncClient } from '../../local/local-sync-client'
14
- import { getSyncKey, projectFile, readLink, removeSyncKey, setSyncKey, writeLink, type SyncLink } from '../../local/local-sync-link'
14
+ import { getSyncKey, projectFile, projectKeyFingerprints, readLink, registerTargetProfile, removeSyncKey, removeTargetProfile, setSyncKey, targetProfileName, writeLink, type SyncLink } from '../../local/local-sync-link'
15
15
  import { describeRepair, repairBases, type BaseRepair } from '../../local/local-sync-base'
16
16
  import { conflictsId, mergeItemFiles, type Choice, type Conflict, type Side } from '../../local/local-sync-merge'
17
17
  import {
@@ -38,6 +38,7 @@ import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
38
38
  import { rememberLocalCatalog } from '../../local/local-sync-models'
39
39
  import { runningTarget, runtimeCatalog } from '../../local/local-providers'
40
40
  import { buildStatusText, projectBuildSummary } from '../../local/local-build-summary'
41
+ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
41
42
  import { kindTitle } from '../../local/local-sync-names'
42
43
  import { describeUnresolved } from '../../local/local-sync-refs'
43
44
  import { blockers, type Blocker, type Need } from '../../local/local-portability'
@@ -433,11 +434,13 @@ const connect = async (ctx: CommandContext) => {
433
434
  }
434
435
  const blueprintFlag = flagString(ctx, 'blueprint')
435
436
  if (blueprintFlag && blueprintFlag !== 'workspace' && blueprintFlag !== 'organization') throw new UsageError('--blueprint is workspace or organization', '`--blueprint organization` reads the whole model and needs an organization key')
437
+ // The stored organization key belongs to the previous link's platform and organization: kept only while both stay.
438
+ const keepsOrganizationKey = !keys.organization && previous?.platform === platform && previous.organization === me.orgId
436
439
  const link: SyncLink = {
437
440
  platform,
438
441
  workspace: me.workspaceId!,
439
442
  workspaceName: identity?.workspace.name ?? previous?.workspaceName,
440
- organization: keys.organization ? me.orgId! : previous?.organization,
443
+ organization: keys.organization ? me.orgId! : keepsOrganizationKey ? previous!.organization : undefined,
441
444
  organizationName: identity?.organization.name ?? previous?.organizationName,
442
445
  blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
443
446
  // The models mapped for this workspace stay; another workspace has its own catalog.
@@ -445,14 +448,26 @@ const connect = async (ctx: CommandContext) => {
445
448
  }
446
449
  // A folder that is not a project yet gets the project file the link lives in.
447
450
  if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
451
+ // The previous target's profile goes with the keys it was made from.
452
+ if (previous && targetProfileName(project, previous) !== targetProfileName(project, link)) await removeTargetProfile(project, previous)
453
+ // Read before the keys are replaced, so a profile holding the key a rotation replaces is still the project's.
454
+ const previousKeys = projectKeyFingerprints(project)
448
455
  setSyncKey(project, 'workspace', keys.workspace)
449
456
  if (keys.organization) setSyncKey(project, 'organization', keys.organization)
457
+ else if (!keepsOrganizationKey) removeSyncKey(project, 'organization')
450
458
  writeLink(project.root, link)
459
+ // The workspace as a CLI profile: a change request's steps for a person name it (`--profile`).
460
+ const { profile, profileError } = await registerTargetProfile(project, link, previousKeys).then(
461
+ (name) => ({ profile: name, profileError: null }),
462
+ (err: Error) => ({ profile: null, profileError: err.message }),
463
+ )
451
464
  return {
452
- data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) } },
465
+ data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) }, profile },
453
466
  text: [
454
467
  `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(platform).host}.`,
455
468
  'Keys are in your keychain; the link is in frontera.yaml.',
469
+ ...(profile ? [`A change request's steps for a person reach this platform with \`--profile ${profile}\`, not the local runtime.`] : []),
470
+ ...(profileError ? [`No CLI profile was recorded for this platform (${profileError}); a change request's steps for a person name none until one is.`] : []),
456
471
  'See what you can pull: `frontera local sync pull --preview`.',
457
472
  ].join('\n'),
458
473
  }
@@ -529,9 +544,10 @@ const status = async (ctx: CommandContext) => {
529
544
  const kept = currentRequest(readRequests(project.root, link.workspace))
530
545
  // How the last `local dev` left the project, counted as Forge's home counts it.
531
546
  const build = projectBuildSummary(project.root, project.id)
547
+ const mcp = mcpServersNotCarried(project.root)
532
548
  const request = kept ? `${kept.state === 'merged' ? 'Applied' : kept.applied && !kept.applied.ok && kept.state !== 'closed' ? 'Apply failed' : kept.state === 'closed' ? 'Closed' : 'Proposed'} · ${plural(kept.refs.length, 'item')} · ${kept.title}` : null
533
549
  return {
534
- data: { link, keys, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build },
550
+ data: { link, keys, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build, notCarried: mcp ? { mcpServers: mcp.files, save: mcp.command } : null },
535
551
  text: [
536
552
  `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(link.platform).host}`,
537
553
  ` Last pull ${lastPull ? when(lastPull) : 'never'}`,
@@ -543,6 +559,7 @@ const status = async (ctx: CommandContext) => {
543
559
  ...(request ? [` Request ${request}`] : []),
544
560
  ...(build ? [` Build ${buildStatusText(build)}`] : []),
545
561
  ...(organizationKeyFor ? [' Actions this workspace’s workflows use governed Actions: run `frontera local sync connect` again with an organization key to bring them'] : []),
562
+ ...(mcp ? [` MCP servers ${mcp.files.join(', ')}: not in a change request; save with \`${mcp.command}\``] : []),
546
563
  ].join('\n'),
547
564
  }
548
565
  }
@@ -944,6 +961,8 @@ const resolve = async (ctx: CommandContext) => {
944
961
  const disconnect = async (ctx: CommandContext) => {
945
962
  const project = localProject(ctx.cwd)
946
963
  const link = readLink(project.root)
964
+ // First, while the keys that show the profile is the project's are still stored.
965
+ if (link) await removeTargetProfile(project, link)
947
966
  removeSyncKey(project, 'workspace')
948
967
  removeSyncKey(project, 'organization')
949
968
  const forget = flagBool(ctx, 'forget-link')
@@ -1,17 +1,28 @@
1
+ import { dirname, resolve } from 'node:path'
2
+
3
+ import { FronteraError } from '@frontera-sdk/core/errors'
4
+
1
5
  import { PlatformApi } from '../../api/platform-api'
2
- import { UsageError } from '../../errors'
6
+ import { CliError, UsageError } from '../../errors'
7
+ import { targetFor } from '../../local/local-dev-sync'
8
+ import { linkedSaveTarget, projectMcpServers, readMcpServerFile, savedLines, type SavedServer } from '../../local/local-mcp-servers'
9
+ import { findSolutionRoot } from '../../local/local-project'
10
+ import { isLoopback } from '../../remote-guard'
3
11
  import { table } from '../../table'
4
- import type { Command } from '../types'
12
+ import type { Command, CommandContext } from '../types'
5
13
 
6
14
  /**
7
- * A workspace's own MCP servers: what is on the workspace, and taking one out
8
- * of service.
15
+ * A workspace's own MCP servers: saving one from its file, what is on the
16
+ * workspace, and taking one out of service.
9
17
  *
10
- * A server is authored as `mcp-servers/<slug>.yaml` in a project and saved by
11
- * `frontera local dev`, which also archives one whose file was removed. These
12
- * commands cover the rest: seeing what a workspace serves and where and how a
13
- * client connects, why a tool is left out for you, and archiving a server the
14
- * project never had a file for.
18
+ * A server is authored as `mcp-servers/<slug>.yaml` in a project. `frontera
19
+ * local dev` saves it on the local runtime, and archives one whose file was
20
+ * removed; a change request does not carry it, so `save` sends the file with
21
+ * the request `local dev` makes, to the workspace the project is linked to
22
+ * (or, unlinked or with `--profile`, the profile's). The rest:
23
+ * seeing what a workspace serves and where and how a client connects, why a
24
+ * tool is left out for you, and archiving a server the project never had a
25
+ * file for.
15
26
  */
16
27
 
17
28
  /** How a client connects: signing in with a Frontera account, or a key where sign-in is not offered. */
@@ -53,7 +64,7 @@ const list: Command = {
53
64
  data: rows,
54
65
  text:
55
66
  rows.length === 0
56
- ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and run `frontera local dev` (`frontera local schema mcp-server` prints the format).'
67
+ ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and save it with `frontera mcp-server save mcp-servers/<slug>.yaml` (`frontera local schema mcp-server` prints the format).'
57
68
  : table(
58
69
  ['slug', 'name', 'tools', 'url'],
59
70
  rows.map((s) => [s.slug ?? '?', s.name ?? '', String(s.tools?.length ?? 0), s.url ?? '']),
@@ -102,4 +113,81 @@ const archive: Command = {
102
113
  },
103
114
  }
104
115
 
105
- export const mcpServerCommands: Command[] = [list, get, archive]
116
+ /** The named file, or every server file in the project; its slug is its file name, as `local dev` reads it. */
117
+ function serverFiles(cwd: string, named: string | undefined): Array<{ root: string; slug: string; file: string }> {
118
+ if (!named) {
119
+ const root = findSolutionRoot(cwd)
120
+ const all = projectMcpServers(root).map((s) => ({ root, ...s }))
121
+ if (!all.length) throw new UsageError('this project has no MCP server files', 'add mcp-servers/<slug>.yaml (`frontera local schema mcp-server` prints the format), then save it')
122
+ return all
123
+ }
124
+ const absolute = resolve(cwd, named)
125
+ const root = dirname(dirname(absolute))
126
+ const target = targetFor(root, absolute)
127
+ if (target?.kind !== 'mcp-server') {
128
+ throw new UsageError(`${named} is not an MCP server file`, 'an MCP server is mcp-servers/<slug>.yaml (or .yml, .json), named by its slug: lowercase letters, digits and dashes, starting with a letter')
129
+ }
130
+ return [{ root, slug: target.name, file: target.file }]
131
+ }
132
+
133
+ /**
134
+ * The credential a save goes with: the link's, unless the command names one
135
+ * (`--profile`, `--api-url`, `--workspace`, or `FRONTERA_TOKEN` with its
136
+ * origin). `FRONTERA_PROFILE` is not such a choice: Forge sets it to the
137
+ * directory's profile, the local runtime's in a project that has one.
138
+ */
139
+ async function saveTarget(ctx: CommandContext, root: string): Promise<{ api: PlatformApi; where: string }> {
140
+ const named = ['profile', 'api-url', 'workspace'].some((f) => typeof ctx.flags[f] === 'string') || !!process.env.FRONTERA_TOKEN?.trim()
141
+ const linked = named ? null : linkedSaveTarget(root)
142
+ if (linked) return { api: new PlatformApi(linked.platform, linked.key), where: linked.where }
143
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
144
+ const workspace = ctx.workspaceId ?? (await api.whoami().catch(() => null))?.workspaceId
145
+ const at = workspace ? `workspace ${workspace} on ${new URL(ctx.apiUrl).host}` : ctx.apiUrl
146
+ return { api, where: isLoopback(ctx.apiUrl) ? `${at}, a local runtime` : at }
147
+ }
148
+
149
+ const save: Command = {
150
+ meta: {
151
+ noun: 'mcp-server',
152
+ verb: 'save',
153
+ args: [{ name: 'file', required: false, description: 'mcp-servers/<slug>.yaml; every MCP server file in the project when left out' }],
154
+ flags: {},
155
+ summary: 'Save an MCP server from its file to the workspace: created, or updated in place. A change request does not carry MCP servers',
156
+ details: [
157
+ 'In a project linked with `frontera local sync connect`, it saves to the linked workspace with the project\'s workspace key, the workspace `frontera local sync push` proposes to. `--profile <name>` saves to that profile\'s workspace instead, and so does an unlinked project\'s save. The output names the workspace it saved to.',
158
+ ],
159
+ examples: ['frontera mcp-server save mcp-servers/tracker.yaml', 'frontera mcp-server save', 'frontera mcp-server save mcp-servers/tracker.yaml --profile acme --json'],
160
+ },
161
+ async run(ctx) {
162
+ const files = serverFiles(ctx.cwd, ctx.positional[0])
163
+ // Every file read before anything is sent.
164
+ const docs = files.map((f) => {
165
+ const read = readMcpServerFile(f.root, f.file)
166
+ if (!read.ok) throw new UsageError(read.problem, 'fix the file (`frontera local schema mcp-server` prints the format), then save again')
167
+ return { ...f, doc: read.doc }
168
+ })
169
+ const { api, where } = await saveTarget(ctx, files[0]!.root)
170
+ const saved: Array<{ slug: string; file: string } & SavedServer> = []
171
+ for (const f of docs) {
172
+ try {
173
+ saved.push({ slug: f.slug, file: f.file, ...(await api.saveMcpServer(f.slug, f.doc)) })
174
+ } catch (err) {
175
+ // The service's reason for closing the route to organization keys, which its 403 names only by a code.
176
+ if (err instanceof FronteraError && (err.details as { detail?: unknown } | undefined)?.detail === 'org-key-route-closed') {
177
+ throw new CliError(`MCP server ${f.slug} was refused: an organization key names no person, and a workspace MCP server runs every tool as the person connected, so it is saved only with a person's sign-in or a workspace key`, {
178
+ code: 'FORBIDDEN',
179
+ hint: 'save it with a profile that signs in as a person or holds a workspace key (`--profile <name>`; `frontera auth list` shows them)',
180
+ cause: err,
181
+ })
182
+ }
183
+ throw err
184
+ }
185
+ }
186
+ return {
187
+ data: saved.map((s) => ({ ...s, where })),
188
+ text: [`Saved to ${where}:`, ...saved.flatMap((s) => savedLines(s.slug, s, 'updated'))].join('\n'),
189
+ }
190
+ },
191
+ }
192
+
193
+ export const mcpServerCommands: Command[] = [list, get, save, archive]
@@ -212,7 +212,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
212
212
  pack: 'Reusable skill bundles — author once, install per workspace',
213
213
  secret: 'Workspace secrets — named here, never printed back',
214
214
  'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
215
- 'mcp-server': 'Workspace MCP servers — list, inspect and archive',
215
+ 'mcp-server': 'Workspace MCP servers — save, list, inspect and archive',
216
216
  function: 'Functions — TypeScript deployed here, run on a schedule',
217
217
  workflow: 'Workflows — declared steps, versioned, promoted, then run',
218
218
  blueprint: 'The shared model of the organization — what an app can read',
@@ -106,6 +106,8 @@ export interface CommandContext {
106
106
  cwd: string
107
107
  apiUrl: string
108
108
  token: string
109
+ /** The profile the credential came from, when it came from one. */
110
+ profile?: string
109
111
  positional: string[]
110
112
  flags: Record<string, string | boolean>
111
113
  /** Every value of a string flag given more than once (`flags` holds the last). */