@frontera-sdk/cli 1.43.10 → 1.44.0
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/README.md +140 -12
- package/package.json +3 -3
- package/src/adopt.ts +436 -0
- package/src/api/apps-api.ts +30 -0
- package/src/api/blueprint-authoring-api.ts +13 -2
- package/src/api/governed-action-api.ts +192 -0
- package/src/api/platform-api.ts +4 -0
- package/src/blueprint/ontology-edit-plan.ts +195 -0
- package/src/blueprint-types.ts +252 -0
- package/src/commands/action/deploy.ts +135 -0
- package/src/commands/action/grant.ts +68 -0
- package/src/commands/action/index-commands.ts +29 -0
- package/src/commands/action/list.ts +49 -0
- package/src/commands/action/prepare.ts +48 -0
- package/src/commands/action/review.ts +94 -0
- package/src/commands/app/deploy.ts +16 -5
- package/src/commands/app/dev.ts +173 -0
- package/src/commands/app/init.ts +270 -28
- package/src/commands/app/sdk.ts +31 -0
- package/src/commands/app/versions.ts +8 -1
- package/src/commands/blueprint/editable.ts +151 -0
- package/src/commands/blueprint/generate-types.ts +58 -0
- package/src/commands/blueprint/get.ts +29 -34
- package/src/commands/blueprint/list.ts +2 -1
- package/src/commands/registry.ts +12 -0
- package/src/context.ts +4 -4
- package/src/dev-broker.ts +71 -0
- package/src/flag-help.ts +24 -1
- package/src/heal.ts +37 -2
- package/src/manifest.ts +89 -8
- package/src/packaging.ts +6 -0
- package/src/project-bootstrap.ts +176 -0
- package/src/project.ts +68 -35
- package/src/provenance.ts +89 -0
- package/src/render-evidence.ts +28 -0
- package/src/sdk-sync.ts +41 -0
- package/src/shadcn-components.ts +106 -0
- package/src/static-app-validation.ts +67 -0
- package/src/template.ts +211 -32
- package/src/templates/next-app-files.ts +1052 -0
- package/src/templates/next-skills.ts +1216 -0
- package/src/vendor/sdk-sources.json +21 -15
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { deriveOntologyEditPlan, PlanDerivationError } from '../../blueprint/ontology-edit-plan'
|
|
6
|
+
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Arm the write path for one published Action.
|
|
10
|
+
*
|
|
11
|
+
* Stops at VALIDATED, deliberately. Review must be performed by someone who
|
|
12
|
+
* authored none of the material — the service refuses a reviewer who did — so
|
|
13
|
+
* a single command that also reviewed would either be refused or would be
|
|
14
|
+
* pretending the separation happened. `action review` is the second half, run
|
|
15
|
+
* with a second credential.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function api(ctx: CommandContext): GovernedActionApi {
|
|
19
|
+
return new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const actionDeploy: Command = {
|
|
23
|
+
meta: {
|
|
24
|
+
noun: 'action',
|
|
25
|
+
verb: 'deploy',
|
|
26
|
+
args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
|
|
27
|
+
flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
|
|
28
|
+
summary: 'Build and validate the write path for a published Action',
|
|
29
|
+
examples: [
|
|
30
|
+
'frontera action deploy escalateTicket --dry-run',
|
|
31
|
+
'frontera action deploy escalateTicket',
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async run(ctx) {
|
|
36
|
+
const apiName = ctx.positional[0]
|
|
37
|
+
if (!apiName) throw new UsageError('missing <action>', 'frontera action deploy escalateTicket')
|
|
38
|
+
|
|
39
|
+
const client = api(ctx)
|
|
40
|
+
const published = await client.publishedAction(apiName)
|
|
41
|
+
const definition = published.definition
|
|
42
|
+
|
|
43
|
+
const objectType = await client.activeObjectType(
|
|
44
|
+
await resolveObjectTypeApiName(client, definition.subject.objectTypeId, apiName),
|
|
45
|
+
)
|
|
46
|
+
if (!objectType) {
|
|
47
|
+
throw new CliError(`Object type for "${apiName}" is not in the active release.`, {
|
|
48
|
+
code: 'NOT_FOUND',
|
|
49
|
+
hint: 'Publish a release that carries it, then deploy.',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let plan
|
|
54
|
+
try {
|
|
55
|
+
plan = deriveOntologyEditPlan(definition, objectType)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof PlanDerivationError) {
|
|
58
|
+
throw new CliError(err.message, { code: 'VALIDATION_ERROR', hint: err.hint })
|
|
59
|
+
}
|
|
60
|
+
throw err
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const writes = Object.entries(plan.set)
|
|
64
|
+
.map(([property, parameter]) => `${property} ← ${parameter}`)
|
|
65
|
+
.join(', ')
|
|
66
|
+
|
|
67
|
+
if (flagBool(ctx, 'dry-run')) {
|
|
68
|
+
return {
|
|
69
|
+
data: { plan, dryRun: true },
|
|
70
|
+
text: `${plan.op} ${plan.objectTypeApiName}, addressed by ${plan.pkParameter}\n`
|
|
71
|
+
+ ` sets ${writes}\n`
|
|
72
|
+
+ (plan.expectedVersionParameter ? ` compare-and-set on ${plan.expectedVersionParameter}\n` : '')
|
|
73
|
+
+ '\nNothing was written. Re-run without --dry-run to build it.',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Client-minted, so a retry after a network failure can name the same
|
|
78
|
+
// artifact instead of orphaning a half-built one.
|
|
79
|
+
const planId = flagString(ctx, 'plan-id') ?? randomUUID()
|
|
80
|
+
const bindingId = flagString(ctx, 'binding-id') ?? randomUUID()
|
|
81
|
+
|
|
82
|
+
const planRevision = await client.createMutationPlanRevision(planId, plan)
|
|
83
|
+
const binding = await client.createBindingRevision(bindingId, {
|
|
84
|
+
actionDefinitionId: definition.id,
|
|
85
|
+
actionContractDigest: published.contractDigest,
|
|
86
|
+
mutationPlanRevisionId: planRevision.id,
|
|
87
|
+
})
|
|
88
|
+
const validation = await client.validateBindingRevision(binding.id, 1)
|
|
89
|
+
|
|
90
|
+
if (validation.status !== 'valid') {
|
|
91
|
+
throw new CliError(`Deployment validation returned "${validation.status}".`, {
|
|
92
|
+
code: 'FAILURE',
|
|
93
|
+
hint: JSON.stringify(validation.findings ?? validation),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
data: {
|
|
99
|
+
action: apiName,
|
|
100
|
+
planId,
|
|
101
|
+
planRevisionId: planRevision.id,
|
|
102
|
+
bindingId,
|
|
103
|
+
bindingRevisionId: binding.id,
|
|
104
|
+
stateId: binding.stateId,
|
|
105
|
+
validationReportId: validation.id,
|
|
106
|
+
status: validation.status,
|
|
107
|
+
},
|
|
108
|
+
text: `Built and validated the write path for "${apiName}".\n`
|
|
109
|
+
+ ` ${plan.op} ${plan.objectTypeApiName} — sets ${writes}\n\n`
|
|
110
|
+
+ 'Next, as a DIFFERENT user — the service refuses a reviewer who authored the material:\n'
|
|
111
|
+
+ ` frontera action review ${binding.id} --report ${validation.id}`,
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The Action names its subject by id; every read route addresses object types
|
|
118
|
+
* by API name. One list read bridges them, rather than making the caller pass
|
|
119
|
+
* a name the Action already knows.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveObjectTypeApiName(
|
|
122
|
+
client: GovernedActionApi,
|
|
123
|
+
objectTypeId: string,
|
|
124
|
+
actionApiName: string,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const types = await client.listActiveObjectTypes()
|
|
127
|
+
const match = types.find((t) => t.id === objectTypeId)
|
|
128
|
+
if (!match?.apiName) {
|
|
129
|
+
throw new CliError(`The object type "${actionApiName}" acts on is not in the active release.`, {
|
|
130
|
+
code: 'NOT_FOUND',
|
|
131
|
+
hint: 'frontera blueprint catalog',
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return match.apiName
|
|
135
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Grant a published Action's invoke capability to a role.
|
|
7
|
+
*
|
|
8
|
+
* Narrow on purpose. This does not edit roles — `role` is non-delegable so a
|
|
9
|
+
* machine credential cannot widen its own authority, and that stays true. It
|
|
10
|
+
* grants exactly one thing: a capability a published Action declares, on a
|
|
11
|
+
* resource the platform does not own.
|
|
12
|
+
*
|
|
13
|
+
* It exists because the two ends could not otherwise meet. An Action may not
|
|
14
|
+
* be gated on a platform resource, and the admin permission tree renders only
|
|
15
|
+
* platform resources — so every capability the invoke check will accept is one
|
|
16
|
+
* the console cannot display, and an Action could be published, deployed and
|
|
17
|
+
* active while remaining invisible to everyone.
|
|
18
|
+
*/
|
|
19
|
+
export const actionGrant: Command = {
|
|
20
|
+
meta: {
|
|
21
|
+
noun: 'action',
|
|
22
|
+
verb: 'grant',
|
|
23
|
+
args: [
|
|
24
|
+
{ name: 'capability', required: true, description: 'The Action’s invoke capability, as resource:action' },
|
|
25
|
+
],
|
|
26
|
+
flags: { role: 'string', revoke: 'boolean' },
|
|
27
|
+
summary: 'Grant a published Action’s invoke capability to a role',
|
|
28
|
+
examples: [
|
|
29
|
+
'frontera action grant support_ticket:escalate_ticket --role owner',
|
|
30
|
+
'frontera action grant support_ticket:escalate_ticket --role editor --revoke',
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async run(ctx) {
|
|
35
|
+
const capability = ctx.positional[0]
|
|
36
|
+
if (!capability) {
|
|
37
|
+
throw new UsageError(
|
|
38
|
+
'missing <capability>',
|
|
39
|
+
'frontera action grant support_ticket:escalate_ticket --role owner',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
const role = flagString(ctx, 'role')
|
|
43
|
+
if (!role) {
|
|
44
|
+
throw new UsageError(
|
|
45
|
+
'missing --role <role>',
|
|
46
|
+
'A capability is held by a role, never by a person directly.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const revoke = flagBool(ctx, 'revoke')
|
|
51
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
52
|
+
const result = await client.setCapabilityGrant(capability, role, revoke)
|
|
53
|
+
|
|
54
|
+
const held = result.actions.length > 0 ? result.actions.join(', ') : '(none)'
|
|
55
|
+
return {
|
|
56
|
+
data: result,
|
|
57
|
+
text: `${revoke ? 'Revoked' : 'Granted'} "${capability}" ${revoke ? 'from' : 'to'} role "${role}".\n`
|
|
58
|
+
+ ` ${capability.split(':')[0]}: ${held}`
|
|
59
|
+
// The other half of the check, and invisible from here: a role without
|
|
60
|
+
// actionRequest:submit cannot invoke ANY Action, however many business
|
|
61
|
+
// capabilities it holds.
|
|
62
|
+
+ (result.roleMaySubmit
|
|
63
|
+
? ''
|
|
64
|
+
: `\n\nRole "${role}" still cannot invoke: it lacks actionRequest: submit.`
|
|
65
|
+
+ '\n Grant that in the console under Roles — it is a platform permission.'),
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { actionDeploy } from './deploy'
|
|
2
|
+
import { actionGrant } from './grant'
|
|
3
|
+
import { actionList } from './list'
|
|
4
|
+
import { actionPrepare } from './prepare'
|
|
5
|
+
import { actionReview } from './review'
|
|
6
|
+
import type { Command } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The deployment plane, which had no client at all.
|
|
10
|
+
*
|
|
11
|
+
* Blueprint authoring reaches the Action's CONTRACT — `blueprint create action`
|
|
12
|
+
* and `blueprint apply` both write it, and `blueprint publish` releases it. What
|
|
13
|
+
* had no verb anywhere was arming the write: a mutation plan, a Binding pinned
|
|
14
|
+
* to the contract digest, a validation against the live target, an independent
|
|
15
|
+
* review and a state-machine activation. Five admin calls with hand-minted
|
|
16
|
+
* UUIDs, or nothing.
|
|
17
|
+
*
|
|
18
|
+
* Invoking is deliberately absent. Both CLI credential kinds present a
|
|
19
|
+
* non-member principal — `wskey:…` and `orgkey:…` — and the invoke check joins
|
|
20
|
+
* organization membership, so a `submit` verb here would refuse every call it
|
|
21
|
+
* ever made. It belongs to a credential that represents a person.
|
|
22
|
+
*/
|
|
23
|
+
export const actionCommands: readonly Command[] = [
|
|
24
|
+
actionList,
|
|
25
|
+
actionPrepare,
|
|
26
|
+
actionDeploy,
|
|
27
|
+
actionReview,
|
|
28
|
+
actionGrant,
|
|
29
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Published Actions and whether anything can run them.
|
|
7
|
+
*
|
|
8
|
+
* `deploymentStatus` is the question this answers: an Action can be published,
|
|
9
|
+
* correct and completely inert, and nothing else in the CLI says so. `unbound`
|
|
10
|
+
* here is the same state `blueprint publish` refuses to create and that an
|
|
11
|
+
* agent silently cannot see.
|
|
12
|
+
*/
|
|
13
|
+
export const actionList: Command = {
|
|
14
|
+
meta: {
|
|
15
|
+
noun: 'action',
|
|
16
|
+
verb: 'list',
|
|
17
|
+
args: [],
|
|
18
|
+
flags: {},
|
|
19
|
+
summary: 'List published Actions and their deployment state',
|
|
20
|
+
examples: ['frontera action list'],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async run(ctx) {
|
|
24
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
25
|
+
const actions = await client.listPublishedActions()
|
|
26
|
+
|
|
27
|
+
if (actions.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
data: { actions: [] },
|
|
30
|
+
text: 'No published Actions.\n Author one, then `frontera blueprint publish`.',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rows = actions.map((entry) => ({
|
|
35
|
+
apiName: entry.definition.apiName,
|
|
36
|
+
deployment: entry.deploymentStatus ?? '—',
|
|
37
|
+
// Present only when it is NOT invocable, which is when it matters.
|
|
38
|
+
reason: entry.reason && entry.reason !== 'ACTION_AVAILABLE' ? entry.reason : '',
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
data: { actions },
|
|
43
|
+
text: table(
|
|
44
|
+
['ACTION', 'DEPLOYMENT', 'WHY NOT'],
|
|
45
|
+
rows.map((r) => [r.apiName, r.deployment, r.reason]),
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import { flagString, type Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Record that a drafted Action ships switched off, so it can be published.
|
|
7
|
+
*
|
|
8
|
+
* Publish refuses an Action with no deployment decision, and every other
|
|
9
|
+
* deployment route acts on a state that already exists — so without this the
|
|
10
|
+
* two requirements are circular and the first row has to be written by hand.
|
|
11
|
+
*
|
|
12
|
+
* It arms nothing. `action deploy` is still the only thing that builds a write
|
|
13
|
+
* path, and it runs AFTER the release, because a Binding is validated against
|
|
14
|
+
* the object type the release actually publishes.
|
|
15
|
+
*/
|
|
16
|
+
export const actionPrepare: Command = {
|
|
17
|
+
meta: {
|
|
18
|
+
noun: 'action',
|
|
19
|
+
verb: 'prepare',
|
|
20
|
+
args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
|
|
21
|
+
flags: { reason: 'string' },
|
|
22
|
+
summary: 'Record that a drafted Action ships disabled, so it can be published',
|
|
23
|
+
examples: [
|
|
24
|
+
'frontera action prepare escalateTicket',
|
|
25
|
+
'frontera action prepare escalateTicket --reason "ships off"',
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async run(ctx) {
|
|
30
|
+
const apiName = ctx.positional[0]
|
|
31
|
+
if (!apiName) throw new UsageError('missing <action>', 'frontera action prepare escalateTicket')
|
|
32
|
+
|
|
33
|
+
const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
34
|
+
const decision = await client.recordDeploymentDecision(
|
|
35
|
+
apiName,
|
|
36
|
+
flagString(ctx, 'reason') ?? 'Published before anything could be bound to it.',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
data: decision,
|
|
41
|
+
text: `"${apiName}" will publish disabled.\n`
|
|
42
|
+
+ ` state ${decision.stateId} · generation ${decision.generation}\n\n`
|
|
43
|
+
+ 'Next:\n'
|
|
44
|
+
+ ' frontera blueprint publish --label "…" \n'
|
|
45
|
+
+ ` frontera action deploy ${apiName}`,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { flagString, type Command, type CommandContext } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Approve a validated write path, and switch it on.
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* Review and activate are one command because the generation arithmetic
|
|
16
|
+
* between them is bookkeeping, not a decision: review takes the state to 3,
|
|
17
|
+
* activate expects exactly that, and a caller who ran them separately would be
|
|
18
|
+
* asked to carry a number they never chose.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function api(ctx: CommandContext): GovernedActionApi {
|
|
22
|
+
return new GovernedActionApi(ctx.apiUrl, ctx.token)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const actionReview: Command = {
|
|
26
|
+
meta: {
|
|
27
|
+
noun: 'action',
|
|
28
|
+
verb: 'review',
|
|
29
|
+
args: [{ name: 'bindingRevision', required: true, description: 'The Binding revision to review' }],
|
|
30
|
+
flags: { report: 'string', reason: 'string', 'no-activate': 'boolean' },
|
|
31
|
+
summary: 'Review a validated write path and activate it',
|
|
32
|
+
examples: [
|
|
33
|
+
'frontera action review 042ca95a-… --report 7c1f… ',
|
|
34
|
+
'frontera action review 042ca95a-… --report 7c1f… --no-activate',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async run(ctx) {
|
|
39
|
+
const bindingRevisionId = ctx.positional[0]
|
|
40
|
+
if (!bindingRevisionId) {
|
|
41
|
+
throw new UsageError('missing <bindingRevision>', 'frontera action review <bindingRevision> --report <id>')
|
|
42
|
+
}
|
|
43
|
+
const validationReportId = flagString(ctx, 'report')
|
|
44
|
+
if (!validationReportId) {
|
|
45
|
+
throw new UsageError(
|
|
46
|
+
'missing --report <validationReportId>',
|
|
47
|
+
'The id `frontera action deploy` printed. Review is pinned to the exact validation it read.',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const client = api(ctx)
|
|
52
|
+
|
|
53
|
+
// Generations are the service's, not ours: validation left the state at 2,
|
|
54
|
+
// review takes it to 3, activation expects 3 and the disabled decision it
|
|
55
|
+
// replaces still at 1.
|
|
56
|
+
await client.reviewBindingRevision(bindingRevisionId, {
|
|
57
|
+
validationReportId,
|
|
58
|
+
expectedStateGeneration: 2,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (ctx.flags['no-activate'] === true) {
|
|
62
|
+
return {
|
|
63
|
+
data: { bindingRevisionId, reviewed: true, activated: false },
|
|
64
|
+
text: 'Reviewed. Not activated — the write path is armed but switched off.',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const binding = await client.listBindings()
|
|
69
|
+
const entry = binding.entries.find((e) => e.revisionId === bindingRevisionId)
|
|
70
|
+
const stateId = entry?.deployment?.stateId
|
|
71
|
+
if (!stateId) {
|
|
72
|
+
throw new CliError('Reviewed, but the deployment state for this revision is not readable.', {
|
|
73
|
+
code: 'FAILURE',
|
|
74
|
+
hint: 'Activate it directly once the state id is known; the review itself has landed.',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const current = binding.entries.find(
|
|
79
|
+
(e) => e.actionDefinitionId === entry.actionDefinitionId && e.deployment?.state === 'disabled',
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
await client.activateDeployment(stateId, {
|
|
83
|
+
expectedGeneration: 3,
|
|
84
|
+
expectedCurrentStateId: current?.deployment?.stateId ?? stateId,
|
|
85
|
+
expectedCurrentGeneration: current?.deployment?.generation ?? 1,
|
|
86
|
+
reason: flagString(ctx, 'reason') ?? 'Activated from the CLI after review.',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
data: { bindingRevisionId, stateId, reviewed: true, activated: true },
|
|
91
|
+
text: 'Reviewed and activated. The Action can now be invoked.',
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
@@ -7,7 +7,9 @@ import { resolveManifest } from '../../manifest'
|
|
|
7
7
|
import { collectSourceFiles, formatBytes } from '../../packaging'
|
|
8
8
|
import { packDirectory } from '../../pack'
|
|
9
9
|
import { readPackageVersion, writeAppId, writeState } from '../../project'
|
|
10
|
-
import {
|
|
10
|
+
import { collectBuildProvenance } from '../../provenance'
|
|
11
|
+
import { validateStaticAppProject } from '../../static-app-validation'
|
|
12
|
+
import { describeRenderEvidence, readRenderEvidence, readRenderThumbnail } from '../../render-evidence'
|
|
11
13
|
import { createTarGz } from '../../tar'
|
|
12
14
|
import { requireProjectFrom } from './shared'
|
|
13
15
|
import { flagBool, flagString, type Command } from '../types'
|
|
@@ -53,10 +55,12 @@ export const appDeploy: Command = {
|
|
|
53
55
|
async run(ctx) {
|
|
54
56
|
const project = requireProjectFrom(ctx)
|
|
55
57
|
const version = flagString(ctx, 'version') ?? readPackageVersion(project.root)
|
|
56
|
-
const
|
|
58
|
+
const outputDirectory = project.outputDirectory ?? 'dist'
|
|
59
|
+
const distDir = join(project.root, outputDirectory)
|
|
60
|
+
validateStaticAppProject(project.root, project.routing ?? 'spa')
|
|
57
61
|
|
|
58
62
|
if (!existsSync(join(distDir, 'index.html'))) {
|
|
59
|
-
throw new CliError(
|
|
63
|
+
throw new CliError(`${outputDirectory}/index.html not found`, {
|
|
60
64
|
code: 'VALIDATION_ERROR',
|
|
61
65
|
hint: 'run your build first: bun run build',
|
|
62
66
|
})
|
|
@@ -86,6 +90,11 @@ export const appDeploy: Command = {
|
|
|
86
90
|
const renderEvidence = readRenderEvidence(project.root)
|
|
87
91
|
ctx.output.note(describeRenderEvidence(renderEvidence))
|
|
88
92
|
|
|
93
|
+
// The picture from that same render. Uploaded whenever one exists — the
|
|
94
|
+
// gallery has shown every app as an identical tinted tile, and a screenshot
|
|
95
|
+
// of a slightly older state of the app says more than its first letter.
|
|
96
|
+
const thumbnail = readRenderThumbnail(project.root)
|
|
97
|
+
|
|
89
98
|
const promote = !flagBool(ctx, 'no-promote')
|
|
90
99
|
const res = await publish(client, app.id, {
|
|
91
100
|
version,
|
|
@@ -95,11 +104,13 @@ export const appDeploy: Command = {
|
|
|
95
104
|
manifest,
|
|
96
105
|
promote,
|
|
97
106
|
renderEvidence,
|
|
107
|
+
provenance: collectBuildProvenance(project.root),
|
|
108
|
+
thumbnail,
|
|
98
109
|
})
|
|
99
110
|
|
|
100
111
|
writeState(project.root, { appId: app.id, parentVersion: res.version })
|
|
101
|
-
//
|
|
102
|
-
//
|
|
112
|
+
// Local environment binding: source remains portable and package.json
|
|
113
|
+
// remains ordinary package metadata.
|
|
103
114
|
writeAppId(project.root, app.id)
|
|
104
115
|
|
|
105
116
|
// The one line that turns "published" into something a person can act on.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { AppsApi } from '../../api/apps-api'
|
|
2
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
3
|
+
import { createDevSessionBroker } from '../../dev-broker'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { detectAppFramework } from '../../provenance'
|
|
6
|
+
import { writeAppId } from '../../project'
|
|
7
|
+
import { requireProjectFrom } from './shared'
|
|
8
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 3000
|
|
11
|
+
const READY_TIMEOUT_MS = 30_000
|
|
12
|
+
|
|
13
|
+
function appPort(raw: string | undefined): number {
|
|
14
|
+
if (raw === undefined) return DEFAULT_PORT
|
|
15
|
+
const port = Number(raw)
|
|
16
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
17
|
+
throw new UsageError('--port must be an integer from 1 to 65535', 'for example: frontera app dev --port 3210')
|
|
18
|
+
}
|
|
19
|
+
return port
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function waitForApp(origin: string, process: Bun.Subprocess): Promise<void> {
|
|
23
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
if (process.exitCode !== null) {
|
|
26
|
+
throw new CliError(`App dev server exited with code ${process.exitCode}`, {
|
|
27
|
+
code: 'FAILURE',
|
|
28
|
+
hint: 'run `bun run dev` directly to inspect the framework error',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(origin, { redirect: 'manual' })
|
|
33
|
+
if (response.status < 500) return
|
|
34
|
+
} catch {
|
|
35
|
+
// The listener is not ready yet.
|
|
36
|
+
}
|
|
37
|
+
await Bun.sleep(150)
|
|
38
|
+
}
|
|
39
|
+
throw new CliError(`App dev server was not ready after ${READY_TIMEOUT_MS / 1000} seconds`, {
|
|
40
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
41
|
+
hint: 'inspect the dev-server output above, or choose another --port',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function openBrowser(url: string): void {
|
|
46
|
+
const command = process.platform === 'darwin'
|
|
47
|
+
? ['open', url]
|
|
48
|
+
: process.platform === 'win32'
|
|
49
|
+
? ['cmd', '/c', 'start', '', url]
|
|
50
|
+
: ['xdg-open', url]
|
|
51
|
+
try {
|
|
52
|
+
Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref()
|
|
53
|
+
} catch {
|
|
54
|
+
// Opening is convenience only; the URL is always printed.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const appDev: Command = {
|
|
59
|
+
meta: {
|
|
60
|
+
noun: 'app',
|
|
61
|
+
verb: 'dev',
|
|
62
|
+
args: [],
|
|
63
|
+
flags: { port: 'string', 'no-open': 'boolean' },
|
|
64
|
+
summary: 'Run an App locally with short-lived authenticated Blueprint access',
|
|
65
|
+
examples: ['frontera app dev', 'frontera app dev --port 3210 --no-open'],
|
|
66
|
+
needsProject: true,
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async run(ctx) {
|
|
70
|
+
const project = requireProjectFrom(ctx)
|
|
71
|
+
const port = appPort(flagString(ctx, 'port'))
|
|
72
|
+
const appOrigin = `http://127.0.0.1:${port}`
|
|
73
|
+
const framework = detectAppFramework(project.root)
|
|
74
|
+
const apps = new AppsApi(ctx.apiUrl, ctx.token)
|
|
75
|
+
const app = await apps.ensureApp(project.slug, project.displayName, project.appId)
|
|
76
|
+
writeAppId(project.root, app.id)
|
|
77
|
+
|
|
78
|
+
let broker: ReturnType<typeof createDevSessionBroker> | null = null
|
|
79
|
+
if (framework === 'next') {
|
|
80
|
+
const identity = await new PlatformApi(ctx.apiUrl, ctx.token).whoami()
|
|
81
|
+
if (!identity.workspaceId || !identity.orgId) {
|
|
82
|
+
throw new UsageError(
|
|
83
|
+
'App development requires a workspace-scoped credential',
|
|
84
|
+
'run `frontera login` with an sk-ws- App-scoped or workspace key',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
broker = createDevSessionBroker({
|
|
88
|
+
appOrigin,
|
|
89
|
+
loadSession: async () => {
|
|
90
|
+
const session = await apps.appToken(app.id)
|
|
91
|
+
return {
|
|
92
|
+
init: {
|
|
93
|
+
type: 'frontera:init',
|
|
94
|
+
token: session.token,
|
|
95
|
+
apiBaseUrl: ctx.apiUrl.replace(/\/+$/, ''),
|
|
96
|
+
appId: app.id,
|
|
97
|
+
version: 'dev',
|
|
98
|
+
orgId: identity.orgId,
|
|
99
|
+
workspaceId: identity.workspaceId,
|
|
100
|
+
theme: { tokens: {} },
|
|
101
|
+
state: {},
|
|
102
|
+
},
|
|
103
|
+
expiresAt: session.expiresAt,
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
ctx.output.note('[dev] the browser receives only a short-lived sk-app token; the stored CLI key stays in the broker')
|
|
108
|
+
} else if (framework === 'vite') {
|
|
109
|
+
ctx.output.note('[dev] legacy Vite mode uses dev-host.html and the existing .env.local compatibility flow')
|
|
110
|
+
} else {
|
|
111
|
+
throw new UsageError(
|
|
112
|
+
'could not detect a supported App development framework',
|
|
113
|
+
'add Next.js or Vite to package.json dependencies',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetUrl = framework === 'vite' ? `${appOrigin}/dev-host.html` : appOrigin
|
|
118
|
+
ctx.output.note(`[dev] ${app.slug} at ${targetUrl}`)
|
|
119
|
+
|
|
120
|
+
let child: Bun.Subprocess
|
|
121
|
+
try {
|
|
122
|
+
child = Bun.spawn(
|
|
123
|
+
framework === 'vite'
|
|
124
|
+
? ['bun', 'run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port)]
|
|
125
|
+
: ['bun', 'run', 'dev', '--', '--hostname', '127.0.0.1', '--port', String(port)],
|
|
126
|
+
{
|
|
127
|
+
cwd: project.root,
|
|
128
|
+
env: broker
|
|
129
|
+
? { ...process.env, NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT: broker.endpoint }
|
|
130
|
+
: process.env,
|
|
131
|
+
stdin: 'inherit',
|
|
132
|
+
stdout: 'inherit',
|
|
133
|
+
stderr: 'inherit',
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
broker?.server.stop(true)
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let stopped = false
|
|
142
|
+
const stop = () => {
|
|
143
|
+
if (stopped) return
|
|
144
|
+
stopped = true
|
|
145
|
+
child.kill('SIGTERM')
|
|
146
|
+
broker?.server.stop(true)
|
|
147
|
+
}
|
|
148
|
+
const onSignal = () => stop()
|
|
149
|
+
process.on('SIGINT', onSignal)
|
|
150
|
+
process.on('SIGTERM', onSignal)
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await waitForApp(targetUrl, child)
|
|
154
|
+
ctx.output.note(`[dev] ready: ${targetUrl}`)
|
|
155
|
+
if (!flagBool(ctx, 'no-open')) openBrowser(targetUrl)
|
|
156
|
+
const exitCode = await child.exited
|
|
157
|
+
if (!stopped && exitCode !== 0) {
|
|
158
|
+
throw new CliError(`App dev server exited with code ${exitCode}`, {
|
|
159
|
+
code: 'FAILURE',
|
|
160
|
+
hint: 'review the framework output above',
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
data: { appId: app.id, origin: targetUrl, exitCode },
|
|
165
|
+
text: `Stopped ${app.slug} local development server.`,
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
process.off('SIGINT', onSignal)
|
|
169
|
+
process.off('SIGTERM', onSignal)
|
|
170
|
+
stop()
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}
|