@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.
- package/package.json +4 -4
- package/src/api/apps-api.ts +2 -2
- package/src/api/blueprint-authoring-api.ts +19 -19
- package/src/api/governed-action-api.ts +78 -3
- package/src/api/platform-api.ts +20 -0
- package/src/blueprint/dataset-revision.ts +66 -0
- package/src/blueprint/diff.ts +134 -6
- package/src/blueprint/publish-plan.ts +253 -0
- package/src/blueprint/render.ts +19 -0
- package/src/commands/action/deploy.ts +143 -25
- package/src/commands/action/index-commands.ts +3 -3
- package/src/commands/action/list.ts +3 -3
- package/src/commands/action/prepare.ts +5 -3
- package/src/commands/action/retire.ts +78 -26
- package/src/commands/action/review.ts +86 -25
- 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/blueprint/authoring.ts +110 -24
- package/src/commands/blueprint/bind.ts +10 -46
- package/src/commands/blueprint/declarative.ts +124 -30
- package/src/commands/blueprint/editable.ts +3 -5
- 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/dev.ts +1 -21
- package/src/commands/local/index-commands.ts +1 -1
- package/src/commands/local/snapshot.ts +2 -2
- package/src/commands/local/steps.ts +11 -7
- package/src/commands/local/sync-request.ts +45 -8
- package/src/commands/local/sync.ts +23 -4
- package/src/commands/mcp-server/index-commands.ts +99 -11
- package/src/commands/registry.ts +1 -1
- package/src/commands/types.ts +2 -0
- package/src/device-login.ts +19 -1
- package/src/exit.ts +5 -0
- package/src/flag-help.ts +2 -1
- package/src/forge/permission-rules.ts +2 -0
- package/src/local/local-actions.ts +6 -5
- package/src/local/local-app-runtime.ts +6 -4
- package/src/local/local-blueprint-schemas.ts +2 -2
- package/src/local/local-build-inputs.ts +2 -1
- package/src/local/local-db-snapshot.ts +62 -46
- package/src/local/local-dev-references.ts +0 -23
- package/src/local/local-docker.ts +84 -10
- package/src/local/local-mcp-servers.ts +79 -14
- package/src/local/local-person-steps.ts +121 -19
- package/src/local/local-project.ts +5 -1
- package/src/local/local-supervisor.ts +41 -3
- package/src/local/local-sync-link.ts +76 -0
- package/src/local/local-sync-precheck.ts +1 -1
- package/src/local/local-sync-push.ts +8 -2
- package/src/local/local-sync-remote.ts +18 -0
- package/src/main.ts +7 -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 +8 -8
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -1,21 +1,29 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
+
import { canPrompt, promptConfirm } from '../../prompt'
|
|
6
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
* Retire an Action's deployment
|
|
9
|
+
* Retire an Action's deployment, so a changed Action can publish (Forge
|
|
10
|
+
* improvement plan X11).
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 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
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
* person with
|
|
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: '
|
|
35
|
+
summary: 'Switch off and retire an Action’s deployment, so a changed Action can publish',
|
|
28
36
|
details: [
|
|
29
|
-
'For an Action
|
|
30
|
-
'
|
|
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 "
|
|
34
|
-
'frontera action
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
57
|
-
text:
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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, {
|
|
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.
|
package/src/commands/app/dev.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
239
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/app/pull.ts
CHANGED
|
@@ -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, {
|
|
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 {
|
package/src/commands/app/save.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { AppsApi } from '../../api/apps-api'
|
|
2
2
|
import { packDirectory } from '../../pack'
|
|
3
|
-
import {
|
|
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
|
|
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
|
},
|