@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.
- package/package.json +4 -4
- package/src/api/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- package/src/commands/app/checks.ts +1 -1
- package/src/commands/app/deploy.ts +9 -9
- package/src/commands/app/deployments.ts +1 -1
- package/src/commands/app/dev.ts +79 -14
- package/src/commands/app/promote.ts +1 -1
- package/src/commands/app/pull.ts +4 -2
- package/src/commands/app/rollback.ts +1 -1
- package/src/commands/app/save.ts +3 -7
- package/src/commands/app/shared.ts +59 -2
- package/src/commands/app/unpin.ts +1 -1
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- package/src/commands/knowledge/index-commands.ts +15 -2
- package/src/commands/local/check.ts +21 -13
- package/src/commands/local/data.ts +1 -1
- package/src/commands/local/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -1
- package/src/mcp/mcp-apps.ts +96 -20
- package/src/mcp/tools/agent-tools.ts +29 -7
- package/src/mcp/tools/app-tools.ts +3 -3
- package/src/mcp/tools/project-tools.ts +2 -1
- package/src/project.ts +47 -11
- package/src/remote-guard.ts +4 -1
- package/src/templates/next-app-files.ts +4 -1
- package/src/templates/next-skills.ts +9 -5
- package/src/vendor/kit-assets.json +8 -8
- 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`,
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* runs, evaluations) is kept across it
|
|
60
|
-
* check's results can be compared with 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
|
|
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 = {
|
|
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
|
|
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) },
|
|
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,
|
|
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}`) },
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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)
|
|
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:
|
|
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
|
|
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)' : ''}`),
|
|
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
|
|
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:
|
|
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
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
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]
|
package/src/commands/registry.ts
CHANGED
|
@@ -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',
|
package/src/commands/types.ts
CHANGED
|
@@ -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). */
|