@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
package/src/mcp/mcp-apps.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
119
|
-
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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,
|
|
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)))
|
|
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
|
-
//
|
|
239
|
-
env: { ...
|
|
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 = {
|
|
253
|
-
|
|
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 === '
|
|
432
|
-
?
|
|
433
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
9
|
-
|
|
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
|
|
87
|
-
// portable across Frontera organizations; first authenticated
|
|
88
|
-
// clone to the App in that environment
|
|
89
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
package/src/remote-guard.ts
CHANGED
|
@@ -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+)?(
|
|
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',
|