@frontera-sdk/cli 1.53.5 → 1.53.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +2 -2
  3. package/src/api/blueprint-authoring-api.ts +19 -19
  4. package/src/api/governed-action-api.ts +78 -3
  5. package/src/api/platform-api.ts +20 -0
  6. package/src/blueprint/dataset-revision.ts +66 -0
  7. package/src/blueprint/diff.ts +134 -6
  8. package/src/blueprint/publish-plan.ts +253 -0
  9. package/src/blueprint/render.ts +19 -0
  10. package/src/commands/action/deploy.ts +143 -25
  11. package/src/commands/action/index-commands.ts +3 -3
  12. package/src/commands/action/list.ts +3 -3
  13. package/src/commands/action/prepare.ts +5 -3
  14. package/src/commands/action/retire.ts +78 -26
  15. package/src/commands/action/review.ts +86 -25
  16. package/src/commands/app/checks.ts +1 -1
  17. package/src/commands/app/deploy.ts +9 -9
  18. package/src/commands/app/deployments.ts +1 -1
  19. package/src/commands/app/dev.ts +79 -14
  20. package/src/commands/app/promote.ts +1 -1
  21. package/src/commands/app/pull.ts +4 -2
  22. package/src/commands/app/rollback.ts +1 -1
  23. package/src/commands/app/save.ts +3 -7
  24. package/src/commands/app/shared.ts +59 -2
  25. package/src/commands/app/unpin.ts +1 -1
  26. package/src/commands/app/versions.ts +1 -1
  27. package/src/commands/blueprint/authoring.ts +110 -24
  28. package/src/commands/blueprint/bind.ts +10 -46
  29. package/src/commands/blueprint/declarative.ts +124 -30
  30. package/src/commands/blueprint/editable.ts +3 -5
  31. package/src/commands/knowledge/index-commands.ts +15 -2
  32. package/src/commands/local/check.ts +21 -13
  33. package/src/commands/local/data.ts +1 -1
  34. package/src/commands/local/dev.ts +1 -21
  35. package/src/commands/local/index-commands.ts +1 -1
  36. package/src/commands/local/snapshot.ts +2 -2
  37. package/src/commands/local/steps.ts +11 -7
  38. package/src/commands/local/sync-request.ts +45 -8
  39. package/src/commands/local/sync.ts +23 -4
  40. package/src/commands/mcp-server/index-commands.ts +99 -11
  41. package/src/commands/registry.ts +1 -1
  42. package/src/commands/types.ts +2 -0
  43. package/src/device-login.ts +19 -1
  44. package/src/exit.ts +5 -0
  45. package/src/flag-help.ts +2 -1
  46. package/src/forge/permission-rules.ts +2 -0
  47. package/src/local/local-actions.ts +6 -5
  48. package/src/local/local-app-runtime.ts +6 -4
  49. package/src/local/local-blueprint-schemas.ts +2 -2
  50. package/src/local/local-build-inputs.ts +2 -1
  51. package/src/local/local-db-snapshot.ts +62 -46
  52. package/src/local/local-dev-references.ts +0 -23
  53. package/src/local/local-docker.ts +84 -10
  54. package/src/local/local-mcp-servers.ts +79 -14
  55. package/src/local/local-person-steps.ts +121 -19
  56. package/src/local/local-project.ts +5 -1
  57. package/src/local/local-supervisor.ts +41 -3
  58. package/src/local/local-sync-link.ts +76 -0
  59. package/src/local/local-sync-precheck.ts +1 -1
  60. package/src/local/local-sync-push.ts +8 -2
  61. package/src/local/local-sync-remote.ts +18 -0
  62. package/src/main.ts +7 -1
  63. package/src/mcp/mcp-apps.ts +96 -20
  64. package/src/mcp/tools/agent-tools.ts +29 -7
  65. package/src/mcp/tools/app-tools.ts +3 -3
  66. package/src/mcp/tools/project-tools.ts +2 -1
  67. package/src/project.ts +47 -11
  68. package/src/remote-guard.ts +4 -1
  69. package/src/templates/next-app-files.ts +4 -1
  70. package/src/templates/next-skills.ts +9 -5
  71. package/src/vendor/kit-assets.json +8 -8
  72. package/src/vendor/sdk-sources.json +17 -17
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './e
9
9
  import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
10
10
  import { localRuntimeAt } from './local/local-runtime-target'
11
11
  import { acquireLock, lockRefusal, locksRuntime, projectStateDir, recordWrite, writeCommand } from './local/local-writers'
12
+ import { tokenFromStdin } from './mcp/mcp-apps'
12
13
  import { resolveOrganization, setSelectedOrganization } from './organization'
13
14
  import { canPrompt, promptConfirm } from './prompt'
14
15
  import { remoteFromLocalProject, remoteWords } from './remote-guard'
@@ -228,6 +229,10 @@ async function main(): Promise<number> {
228
229
  // project are always read from one place. Two directories would mean
229
230
  // `--dir ../other-customer` could edit one customer's app with another
230
231
  // customer's key.
232
+ //
233
+ // The `frontera app dev` that `--detach` starts takes its caller's key on stdin, used as FRONTERA_TOKEN
234
+ // would be, so it is never in the environment the App's own server inherits.
235
+ const piped = await tokenFromStdin()
231
236
  let credential = command.meta.offline
232
237
  ? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
233
238
  : await resolveCredential(
@@ -237,7 +242,7 @@ async function main(): Promise<number> {
237
242
  // In a local project, an organization command takes the runtime's organization key.
238
243
  organization: commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit',
239
244
  },
240
- { cwd },
245
+ { cwd, ...(piped ? { env: { ...process.env, FRONTERA_TOKEN: piped } } : {}) },
241
246
  )
242
247
  // A command that acts as a person takes the browser sign-in (`auth login --browser`) over the
243
248
  // profile's key, which it would refuse; nothing else does. A raw FRONTERA_TOKEN still wins.
@@ -377,6 +382,7 @@ async function main(): Promise<number> {
377
382
  cwd,
378
383
  apiUrl: credential.apiUrl,
379
384
  token: credential.token,
385
+ ...('profile' in credential && credential.profile ? { profile: credential.profile } : {}),
380
386
  positional,
381
387
  flags,
382
388
  repeated,
@@ -3,9 +3,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
3
3
  import { basename, dirname, join, relative } from 'node:path'
4
4
 
5
5
  import { isLoopbackPortFree } from '../dev-broker'
6
- import { findSolutionRoot, localProject, type LocalProject } from '../local/local-project'
6
+ import { findSolutionRoot, localProject, localProjectAt, type LocalProject } from '../local/local-project'
7
7
  import { isAlive } from '../local/local-state'
8
- import { readProject } from '../project'
8
+ import { appTarget, readProject } from '../project'
9
9
  import { ensureAppPackages } from '../project-bootstrap'
10
10
  import { detectAppFramework } from '../provenance'
11
11
  import { selfCommand } from './mcp-cli'
@@ -90,13 +90,28 @@ export interface DevServer {
90
90
  url: string
91
91
  startedAt: string
92
92
  log: string
93
+ /** What its sessions are minted against (`appTarget`: the API origin and workspace). Absent: an older record. */
94
+ target?: string
93
95
  /**
94
96
  * The process that started it: an MCP session (`app_dev`, `app_render`) or
95
97
  * Forge (its Start). The server stops when that process ends (`frontera app
96
98
  * dev` watches it), so a server an agent or Forge started never outlives it.
97
- * Absent: started by hand.
99
+ * `detached`: `frontera app dev --detach`, which nothing owns, so it runs
100
+ * until it is stopped. Absent: started by hand.
98
101
  */
99
- owner?: { kind: 'mcp' | 'forge'; pid: number }
102
+ owner?: { kind: 'mcp' | 'forge'; pid: number } | { kind: 'detached' }
103
+ }
104
+
105
+ /** What a dev server runs against: the credential `frontera app dev` gets, and the target it reaches (`appTarget`), null when unknown. */
106
+ export interface DevCredential {
107
+ env: Record<string, string>
108
+ target: string | null
109
+ /**
110
+ * A key given on `frontera app dev`'s stdin instead of in `env`, so the
111
+ * App's own server, which inherits that environment, never holds it
112
+ * (`--detach` with the caller's profile key or browser sign-in).
113
+ */
114
+ token?: string
100
115
  }
101
116
 
102
117
  /** How a dev server this process started ended: its exit code (or signal) and the last lines it wrote. */
@@ -115,8 +130,14 @@ export interface EnsureDevServerOptions {
115
130
  * module (Forge) passes its own, or it would start a copy of itself.
116
131
  */
117
132
  command?: string[]
118
- /** Who owns the server's lifetime (this process either way). Default: an MCP session. */
119
- owner?: 'mcp' | 'forge'
133
+ /**
134
+ * Who owns the server's lifetime: an MCP session (the default) or Forge,
135
+ * this process either way; `detached` for `frontera app dev --detach`,
136
+ * whose server outlives it.
137
+ */
138
+ owner?: 'mcp' | 'forge' | 'detached'
139
+ /** The port to listen on. Default: a free one from 3100. */
140
+ port?: number
120
141
  /** Called once when the server this call started ends, however it ends. */
121
142
  onExit?: (exit: DevServerExit) => void
122
143
  }
@@ -124,6 +145,17 @@ export interface EnsureDevServerOptions {
124
145
  /** What tells `frontera app dev` which process started it, so it stops when that one ends. */
125
146
  export const DEV_OWNER_ENV = 'FRONTERA_APP_DEV_OWNER_PID'
126
147
 
148
+ /** What tells `frontera app dev` that its credential comes on stdin (`DevCredential.token`). */
149
+ const TOKEN_ON_STDIN_ENV = 'FRONTERA_TOKEN_ON_STDIN'
150
+
151
+ /** The key `ensureDevServer` wrote to this process's stdin, read once; null when it was started any other way. */
152
+ export async function tokenFromStdin(): Promise<string | null> {
153
+ if (process.env[TOKEN_ON_STDIN_ENV] !== '1') return null
154
+ // Gone before anything is spawned: a `frontera` the App's dev script runs must not wait for a key on stdin.
155
+ delete process.env[TOKEN_ON_STDIN_ENV]
156
+ return (await Bun.stdin.text()).trim() || null
157
+ }
158
+
127
159
  /** The dev servers this process started, by project and App, so they stop with the MCP session or Forge (`stopStartedDevServers`). */
128
160
  const startedHere = new Map<string, { project: LocalProject; slug: string; pid: number }>()
129
161
 
@@ -160,6 +192,15 @@ export function enclosingSolution(appRoot: string): LocalProject | null {
160
192
  return localProject(root)
161
193
  }
162
194
 
195
+ /**
196
+ * Where an App folder's dev server is recorded: in its solution, where the
197
+ * solution's agents, Forge and `local stop` look, or in the App folder itself
198
+ * when it stands alone.
199
+ */
200
+ export function devServerHome(appRoot: string): LocalProject {
201
+ return enclosingSolution(appRoot) ?? localProjectAt(appRoot)
202
+ }
203
+
163
204
  /**
164
205
  * Record a dev server another command started (`frontera app dev` run by hand
165
206
  * or by another session), so `app_dev`, `app_render` and Forge reuse it rather
@@ -202,16 +243,34 @@ export function localAppEnv(link: RuntimeLink): Record<string, string> {
202
243
  return { FRONTERA_TOKEN: key, FRONTERA_API_URL: link.serviceUrl, FRONTERA_ORG_ID: link.creds.organizationId }
203
244
  }
204
245
 
246
+ /** The local runtime as what a dev server runs against. */
247
+ export function localDevCredential(link: RuntimeLink): DevCredential {
248
+ return { env: localAppEnv(link), target: appTarget(link.serviceUrl, link.creds.workspaceId) }
249
+ }
250
+
251
+ /** "http://127.0.0.1:4000, workspace ws_1": a target (`appTarget`) in a sentence. */
252
+ function targetWords(target: string): string {
253
+ const [origin, workspace] = target.split('#')
254
+ return `${origin}, workspace ${workspace}`
255
+ }
256
+
205
257
  const READY_MS = 60_000
206
258
 
207
259
  /**
208
- * The App's dev server against the local runtime: the one already running,
209
- * or a new `frontera app dev` started in the background, its output in
210
- * `.frontera/local/logs/app-<slug>.log`.
260
+ * The App's dev server against `credential` (the local runtime, or the
261
+ * workspace `frontera app dev --detach` runs against): the one already
262
+ * running, or a new `frontera app dev` started in its own session, its
263
+ * output in `.frontera/local/logs/app-<slug>.log`.
211
264
  */
212
- export async function ensureDevServer(project: LocalProject, app: AppProjectRef, link: RuntimeLink, options: EnsureDevServerOptions = {}): Promise<{ server: DevServer; reused: boolean; installed?: string; trackedFiles?: string[] }> {
265
+ export async function ensureDevServer(project: LocalProject, app: AppProjectRef, credential: DevCredential, options: EnsureDevServerOptions = {}): Promise<{ server: DevServer; reused: boolean; installed?: string; trackedFiles?: string[] }> {
213
266
  const running = readDevServer(project, app.slug)
214
- if (running && (await answers(running.url))) return { server: running, reused: true }
267
+ if (running && (await answers(running.url))) {
268
+ // One server per App folder (Next.js refuses a second), so one against another target is refused, never passed off as this one.
269
+ if (running.target && credential.target && running.target !== credential.target) {
270
+ throw new Error(`${app.slug}'s dev server on port ${running.port} runs against ${targetWords(running.target)}, not ${targetWords(credential.target)}. Stop it with \`frontera app dev --stop\` in ${app.root}, then try again.`)
271
+ }
272
+ return { server: running, reused: true }
273
+ }
215
274
  if (app.framework === 'unknown') throw new Error(`${app.slug} has neither Next.js nor Vite in package.json, so it has no dev server to start.`)
216
275
  // A pulled App comes without node_modules: its packages are installed first (bun install), or `next` isn't there to start.
217
276
  // From its bun.lock as it is when it has one: a pulled App's lockfile is never rewritten here. One whose
@@ -223,7 +282,7 @@ export async function ensureDevServer(project: LocalProject, app: AppProjectRef,
223
282
  // From its own SDK copy (package.json and bun.lock disagree because of it): said with what kept both files as pulled.
224
283
  const where = relative(project.root, app.root) || app.slug
225
284
  const installed = install ? (install.fromCopy || install.fromLock ? `${where} had no node_modules: ${install.summary}` : `${where} had no node_modules: ran bun install first${frozen ? ' from its bun.lock as it is (--frozen-lockfile; the lockfile is unchanged)' : ''} (${install.summary})`) : undefined
226
- const port = await freePort()
285
+ const port = options.port || (await freePort())
227
286
  mkdirSync(join(project.stateDir, 'logs'), { recursive: true })
228
287
  mkdirSync(devDir(project), { recursive: true })
229
288
  const log = join(project.stateDir, 'logs', `app-${app.slug}.log`)
@@ -231,13 +290,19 @@ export async function ensureDevServer(project: LocalProject, app: AppProjectRef,
231
290
  // Where this run's output starts in the log, which keeps earlier runs: its tail is this run's alone.
232
291
  const from = statSync(log).size
233
292
  const [exe, ...pre] = options.command ?? selfCommand()
293
+ const owned = options.owner !== 'detached'
294
+ const env: NodeJS.ProcessEnv = { ...process.env, ...credential.env, NO_COLOR: '1', ...(credential.token ? { [TOKEN_ON_STDIN_ENV]: '1' } : {}) }
295
+ delete env[DEV_OWNER_ENV]
234
296
  const child = spawn(exe!, [...pre, 'app', 'dev', '--no-open', '--port', String(port)], {
235
297
  cwd: app.root,
298
+ // Its own session: a signal to the caller's process group (a shell's job, an agent's background command) never reaches it.
236
299
  detached: true,
237
- stdio: ['ignore', fd, fd],
238
- // The server stops when this process ends, however it ends (`frontera app dev` watches it).
239
- env: { ...process.env, ...localAppEnv(link), NO_COLOR: '1', [DEV_OWNER_ENV]: String(process.pid) },
300
+ stdio: [credential.token ? 'pipe' : 'ignore', fd, fd],
301
+ // An owned server stops when this process ends, however it ends (`frontera app dev` watches it); a detached one runs until stopped.
302
+ env: owned ? { ...env, [DEV_OWNER_ENV]: String(process.pid) } : env,
240
303
  })
304
+ // A child that dies before reading it closes the pipe: what it said goes to the log, not an unhandled EPIPE here.
305
+ if (credential.token) child.stdin!.on('error', () => {}).end(credential.token)
241
306
  closeSync(fd)
242
307
  let exited: DevServerExit | null = null
243
308
  child.on('exit', (code, signal) => {
@@ -249,8 +314,17 @@ export async function ensureDevServer(project: LocalProject, app: AppProjectRef,
249
314
  })
250
315
  child.unref()
251
316
  const origin = `http://127.0.0.1:${port}`
252
- const server: DevServer = { slug: app.slug, pid: child.pid!, port, url: app.framework === 'vite' ? `${origin}/dev-host.html` : origin, startedAt: new Date().toISOString(), log: relative(project.root, log), owner: { kind: options.owner ?? 'mcp', pid: process.pid } }
253
- startedHere.set(`${project.root}\0${app.slug}`, { project, slug: app.slug, pid: server.pid })
317
+ const server: DevServer = {
318
+ slug: app.slug,
319
+ pid: child.pid!,
320
+ port,
321
+ url: app.framework === 'vite' ? `${origin}/dev-host.html` : origin,
322
+ startedAt: new Date().toISOString(),
323
+ log: relative(project.root, log),
324
+ ...(credential.target ? { target: credential.target } : {}),
325
+ owner: owned ? { kind: options.owner ?? 'mcp', pid: process.pid } : { kind: 'detached' },
326
+ }
327
+ if (owned) startedHere.set(`${project.root}\0${app.slug}`, { project, slug: app.slug, pid: server.pid })
254
328
  writeFileSync(devFile(project, app.slug), `${JSON.stringify(server, null, 2)}\n`)
255
329
  const deadline = Date.now() + READY_MS
256
330
  while (Date.now() < deadline) {
@@ -428,8 +502,10 @@ export function watchOwner(owner: number, onGone: () => void, everyMs = 5_000):
428
502
  export function devServerLine(s: DevServer): string {
429
503
  const by = !s.owner
430
504
  ? 'started by hand (`frontera app dev`)'
431
- : s.owner.kind === 'forge'
432
- ? `started from Forge (pid ${s.owner.pid}); it stops when Forge stops it or quits`
433
- : `started by an MCP session (pid ${s.owner.pid}); it stops when that session ends`
505
+ : s.owner.kind === 'detached'
506
+ ? 'started with `frontera app dev --detach`; it runs until `frontera app dev --stop` or `frontera local stop` stops it'
507
+ : s.owner.kind === 'forge'
508
+ ? `started from Forge (pid ${s.owner.pid}); it stops when Forge stops it or quits`
509
+ : `started by an MCP session (pid ${s.owner.pid}); it stops when that session ends`
434
510
  return `${s.slug} on port ${s.port} (${s.url}), ${by}`
435
511
  }
@@ -2,12 +2,13 @@ import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import { itemBuild } from '../../local/local-build-summary'
5
- import { restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
5
+ import { readBaseline, restoreBaseline, saveBaseline, type BaselineRecord, type BaselineSaver, type SnapshotDeps } from '../../local/local-db-snapshot'
6
6
  import type { LocalProject } from '../../local/local-project'
7
7
  import { providerRefusal } from '../../local/local-provider-refusal'
8
8
  import { waitForDatabaseReady } from '../../local/local-runtime-health'
9
9
  import { plainVariables, redactedVariablesOfTimeline, variablesOfTimeline } from '../../local/local-test-cases'
10
10
  import { chatTarget, type ToolCall } from '../../local/local-test-runner'
11
+ import { lockRefusal, readLock } from '../../local/local-writers'
11
12
  import { dropTurn, readTurn, reattachTurn, startTurn, TURN_IDLE_MS, type HeldTurn, type PendingDecision, type TurnRead } from '../mcp-chat'
12
13
  import { conversationTranscript, searchConversations } from '../mcp-conversations'
13
14
  import { errorResult, intArg, jsonResult, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
@@ -339,7 +340,22 @@ export function simulatedWritesLine(count: number): string {
339
340
  return `${count} write${count === 1 ? '' : 's'} simulated, not applied: nothing changed, whatever the reply says (realActions: true in a new conversation runs them for real)`
340
341
  }
341
342
 
342
- export function agentTools(project: LocalProject, memory: McpMemory): McpTool[] {
343
+ const SAVED_BY: Record<BaselineSaver, string> = {
344
+ check: 'a `frontera local check`, which saves them again before its tests',
345
+ snapshot: '`frontera local snapshot save`',
346
+ data: '`frontera local data load`',
347
+ conversation: 'another conversation started with rollback: true',
348
+ }
349
+
350
+ /** Why agent_rollback put nothing back: the records saved for the conversation are no longer the ones a restore puts back. */
351
+ function replacedWords(savedAt: string, stored: BaselineRecord): string {
352
+ return `The records saved when this conversation started (${savedAt}) were replaced at ${stored.savedAt} by ${stored.savedBy ? SAVED_BY[stored.savedBy] : 'a later save (a `frontera local check`, `frontera local snapshot save`, or another conversation started with rollback: true)'}. `
353
+ + 'Nothing was put back: putting back that later save would keep what this conversation wrote. '
354
+ + 'What its real actions wrote is still there: look at it with blueprint_query and change it back, or start the conversation again with rollback: true and call agent_rollback before the next check.'
355
+ }
356
+
357
+ /** `snapshot`: how the records are saved and put back for a conversation with rollback, a stand-in's in tests. */
358
+ export function agentTools(project: LocalProject, memory: McpMemory, snapshot: SnapshotDeps = {}): McpTool[] {
343
359
  /**
344
360
  * The turn as the agent reads it: the reply so far, what the Agent waits
345
361
  * on (kept, with its stream, for the answer), or why the turn stopped
@@ -553,7 +569,7 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
553
569
  },
554
570
  rollback: {
555
571
  type: 'boolean',
556
- description: "With realActions, when starting: save the local runtime's records first, so agent_rollback puts back what this conversation's actions changed once you have read them (default false). It replaces the runtime's saved baseline, as `frontera local snapshot save` does.",
572
+ description: "With realActions, when starting: save the local runtime's records first, so agent_rollback puts back what this conversation's actions changed once you have read them (default false). It replaces the runtime's saved baseline, as `frontera local snapshot save` does, so it is refused while a `frontera local check`, reset or restart holds the runtime.",
557
573
  },
558
574
  },
559
575
  required: ['agent', 'message'],
@@ -577,9 +593,13 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
577
593
  if (rollback && (conversationId || !realActions)) return errorResult('rollback is chosen when a conversation starts with realActions: true; it saves the records first, and agent_rollback puts them back.')
578
594
  let savedAt: string | null = null
579
595
  if (rollback) {
580
- const saved = await saveBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, true)
596
+ // The lock a running check, reset or restart holds (`local-writers.ts`), which `main.ts` takes for the CLI only.
597
+ const held = lockRefusal(project.stateDir, 'local snapshot save') ? readLock(project.stateDir) : null
598
+ if (held) return errorResult(`The conversation was not started: \`frontera ${held.command}\` holds the local runtime since ${held.since.slice(11, 19)}, and rollback would save the records first, replacing the baseline under that run (a check rolls back to its own). Start it again once that run has finished; \`frontera local status\` says whether it still holds the runtime.`)
599
+ const saved = await saveBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl), ...snapshot }, 'conversation')
581
600
  if (!saved.ok) return errorResult(`The records could not be saved first, so the conversation was not started: ${saved.note}`)
582
- savedAt = new Date().toISOString()
601
+ // The saved records' own time: agent_rollback tells them from a later save by it.
602
+ savedAt = readBaseline(project, link.creds.organizationId)?.savedAt ?? new Date().toISOString()
583
603
  }
584
604
 
585
605
  let target: { agentId: string; agentName: string }
@@ -655,7 +675,7 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
655
675
  {
656
676
  name: 'agent_rollback',
657
677
  description:
658
- "Put the local runtime's records back as they were when a conversation started with rollback: true (Forge improvement plan L2): what its real actions wrote, and anything else written since, is undone. The conversation itself, its traces and run history stay to read. Do it once you have checked what the actions did (blueprint_query).",
678
+ "Put the local runtime's records back as they were when a conversation started with rollback: true (Forge improvement plan L2): what its real actions wrote, and anything else written since, is undone. The conversation itself, its traces and run history stay to read. Do it once you have checked what the actions did (blueprint_query), and before the next `frontera local check`: a check saves the records again, and then this refuses rather than keep the conversation's writes.",
659
679
  inputSchema: {
660
680
  type: 'object',
661
681
  properties: { conversationId: { type: 'string', description: 'The conversation agent_chat started with rollback: true.' } },
@@ -669,7 +689,9 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
669
689
  const known = memory.conversations.get(conversationId)
670
690
  if (!known?.rollback) return errorResult('That conversation was not started with rollback: true in this session, so there is nothing saved for it. `frontera local snapshot restore` puts back the runtime\'s saved baseline, whenever it was taken.')
671
691
  if (known.rollback.rolledBack) return errorResult(`Already put back, at ${known.rollback.rolledBack}.`)
672
- const restored = await restoreBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, { secrets: 'keep', history: 'keep' })
692
+ const stored = readBaseline(project, link.creds.organizationId)
693
+ if (stored && stored.savedAt !== known.rollback.savedAt) return errorResult(replacedWords(known.rollback.savedAt, stored))
694
+ const restored = await restoreBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl), ...snapshot }, { secrets: 'keep', history: 'keep' })
673
695
  if (!restored.ok) return errorResult(`The records could not be put back: ${restored.note}`)
674
696
  known.rollback.rolledBack = new Date().toISOString()
675
697
  return jsonResult({ conversationId, putBack: `the records as they were at ${known.rollback.savedAt}`, note: restored.note, kept: 'the conversation, its traces and run history' })
@@ -7,7 +7,7 @@ import type { LocalProject } from '../../local/local-project'
7
7
  import { forgeSession, observeInForge, readForgeLink } from '../forge-link'
8
8
  import { AXE_RUN, axeSource, summarizeA11y } from '../a11y-scan'
9
9
  import { observeHeadless } from '../headless'
10
- import { ensureDevServer, findAppProjects, listDevServers, localAppEnv, resolveAppProject, stopDevServerAndSay } from '../mcp-apps'
10
+ import { ensureDevServer, findAppProjects, listDevServers, localAppEnv, localDevCredential, resolveAppProject, stopDevServerAndSay } from '../mcp-apps'
11
11
  import { runSelf } from '../mcp-cli'
12
12
  import { errorResult, intArg, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
13
13
  import { redactSecret, requireRuntime, runtimeLink, signedStudioUrl } from '../mcp-runtime'
@@ -82,7 +82,7 @@ export function appTools(project: LocalProject): McpTool[] {
82
82
  return { content: [{ type: 'text', text: stopped ? [`Stopped ${app.slug}'s dev server.`, ...trackedFiles.map((l) => `${app.path}: ${l}.`)].join('\n') : `${app.slug} had no dev server running.` }] }
83
83
  }
84
84
  const link = requireRuntime(project)
85
- const { server, reused, installed, trackedFiles } = await ensureDevServer(project, app, link)
85
+ const { server, reused, installed, trackedFiles } = await ensureDevServer(project, app, localDevCredential(link))
86
86
  // An installed SDK older than (or without what is in) the one the skills describe: said with the upgrade.
87
87
  const sdkLines = sdkDriftLines(app.path, appSdkDrift(app.root))
88
88
  const sdk = sdkLines.filter((l) => !/^note:/.test(l))
@@ -130,7 +130,7 @@ export function appTools(project: LocalProject): McpTool[] {
130
130
  target = renderTargetOf(explicit, app.slug, listDevServers(project).find((s) => s.slug === app.slug)?.url ?? null)
131
131
  } else {
132
132
  if (typeof link === 'string') return errorResult(link)
133
- const dev = await ensureDevServer(project, app, link)
133
+ const dev = await ensureDevServer(project, app, localDevCredential(link))
134
134
  installed = dev.installed
135
135
  trackedFiles = dev.trackedFiles
136
136
  url = joinPath(dev.server.url, path)
@@ -11,6 +11,7 @@ import { localOrganizationId } from '../../local/local-blueprint-outline'
11
11
  import { docsStatus } from '../../local/local-docs'
12
12
  import { readConfig } from '../../local/local-env'
13
13
  import { liveAppDeployments, workspaceGet } from '../../local/local-app-runtime'
14
+ import { appTarget } from '../../project'
14
15
  import { projectBuildRows, projectBuildSummary } from '../../local/local-build-summary'
15
16
  import { localSecretPresence } from '../../local/local-secrets'
16
17
  import { readBaseline } from '../../local/local-db-snapshot'
@@ -80,7 +81,7 @@ export function projectTools(project: LocalProject): McpTool[] {
80
81
  // The runtime's secrets now: a "Needs a value" follows them, not the last apply.
81
82
  const secrets = typeof link === 'string' ? null : await localSecretPresence(link.serviceUrl, link.creds)
82
83
  // Each Custom App's deployment as the runtime holds it: a folder's record is not proof it is still there.
83
- const liveApps = typeof link === 'string' ? null : await liveAppDeployments(project.root, workspaceGet(link.serviceUrl, link.creds)).catch(() => null)
84
+ const liveApps = typeof link === 'string' ? null : await liveAppDeployments(project.root, workspaceGet(link.serviceUrl, link.creds), appTarget(link.serviceUrl, link.creds.workspaceId)).catch(() => null)
84
85
  // The rows the build line counts, object types and items never applied included, so the two agree.
85
86
  const rows = projectBuildRows(project.root, project.id, null, null, secrets, liveApps) ?? []
86
87
  const builds: Record<string, ItemBuild> = Object.fromEntries(rows.map(({ ref, ...b }) => [ref, b]))
package/src/project.ts CHANGED
@@ -3,10 +3,16 @@ import { join, dirname } from 'node:path'
3
3
  import { execSync } from 'node:child_process'
4
4
 
5
5
  import { collectSourceFiles, isExcluded } from './packaging'
6
+ import { isLoopback } from './remote-guard'
6
7
 
7
8
  export interface ProjectConfig {
8
- /** The app's real identifier; null until the first ensure. */
9
- appId: string | null
9
+ /**
10
+ * An App id from before the folder kept one per target (`appBinding`):
11
+ * state.json's single `appId`, else package.json `frontera.appId`. Named
12
+ * only where the folder has no binding; the platform ignores an id that is
13
+ * not one of the workspace's Apps.
14
+ */
15
+ fallbackAppId: string | null
10
16
  /** Bootstrap name for the first deploy only — the platform owns it after. */
11
17
  slug: string
12
18
  displayName: string
@@ -83,10 +89,10 @@ export function readProject(dir: string): ProjectConfig {
83
89
  if (!slug) throw new Error('package.json needs a "name"')
84
90
 
85
91
  return {
86
- // The real identifier is local environment state. A source tree remains
87
- // portable across Frontera organizations; first authenticated use binds a
88
- // clone to the App in that environment and subsequent calls use the id.
89
- appId: state.appId ?? pkg.frontera?.appId ?? null,
92
+ // The real identifier is local environment state, one per target. A source
93
+ // tree remains portable across Frontera organizations; first authenticated
94
+ // use binds a clone to the App in that environment.
95
+ fallbackAppId: state.appId ?? pkg.frontera?.appId ?? null,
90
96
  slug,
91
97
  displayName: config.displayName ?? pkg.frontera?.displayName ?? slug,
92
98
  description: config.description ?? pkg.frontera?.description,
@@ -130,12 +136,39 @@ function stringList(value: unknown, field: string): string[] {
130
136
  }
131
137
 
132
138
  /**
133
- * Record the environment-specific app binding locally. Source remains portable
134
- * across organizations and package.json remains ordinary package metadata.
139
+ * Where an App folder deploys: the API's origin and the workspace the
140
+ * credential acts in there. Not the credential itself: keys rotate, and two
141
+ * keys can reach one workspace.
142
+ */
143
+ export function appTarget(apiUrl: string, workspaceId: string): string {
144
+ return `${new URL(apiUrl).origin}#${workspaceId}`
145
+ }
146
+
147
+ /**
148
+ * The App this folder is bound to in `target`, and whether it is bound to
149
+ * Apps in another workspace. A folder deploys to a workspace and to the local
150
+ * runtime (`app dev`, `local dev`); one id for all of them meant a deploy
151
+ * named another target's App, which the platform ignores, so a renamed
152
+ * folder created a second App. A local runtime's binding is not another
153
+ * workspace: building there first is the usual way to a first deploy.
135
154
  */
136
- export function writeAppId(dir: string, appId: string): void {
137
- if (readState(dir).appId === appId) return
138
- writeState(dir, { appId })
155
+ export function appBinding(dir: string, target: string | null): { id: string | null; elsewhere: boolean } {
156
+ const apps = readState(dir).apps ?? {}
157
+ return { id: (target && apps[target]) || null, elsewhere: Object.keys(apps).some((t) => t !== target && !isLoopback(t)) }
158
+ }
159
+
160
+ /**
161
+ * Record the folder's App in `target`. Source remains portable across
162
+ * organizations and package.json remains ordinary package metadata. The
163
+ * single id of an older CLI moves into the binding once the platform answers
164
+ * with it here, which proves it is this target's.
165
+ */
166
+ export function bindApp(dir: string, target: string | null, appId: string): void {
167
+ if (!target) return
168
+ const state = readState(dir)
169
+ const adopted = state.appId === appId
170
+ if (state.apps?.[target] === appId && !adopted) return
171
+ writeState(dir, { apps: { ...state.apps, [target]: appId }, ...(adopted ? { appId: undefined } : {}) })
139
172
  }
140
173
 
141
174
  export function readPackageVersion(dir: string): string {
@@ -147,6 +180,9 @@ export function readPackageVersion(dir: string): string {
147
180
  interface ProjectState {
148
181
  /** The last deployment made from this folder (`app deploy`): what it was deployed as. */
149
182
  parentVersion?: string | null
183
+ /** The folder's App in each target it deploys to, by `appTarget`. */
184
+ apps?: Record<string, string>
185
+ /** An older CLI's one id for every target: read until a deploy proves whose it is (`bindApp`). */
150
186
  appId?: string
151
187
  /**
152
188
  * The version the working tree was pulled from (`app pull`, or a local
@@ -26,7 +26,10 @@ export function isReadOnlyCommand(meta: { noun: string; verb?: string }): boolea
26
26
  return FRONTERA_READ_ONLY_COMMANDS.includes(path) || (!!meta.verb && READ_VERBS.has(meta.verb))
27
27
  }
28
28
 
29
- const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/i
29
+ const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?([/#]|$)/i
30
+
31
+ /** An origin on this computer, or an App target there (`appTarget`, `<origin>#<workspace>`): a local runtime's. */
32
+ export const isLoopback = (url: string): boolean => LOOPBACK.test(url)
30
33
 
31
34
  /**
32
35
  * Where a command is going when the guard should ask, or null when it runs
@@ -1001,7 +1001,10 @@ bun run check
1001
1001
 
1002
1002
  That is \`typecheck\`, then \`bun test\`, then \`build\` — the build is the only
1003
1003
  thing that proves the static export still works. For anything touching data,
1004
- also run it against real data with \`frontera app dev\`.
1004
+ also run it against real data with \`frontera app dev --detach\`: it returns once
1005
+ the App answers and keeps running after your shell ends, and
1006
+ \`frontera app dev --stop\` stops it. Plain \`frontera app dev\` runs in the
1007
+ foreground, for a person's terminal.
1005
1008
  `,
1006
1009
 
1007
1010
  // `@AGENTS.md`, not `./AGENTS.md`. The latter is a relative path Claude
@@ -143,8 +143,12 @@ const dispatcher: Skill = {
143
143
  'against real data:',
144
144
  '',
145
145
  '```bash',
146
- 'frontera app dev',
146
+ 'frontera app dev --detach',
147
147
  '```',
148
+ '',
149
+ 'It returns once the App answers and the server keeps running after your shell ends;',
150
+ '`frontera app dev --stop` stops it. Plain `frontera app dev` runs in the foreground, for a',
151
+ "person's terminal.",
148
152
  ],
149
153
  }
150
154
 
@@ -369,7 +373,7 @@ const session: Skill = {
369
373
  '## Verification',
370
374
  '',
371
375
  '```bash',
372
- 'frontera app dev',
376
+ 'frontera app dev --detach',
373
377
  '```',
374
378
  '',
375
379
  'The App should mount with real rows. A page that renders but 401s on every read means the',
@@ -489,7 +493,7 @@ const blueprintData: Skill = {
489
493
  '```',
490
494
  '',
491
495
  '```bash',
492
- 'frontera app dev',
496
+ 'frontera app dev --detach',
493
497
  '```',
494
498
  '',
495
499
  'Compare the number under the table with the row count for the same filter in the platform. If',
@@ -627,7 +631,7 @@ const actions: Skill = {
627
631
  '```',
628
632
  '',
629
633
  '```bash',
630
- 'frontera app dev',
634
+ 'frontera app dev --detach',
631
635
  '```',
632
636
  '',
633
637
  'Submit one for real and watch it to a terminal state. A flow only tested to the submit call has',
@@ -938,7 +942,7 @@ const queryStates: Skill = {
938
942
  '## Verification',
939
943
  '',
940
944
  '```bash',
941
- 'frontera app dev',
945
+ 'frontera app dev --detach',
942
946
  '```',
943
947
  '',
944
948
  'Exercise all four states: a filter that matches nothing, a stopped network, a first load, and a',