@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
@@ -1,21 +1,29 @@
1
- import { GovernedActionApi } from '../../api/governed-action-api'
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
2
4
  import { CliError, UsageError } from '../../errors'
3
- import { flagString, type Command } from '../types'
5
+ import { canPrompt, promptConfirm } from '../../prompt'
6
+ import { flagBool, flagString, type Command } from '../types'
4
7
 
5
8
  /**
6
- * Retire an Action's deployment decision (Forge improvement plan X11).
9
+ * Retire an Action's deployment, so a changed Action can publish (Forge
10
+ * improvement plan X11).
7
11
  *
8
- * `action prepare` records that a drafted Action ships switched off, and the
9
- * decision pins the Action's contract as it was then: editing the drafted
10
- * Action after preparing it left it stranded, since publish refuses the
11
- * changed contract and a second prepare refuses a second decision. Retiring
12
- * the decision stands it down, so `action prepare` can record one for the
13
- * contract as it is now.
12
+ * A deployment pins the Action's contract as it was when it was decided:
13
+ * prepared, or deployed and turned on. Publish refuses a changed contract while
14
+ * a deployment still pins the old one, and a deployment cannot follow a new
15
+ * contract. Retiring stands it down, and the next `blueprint publish` records
16
+ * that the Action as it is now ships switched off.
14
17
  *
15
- * Only a decision that was never bound, or one already draining, retires:
16
- * the service refuses an active deployment, which is switched off first. It
17
- * arms nothing and removes nothing from the Blueprint. It takes a signed-in
18
- * person with `actionBinding: retire`.
18
+ * A running deployment is switched off first, as the service's `disable` does
19
+ * it: the running one drains and a disabled decision takes its place. Then
20
+ * every draining deployment of the Action and the decision are retired. A run
21
+ * cut short is finished by running it again. It takes a signed-in person with
22
+ * `actionBinding: activate` and `retire`, and removes nothing from the Blueprint.
23
+ * Switching off a running deployment is asked first, naming the Action and the
24
+ * host; `--yes` answers for a caller with no terminal. When the write path the
25
+ * Action last ran calls another system, it says that a second person reviews
26
+ * the one that replaces it, which retiring does not change.
19
27
  */
20
28
  export const actionRetire: Command = {
21
29
  meta: {
@@ -24,14 +32,17 @@ export const actionRetire: Command = {
24
32
  authLane: 'session',
25
33
  args: [{ name: 'action', required: true, description: 'The Action’s API name' }],
26
34
  flags: { reason: 'string' },
27
- summary: 'Retire an Action’s deployment decision, so a changed draft can be prepared again',
35
+ summary: 'Switch off and retire an Action’s deployment, so a changed Action can publish',
28
36
  details: [
29
- 'For an Action prepared before its draft changed: retire, then `frontera action prepare <action>` records a decision for the contract as it is now.',
30
- 'An active deployment is refused: switch it off in Console first.',
37
+ 'For an Action changed after it was prepared or deployed: retire, then `frontera blueprint publish` records a decision for the Action as it is now.',
38
+ 'A running deployment is switched off first; the Action does not run again until `frontera action deploy <action>` turns it on.',
39
+ 'It asks before switching off a running deployment, naming the Action and the host; --yes switches it off without asking.',
31
40
  ],
32
41
  examples: [
33
- 'frontera action retire escalateTicket --reason "draft changed"',
34
- 'frontera action prepare escalateTicket',
42
+ 'frontera action retire escalateTicket --reason "the Action changed"',
43
+ 'frontera action retire escalateTicket --reason "the Action changed" --yes',
44
+ 'frontera blueprint publish',
45
+ 'frontera action deploy escalateTicket',
35
46
  ],
36
47
  },
37
48
 
@@ -39,22 +50,63 @@ export const actionRetire: Command = {
39
50
  const apiName = ctx.positional[0]
40
51
  if (!apiName) throw new UsageError('missing <action>', 'frontera action retire escalateTicket --reason "…"')
41
52
  const reason = flagString(ctx, 'reason')?.trim()
42
- if (!reason) throw new UsageError('say why with --reason', 'the reason is kept with the deployment’s history: frontera action retire escalateTicket --reason "the draft changed after it was prepared"')
53
+ if (!reason) throw new UsageError('say why with --reason', 'the reason is kept with the deployment’s history: frontera action retire escalateTicket --reason "the Action changed"')
43
54
 
44
55
  const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
45
56
  const definitionId = await client.actionDefinitionId(apiName)
46
57
  if (!definitionId) throw new CliError(`No Action "${apiName}" on the draft or in the release.`, { code: 'ACTION_NOT_FOUND', hint: 'frontera action list' })
47
58
  const current = await client.currentDeployment(definitionId)
48
- if (!current) {
49
- return { data: { action: apiName, retired: false }, text: `"${apiName}" carries no deployment decision: nothing to retire. \`frontera action prepare ${apiName}\` records one.` }
59
+ const running = current?.state === 'active'
60
+ const at = typeof ctx.flags.profile === 'string' ? ` --profile ${ctx.flags.profile}` : ''
61
+ if (running && !flagBool(ctx, 'yes')) {
62
+ const said = `"${apiName}" is running on ${new URL(ctx.apiUrl).host}; retiring it switches it off until it is deployed again`
63
+ const go = (ctx.interactive ?? canPrompt()) && ctx.flags['no-input'] !== true ? await promptConfirm(`${said}. Switch it off?`) : false
64
+ if (!go) {
65
+ throw new CliError(`${said}, so nothing was retired.`, {
66
+ code: 'USAGE',
67
+ hint: `to switch it off, add --yes: frontera action retire ${apiName} --reason ${JSON.stringify(reason)} --yes${at}`,
68
+ })
69
+ }
70
+ }
71
+ const decision = current?.state === 'active'
72
+ ? await client.disableDeployment(randomUUID(), {
73
+ bindingId: randomUUID(),
74
+ actionDefinitionId: definitionId,
75
+ actionContractDigest: current.actionContractDigest,
76
+ expectedCurrentStateId: current.stateId,
77
+ expectedCurrentGeneration: current.generation,
78
+ reason,
79
+ })
80
+ : current
81
+ const draining = await client.drainingDeployments(definitionId)
82
+ for (const deployment of draining) await client.retireDeployment(deployment.stateId, deployment.generation, reason)
83
+ const retired = decision ? await client.retireDeployment(decision.stateId, decision.generation, reason) : null
84
+
85
+ if (!current && draining.length === 0) {
86
+ return { data: { action: apiName, retired: false }, text: `"${apiName}" carries no deployment decision: nothing to retire. \`frontera blueprint publish\` records one.` }
50
87
  }
51
- if (current.state === 'active') {
52
- throw new CliError(`"${apiName}" is deployed and switched on; an active deployment isn't retired.`, { code: 'CONFLICT', hint: 'switch it off in Console (the Action’s deployment) first, then retire it' })
88
+ // Read after the writes landed, so a failure here is reported as a note, not as a failed retire.
89
+ let external: ExternalWritePath | null = null
90
+ let unread: string | null = null
91
+ try {
92
+ external = await client.externalWritePath(definitionId)
93
+ } catch (err) {
94
+ unread = err instanceof Error ? err.message : String(err)
53
95
  }
54
- const retired = await client.retireDeployment(current.stateId, current.generation, reason)
96
+ const secondPerson = unread !== null
97
+ ? `Could not read whether the last write path "${apiName}" ran calls another system (${unread}). If it does, a second person reviews the one that replaces it; \`frontera action deploy ${apiName}${at}\` reads it again before it builds.\n\n`
98
+ : external
99
+ ? `The last write path "${apiName}" ran calls another system (${external.planKind ?? 'a kind the platform cannot read'}), which a second person reviewed, so a second person reviews the one that replaces it. `
100
+ + `\`frontera action deploy ${apiName}${at}\` builds it and prints the command they run.\n\n`
101
+ : ''
102
+ const what = running
103
+ ? `Switched off the running deployment of "${apiName}" and retired it. "${apiName}" does not run until it is deployed again.`
104
+ : current
105
+ ? `Retired the ${current.state} deployment decision of "${apiName}".`
106
+ : `Retired the deployment of "${apiName}" that was still draining.`
55
107
  return {
56
- data: { action: apiName, retired: true, from: current.state, deployment: retired },
57
- text: `Retired the ${current.state} deployment decision of "${apiName}".\n\nNext:\n frontera action prepare ${apiName}`,
108
+ data: { action: apiName, retired: true, from: current?.state ?? 'draining', switchedOff: running, deployment: retired, externalWritePath: external, ...(unread !== null ? { externalWritePathError: unread } : {}) },
109
+ text: `${what}\n\n${secondPerson}Next:\n frontera blueprint publish${at}${running ? `\n frontera action deploy ${apiName}${at}` : ''}`,
58
110
  }
59
111
  },
60
112
  }
@@ -1,16 +1,18 @@
1
- import { GovernedActionApi } from '../../api/governed-action-api'
1
+ import { GovernedActionApi, type ExternalWritePath } from '../../api/governed-action-api'
2
2
  import { CliError, UsageError } from '../../errors'
3
3
  import { flagString, type Command, type CommandContext } from '../types'
4
4
 
5
5
  /**
6
6
  * Approve a validated write path, and switch it on.
7
7
  *
8
- * Its own verb because it must run as a DIFFERENT principal: the service
9
- * refuses a reviewer who authored the Binding, the connector or the plan. An
10
- * organization key's principal is `orgkey:<org>:<key>`, so a second key is a
11
- * second reviewer — no second human required to arm a deployment in a
12
- * non-production organization, and a real one required wherever the roles are
13
- * held by people.
8
+ * Its own verb because only a person's session may review, and `action
9
+ * deploy` may have built the write path with an organization key. The person
10
+ * who built a write path that only edits Blueprint records may review it; one
11
+ * that calls another system needs a second person, and the service refuses
12
+ * its author. So does one that replaces a write path calling another system,
13
+ * and nobody's review turns that one on when an API key built it, so it asks
14
+ * before it reviews. `action deploy` run as a person does both and ends with
15
+ * the same two steps.
14
16
  *
15
17
  * Review and activate are one command because the bookkeeping between them is
16
18
  * not a decision: activation names the generation review left the revision's
@@ -35,15 +37,50 @@ const READ_REFUSED_HINT =
35
37
  * longer switch on. The message stays the service's; the hint says which step
36
38
  * refused and what did not happen.
37
39
  */
38
- async function requireReadableRevision(client: GovernedActionApi, bindingRevisionId: string): Promise<void> {
40
+ async function requireReadableRevision(client: GovernedActionApi, bindingRevisionId: string) {
39
41
  try {
40
- await client.getBindingRevision(bindingRevisionId)
42
+ return await client.getBindingRevision(bindingRevisionId)
41
43
  } catch (err) {
42
44
  if (!(err instanceof CliError) || err.code !== 'FORBIDDEN') throw err
43
45
  throw new CliError(err.message, { code: 'FORBIDDEN', hint: READ_REFUSED_HINT, cause: err })
44
46
  }
45
47
  }
46
48
 
49
+ /** What the Action does on a write path that calls another system, said of `subject`. */
50
+ export function externalWritePathSentence(subject: string, external: ExternalWritePath): string {
51
+ const where = external.state === 'active' ? 'runs on' : external.state === 'draining' ? 'is still finishing requests on' : 'last ran on'
52
+ return `${subject} ${where} a write path that calls another system (${external.planKind ?? 'a kind the platform cannot read'}), which a second person reviewed.`
53
+ }
54
+
55
+ /**
56
+ * Asked before a review that activation follows. While the Action runs,
57
+ * drains or last ran a write path that calls another system, activation turns
58
+ * a new one on only for a reviewer who built none of it, and never one an API
59
+ * key built, so a review it would refuse is not recorded.
60
+ */
61
+ async function requireReviewTurnsItOn(
62
+ client: GovernedActionApi,
63
+ revision: { id: string; actionDefinitionId: string },
64
+ validationReportId: string,
65
+ ): Promise<void> {
66
+ const external = await client.externalWritePath(revision.actionDefinitionId, revision.id)
67
+ if (!external?.reviewRefusal) return
68
+ const keyBuilt = external.reviewRefusal === 'key_built'
69
+ throw new CliError(
70
+ `${externalWritePathSentence('The Action', external)} `
71
+ + (keyBuilt
72
+ ? 'This one was built with an API key, so no review turns it on in its place, and nothing was reviewed.'
73
+ : 'You built this one, so a second person reviews it, and nothing was reviewed.'),
74
+ {
75
+ code: 'CONFLICT',
76
+ hint: keyBuilt
77
+ ? 'a person signed in as themselves runs `frontera action deploy` for this Action, which builds a new one as them, and a second person reviews it with the command it prints'
78
+ : `a second person who may review Actions turns it on with: frontera action review ${revision.id} --report ${validationReportId}`,
79
+ details: { reviewRefusal: external.reviewRefusal, replacedPlanKind: external.planKind, replacedState: external.state },
80
+ },
81
+ )
82
+ }
83
+
47
84
  /** The review has landed and the states activation names could not be read. */
48
85
  function notReadable(cause?: unknown): CliError {
49
86
  return new CliError('Reviewed, but the deployment state for this revision is not readable.', {
@@ -83,6 +120,43 @@ async function readActivation(client: GovernedActionApi, bindingRevisionId: stri
83
120
  }
84
121
  }
85
122
 
123
+ /**
124
+ * Review the revision at the exact validation it read. When it will be
125
+ * activated next, a reviewer who may not read it, or whose review activation
126
+ * would refuse, is refused first, with nothing reviewed.
127
+ */
128
+ export async function reviewRevision(client: GovernedActionApi, input: {
129
+ bindingRevisionId: string
130
+ validationReportId: string
131
+ activating: boolean
132
+ }): Promise<void> {
133
+ if (input.activating) {
134
+ const revision = await requireReadableRevision(client, input.bindingRevisionId)
135
+ await requireReviewTurnsItOn(client, revision, input.validationReportId)
136
+ }
137
+ // The generation is the service's, not ours: validation left the state at 2.
138
+ await client.reviewBindingRevision(input.bindingRevisionId, {
139
+ validationReportId: input.validationReportId,
140
+ expectedStateGeneration: 2,
141
+ })
142
+ }
143
+
144
+ /** Switch a reviewed revision on over whatever its Action is on now. */
145
+ export async function activateReviewed(
146
+ client: GovernedActionApi,
147
+ bindingRevisionId: string,
148
+ reason = 'Activated from the CLI after review.',
149
+ ): Promise<{ stateId: string }> {
150
+ const { target, current } = await readActivation(client, bindingRevisionId)
151
+ await client.activateDeployment(target.stateId, {
152
+ expectedGeneration: target.generation,
153
+ expectedCurrentStateId: current?.stateId ?? null,
154
+ expectedCurrentGeneration: current?.generation ?? null,
155
+ reason,
156
+ })
157
+ return { stateId: target.stateId }
158
+ }
159
+
86
160
  export const actionReview: Command = {
87
161
  meta: {
88
162
  noun: 'action',
@@ -113,13 +187,7 @@ export const actionReview: Command = {
113
187
  const client = api(ctx)
114
188
  const activate = ctx.flags['no-activate'] !== true
115
189
 
116
- if (activate) await requireReadableRevision(client, bindingRevisionId)
117
-
118
- // The generation is the service's, not ours: validation left the state at 2.
119
- await client.reviewBindingRevision(bindingRevisionId, {
120
- validationReportId,
121
- expectedStateGeneration: 2,
122
- })
190
+ await reviewRevision(client, { bindingRevisionId, validationReportId, activating: activate })
123
191
 
124
192
  if (!activate) {
125
193
  return {
@@ -128,17 +196,10 @@ export const actionReview: Command = {
128
196
  }
129
197
  }
130
198
 
131
- const { target, current } = await readActivation(client, bindingRevisionId)
132
-
133
- await client.activateDeployment(target.stateId, {
134
- expectedGeneration: target.generation,
135
- expectedCurrentStateId: current?.stateId ?? null,
136
- expectedCurrentGeneration: current?.generation ?? null,
137
- reason: flagString(ctx, 'reason') ?? 'Activated from the CLI after review.',
138
- })
199
+ const { stateId } = await activateReviewed(client, bindingRevisionId, flagString(ctx, 'reason'))
139
200
 
140
201
  return {
141
- data: { bindingRevisionId, stateId: target.stateId, reviewed: true, activated: true },
202
+ data: { bindingRevisionId, stateId, reviewed: true, activated: true },
142
203
  text: 'Reviewed and activated. The Action can now be invoked.',
143
204
  }
144
205
  },
@@ -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
  },