@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.
Files changed (45) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/platform-api.ts +20 -0
  4. package/src/commands/app/checks.ts +1 -1
  5. package/src/commands/app/deploy.ts +9 -9
  6. package/src/commands/app/deployments.ts +1 -1
  7. package/src/commands/app/dev.ts +79 -14
  8. package/src/commands/app/promote.ts +1 -1
  9. package/src/commands/app/pull.ts +4 -2
  10. package/src/commands/app/rollback.ts +1 -1
  11. package/src/commands/app/save.ts +3 -7
  12. package/src/commands/app/shared.ts +59 -2
  13. package/src/commands/app/unpin.ts +1 -1
  14. package/src/commands/app/versions.ts +1 -1
  15. package/src/commands/knowledge/index-commands.ts +15 -2
  16. package/src/commands/local/check.ts +21 -13
  17. package/src/commands/local/data.ts +1 -1
  18. package/src/commands/local/index-commands.ts +1 -1
  19. package/src/commands/local/snapshot.ts +2 -2
  20. package/src/commands/local/sync-request.ts +17 -3
  21. package/src/commands/local/sync.ts +4 -1
  22. package/src/commands/mcp-server/index-commands.ts +99 -11
  23. package/src/commands/registry.ts +1 -1
  24. package/src/flag-help.ts +2 -1
  25. package/src/forge/permission-rules.ts +2 -0
  26. package/src/local/local-app-runtime.ts +6 -4
  27. package/src/local/local-build-inputs.ts +2 -1
  28. package/src/local/local-db-snapshot.ts +62 -46
  29. package/src/local/local-docker.ts +84 -10
  30. package/src/local/local-mcp-servers.ts +79 -14
  31. package/src/local/local-person-steps.ts +29 -11
  32. package/src/local/local-project.ts +5 -1
  33. package/src/local/local-supervisor.ts +41 -3
  34. package/src/local/local-sync-remote.ts +18 -0
  35. package/src/main.ts +6 -1
  36. package/src/mcp/mcp-apps.ts +96 -20
  37. package/src/mcp/tools/agent-tools.ts +29 -7
  38. package/src/mcp/tools/app-tools.ts +3 -3
  39. package/src/mcp/tools/project-tools.ts +2 -1
  40. package/src/project.ts +47 -11
  41. package/src/remote-guard.ts +4 -1
  42. package/src/templates/next-app-files.ts +4 -1
  43. package/src/templates/next-skills.ts +9 -5
  44. package/src/vendor/kit-assets.json +6 -6
  45. 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}`) }, true))
60
+ const saved = await ctx.output.task('Saving the database as the baseline', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, 'snapshot'))
61
61
  if (!saved.ok) throw new CliError(saved.note, { code: 'LOCAL_SNAPSHOT_FAILED', hint: 'see `frontera local logs postgres`' })
62
62
  const knowledge = settled?.lines ?? []
63
63
  return {
@@ -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) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
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: what is on the workspace, and taking one out
8
- * of service.
15
+ * A workspace's own MCP servers: saving one from its file, what is on the
16
+ * workspace, and taking one out of service.
9
17
  *
10
- * A server is authored as `mcp-servers/<slug>.yaml` in a project and saved by
11
- * `frontera local dev`, which also archives one whose file was removed. These
12
- * commands cover the rest: seeing what a workspace serves and where and how a
13
- * client connects, why a tool is left out for you, and archiving a server the
14
- * project never had a file for.
18
+ * A server is authored as `mcp-servers/<slug>.yaml` in a project. `frontera
19
+ * local dev` saves it on the local runtime, and archives one whose file was
20
+ * removed; a change request does not carry it, so `save` sends the file with
21
+ * the request `local dev` makes, to the workspace the project is linked to
22
+ * (or, unlinked or with `--profile`, the profile's). The rest:
23
+ * seeing what a workspace serves and where and how a client connects, why a
24
+ * tool is left out for you, and archiving a server the project never had a
25
+ * file for.
15
26
  */
16
27
 
17
28
  /** How a client connects: signing in with a Frontera account, or a key where sign-in is not offered. */
@@ -53,7 +64,7 @@ const list: Command = {
53
64
  data: rows,
54
65
  text:
55
66
  rows.length === 0
56
- ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and run `frontera local dev` (`frontera local schema mcp-server` prints the format).'
67
+ ? 'No MCP servers in this workspace. Add mcp-servers/<slug>.yaml to the project and save it with `frontera mcp-server save mcp-servers/<slug>.yaml` (`frontera local schema mcp-server` prints the format).'
57
68
  : table(
58
69
  ['slug', 'name', 'tools', 'url'],
59
70
  rows.map((s) => [s.slug ?? '?', s.name ?? '', String(s.tools?.length ?? 0), s.url ?? '']),
@@ -102,4 +113,81 @@ const archive: Command = {
102
113
  },
103
114
  }
104
115
 
105
- export const mcpServerCommands: Command[] = [list, get, archive]
116
+ /** The named file, or every server file in the project; its slug is its file name, as `local dev` reads it. */
117
+ function serverFiles(cwd: string, named: string | undefined): Array<{ root: string; slug: string; file: string }> {
118
+ if (!named) {
119
+ const root = findSolutionRoot(cwd)
120
+ const all = projectMcpServers(root).map((s) => ({ root, ...s }))
121
+ if (!all.length) throw new UsageError('this project has no MCP server files', 'add mcp-servers/<slug>.yaml (`frontera local schema mcp-server` prints the format), then save it')
122
+ return all
123
+ }
124
+ const absolute = resolve(cwd, named)
125
+ const root = dirname(dirname(absolute))
126
+ const target = targetFor(root, absolute)
127
+ if (target?.kind !== 'mcp-server') {
128
+ throw new UsageError(`${named} is not an MCP server file`, 'an MCP server is mcp-servers/<slug>.yaml (or .yml, .json), named by its slug: lowercase letters, digits and dashes, starting with a letter')
129
+ }
130
+ return [{ root, slug: target.name, file: target.file }]
131
+ }
132
+
133
+ /**
134
+ * The credential a save goes with: the link's, unless the command names one
135
+ * (`--profile`, `--api-url`, `--workspace`, or `FRONTERA_TOKEN` with its
136
+ * origin). `FRONTERA_PROFILE` is not such a choice: Forge sets it to the
137
+ * directory's profile, the local runtime's in a project that has one.
138
+ */
139
+ async function saveTarget(ctx: CommandContext, root: string): Promise<{ api: PlatformApi; where: string }> {
140
+ const named = ['profile', 'api-url', 'workspace'].some((f) => typeof ctx.flags[f] === 'string') || !!process.env.FRONTERA_TOKEN?.trim()
141
+ const linked = named ? null : linkedSaveTarget(root)
142
+ if (linked) return { api: new PlatformApi(linked.platform, linked.key), where: linked.where }
143
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
144
+ const workspace = ctx.workspaceId ?? (await api.whoami().catch(() => null))?.workspaceId
145
+ const at = workspace ? `workspace ${workspace} on ${new URL(ctx.apiUrl).host}` : ctx.apiUrl
146
+ return { api, where: isLoopback(ctx.apiUrl) ? `${at}, a local runtime` : at }
147
+ }
148
+
149
+ const save: Command = {
150
+ meta: {
151
+ noun: 'mcp-server',
152
+ verb: 'save',
153
+ args: [{ name: 'file', required: false, description: 'mcp-servers/<slug>.yaml; every MCP server file in the project when left out' }],
154
+ flags: {},
155
+ summary: 'Save an MCP server from its file to the workspace: created, or updated in place. A change request does not carry MCP servers',
156
+ details: [
157
+ 'In a project linked with `frontera local sync connect`, it saves to the linked workspace with the project\'s workspace key, the workspace `frontera local sync push` proposes to. `--profile <name>` saves to that profile\'s workspace instead, and so does an unlinked project\'s save. The output names the workspace it saved to.',
158
+ ],
159
+ examples: ['frontera mcp-server save mcp-servers/tracker.yaml', 'frontera mcp-server save', 'frontera mcp-server save mcp-servers/tracker.yaml --profile acme --json'],
160
+ },
161
+ async run(ctx) {
162
+ const files = serverFiles(ctx.cwd, ctx.positional[0])
163
+ // Every file read before anything is sent.
164
+ const docs = files.map((f) => {
165
+ const read = readMcpServerFile(f.root, f.file)
166
+ if (!read.ok) throw new UsageError(read.problem, 'fix the file (`frontera local schema mcp-server` prints the format), then save again')
167
+ return { ...f, doc: read.doc }
168
+ })
169
+ const { api, where } = await saveTarget(ctx, files[0]!.root)
170
+ const saved: Array<{ slug: string; file: string } & SavedServer> = []
171
+ for (const f of docs) {
172
+ try {
173
+ saved.push({ slug: f.slug, file: f.file, ...(await api.saveMcpServer(f.slug, f.doc)) })
174
+ } catch (err) {
175
+ // The service's reason for closing the route to organization keys, which its 403 names only by a code.
176
+ if (err instanceof FronteraError && (err.details as { detail?: unknown } | undefined)?.detail === 'org-key-route-closed') {
177
+ throw new CliError(`MCP server ${f.slug} was refused: an organization key names no person, and a workspace MCP server runs every tool as the person connected, so it is saved only with a person's sign-in or a workspace key`, {
178
+ code: 'FORBIDDEN',
179
+ hint: 'save it with a profile that signs in as a person or holds a workspace key (`--profile <name>`; `frontera auth list` shows them)',
180
+ cause: err,
181
+ })
182
+ }
183
+ throw err
184
+ }
185
+ }
186
+ return {
187
+ data: saved.map((s) => ({ ...s, where })),
188
+ text: [`Saved to ${where}:`, ...saved.flatMap((s) => savedLines(s.slug, s, 'updated'))].join('\n'),
189
+ }
190
+ },
191
+ }
192
+
193
+ export const mcpServerCommands: Command[] = [list, get, save, archive]
@@ -212,7 +212,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
212
212
  pack: 'Reusable skill bundles — author once, install per workspace',
213
213
  secret: 'Workspace secrets — named here, never printed back',
214
214
  'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
215
- 'mcp-server': 'Workspace MCP servers — list, inspect and archive',
215
+ 'mcp-server': 'Workspace MCP servers — save, list, inspect and archive',
216
216
  function: 'Functions — TypeScript deployed here, run on a schedule',
217
217
  workflow: 'Workflows — declared steps, versioned, promoted, then run',
218
218
  blueprint: 'The shared model of the organization — what an app can read',
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
- if (!deployment || !state.appId) continue
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(state.appId)}/deployments`)
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
- * The baseline is keyed by the applied build: a digest of what `local dev`
24
- * recorded as applied (`applied-hashes.json`). It is taken again only when
25
- * that changed. A copy of the hash and build records is kept with it, so a
26
- * restore puts the CLI's record of what is applied back with the database:
27
- * anything applied after the baseline counts as not applied, and the next
28
- * `local dev` applies it again.
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 && typeof saved.buildHash === 'string' ? (saved as BaselineRecord) : null
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
- * Take the baseline of the database as it is now, unless the stored one is of
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, took: false, ms: now() - started, note: `couldn’t drop the previous baseline (${BASELINE_DB}); see \`frontera local logs postgres\`` }
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, took: false, ms, note: `couldn’t copy the database to ${BASELINE_DB}: something kept a connection open; see \`frontera local logs postgres\`` }
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, took: true, ms, note: `took a baseline of the applied database (${ms} ms)${late}` }
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
- if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `restored the database (${ms} ms), but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
363
- return { ok: true, ms, ...counted, ...withSecrets, note: `restored the database as applied (${ms} ms)${tail}` }
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')