@frontera-sdk/cli 1.43.9 → 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.
Files changed (42) hide show
  1. package/README.md +140 -12
  2. package/package.json +3 -3
  3. package/src/adopt.ts +436 -0
  4. package/src/api/apps-api.ts +30 -0
  5. package/src/api/blueprint-authoring-api.ts +13 -2
  6. package/src/api/governed-action-api.ts +192 -0
  7. package/src/api/platform-api.ts +4 -0
  8. package/src/blueprint/ontology-edit-plan.ts +195 -0
  9. package/src/blueprint-types.ts +252 -0
  10. package/src/commands/action/deploy.ts +135 -0
  11. package/src/commands/action/grant.ts +68 -0
  12. package/src/commands/action/index-commands.ts +29 -0
  13. package/src/commands/action/list.ts +49 -0
  14. package/src/commands/action/prepare.ts +48 -0
  15. package/src/commands/action/review.ts +94 -0
  16. package/src/commands/app/deploy.ts +16 -5
  17. package/src/commands/app/dev.ts +173 -0
  18. package/src/commands/app/init.ts +270 -28
  19. package/src/commands/app/sdk.ts +31 -0
  20. package/src/commands/app/versions.ts +8 -1
  21. package/src/commands/blueprint/editable.ts +151 -0
  22. package/src/commands/blueprint/generate-types.ts +58 -0
  23. package/src/commands/blueprint/get.ts +29 -34
  24. package/src/commands/blueprint/list.ts +2 -1
  25. package/src/commands/registry.ts +12 -0
  26. package/src/context.ts +4 -4
  27. package/src/dev-broker.ts +71 -0
  28. package/src/flag-help.ts +24 -1
  29. package/src/heal.ts +37 -2
  30. package/src/manifest.ts +89 -8
  31. package/src/packaging.ts +6 -0
  32. package/src/project-bootstrap.ts +176 -0
  33. package/src/project.ts +68 -35
  34. package/src/provenance.ts +89 -0
  35. package/src/render-evidence.ts +28 -0
  36. package/src/sdk-sync.ts +41 -0
  37. package/src/shadcn-components.ts +106 -0
  38. package/src/static-app-validation.ts +67 -0
  39. package/src/template.ts +211 -32
  40. package/src/templates/next-app-files.ts +1052 -0
  41. package/src/templates/next-skills.ts +1216 -0
  42. 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 { describeRenderEvidence, readRenderEvidence } from '../../render-evidence'
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 distDir = join(project.root, 'dist')
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('dist/index.html not found', {
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
- // Committed alongside the code, so a clone deploys to THIS app even after
102
- // the slug is renamed — and any stale slug is dropped.
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
+ }