@frontera-sdk/cli 1.53.5 → 1.53.6
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/platform-api.ts +20 -0
- 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/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/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/sync-request.ts +17 -3
- package/src/commands/local/sync.ts +4 -1
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +29 -11
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +6 -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 +6 -6
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -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 {
|
|
@@ -8,6 +8,7 @@ import { packFunctionSource } from '../../pack'
|
|
|
8
8
|
import { createTarGz } from '../../tar'
|
|
9
9
|
import { functionEntry } from '../../local/local-dev-sync'
|
|
10
10
|
import { adoptProposedLessons } from '../../local/local-lessons'
|
|
11
|
+
import { mcpServersNotCarried } from '../../local/local-mcp-servers'
|
|
11
12
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
12
13
|
import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
13
14
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
@@ -463,7 +464,7 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
|
|
|
463
464
|
}
|
|
464
465
|
}
|
|
465
466
|
|
|
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 {
|
|
467
|
+
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
468
|
const ok = items.filter((i) => i.proposable && !i.sample)
|
|
468
469
|
const samples = items.filter((i) => i.proposable && i.sample)
|
|
469
470
|
const not = items.filter((i) => !i.proposable)
|
|
@@ -480,6 +481,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
480
481
|
if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
|
|
481
482
|
if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
|
|
482
483
|
if (orgNote) lines.push('', orgNote)
|
|
484
|
+
if (notCarried) lines.push('', notCarried)
|
|
483
485
|
if (!available) lines.push('', 'This platform does not take change requests yet.')
|
|
484
486
|
if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
|
|
485
487
|
return lines.join('\n')
|
|
@@ -567,11 +569,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
567
569
|
const updating = kept && isOpen(kept.state) ? kept : null
|
|
568
570
|
const orgStored = !!getSyncKey(project, 'organization')
|
|
569
571
|
const titles = Object.fromEntries(all.map((i) => [i.ref, i.title]))
|
|
572
|
+
const mcp = mcpServersNotCarried(project.root)
|
|
573
|
+
const notCarried = mcp ? { mcpServers: mcp.files, save: mcp.command } : null
|
|
570
574
|
|
|
571
575
|
let chosen: PushItem[]
|
|
572
576
|
if (named.length) {
|
|
573
577
|
const byRef = new Map(all.map((i) => [i.ref, i]))
|
|
574
578
|
const unknown = named.filter((r) => !byRef.has(r))
|
|
579
|
+
const servers = unknown.filter((r) => r.startsWith('mcp-server'))
|
|
580
|
+
if (mcp && servers.length) throw new UsageError(`a change request does not carry ${servers.join(', ')}`, mcp.line)
|
|
575
581
|
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
582
|
chosen = named.map((r) => byRef.get(r)!)
|
|
577
583
|
} else chosen = all
|
|
@@ -600,9 +606,10 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
600
606
|
// Apply's steps in order, and the ones the project's credentials can't take (N3).
|
|
601
607
|
applyPlan: checked.plan,
|
|
602
608
|
needsPerson: person,
|
|
609
|
+
notCarried,
|
|
603
610
|
fetchedAt: snapshot.fetchedAt,
|
|
604
611
|
},
|
|
605
|
-
text: previewText(link, chosen, updating, orgNote, available, usedBy, person),
|
|
612
|
+
text: previewText(link, chosen, updating, orgNote, available, usedBy, person, mcp?.line),
|
|
606
613
|
}
|
|
607
614
|
}
|
|
608
615
|
|
|
@@ -619,7 +626,12 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
619
626
|
}
|
|
620
627
|
const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
|
|
621
628
|
const releaseAsks = parseReleases(flagString(ctx, 'release'))
|
|
622
|
-
if (!going.length && !releaseAsks.length)
|
|
629
|
+
if (!going.length && !releaseAsks.length) {
|
|
630
|
+
throw new CliError(`nothing to propose: nothing ${mcp ? 'a change request carries ' : ''}changed here since the last pull`, {
|
|
631
|
+
code: 'SYNC_NOTHING_TO_PUSH',
|
|
632
|
+
hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
|
|
633
|
+
})
|
|
634
|
+
}
|
|
623
635
|
const title = flagString(ctx, 'title')?.trim()
|
|
624
636
|
const description = flagString(ctx, 'description')
|
|
625
637
|
// The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
|
|
@@ -671,6 +683,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
671
683
|
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
|
|
672
684
|
samples: leftSamples.map((i) => i.ref),
|
|
673
685
|
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
686
|
+
notCarried,
|
|
674
687
|
},
|
|
675
688
|
text: [
|
|
676
689
|
`${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
|
|
@@ -679,6 +692,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
679
692
|
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
|
|
680
693
|
...(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
694
|
...(orgNote ? ['', orgNote] : []),
|
|
695
|
+
...(mcp ? ['', mcp.line] : []),
|
|
682
696
|
'',
|
|
683
697
|
'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
|
|
684
698
|
].join('\n'),
|
|
@@ -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'
|
|
@@ -529,9 +530,10 @@ const status = async (ctx: CommandContext) => {
|
|
|
529
530
|
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
530
531
|
// How the last `local dev` left the project, counted as Forge's home counts it.
|
|
531
532
|
const build = projectBuildSummary(project.root, project.id)
|
|
533
|
+
const mcp = mcpServersNotCarried(project.root)
|
|
532
534
|
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
535
|
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 },
|
|
536
|
+
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
537
|
text: [
|
|
536
538
|
`Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(link.platform).host}`,
|
|
537
539
|
` Last pull ${lastPull ? when(lastPull) : 'never'}`,
|
|
@@ -543,6 +545,7 @@ const status = async (ctx: CommandContext) => {
|
|
|
543
545
|
...(request ? [` Request ${request}`] : []),
|
|
544
546
|
...(build ? [` Build ${buildStatusText(build)}`] : []),
|
|
545
547
|
...(organizationKeyFor ? [' Actions this workspace’s workflows use governed Actions: run `frontera local sync connect` again with an organization key to bring them'] : []),
|
|
548
|
+
...(mcp ? [` MCP servers ${mcp.files.join(', ')}: not in a change request; save with \`${mcp.command}\``] : []),
|
|
546
549
|
].join('\n'),
|
|
547
550
|
}
|
|
548
551
|
}
|
|
@@ -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/flag-help.ts
CHANGED
|
@@ -127,6 +127,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
127
127
|
framework: 'App scaffold to create: next (default) or react (legacy Vite)',
|
|
128
128
|
port: 'loopback port to listen on — the App development server, or the Forge daemon (0: any free port)',
|
|
129
129
|
'no-open': 'start local development without opening a browser window',
|
|
130
|
+
detach: 'start the App development server in its own session and return once it answers: it keeps running after this shell ends',
|
|
130
131
|
'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
|
|
131
132
|
'no-git': 'scaffold without creating a repository and an initial commit',
|
|
132
133
|
'no-components': 'scaffold without fetching the baseline shadcn components',
|
|
@@ -283,7 +284,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
283
284
|
title: 'the change request’s title; a push that opens a new one needs it',
|
|
284
285
|
why: 'a person-step: why it is needed now, in a sentence',
|
|
285
286
|
changes: 'a person-step: what it changes, in the workspace’s words',
|
|
286
|
-
credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person)',
|
|
287
|
+
credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person, registry-owner)',
|
|
287
288
|
delivery: 'a person-step: the delivery it belongs to; steps with the same one show as one card',
|
|
288
289
|
all: 'list the settled ones too',
|
|
289
290
|
browser: 'sign in by approving a code in the browser, as yourself',
|
|
@@ -113,6 +113,8 @@ export const FRONTERA_PERSON_COMMANDS: readonly string[] = [
|
|
|
113
113
|
'app rollback',
|
|
114
114
|
'agent publish',
|
|
115
115
|
'chat-app publish',
|
|
116
|
+
// Live in the workspace at once, its tools run as whoever connects; a change request does not carry it.
|
|
117
|
+
'mcp-server save',
|
|
116
118
|
'function promote',
|
|
117
119
|
'workflow promote',
|
|
118
120
|
'local sync request apply',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
|
|
3
|
-
import { readState } from '../project'
|
|
3
|
+
import { appBinding, readState } from '../project'
|
|
4
4
|
import { customAppFolders } from './local-apps'
|
|
5
5
|
import type { LocalCredentials } from './local-state'
|
|
6
6
|
|
|
@@ -42,16 +42,18 @@ export function workspaceGet(serviceUrl: string, creds: Pick<LocalCredentials, '
|
|
|
42
42
|
* holds it. Null when the runtime could not be asked; a folder the runtime
|
|
43
43
|
* could not answer for is left out (the folder's record speaks for it).
|
|
44
44
|
*/
|
|
45
|
-
export async function liveAppDeployments(root: string, get: Get | null): Promise<Map<string, LiveApp> | null> {
|
|
45
|
+
export async function liveAppDeployments(root: string, get: Get | null, target: string): Promise<Map<string, LiveApp> | null> {
|
|
46
46
|
if (!get) return null
|
|
47
47
|
const out = new Map<string, LiveApp>()
|
|
48
48
|
for (const dir of customAppFolders(root)) {
|
|
49
49
|
const state = readState(join(root, dir))
|
|
50
50
|
const deployment = typeof state.parentVersion === 'string' && state.parentVersion ? state.parentVersion : null
|
|
51
|
-
|
|
51
|
+
// The folder's App on this runtime (`appTarget`); an older CLI's single id otherwise.
|
|
52
|
+
const appId = appBinding(join(root, dir), target).id ?? state.appId
|
|
53
|
+
if (!deployment || !appId) continue
|
|
52
54
|
const name = dir.split('/').pop()!
|
|
53
55
|
try {
|
|
54
|
-
const res = await get(`/v1/platform-apps/${encodeURIComponent(
|
|
56
|
+
const res = await get(`/v1/platform-apps/${encodeURIComponent(appId)}/deployments`)
|
|
55
57
|
if (res.status === 404) {
|
|
56
58
|
out.set(name, { app: false, deployment: null })
|
|
57
59
|
continue
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { liveActionStates, localActionCalls, type LiveAction } from './local-actions'
|
|
2
|
+
import { appTarget } from '../project'
|
|
2
3
|
import { liveAppDeployments, workspaceGet, type LiveApp } from './local-app-runtime'
|
|
3
4
|
import { projectBuildRows, projectBuildSummary, type BuildRow, type ProjectBuildSummary } from './local-build-summary'
|
|
4
5
|
import { localSecretPresence, type SecretPresence } from './local-secrets'
|
|
@@ -26,7 +27,7 @@ export async function runtimeBuildInputs(state: LocalState | null, root?: string
|
|
|
26
27
|
const [live, secrets, apps] = await Promise.all([
|
|
27
28
|
creds.orgKey ? Promise.resolve().then(() => liveActionStates(localActionCalls(serviceUrl, creds))).catch(() => null) : Promise.resolve(null),
|
|
28
29
|
localSecretPresence(serviceUrl, creds).catch(() => null),
|
|
29
|
-
root ? liveAppDeployments(root, workspaceGet(serviceUrl, creds)).catch(() => null) : Promise.resolve(null),
|
|
30
|
+
root ? liveAppDeployments(root, workspaceGet(serviceUrl, creds), appTarget(serviceUrl, creds.workspaceId)).catch(() => null) : Promise.resolve(null),
|
|
30
31
|
])
|
|
31
32
|
return { live, secrets, apps }
|
|
32
33
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
1
|
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
2
|
import { join } from 'node:path'
|
|
4
3
|
|
|
5
4
|
import { writeFileAtomic } from '../fs-atomic'
|
|
6
|
-
import { loadHashes } from './local-applied'
|
|
7
5
|
import { appDeployments, appsLostByRestore, appsLostWords, markAppsLostByRestore, type AppLostByRestore } from './local-app-restore'
|
|
8
6
|
import { execInContainer, queryInContainer } from './local-docker'
|
|
9
7
|
import { dumpHistory, historyWords, loadHistory, type HistoryCarried } from './local-history-carry'
|
|
@@ -20,12 +18,19 @@ import { containerName } from './local-supervisor'
|
|
|
20
18
|
* `realActions` or a `decision`) is rolled back afterwards instead of growing
|
|
21
19
|
* the data on every `local check`.
|
|
22
20
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* Every save takes a new copy: the baseline is the database just before what
|
|
22
|
+
* is to be rolled back (`local check` saves it after its apply, right before
|
|
23
|
+
* its tests), so a restore takes away only what was written since. A copy kept
|
|
24
|
+
* from an earlier save would also take away what was written between the two,
|
|
25
|
+
* such as a person's sign-up or an App's records. A copy of the hash and build
|
|
26
|
+
* records is kept with it, so a restore puts the CLI's record of what is
|
|
27
|
+
* applied back with the database: anything applied after the baseline counts
|
|
28
|
+
* as not applied, and the next `local dev` applies it again.
|
|
29
|
+
*
|
|
30
|
+
* A check's baseline is marked from its save until its restore, so a check
|
|
31
|
+
* cut off in between (Ctrl+C, an agent's time limit, a crash), which left
|
|
32
|
+
* its tests' writes, is put right by the next check before it applies
|
|
33
|
+
* (`restoreCutOffCheck`).
|
|
29
34
|
*
|
|
30
35
|
* Only the platform database is copied. The managed datasets' catalog
|
|
31
36
|
* database, object storage and local traces are left as they are.
|
|
@@ -52,6 +57,8 @@ import { containerName } from './local-supervisor'
|
|
|
52
57
|
*/
|
|
53
58
|
|
|
54
59
|
export const BASELINE_DB = `${POSTGRES_DB}_baseline`
|
|
60
|
+
/** What took a baseline: `local check`, `local snapshot save`, `local data load`, or an Agent conversation with rollback (MCP `agent_chat`). */
|
|
61
|
+
export type BaselineSaver = 'check' | 'snapshot' | 'data' | 'conversation'
|
|
55
62
|
/** The database psql connects to while it copies, drops and creates the platform's. */
|
|
56
63
|
const MAINTENANCE_DB = 'postgres'
|
|
57
64
|
|
|
@@ -60,8 +67,6 @@ export interface BaselineRecord {
|
|
|
60
67
|
organizationId: string
|
|
61
68
|
database: string
|
|
62
69
|
baseline: string
|
|
63
|
-
/** The applied build the baseline is of (`buildHash`). */
|
|
64
|
-
buildHash: string
|
|
65
70
|
savedAt: string
|
|
66
71
|
saveMs: number
|
|
67
72
|
/** `applied-hashes.json` and `build.json` as they were when the baseline was taken; null when absent. */
|
|
@@ -74,6 +79,10 @@ export interface BaselineRecord {
|
|
|
74
79
|
* Absent on a baseline taken before it was kept.
|
|
75
80
|
*/
|
|
76
81
|
apps?: Record<string, string | null>
|
|
82
|
+
/** Absent on a baseline an older CLI took. */
|
|
83
|
+
savedBy?: BaselineSaver
|
|
84
|
+
/** A `local check` took it and has not restored it yet: still running, or cut off before its rollback. */
|
|
85
|
+
checkInProgress?: boolean
|
|
77
86
|
restoredAt?: string
|
|
78
87
|
restoreMs?: number
|
|
79
88
|
}
|
|
@@ -102,8 +111,6 @@ export interface SnapshotOutcome {
|
|
|
102
111
|
/** What happened, in a line. */
|
|
103
112
|
note: string
|
|
104
113
|
ms: number
|
|
105
|
-
/** save: false when the stored baseline is already of this build. */
|
|
106
|
-
took?: boolean
|
|
107
114
|
/** restore: items applied after the baseline, now counted as not applied (Custom Apps deployed after it included). */
|
|
108
115
|
reapply?: number
|
|
109
116
|
/** restore: the Custom Apps deployed after the baseline, which the restored database does not have. */
|
|
@@ -136,26 +143,11 @@ function readText(path: string): string | null {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
/**
|
|
140
|
-
* What is applied to this runtime, as one digest: the hash record, in a stable
|
|
141
|
-
* order, and each Custom App's deployment. An App deployed since the baseline
|
|
142
|
-
* changes it, so `local check` takes the baseline again rather than rolling the
|
|
143
|
-
* App back out of the runtime after its tests.
|
|
144
|
-
*/
|
|
145
|
-
export function buildHash(project: LocalProject, organizationId: string): string {
|
|
146
|
-
const entries = [...loadHashes(project, organizationId)].sort(([a], [b]) => a.localeCompare(b))
|
|
147
|
-
const apps = Object.entries(appDeployments(project.root)).filter(([, d]) => d).sort(([a], [b]) => a.localeCompare(b))
|
|
148
|
-
const hash = createHash('sha256').update(organizationId).update('\0').update(JSON.stringify(entries))
|
|
149
|
-
// Only when there is one, so a project without Apps keeps the digest its baseline was taken under.
|
|
150
|
-
if (apps.length) hash.update('\0apps\0').update(JSON.stringify(apps))
|
|
151
|
-
return hash.digest('hex')
|
|
152
|
-
}
|
|
153
|
-
|
|
154
146
|
/** The stored baseline's record, for this organization (after a reset the runtime is another one). */
|
|
155
147
|
export function readBaseline(project: LocalProject, organizationId: string): BaselineRecord | null {
|
|
156
148
|
try {
|
|
157
149
|
const saved = JSON.parse(readFileSync(recordPath(project), 'utf8')) as Partial<BaselineRecord>
|
|
158
|
-
return saved.version === 1 && saved.organizationId === organizationId
|
|
150
|
+
return saved.version === 1 && saved.organizationId === organizationId ? (saved as BaselineRecord) : null
|
|
159
151
|
} catch {
|
|
160
152
|
return null
|
|
161
153
|
}
|
|
@@ -225,24 +217,15 @@ function withLock<T>(project: LocalProject, run: () => Promise<T>): Promise<T> {
|
|
|
225
217
|
return run().finally(() => rmSync(lockPath(project), { force: true }))
|
|
226
218
|
}
|
|
227
219
|
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
* this build already (`force` takes it regardless).
|
|
231
|
-
*/
|
|
232
|
-
export async function saveBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, force = false): Promise<SnapshotOutcome> {
|
|
220
|
+
/** Take the baseline of the database as it is now, replacing the one before. */
|
|
221
|
+
export async function saveBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, by?: BaselineSaver): Promise<SnapshotOutcome> {
|
|
233
222
|
const exec = deps.exec ?? execInContainer
|
|
234
223
|
const now = deps.now ?? Date.now
|
|
235
224
|
const psql = psqlFor(project, exec)
|
|
236
|
-
const hash = buildHash(project, organizationId)
|
|
237
|
-
const stored = readBaseline(project, organizationId)
|
|
238
225
|
const started = now()
|
|
239
|
-
if (!force && stored?.buildHash === hash && psql(exists(BASELINE_DB))) {
|
|
240
|
-
return { ok: true, took: false, ms: 0, note: `the stored baseline is of this build (taken ${stored.savedAt})` }
|
|
241
|
-
}
|
|
242
226
|
return withLock(project, async () => {
|
|
243
|
-
// The copy before last is dropped first: a template is only ever this one build.
|
|
244
227
|
if (!psql(`DROP DATABASE IF EXISTS ${BASELINE_DB}`)) {
|
|
245
|
-
return { ok: false,
|
|
228
|
+
return { ok: false, ms: now() - started, note: `couldn’t drop the previous baseline (${BASELINE_DB}); see \`frontera local logs postgres\`` }
|
|
246
229
|
}
|
|
247
230
|
psql(refuse(POSTGRES_DB, false))
|
|
248
231
|
let copied = false
|
|
@@ -252,7 +235,7 @@ export async function saveBaseline(project: LocalProject, organizationId: string
|
|
|
252
235
|
psql(refuse(POSTGRES_DB, true))
|
|
253
236
|
}
|
|
254
237
|
const ms = now() - started
|
|
255
|
-
if (!copied) return { ok: false,
|
|
238
|
+
if (!copied) return { ok: false, ms, note: `couldn’t copy the database to ${BASELINE_DB}: something kept a connection open; see \`frontera local logs postgres\`` }
|
|
256
239
|
// Nothing connects to the baseline: it is only ever a template.
|
|
257
240
|
psql(refuse(BASELINE_DB, false))
|
|
258
241
|
await settle(deps)
|
|
@@ -262,15 +245,16 @@ export async function saveBaseline(project: LocalProject, organizationId: string
|
|
|
262
245
|
organizationId,
|
|
263
246
|
database: POSTGRES_DB,
|
|
264
247
|
baseline: BASELINE_DB,
|
|
265
|
-
buildHash: hash,
|
|
266
248
|
savedAt: new Date(now()).toISOString(),
|
|
267
249
|
saveMs: ms,
|
|
268
250
|
applied: readText(hashesFile(project)),
|
|
269
251
|
build: readText(buildFile(project)),
|
|
270
252
|
apps: appDeployments(project.root),
|
|
253
|
+
...(by ? { savedBy: by } : {}),
|
|
254
|
+
...(by === 'check' ? { checkInProgress: true } : {}),
|
|
271
255
|
})
|
|
272
256
|
const late = answering ? '' : '; the service isn’t answering since (see `frontera local logs service`)'
|
|
273
|
-
return { ok: true,
|
|
257
|
+
return { ok: true, ms, note: `took a baseline of the database as it is now (${ms} ms)${late}` }
|
|
274
258
|
})
|
|
275
259
|
}
|
|
276
260
|
|
|
@@ -352,17 +336,49 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
|
|
|
352
336
|
const answering = deps.serviceAnswers ? await deps.serviceAnswers() : true
|
|
353
337
|
const ms = now() - started
|
|
354
338
|
// Written last: a watching `local dev` reads it to take the record above.
|
|
355
|
-
writeBaseline(project, { ...stored, restoredAt, restoreMs: ms })
|
|
339
|
+
writeBaseline(project, { ...stored, checkInProgress: undefined, restoredAt, restoreMs: ms })
|
|
356
340
|
const kept = secretsWords(secrets)
|
|
357
341
|
const ran = historyWords(history)
|
|
358
342
|
const lostApps = appsLostWords(appsLost)
|
|
359
343
|
const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}${ran ? `; ${ran}` : ''}`
|
|
360
344
|
const withSecrets = { ...(secrets ? { secrets } : {}), ...(history ? { history } : {}) }
|
|
361
345
|
const counted = { reapply: reapply + appsLost.length, ...(appsLost.length ? { appsLost } : {}) }
|
|
362
|
-
|
|
363
|
-
return { ok:
|
|
346
|
+
const back = `restored the database to the baseline of ${stored.savedAt} (${ms} ms), so anything written since then is gone`
|
|
347
|
+
if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `${back}, but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
|
|
348
|
+
return { ok: true, ms, ...counted, ...withSecrets, note: `${back}${tail}` }
|
|
364
349
|
})
|
|
365
350
|
}
|
|
366
351
|
|
|
352
|
+
/** How long after its save a cut-off check's baseline is put back by the next check: later, what was written since is more likely the person's own than a test's. */
|
|
353
|
+
const CUT_OFF_RESTORE_MS = 60 * 60_000
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The baseline a `local check` cut off before its rollback took, put back:
|
|
357
|
+
* its tests' writes, and anything written since, are gone. When it was taken
|
|
358
|
+
* more than an hour ago, the data is left as it is instead and the mark
|
|
359
|
+
* cleared (`left`: when it was taken), so the next check goes on with a new
|
|
360
|
+
* baseline. Null when the last check finished (or the baseline was saved
|
|
361
|
+
* again since). The runtime lock keeps a second check from running, so a
|
|
362
|
+
* marked baseline found by a check is never a running check's.
|
|
363
|
+
*/
|
|
364
|
+
export async function restoreCutOffCheck(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}): Promise<(SnapshotOutcome & { left?: string }) | null> {
|
|
365
|
+
const stored = readBaseline(project, organizationId)
|
|
366
|
+
if (!stored?.checkInProgress) return null
|
|
367
|
+
if ((deps.now ?? Date.now)() - Date.parse(stored.savedAt) <= CUT_OFF_RESTORE_MS) return restoreBaseline(project, organizationId, deps)
|
|
368
|
+
writeBaseline(project, { ...stored, checkInProgress: undefined })
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
ms: 0,
|
|
372
|
+
left: stored.savedAt,
|
|
373
|
+
note: `the last \`frontera local check\` was cut off at ${stored.savedAt}, more than an hour ago, before it rolled back its tests’ writes. They are still in the data: rolling back now would also take away everything written since. \`frontera local snapshot restore\` puts that baseline back by hand until the next save of one, which a check makes before its tests`,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** A check whose tests did not run after its save: nothing of theirs for a later check to put back. */
|
|
378
|
+
export function endCheckBaseline(project: LocalProject, organizationId: string): void {
|
|
379
|
+
const stored = readBaseline(project, organizationId)
|
|
380
|
+
if (stored?.checkInProgress) writeBaseline(project, { ...stored, checkInProgress: undefined })
|
|
381
|
+
}
|
|
382
|
+
|
|
367
383
|
/** The record file's path relative to the project, which `local dev` watches for a restore. */
|
|
368
384
|
export const BASELINE_RECORD = join('.frontera', 'local', 'db-baseline.json')
|