@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.5",
3
+ "version": "1.53.6",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.5",
44
- "@frontera-sdk/functions": "1.53.5",
43
+ "@frontera-sdk/core": "1.53.6",
44
+ "@frontera-sdk/functions": "1.53.6",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.5",
52
+ "@frontera-sdk/forge-contracts": "1.53.6",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -159,8 +159,8 @@ export class AppsApi {
159
159
  * `pull` used `ensureApp`, so a typo or a slug the platform had since
160
160
  * renamed produced a brand new empty app instead of an error.
161
161
  */
162
- resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string }> {
163
- return this.client.request<{ id: string; slug: string; displayName: string }>(
162
+ resolveApp(slugOrId: string): Promise<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }> {
163
+ return this.client.request<{ id: string; slug: string; displayName: string; kind?: 'code' | 'chat' }>(
164
164
  `/v1/platform-apps/resolve/${encodeURIComponent(slugOrId)}`,
165
165
  )
166
166
  }
@@ -4,6 +4,7 @@ import type { MetricQueryBody, MetricQueryResult } from '../blueprint/metric-que
4
4
  import { organizationHeaders, selectedOrganization } from '../organization'
5
5
 
6
6
  import { CliError } from '../errors'
7
+ import type { SavedServer } from '../local/local-mcp-servers'
7
8
  import { BLUEPRINT_SCHEMA_CAPABILITIES } from '../blueprint-types'
8
9
 
9
10
  /**
@@ -291,6 +292,11 @@ export class PlatformApi {
291
292
  return this.get<{ server: unknown }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`)
292
293
  }
293
294
 
295
+ /** Created, or replaced in place: `created` says which. */
296
+ saveMcpServer(slug: string, definition: Record<string, unknown>): Promise<SavedServer> {
297
+ return this.client.request<SavedServer>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'PUT', body: definition })
298
+ }
299
+
294
300
  /** Archived, not deleted: the Function runs its tools started still name it. */
295
301
  archiveMcpServer(slug: string): Promise<{ slug: string; archived: boolean }> {
296
302
  return this.client.request<{ slug: string; archived: boolean }>(`/v1/mcp-servers/${encodeURIComponent(slug)}`, { method: 'DELETE' })
@@ -721,6 +727,20 @@ export class PlatformApi {
721
727
 
722
728
  // ── Knowledge ─────────────────────────────────────────────────────────────
723
729
 
730
+ /**
731
+ * Whether the platform runs the Knowledge app. Its routes answer 404 before any sign-in check where it does not, so
732
+ * any other answer, a refusal of this key included, means it runs. No answer counts as not running.
733
+ */
734
+ async knowledgeAppRuns(): Promise<boolean> {
735
+ try {
736
+ await this.get<unknown>('/v1/knowledge-app/me')
737
+ return true
738
+ } catch (err) {
739
+ const status = (err as { status?: number }).status
740
+ return status !== undefined && status !== 404
741
+ }
742
+ }
743
+
724
744
  knowledgeBases(workspaceId: string) {
725
745
  return this.getList<unknown>(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`)
726
746
  }
@@ -37,7 +37,7 @@ export const appChecks: Command = {
37
37
  if (!deployment) throw new UsageError('say which deployment', 'frontera app checks <deployment id or label>')
38
38
  const project = requireProjectFrom(ctx)
39
39
  const client = new AppsApi(ctx.apiUrl, ctx.token)
40
- const app = await projectApp(client, project)
40
+ const app = await projectApp(ctx, client, project)
41
41
  const res = await client.checks(app.id, deployment)
42
42
  return { data: res, text: renderChecks(res.deployment, res.checks) }
43
43
  },
@@ -9,14 +9,14 @@ import { resolveManifest } from '../../manifest'
9
9
  import { collectSourceFiles, formatBytes } from '../../packaging'
10
10
  import { packDirectory } from '../../pack'
11
11
  import { collectAppContract } from '../../app-contract'
12
- import { writeAppId, writeState } from '../../project'
12
+ import { writeState } from '../../project'
13
13
  import { collectBuildProvenance } from '../../provenance'
14
14
  import { validateStaticAppProject } from '../../static-app-validation'
15
15
  import { describeRenderEvidence, readRenderEvidence, readRenderThumbnail, type RenderEvidence } from '../../render-evidence'
16
16
  import { createTarGz } from '../../tar'
17
17
  import { keepBuildWrittenFiles } from '../../local/local-app-pulled'
18
18
  import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
19
- import { requireProjectFrom } from './shared'
19
+ import { ensureProjectApp, requireProjectFrom } from './shared'
20
20
  import { canPrompt } from '../../prompt'
21
21
  import { flagBool, flagString, type Command } from '../types'
22
22
 
@@ -196,12 +196,14 @@ export const appDeploy: Command = {
196
196
  noun: 'app',
197
197
  verb: 'deploy',
198
198
  args: [],
199
- flags: { label: 'string', version: 'string', prod: 'boolean', preview: 'boolean', 'no-promote': 'boolean', 'skip-build-check': 'boolean', build: 'boolean' },
199
+ flags: { label: 'string', version: 'string', prod: 'boolean', preview: 'boolean', 'no-promote': 'boolean', 'skip-build-check': 'boolean', build: 'boolean', app: 'string' },
200
+ flagHelp: { app: 'an App this workspace already has, by its slug: deploy to it, and keep this folder deploying to it here' },
200
201
  summary: 'Upload the build as a preview deployment; --build to build first, --prod to go live',
201
202
  details: [
202
203
  'A deploy is a preview unless --prod is given. A pipeline (a key, no terminal) must say which it wants: --prod or --preview. Without either it exits with an error, so a pipeline written when a deploy went live stops instead of silently making previews.',
204
+ 'The folder deploys to the App it deployed to before in the same workspace, whatever its package.json name says now: a deploy never renames an App. In a workspace it has not deployed to, it deploys to the App named like the folder, and creates that App when there is none, which the output says.',
203
205
  ],
204
- examples: ['frontera app deploy', 'frontera app deploy --build', 'frontera app deploy --label 1.4.0', 'frontera app deploy --prod', 'frontera app deploy --preview', 'frontera app deploy --prod --no-promote'],
206
+ examples: ['frontera app deploy', 'frontera app deploy --build', 'frontera app deploy --label 1.4.0', 'frontera app deploy --prod', 'frontera app deploy --preview', 'frontera app deploy --prod --no-promote', 'frontera app deploy --app acme-shipments'],
205
207
  needsProject: true,
206
208
  },
207
209
 
@@ -284,7 +286,7 @@ export const appDeploy: Command = {
284
286
  }
285
287
 
286
288
  const client = new AppsApi(ctx.apiUrl, ctx.token)
287
- const app = await client.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
289
+ const app = await ensureProjectApp(ctx, client, project, flagString(ctx, 'app'))
288
290
 
289
291
  ctx.output.note(
290
292
  ` bundle ${distFiles.length} files, ${formatBytes(distFiles.reduce((n, f) => n + f.bytes.byteLength, 0))}`,
@@ -342,10 +344,7 @@ export const appDeploy: Command = {
342
344
  const deployment = res.deployment ?? res.version
343
345
 
344
346
  // On the runtime again: whatever a database restore had taken away is back.
345
- writeState(project.root, { appId: app.id, parentVersion: deployment, restoredAway: undefined })
346
- // Local environment binding: source remains portable and package.json
347
- // remains ordinary package metadata.
348
- writeAppId(project.root, app.id)
347
+ writeState(project.root, { parentVersion: deployment, restoredAway: undefined })
349
348
 
350
349
  // Where a person opens what was just made: its own link, and the moving
351
350
  // links of its branch and session. A preview was once described as one
@@ -360,6 +359,7 @@ export const appDeploy: Command = {
360
359
  const data = {
361
360
  app: app.slug,
362
361
  appId: app.id,
362
+ ...(app.created ? { created: app.created } : {}),
363
363
  deployment,
364
364
  // The same id, under its earlier name, for scripts that read `version`.
365
365
  version: deployment,
@@ -72,7 +72,7 @@ export const appDeployments: Command = {
72
72
  async run(ctx) {
73
73
  const project = requireProjectFrom(ctx)
74
74
  const client = new AppsApi(ctx.apiUrl, ctx.token)
75
- const app = await projectApp(client, project)
75
+ const app = await projectApp(ctx, client, project)
76
76
  const ledger = await client.deployments(app.id)
77
77
  // The checks of the newest deployments that still have files; an older or
78
78
  // failed read falls back to the render evidence the ledger already carries.
@@ -1,19 +1,31 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, relative } from 'node:path'
3
3
 
4
4
  import { AppsApi } from '../../api/apps-api'
5
5
  import { PlatformApi } from '../../api/platform-api'
6
6
  import { createDevSessionBroker, freeLoopbackPort, isLoopbackPortFree } from '../../dev-broker'
7
7
  import { CliError, UsageError } from '../../errors'
8
- import { DEV_OWNER_ENV, enclosingSolution, forgetDevServer, registerDevServer, watchOwner } from '../../mcp/mcp-apps'
8
+ import {
9
+ DEV_OWNER_ENV,
10
+ devServerHome,
11
+ devServerLine,
12
+ enclosingSolution,
13
+ ensureDevServer,
14
+ forgetDevServer,
15
+ readDevServer,
16
+ registerDevServer,
17
+ stopDevServerAndSay,
18
+ watchOwner,
19
+ } from '../../mcp/mcp-apps'
9
20
  import { ensureAppPackages } from '../../project-bootstrap'
10
21
  import { detectAppFramework } from '../../provenance'
11
- import { writeAppId } from '../../project'
22
+ import type { AppProject } from '../../context'
23
+ import { bindApp } from '../../project'
12
24
  import { mintDevSession, writeDevSessionState } from '../../local/local-app-dev-session'
13
25
  import { keepDevWrittenFiles, leftLine, putBackLine } from '../../local/local-app-dev-files'
14
26
  import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
15
- import { requireProjectFrom } from './shared'
16
- import { flagBool, flagString, type Command } from '../types'
27
+ import { ensureProjectApp, requireProjectFrom } from './shared'
28
+ import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
17
29
 
18
30
  const DEFAULT_PORT = 3000
19
31
  const READY_TIMEOUT_MS = 30_000
@@ -74,19 +86,69 @@ function withoutOwner(env: NodeJS.ProcessEnv): Record<string, string> {
74
86
  return rest as Record<string, string>
75
87
  }
76
88
 
89
+ /**
90
+ * `--detach`: the same server, started by `ensureDevServer` in its own session with its output in a log, so it
91
+ * outlives the shell that started it (an agent's background command is killed at its time limit, with its whole
92
+ * process group). Recorded where `--stop`, `local stop`, MCP `app_dev` and Forge find it; a live one is reused.
93
+ */
94
+ async function detach(ctx: CommandContext, project: AppProject): Promise<CommandResult> {
95
+ const raw = flagString(ctx, 'port')
96
+ const port = raw === undefined ? 0 : appPort(raw)
97
+ // Here, not in the server's log: a creation is said where the caller reads it, and a bad credential fails now.
98
+ const app = await ensureProjectApp(ctx, new AppsApi(ctx.apiUrl, ctx.token), project)
99
+ const home = devServerHome(project.root)
100
+ const ref = { slug: project.slug, name: project.displayName, root: project.root, path: relative(home.root, project.root) || '.', framework: detectAppFramework(project.root) }
101
+ let started: Awaited<ReturnType<typeof ensureDevServer>>
102
+ try {
103
+ started = await ensureDevServer(home, ref, { env: { FRONTERA_API_URL: ctx.apiUrl }, token: ctx.token, target: app.target }, { owner: 'detached', port })
104
+ } catch (error) {
105
+ throw new CliError((error as Error).message, { code: 'FAILURE', cause: error, hint: '`frontera app dev` without --detach runs the server in the foreground, where its output shows as it starts' })
106
+ }
107
+ const { server, reused, installed, trackedFiles } = started
108
+ if (installed) ctx.output.note(`[dev] ${installed}`)
109
+ for (const line of trackedFiles ?? []) ctx.output.note(`[dev] ${line}`)
110
+ const logFile = join(home.root, server.log)
111
+ const log = existsSync(logFile) ? relative(ctx.cwd, logFile) : server.log
112
+ return {
113
+ data: { appId: app.id, origin: server.url, port: server.port, pid: server.pid, log, reused },
114
+ text: [
115
+ reused ? `Reused the dev server already running: ${devServerLine(server)}.` : `${app.slug} runs at ${server.url} in its own session: it keeps running after this shell ends.`,
116
+ ` log: ${log}`,
117
+ ' stop it: `frontera app dev --stop` in this folder',
118
+ ].join('\n'),
119
+ }
120
+ }
121
+
122
+ /** `--stop`: the App's recorded dev server, however it was started, and what it put back as it stopped. */
123
+ async function stopRecorded(ctx: CommandContext, project: AppProject): Promise<CommandResult> {
124
+ const home = devServerHome(project.root)
125
+ const server = readDevServer(home, project.slug)
126
+ const { stopped, trackedFiles } = await stopDevServerAndSay(home, project.slug)
127
+ for (const line of trackedFiles) ctx.output.note(`[dev] ${line}`)
128
+ if (!stopped || !server) return { data: { stopped: false }, text: `${project.slug} had no dev server running.` }
129
+ return { data: { stopped: true, port: server.port, pid: server.pid }, text: `Stopped ${project.slug}'s dev server (port ${server.port}).` }
130
+ }
131
+
77
132
  export const appDev: Command = {
78
133
  meta: {
79
134
  noun: 'app',
80
135
  verb: 'dev',
81
136
  args: [],
82
- flags: { port: 'string', 'no-open': 'boolean' },
137
+ flags: { port: 'string', 'no-open': 'boolean', detach: 'boolean', stop: 'boolean' },
138
+ flagHelp: { stop: 'stop this App\'s dev server, however it was started, and exit' },
83
139
  summary: 'Run an App locally with short-lived authenticated Blueprint access',
84
- examples: ['frontera app dev', 'frontera app dev --port 3210 --no-open', 'frontera app dev --port 0'],
140
+ details: [
141
+ 'Without --detach the server runs in the foreground until you stop it (Ctrl+C). With --detach it starts in its own session, returns once the App answers, and prints its URL, its log and how to stop it: use it from a coding agent, whose background commands end at a time limit. Run it again to get the same server back.',
142
+ ],
143
+ examples: ['frontera app dev', 'frontera app dev --detach', 'frontera app dev --stop', 'frontera app dev --port 3210 --no-open', 'frontera app dev --port 0'],
85
144
  needsProject: true,
86
145
  },
87
146
 
88
147
  async run(ctx) {
89
148
  const project = requireProjectFrom(ctx)
149
+ if (flagBool(ctx, 'detach') && flagBool(ctx, 'stop')) throw new UsageError('--detach and --stop do opposite things', 'start it with `frontera app dev --detach`, stop it with `frontera app dev --stop`')
150
+ if (flagBool(ctx, 'stop')) return stopRecorded(ctx, project)
151
+ if (flagBool(ctx, 'detach')) return detach(ctx, project)
90
152
  const asked = appPort(flagString(ctx, 'port'))
91
153
  // `--port 0`: one free on every loopback, said with the URL below.
92
154
  const port = asked === 0 ? await freeLoopbackPort() : asked
@@ -109,10 +171,10 @@ export const appDev: Command = {
109
171
  }
110
172
  if (installed) ctx.output.note(`[dev] ${installed.summary}`)
111
173
  const apps = new AppsApi(ctx.apiUrl, ctx.token)
112
- const app = await apps.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
113
- writeAppId(project.root, app.id)
174
+ const app = await ensureProjectApp(ctx, apps, project)
114
175
  // The solution this App is in, if any: where status reads how this server's sessions fare.
115
176
  const solution = enclosingSolution(project.root)
177
+ const home = devServerHome(project.root)
116
178
  // The id sessions are minted for: a restore to a baseline from before the App existed takes it away.
117
179
  let appId = app.id
118
180
 
@@ -132,12 +194,12 @@ export const appDev: Command = {
132
194
  appId,
133
195
  mint: (id) => apps.appToken(id),
134
196
  // Gone from the runtime (`local snapshot restore`): registered again by its slug, as `app dev` started.
135
- reRegister: async () => (await apps.ensureApp(project.slug, project.displayName, null, { icon: project.icon })).id,
197
+ reRegister: async () => (await apps.ensureApp(app.slug, project.displayName, null, { icon: project.icon })).id,
136
198
  onReRegistered: (id) => {
137
199
  if (id === appId) return
138
200
  ctx.output.note(`[dev] the runtime no longer had ${app.slug} (a restore of its database?): registered it again as ${id}; \`frontera app deploy\` puts its code back`)
139
201
  appId = id
140
- writeAppId(project.root, id)
202
+ bindApp(project.root, app.target, id)
141
203
  },
142
204
  record: solution ? (state) => writeDevSessionState(solution.stateDir, project.slug, state) : undefined,
143
205
  })
@@ -235,8 +297,11 @@ export const appDev: Command = {
235
297
  const back = putBackLine(kept.afterReady(), 'started')
236
298
  if (back) ctx.output.note(`[dev] ${back}`)
237
299
  ctx.output.note(`[dev] ready: ${targetUrl}`)
238
- // In a solution, say so where its agents look, so they render this server instead of starting another.
239
- if (solution && !owner) registerDevServer(solution, { slug: project.slug, pid: process.pid, port, url: targetUrl, startedAt: new Date().toISOString(), log: 'the terminal running frontera app dev' })
300
+ // Said where agents, Forge and `--detach` look, so they use this server instead of starting another. One
301
+ // `--detach` started is already recorded, with its log; a live one another process recorded stays.
302
+ if (!owner && !readDevServer(home, project.slug)) {
303
+ registerDevServer(home, { slug: project.slug, pid: process.pid, port, url: targetUrl, startedAt: new Date().toISOString(), log: 'the terminal running frontera app dev', ...(app.target ? { target: app.target } : {}) })
304
+ }
240
305
  if (!flagBool(ctx, 'no-open')) openBrowser(targetUrl)
241
306
  const exitCode = await child.exited
242
307
  if (!stopped && exitCode !== 0) {
@@ -253,7 +318,7 @@ export const appDev: Command = {
253
318
  unwatch?.()
254
319
  process.off('SIGINT', onSignal)
255
320
  process.off('SIGTERM', onSignal)
256
- if (solution) forgetDevServer(solution, project.slug, process.pid)
321
+ forgetDevServer(home, project.slug, process.pid)
257
322
  stop()
258
323
  // Once the framework's server has exited (or after a while, whatever it does), what it wrote goes back.
259
324
  await Promise.race([child.exited, Bun.sleep(STOP_WAIT_MS)])
@@ -35,7 +35,7 @@ export const appPromote: Command = {
35
35
  if (force && !reason?.trim()) throw new UsageError('--force needs --reason', 'frontera app promote <deployment> --force --reason "why the checks can wait"')
36
36
  const project = requireProjectFrom(ctx)
37
37
  const client = new AppsApi(ctx.apiUrl, ctx.token)
38
- const app = await projectApp(client, project)
38
+ const app = await projectApp(ctx, client, project)
39
39
  try {
40
40
  const res = await client.promote(app.id, { deployment, ...(force ? { force: true, reason } : {}) })
41
41
  const name = res.label ?? res.promoted
@@ -3,9 +3,10 @@ import { join } from 'node:path'
3
3
  import { AppsApi } from '../../api/apps-api'
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { healProject } from '../../heal'
6
- import { dirtyFiles, writeState } from '../../project'
6
+ import { bindApp, dirtyFiles, writeState } from '../../project'
7
7
  import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
8
8
  import { flagBool, type Command } from '../types'
9
+ import { credentialTarget } from './shared'
9
10
  import { unpackTo } from '../../untar'
10
11
 
11
12
  export const appPull: Command = {
@@ -96,7 +97,8 @@ export const appPull: Command = {
96
97
  const count = unpackTo(dir, bytes)
97
98
  // What was pulled, apart from a deployment from here: the folder is not read as deployed until `app deploy`,
98
99
  // whose parent is this version (`readProject`). A deployment recorded before this pull no longer describes the tree.
99
- writeState(dir, { appId: app.id, parentVersion: null, pulledFrom: app.id, pulledVersion: parentVersion })
100
+ writeState(dir, { parentVersion: null, pulledFrom: app.id, pulledVersion: parentVersion })
101
+ bindApp(dir, await credentialTarget(ctx), app.id)
100
102
 
101
103
  // Pull's contract is "give me a working copy", and a copy that cannot
102
104
  // `bun install` is not one. Apps stored by an older toolchain carry
@@ -25,7 +25,7 @@ export const appRollback: Command = {
25
25
  if (!undo && !reason?.trim()) throw new UsageError('a rollback needs --reason', 'frontera app rollback --reason "what went wrong"')
26
26
  const project = requireProjectFrom(ctx)
27
27
  const client = new AppsApi(ctx.apiUrl, ctx.token)
28
- const app = await projectApp(client, project)
28
+ const app = await projectApp(ctx, client, project)
29
29
  if (undo) {
30
30
  const res = await client.undoRollback(app.id)
31
31
  return {
@@ -1,7 +1,6 @@
1
1
  import { AppsApi } from '../../api/apps-api'
2
2
  import { packDirectory } from '../../pack'
3
- import { writeAppId, writeState } from '../../project'
4
- import { requireProjectFrom } from './shared'
3
+ import { ensureProjectApp, requireProjectFrom } from './shared'
5
4
  import type { Command } from '../types'
6
5
 
7
6
  export const appSave: Command = {
@@ -21,15 +20,12 @@ export const appSave: Command = {
21
20
 
22
21
  // save and deploy are the only verbs that may bring an app into
23
22
  // existence; every read verb resolves instead.
24
- const app = await client.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
23
+ const app = await ensureProjectApp(ctx, client, project)
25
24
  const { tgz } = packDirectory(project.root, ctx.output, 'packaged')
26
25
  const res = await client.saveDraft(app.id, tgz, project.parentVersion)
27
26
 
28
- writeState(project.root, { appId: app.id })
29
- writeAppId(project.root, app.id)
30
-
31
27
  return {
32
- data: { app: app.slug, appId: app.id, fileCount: res.fileCount },
28
+ data: { app: app.slug, appId: app.id, ...(app.created ? { created: app.created } : {}), fileCount: res.fileCount },
33
29
  text: `Saved draft for ${app.slug} (${res.fileCount} files)`,
34
30
  }
35
31
  },
@@ -1,5 +1,7 @@
1
1
  import { AppsApi } from '../../api/apps-api'
2
+ import { PlatformApi } from '../../api/platform-api'
2
3
  import { UsageError } from '../../errors'
4
+ import { appBinding, appTarget, bindApp } from '../../project'
3
5
  import type { AppProject } from '../../context'
4
6
  import type { CommandContext } from '../types'
5
7
 
@@ -13,6 +15,16 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
13
15
  return ctx.project
14
16
  }
15
17
 
18
+ /**
19
+ * The target this credential reaches (`appTarget`): its workspace as `whoami`
20
+ * answers, asked the way `AppsApi` asks, so it is the workspace the App calls
21
+ * land in. Null when it cannot say; the folder's bindings are then left alone.
22
+ */
23
+ export async function credentialTarget(ctx: CommandContext): Promise<string | null> {
24
+ const me = await new PlatformApi(ctx.apiUrl, ctx.token).whoami().catch(() => null)
25
+ return me?.workspaceId ? appTarget(ctx.apiUrl, me.workspaceId) : null
26
+ }
27
+
16
28
  /**
17
29
  * The app this project belongs to, without creating one.
18
30
  *
@@ -20,6 +32,51 @@ export function requireProjectFrom(ctx: CommandContext): AppProject {
20
32
  * that ensured instead would turn a typo, or a slug the platform had since
21
33
  * renamed, into a brand new empty app.
22
34
  */
23
- export function projectApp(client: AppsApi, project: AppProject) {
24
- return client.resolveApp(project.appId ?? project.slug)
35
+ export async function projectApp(ctx: CommandContext, client: AppsApi, project: AppProject) {
36
+ const bound = appBinding(project.root, await credentialTarget(ctx)).id
37
+ return client.resolveApp(bound ?? project.fallbackAppId ?? project.slug)
38
+ }
39
+
40
+ /**
41
+ * The folder's App where the credential acts, created when it has none there
42
+ * (`save`, `deploy`, `app dev`), and bound to the folder for that target. A
43
+ * creation is said: a person who renamed the folder or moved it to another
44
+ * workspace must not learn of a second App from the gallery. `app` names an
45
+ * existing App to deploy to instead, which never creates one.
46
+ */
47
+ export async function ensureProjectApp(
48
+ ctx: CommandContext,
49
+ client: AppsApi,
50
+ project: AppProject,
51
+ app?: string,
52
+ ): Promise<{ id: string; slug: string; created: string | null; target: string | null }> {
53
+ const target = await credentialTarget(ctx)
54
+ const known = appBinding(project.root, target)
55
+ const named = app ? await client.resolveApp(app) : null
56
+ // Refused before the folder is bound to it: every later deploy would send its id, and fail the same way.
57
+ if (named?.kind && named.kind !== 'code') {
58
+ throw new UsageError(`${named.slug} is a Chat App, and a folder deploys to a Custom App`, '`frontera app list` names the Apps; pass a Custom App\'s slug to `--app`, or leave `--app` out to deploy to the folder\'s own')
59
+ }
60
+ const ensured = named
61
+ ? { ...named, created: false }
62
+ : await client.ensureApp(project.slug, project.displayName, known.id ?? project.fallbackAppId, { icon: project.icon })
63
+ bindApp(project.root, target, ensured.id)
64
+ const created = ensured.created ? createdApp(ensured.slug, known.id ? 'gone' : known.elsewhere || project.fallbackAppId ? 'elsewhere' : 'first') : null
65
+ if (created) ctx.output.note(` ${created}`)
66
+ // The platform owns the name once the App exists (App settings renames it); the folder's name only creates one.
67
+ else if (ensured.slug !== project.slug) ctx.output.note(` bound to the App ${ensured.slug} here: this folder's own name, ${project.slug}, names an App only on its first deploy to a workspace and never renames one (rename it in Apps, App settings)`)
68
+ return { id: ensured.id, slug: ensured.slug, created, target }
69
+ }
70
+
71
+ /**
72
+ * What a creation says: plain for a folder's first deploy anywhere. A folder
73
+ * that has an App elsewhere, or had one here, gets the way back too, since
74
+ * this workspace may hold its App under another name.
75
+ */
76
+ function createdApp(slug: string, before: 'first' | 'gone' | 'elsewhere'): string {
77
+ if (before === 'first') return `created the App ${slug} in this workspace`
78
+ const why = before === 'gone' ? ': the App this folder deployed to here is gone' : ', this folder\'s first deploy here (it has deployed to other workspaces)'
79
+ return `created a new App ${slug} in this workspace${why}. `
80
+ + 'If this workspace already had the App under another name, deploy to that one instead with `frontera app deploy --app <slug>` (`frontera app list` names them) '
81
+ + `and archive ${slug} in Apps. A deploy never renames an App: rename one in Apps, App settings.`
25
82
  }
@@ -20,7 +20,7 @@ export const appUnpin: Command = {
20
20
  if (!reason?.trim()) throw new UsageError('unpinning needs --reason', 'frontera app unpin --reason "why production may move again"')
21
21
  const project = requireProjectFrom(ctx)
22
22
  const client = new AppsApi(ctx.apiUrl, ctx.token)
23
- const app = await projectApp(client, project)
23
+ const app = await projectApp(ctx, client, project)
24
24
  const res = await client.unpin(app.id, reason)
25
25
  return { data: { app: app.slug, appId: app.id, ...res }, text: `Unpinned ${app.slug}: production keeps serving ${res.productionLabel ?? res.production ?? 'what it serves'}, and promotion works again` }
26
26
  },
@@ -17,7 +17,7 @@ export const appVersions: Command = {
17
17
  async run(ctx) {
18
18
  const project = requireProjectFrom(ctx)
19
19
  const client = new AppsApi(ctx.apiUrl, ctx.token)
20
- const app = await projectApp(client, project)
20
+ const app = await projectApp(ctx, client, project)
21
21
  const versions = await client.listVersions(app.id)
22
22
 
23
23
  return {
@@ -26,6 +26,19 @@ interface KnowledgeRow {
26
26
  }
27
27
 
28
28
 
29
+ /**
30
+ * These commands manage the knowledge bases Agents attach. The Knowledge app's
31
+ * bases (organization and workspace) are another thing with no command or
32
+ * project file, and no local runtime runs the app. Said only where the
33
+ * platform runs the app: elsewhere there is no such base to mistake for one.
34
+ */
35
+ async function knowledgeAppNote(api: PlatformApi): Promise<string> {
36
+ return (await api.knowledgeAppRuns())
37
+ ? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, not on a local runtime. '
38
+ + 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there.'
39
+ : ''
40
+ }
41
+
29
42
  /** Accept a base by name or id — `knowledge list` shows both. */
30
43
  async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
31
44
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -38,7 +51,7 @@ async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<st
38
51
 
39
52
  throw new CliError(`no knowledge base named "${ref}"`, {
40
53
  code: 'NOT_FOUND',
41
- hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}`,
54
+ hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}.${await knowledgeAppNote(client)}`,
42
55
  })
43
56
  }
44
57
 
@@ -61,7 +74,7 @@ const list: Command = {
61
74
  data: rows,
62
75
  text:
63
76
  rows.length === 0
64
- ? 'No knowledge bases in this workspace.'
77
+ ? `No knowledge bases in this workspace.${await knowledgeAppNote(api)}`
65
78
  : // NAME first: it is the value `knowledge sources` takes, not the id.
66
79
  // No `displayName` on these rows, so a column for it was always
67
80
  // blank and shifted the description out of alignment.
@@ -12,7 +12,7 @@ import { appPulledAsIs, keepBuildWrittenFiles } from '../../local/local-app-pull
12
12
  import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
13
13
  import { repullRefFor } from '../../local/local-app-code'
14
14
  import { customAppFolders } from '../../local/local-apps'
15
- import { readBaseline, restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
15
+ import { endCheckBaseline, readBaseline, restoreBaseline, restoreCutOffCheck, saveBaseline } from '../../local/local-db-snapshot'
16
16
  import { localIngestApi, settleKnowledge, type KnowledgeSettle } from '../../local/local-knowledge-ingestion'
17
17
  import { localProject, type LocalProject } from '../../local/local-project'
18
18
  import { projectSecretLines } from '../../local/local-project-secrets'
@@ -53,12 +53,12 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
53
53
  *
54
54
  * The tests write for real (a workflow test always does, as does an Agent
55
55
  * case with `realActions` or a `decision`), so after the apply the database
56
- * is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
57
- * is applied changed), and after the tests it is restored: each check starts
58
- * from the data the project's files make. The run history (conversations,
59
- * runs, evaluations) is kept across it (`local-history-carry.ts`), so one
60
- * check's results can be compared with the last. `--keep-state` keeps what the
61
- * tests wrote, to look at it.
56
+ * is kept as a baseline (`local-db-snapshot.ts`, a new copy every check), and
57
+ * after the tests it is restored: the tests' writes are rolled back, and what
58
+ * was written before the check (a person's sign-up, an App's records) stays.
59
+ * The run history (conversations, runs, evaluations) is kept across it
60
+ * (`local-history-carry.ts`), so one check's results can be compared with the
61
+ * last. `--keep-state` keeps what the tests wrote, to look at it.
62
62
  *
63
63
  * Knowledge documents are read by a background job whose work lands in the
64
64
  * database: before the baseline and the tests, the check waits (bounded)
@@ -104,10 +104,8 @@ export function blockedBy(step: CheckStepName, failed: ReadonlySet<CheckStepName
104
104
  /** What `--skip` may leave out: the runtime and the apply are the gate itself. */
105
105
  const SKIPPABLE: readonly CheckStepName[] = ['typecheck', 'unit', 'apps', 'agents', 'workflows']
106
106
 
107
- /** What became of the tests' writes: rolled back to the applied baseline, kept, or not rolled back and why. */
107
+ /** What became of the tests' writes: rolled back to the baseline taken before them, kept, or not rolled back and why. */
108
108
  export interface CheckTestData {
109
- /** taken: a new baseline after this apply; reused: the stored one is of this build. */
110
- baseline?: 'taken' | 'reused'
111
109
  rolledBack: boolean
112
110
  /** --keep-state */
113
111
  kept?: boolean
@@ -406,6 +404,13 @@ async function check(ctx: CommandContext) {
406
404
  continue
407
405
  }
408
406
  if (step === 'apply') {
407
+ // A check cut off before its rollback left its tests' writes: its baseline goes back before this apply (within the hour).
408
+ const before = readState(project)
409
+ if (before?.credentials && running(project)) {
410
+ const cut = await restoreCutOffCheck(project, before.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${before.ports.service}`) })
411
+ if (cut?.left) ctx.output.note(`── test data: ${cut.note}`)
412
+ else if (cut) ctx.output.note(`── test data: the last \`frontera local check\` stopped before it rolled back its tests’ writes, ${cut.ok ? 'so that is done first' : 'and they couldn’t be rolled back now'}: ${cut.note}`)
413
+ }
409
414
  say(step, 'frontera local dev --once')
410
415
  // Which Agents serve customers: an operating.json leaves an Agent out of the built-in Chat (X4).
411
416
  if (customerFacing.length) ctx.output.note(` note: ${customerFacingWords(customerFacing)}`)
@@ -448,9 +453,9 @@ async function check(ctx: CommandContext) {
448
453
  if (state?.credentials && running(project) && testsWillRun()) {
449
454
  if (keepState) testData = { rolledBack: false, kept: true, note: 'kept what the tests write (--keep-state)' }
450
455
  else {
451
- const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
456
+ const saved = await saveBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, 'check')
452
457
  baselineReady = saved.ok
453
- if (saved.ok) testData = { baseline: saved.took ? 'taken' : 'reused', rolledBack: false, note: saved.note }
458
+ if (saved.ok) testData = { rolledBack: false, note: saved.note }
454
459
  else testData = { rolledBack: false, note: `not rolled back: ${saved.note}` }
455
460
  ctx.output.note(` test data: ${saved.ok ? saved.note : `couldn’t keep a baseline, so what the tests write stays: ${saved.note}`}`)
456
461
  }
@@ -666,7 +671,10 @@ async function check(ctx: CommandContext) {
666
671
  secretNotes.push(...lost)
667
672
  }
668
673
  }
669
- } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); the next `frontera local check` rolls them back')
674
+ } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); they stay: the next `frontera local check` rolls back only its own tests’ writes')
675
+ // A baseline taken for tests that then did not run holds nothing of theirs to put back.
676
+ const ended = readState(project)
677
+ if (baselineReady && !testsRan && ended?.credentials) endCheckBaseline(project, ended.credentials.organizationId)
670
678
  const testDataField = { ...(testData && testsRan ? { testData } : {}), ...(customerFacing.length ? { customerFacing } : {}) }
671
679
 
672
680
  if (flagBool(ctx, 'stop')) {
@@ -114,7 +114,7 @@ async function loadCommand(ctx: Parameters<Command['run']>[0]) {
114
114
  const organizationId = state.credentials.organizationId
115
115
  const snapshot = !flagBool(ctx, 'no-snapshot')
116
116
  if (snapshot) {
117
- const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, true))
117
+ const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, 'data'))
118
118
  if (!saved.ok) throw new CliError(`the records could not be saved first, so nothing was loaded: ${saved.note}`, { code: 'LOCAL_SNAPSHOT_FAILED', hint: '`--no-snapshot` loads without saving them' })
119
119
  }
120
120
  const persona = await actAsLocalPersona({ meta: { noun: 'action', verb: 'submit' }, cwd: ctx.cwd, apiUrl: serviceUrl, wanted: 'submitter' })
@@ -489,7 +489,7 @@ async function status(ctx: CommandContext) {
489
489
  docs: docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource }),
490
490
  notices: projectNotices(project.root),
491
491
  // The App dev servers running against this runtime, with their ports: `frontera local stop` stops them.
492
- appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner ? { ownerPid: s.owner.pid } : {}) })),
492
+ appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner && 'pid' in s.owner ? { ownerPid: s.owner.pid } : {}) })),
493
493
  // Knowledge documents pending or processing with no ingestion behind them, each with its fix.
494
494
  ...(stuck.length ? { knowledgeStuck: stuck } : {}),
495
495
  // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).