@frontera-sdk/cli 1.43.10 → 1.44.1
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { parseBlueprintSchema, type BlueprintSchemaObject } from '../../blueprint-types'
|
|
2
3
|
import { CliError, UsageError } from '../../errors'
|
|
3
4
|
import type { Command } from '../types'
|
|
4
5
|
|
|
@@ -36,14 +37,15 @@ interface ObjectTypeRow {
|
|
|
36
37
|
* error that names a different concept than the argument does, and does not
|
|
37
38
|
* say the one thing that would fix it.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* Resolution happens only inside the granted schema. That both corrects case
|
|
41
|
+
* and prevents a detail lookup from becoming an existence oracle for an
|
|
42
|
+
* ungranted organization object type.
|
|
42
43
|
*/
|
|
43
|
-
|
|
44
|
-
const types = (await api.blueprintObjectTypes()) as ObjectTypeRow[]
|
|
44
|
+
function resolveApiName(types: ObjectTypeRow[], typed: string): string {
|
|
45
45
|
const names = types.map((t) => t.apiName).filter((n): n is string => Boolean(n))
|
|
46
46
|
|
|
47
|
+
const exact = names.find((n) => n === typed)
|
|
48
|
+
if (exact) return exact
|
|
47
49
|
const insensitive = names.find((n) => n.toLowerCase() === typed.toLowerCase())
|
|
48
50
|
if (insensitive) return insensitive
|
|
49
51
|
|
|
@@ -82,21 +84,10 @@ export const blueprintGet: Command = {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// looked correct only when the case already matched.
|
|
90
|
-
let payload: { objectType?: ObjectTypeRow & { description?: string | null }; properties?: Property[] }
|
|
91
|
-
try {
|
|
92
|
-
payload = (await api.blueprintObjectType(typed)) as typeof payload
|
|
93
|
-
} catch {
|
|
94
|
-
// Miss: resolve the case, or fail with the names that do exist.
|
|
95
|
-
payload = (await api.blueprintObjectType(await resolveApiName(api, typed))) as typeof payload
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const type = payload.objectType ?? {}
|
|
99
|
-
const apiName = type.apiName ?? typed
|
|
87
|
+
const schema = parseBlueprintSchema(await api.blueprintSchema())
|
|
88
|
+
const apiName = resolveApiName(schema.objectTypes, typed)
|
|
89
|
+
const type = schema.objectTypes.find((objectType) => objectType.apiName === apiName) as BlueprintSchemaObject
|
|
90
|
+
const grantedNames = new Set(schema.objectTypes.map((objectType) => objectType.apiName))
|
|
100
91
|
|
|
101
92
|
// Relations and metrics, not just columns. A property list alone says what
|
|
102
93
|
// a table holds and nothing about how it joins or what is measured on it —
|
|
@@ -115,14 +106,21 @@ export const blueprintGet: Command = {
|
|
|
115
106
|
for (const t of types as Array<{ id?: string; apiName?: string }>) {
|
|
116
107
|
if (t.id && t.apiName) nameById.set(t.id, t.apiName)
|
|
117
108
|
}
|
|
118
|
-
const selfId = (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
const selfId = (types as Array<{ id?: string; apiName?: string }>)
|
|
110
|
+
.find((candidate) => candidate.apiName === apiName)?.id ?? ''
|
|
111
|
+
|
|
112
|
+
const related = (links as Link[]).flatMap((link) => {
|
|
113
|
+
if (link.fromObjectTypeId !== selfId && link.toObjectTypeId !== selfId) return []
|
|
114
|
+
const outbound = link.fromObjectTypeId === selfId
|
|
115
|
+
const other = nameById.get((outbound ? link.toObjectTypeId : link.fromObjectTypeId) ?? '')
|
|
116
|
+
if (!other || !grantedNames.has(other)) return []
|
|
117
|
+
return [{ apiName: link.apiName, direction: outbound ? 'outbound' as const : 'inbound' as const, otherObjectType: other, cardinality: link.cardinality }]
|
|
118
|
+
})
|
|
119
|
+
const own = (metrics as Metric[])
|
|
120
|
+
.filter((metric) => metric.objectTypeId === selfId)
|
|
121
|
+
.map((metric) => ({ apiName: metric.apiName, displayName: metric.displayName }))
|
|
122
|
+
|
|
123
|
+
const props = type.properties as Property[]
|
|
126
124
|
const heading = [apiName, type.displayName].filter(Boolean)
|
|
127
125
|
const lines = [
|
|
128
126
|
heading[1] && heading[1] !== heading[0] ? `${heading[0]} — ${heading[1]}` : String(heading[0]),
|
|
@@ -139,11 +137,8 @@ export const blueprintGet: Command = {
|
|
|
139
137
|
`Relations (${related.length})`,
|
|
140
138
|
...(related.length === 0
|
|
141
139
|
? [' (none)']
|
|
142
|
-
: related.map((
|
|
143
|
-
|
|
144
|
-
const otherId = outbound ? l.toObjectTypeId : l.fromObjectTypeId
|
|
145
|
-
const other = nameById.get(otherId ?? '') ?? otherId ?? ''
|
|
146
|
-
return ` ${(l.apiName ?? '?').padEnd(26)}${outbound ? '→' : '←'} ${other.padEnd(18)}${l.cardinality ?? ''}`
|
|
140
|
+
: related.map((relation) => {
|
|
141
|
+
return ` ${(relation.apiName ?? '?').padEnd(26)}${relation.direction === 'outbound' ? '→' : '←'} ${relation.otherObjectType.padEnd(18)}${relation.cardinality ?? ''}`
|
|
147
142
|
})),
|
|
148
143
|
'',
|
|
149
144
|
`Metrics (${own.length})`,
|
|
@@ -153,7 +148,7 @@ export const blueprintGet: Command = {
|
|
|
153
148
|
]
|
|
154
149
|
|
|
155
150
|
return {
|
|
156
|
-
data: {
|
|
151
|
+
data: { objectType: type, properties: props, relations: related, metrics: own },
|
|
157
152
|
text: lines.join('\n'),
|
|
158
153
|
}
|
|
159
154
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { parseBlueprintSchema } from '../../blueprint-types'
|
|
2
3
|
import { table } from '../../table'
|
|
3
4
|
import type { Command } from '../types'
|
|
4
5
|
|
|
@@ -28,7 +29,7 @@ export const blueprintList: Command = {
|
|
|
28
29
|
|
|
29
30
|
async run(ctx) {
|
|
30
31
|
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
31
|
-
const types = (await api.
|
|
32
|
+
const types = parseBlueprintSchema(await api.blueprintSchema()).objectTypes
|
|
32
33
|
|
|
33
34
|
return {
|
|
34
35
|
data: types,
|
package/src/commands/registry.ts
CHANGED
|
@@ -12,12 +12,17 @@ import { appSave } from './app/save'
|
|
|
12
12
|
import { appDeploy } from './app/deploy'
|
|
13
13
|
import { appPromote } from './app/promote'
|
|
14
14
|
import { appVersions } from './app/versions'
|
|
15
|
+
import { appSdk } from './app/sdk'
|
|
16
|
+
import { appDev } from './app/dev'
|
|
15
17
|
import { blueprintList } from './blueprint/list'
|
|
16
18
|
import { blueprintGet } from './blueprint/get'
|
|
19
|
+
import { blueprintGenerateTypes } from './blueprint/generate-types'
|
|
17
20
|
import { blueprintAuthoringCommands } from './blueprint/authoring'
|
|
18
21
|
import { blueprintDeclarativeCommands } from './blueprint/declarative'
|
|
19
22
|
import { blueprintBind } from './blueprint/bind'
|
|
23
|
+
import { blueprintEditable } from './blueprint/editable'
|
|
20
24
|
import { blueprintGrant } from './blueprint/grants'
|
|
25
|
+
import { actionCommands } from './action/index-commands'
|
|
21
26
|
import { agentCommands } from './agent/index-commands'
|
|
22
27
|
import { skillCommands } from './skill/index-commands'
|
|
23
28
|
import { pluginCommands } from './plugin/index-commands'
|
|
@@ -58,6 +63,8 @@ export const COMMANDS: readonly Command[] = [
|
|
|
58
63
|
appDeploy,
|
|
59
64
|
appPromote,
|
|
60
65
|
appVersions,
|
|
66
|
+
appSdk,
|
|
67
|
+
appDev,
|
|
61
68
|
|
|
62
69
|
...agentCommands,
|
|
63
70
|
...skillCommands,
|
|
@@ -70,12 +77,16 @@ export const COMMANDS: readonly Command[] = [
|
|
|
70
77
|
|
|
71
78
|
blueprintList,
|
|
72
79
|
blueprintGet,
|
|
80
|
+
blueprintGenerateTypes,
|
|
73
81
|
// Authoring is no longer reserved: the organization API key (`sk-org-`) reaches the
|
|
74
82
|
// organization-level shared draft, which is what these verbs were waiting on.
|
|
75
83
|
...blueprintAuthoringCommands,
|
|
76
84
|
...blueprintDeclarativeCommands,
|
|
77
85
|
blueprintBind,
|
|
86
|
+
blueprintEditable,
|
|
78
87
|
blueprintGrant,
|
|
88
|
+
|
|
89
|
+
...actionCommands,
|
|
79
90
|
]
|
|
80
91
|
|
|
81
92
|
export function findCommand(noun: string, verb: string | undefined): Command | null {
|
|
@@ -106,6 +117,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
|
|
|
106
117
|
secret: 'Workspace secrets — named here, never printed back',
|
|
107
118
|
automation: 'Automations — TypeScript deployed here, run on a schedule',
|
|
108
119
|
blueprint: 'The shared model of the organization — what an app can read',
|
|
120
|
+
action: 'Governed Actions — arm the write path a published Action runs',
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
export function nounSummary(noun: string): string {
|
package/src/context.ts
CHANGED
|
@@ -20,14 +20,14 @@ export interface Context {
|
|
|
20
20
|
/**
|
|
21
21
|
* Is this directory the root of a Frontera app project?
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the field, every JavaScript repository on the machine would look like an app.
|
|
23
|
+
* New projects are marked by `frontera.config.json`. Legacy projects remain
|
|
24
|
+
* marked by `package.json#frontera`. A bare package.json does not match, so an
|
|
25
|
+
* unrelated JavaScript repository never becomes an App accidentally.
|
|
27
26
|
*/
|
|
28
27
|
function isProjectRoot(dir: string): boolean {
|
|
29
28
|
const pkgPath = join(dir, 'package.json')
|
|
30
29
|
if (!existsSync(pkgPath)) return false
|
|
30
|
+
if (existsSync(join(dir, 'frontera.config.json'))) return true
|
|
31
31
|
try {
|
|
32
32
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { frontera?: unknown }
|
|
33
33
|
return typeof pkg.frontera === 'object' && pkg.frontera !== null
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { BridgeInit } from '@frontera-sdk/core/bridge-protocol'
|
|
4
|
+
|
|
5
|
+
export interface DevSessionPayload {
|
|
6
|
+
init: BridgeInit
|
|
7
|
+
expiresAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DevSessionBroker {
|
|
11
|
+
endpoint: string
|
|
12
|
+
server: ReturnType<typeof Bun.serve>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keep the durable CLI credential on the developer's machine while giving the
|
|
17
|
+
* browser only a short-lived App token. The random path is defence in depth;
|
|
18
|
+
* loopback binding and an exact Origin check are the actual trust boundary.
|
|
19
|
+
*/
|
|
20
|
+
export function createDevSessionBroker(options: {
|
|
21
|
+
appOrigin: string
|
|
22
|
+
loadSession(): Promise<DevSessionPayload>
|
|
23
|
+
}): DevSessionBroker {
|
|
24
|
+
const allowedOrigin = new URL(options.appOrigin).origin
|
|
25
|
+
const path = `/.frontera/dev-session/${randomBytes(32).toString('base64url')}`
|
|
26
|
+
|
|
27
|
+
const fetch = async (request: Request): Promise<Response> => {
|
|
28
|
+
const url = new URL(request.url)
|
|
29
|
+
if (url.pathname !== path) return new Response('Not found', { status: 404 })
|
|
30
|
+
|
|
31
|
+
const origin = request.headers.get('origin')
|
|
32
|
+
if (origin !== allowedOrigin) return new Response('Forbidden', { status: 403 })
|
|
33
|
+
|
|
34
|
+
const corsHeaders = {
|
|
35
|
+
'access-control-allow-origin': allowedOrigin,
|
|
36
|
+
'access-control-allow-credentials': 'true',
|
|
37
|
+
'access-control-allow-methods': 'GET, OPTIONS',
|
|
38
|
+
'access-control-allow-headers': 'Accept',
|
|
39
|
+
'cache-control': 'no-store',
|
|
40
|
+
vary: 'Origin',
|
|
41
|
+
}
|
|
42
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: corsHeaders })
|
|
43
|
+
if (request.method !== 'GET') return new Response('Method not allowed', { status: 405, headers: corsHeaders })
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return Response.json({ data: await options.loadSession() }, { headers: corsHeaders })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return Response.json(
|
|
49
|
+
{ error: error instanceof Error ? error.message : 'Could not create a local App session' },
|
|
50
|
+
{ status: 502, headers: corsHeaders },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bun 1.3 rejects port 0 instead of asking the kernel for an ephemeral
|
|
56
|
+
// port. Retry within the IANA dynamic range; binding itself is atomic, so a
|
|
57
|
+
// race can only make one attempt fail and move to the next candidate.
|
|
58
|
+
let server: ReturnType<typeof Bun.serve> | undefined
|
|
59
|
+
let lastError: unknown
|
|
60
|
+
for (let attempt = 0; attempt < 32 && !server; attempt += 1) {
|
|
61
|
+
try {
|
|
62
|
+
const port = 49_152 + Math.floor(Math.random() * (65_535 - 49_152 + 1))
|
|
63
|
+
server = Bun.serve({ hostname: '127.0.0.1', port, fetch })
|
|
64
|
+
} catch (error) {
|
|
65
|
+
lastError = error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!server) throw lastError instanceof Error ? lastError : new Error('Could not bind the local session broker')
|
|
69
|
+
|
|
70
|
+
return { endpoint: `http://127.0.0.1:${server.port}${path}`, server }
|
|
71
|
+
}
|
package/src/flag-help.ts
CHANGED
|
@@ -19,10 +19,22 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
19
19
|
plan: 'write a mapping skeleton to this path instead of binding',
|
|
20
20
|
'accept-contract-change': 'confirm a rebind whose column contract differs from the pinned one',
|
|
21
21
|
revoke: 'remove the grant instead of adding it',
|
|
22
|
+
// Editable properties. Comma-separated API names; `--set ''` clears the set,
|
|
23
|
+
// which is why an empty value is a valid one rather than a missing argument.
|
|
24
|
+
set: 'comma-separated property API names, replacing the whole set',
|
|
25
|
+
// Action deployment.
|
|
26
|
+
'dry-run': 'derive and print the write path without building anything',
|
|
27
|
+
'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
|
|
28
|
+
'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
|
|
29
|
+
reason: 'reason recorded on the deployment transition',
|
|
30
|
+
'no-activate': 'review the write path but leave it switched off',
|
|
31
|
+
role: 'organization role the capability is held by',
|
|
32
|
+
add: 'comma-separated property API names to add to the current set',
|
|
33
|
+
remove: 'comma-separated property API names to drop from the current set',
|
|
22
34
|
// Blueprint authoring (organization API key). `notes` is described further down
|
|
23
35
|
// — this map is keyed by flag name across every command, so one entry serves both.
|
|
24
36
|
label: 'release label recorded on the published or rolled-back release',
|
|
25
|
-
report: 'validation report id to
|
|
37
|
+
report: 'validation report id to act on — the release publishes against it, the review is pinned to it',
|
|
26
38
|
instruction: 'migration instruction applied to every change a rollback discards',
|
|
27
39
|
// Global
|
|
28
40
|
json: 'machine-readable output on stdout; stderr still carries commentary',
|
|
@@ -30,6 +42,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
30
42
|
yes: 'assume yes for confirmations',
|
|
31
43
|
help: 'show this help',
|
|
32
44
|
'api-url': 'API origin to use, overriding FRONTERA_API_URL and the stored default',
|
|
45
|
+
output: 'project-relative .ts path for generated output',
|
|
46
|
+
check: 'verify generated output is current without writing it',
|
|
33
47
|
|
|
34
48
|
// Project resolution
|
|
35
49
|
dir: 'project directory to act on (default: walk up from the working directory)',
|
|
@@ -42,6 +56,12 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
42
56
|
notes: 'note recorded against the published version',
|
|
43
57
|
|
|
44
58
|
// Apps
|
|
59
|
+
framework: 'App scaffold to create: next (default) or react (legacy Vite)',
|
|
60
|
+
port: 'loopback port for the local App development server',
|
|
61
|
+
'no-open': 'start local development without opening a browser window',
|
|
62
|
+
'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
|
|
63
|
+
'no-git': 'scaffold without creating a repository and an initial commit',
|
|
64
|
+
'no-components': 'scaffold without fetching the baseline shadcn components',
|
|
45
65
|
version: 'version to publish, overriding the one in package.json',
|
|
46
66
|
'no-promote': 'publish the version without moving the live pointer',
|
|
47
67
|
'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
|
|
@@ -83,11 +103,14 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
83
103
|
*/
|
|
84
104
|
const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
85
105
|
'api-url': 'origin',
|
|
106
|
+
output: 'path',
|
|
86
107
|
dataset: 'name',
|
|
87
108
|
plan: 'path',
|
|
88
109
|
dir: 'path',
|
|
89
110
|
file: 'path',
|
|
90
111
|
version: 'semver',
|
|
112
|
+
framework: 'next|react',
|
|
113
|
+
port: 'number',
|
|
91
114
|
'expect-revision': 'hash',
|
|
92
115
|
notes: 'text',
|
|
93
116
|
description: 'text',
|
package/src/heal.ts
CHANGED
|
@@ -135,7 +135,7 @@ export function healProject(dir: string): HealResult {
|
|
|
135
135
|
// Already pointing at the copy in this tree — that is the shape this
|
|
136
136
|
// function produces, so re-pulling must not rewrite it back over
|
|
137
137
|
// itself and report a repair that did nothing.
|
|
138
|
-
if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` &&
|
|
138
|
+
if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` && vendorCurrent(dir, pkg)) continue
|
|
139
139
|
vendorSdkPackage(dir, pkg)
|
|
140
140
|
deps[dep] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
|
|
141
141
|
const under = dep === pkg ? '' : ` (legacy name, now ${pkg})`
|
|
@@ -184,7 +184,10 @@ export function healProject(dir: string): HealResult {
|
|
|
184
184
|
// Reacts.
|
|
185
185
|
const imported = importedSdkPackages(join(dir, 'src'))
|
|
186
186
|
for (const { specifier, pkg } of imported) {
|
|
187
|
-
|
|
187
|
+
// `declares` alone is not enough: a tree can declare the dependency and
|
|
188
|
+
// still carry a stale copy, which is the ordinary state of every app
|
|
189
|
+
// scaffolded before the SDK grew a module.
|
|
190
|
+
if (vendorCurrent(dir, pkg) && (declares(manifest, specifier) || specifier === pkg)) continue
|
|
188
191
|
vendorSdkPackage(dir, pkg)
|
|
189
192
|
manifest.dependencies ??= {}
|
|
190
193
|
manifest.dependencies[specifier] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
|
|
@@ -252,6 +255,38 @@ function vendorPresent(dir: string, pkg: AppSdkPackage): boolean {
|
|
|
252
255
|
return existsSync(join(dir, APP_SDK_PACKAGES[pkg].dir))
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Does it carry the CURRENT one — every file this CLI would write, as written?
|
|
260
|
+
*
|
|
261
|
+
* "The directory exists" was the wrong question. An app vendored before a
|
|
262
|
+
* module was added has the directory and not the module, so a heal that
|
|
263
|
+
* skipped on presence left it frozen at whatever the CLI shipped the day it
|
|
264
|
+
* was scaffolded — and `app pull`, which exists to hydrate a tree, silently
|
|
265
|
+
* refused to hydrate the one part it owns.
|
|
266
|
+
*
|
|
267
|
+
* CONTENT, not presence, for the same reason one step on: a file that exists
|
|
268
|
+
* but predates a fix is the ordinary state of every app already deployed, and
|
|
269
|
+
* a fix nothing can deliver is not shipped. The vendored tree is a copy of
|
|
270
|
+
* upstream rather than app code — `heal` owns it and rewrites it — so
|
|
271
|
+
* overwriting a divergent copy is the repair, not a loss.
|
|
272
|
+
*
|
|
273
|
+
* Re-vendoring is safe and idempotent: `vendorSdkPackage` writes into the SAME
|
|
274
|
+
* directory, so this refreshes a copy rather than creating a second one.
|
|
275
|
+
*/
|
|
276
|
+
function vendorCurrent(dir: string, pkg: AppSdkPackage): boolean {
|
|
277
|
+
if (!vendorPresent(dir, pkg)) return false
|
|
278
|
+
const target = join(dir, APP_SDK_PACKAGES[pkg].dir)
|
|
279
|
+
return Object.entries(sdkPackageFiles(pkg)).every(([rel, content]) => {
|
|
280
|
+
const path = join(target, rel)
|
|
281
|
+
if (!existsSync(path)) return false
|
|
282
|
+
try {
|
|
283
|
+
return readFileSync(path, 'utf8') === content
|
|
284
|
+
} catch {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
/** Does it carry any of them? */
|
|
256
291
|
function vendored(dir: string): boolean {
|
|
257
292
|
return SDK_PACKAGE_NAMES.some((pkg) => vendorPresent(dir, pkg))
|
package/src/manifest.ts
CHANGED
|
@@ -15,11 +15,14 @@ import type { ProjectConfig } from './project'
|
|
|
15
15
|
* `connectDomains` reaches the CSP for every app rather than only scaffolded
|
|
16
16
|
* ones — which is the gap this closes.
|
|
17
17
|
*
|
|
18
|
-
* A build that DOES emit
|
|
18
|
+
* A build that DOES emit `<outputDirectory>/frontera.manifest.json` still wins, so a future
|
|
19
19
|
* plugin can add pages and an app-state schema without changing this.
|
|
20
20
|
*/
|
|
21
21
|
export interface AppManifest {
|
|
22
|
-
schemaVersion:
|
|
22
|
+
schemaVersion: 2
|
|
23
|
+
runtime: 'static'
|
|
24
|
+
routing: 'spa' | 'filesystem'
|
|
25
|
+
entrypoint: 'index.html'
|
|
23
26
|
displayName: string
|
|
24
27
|
connectDomains: string[]
|
|
25
28
|
resourceDomains: string[]
|
|
@@ -50,7 +53,10 @@ function assertDomains(field: string, values: string[]): string[] {
|
|
|
50
53
|
|
|
51
54
|
export function buildManifest(project: ProjectConfig): AppManifest {
|
|
52
55
|
return {
|
|
53
|
-
schemaVersion:
|
|
56
|
+
schemaVersion: 2,
|
|
57
|
+
runtime: 'static',
|
|
58
|
+
routing: project.routing ?? 'spa',
|
|
59
|
+
entrypoint: 'index.html',
|
|
54
60
|
displayName: project.displayName,
|
|
55
61
|
connectDomains: assertDomains('connectDomains', project.connectDomains),
|
|
56
62
|
resourceDomains: assertDomains('resourceDomains', project.resourceDomains),
|
|
@@ -60,8 +66,9 @@ export function buildManifest(project: ProjectConfig): AppManifest {
|
|
|
60
66
|
/**
|
|
61
67
|
* The manifest to publish with this build.
|
|
62
68
|
*
|
|
63
|
-
* Read from
|
|
64
|
-
*
|
|
69
|
+
* Read from the configured output directory when the build produced one;
|
|
70
|
+
* otherwise synthesised from `frontera.config.json` (or the legacy
|
|
71
|
+
* package.json field). A malformed emitted manifest is an error rather
|
|
65
72
|
* than a silent fallback: the build meant to say something, and quietly
|
|
66
73
|
* publishing different rules than the author declared is worse than refusing.
|
|
67
74
|
*/
|
|
@@ -69,12 +76,86 @@ export function resolveManifest(distDir: string, project: ProjectConfig): AppMan
|
|
|
69
76
|
const emitted = join(distDir, 'frontera.manifest.json')
|
|
70
77
|
if (!existsSync(emitted)) return buildManifest(project)
|
|
71
78
|
|
|
79
|
+
let parsed: unknown
|
|
72
80
|
try {
|
|
73
|
-
|
|
81
|
+
parsed = JSON.parse(readFileSync(emitted, 'utf8'))
|
|
74
82
|
} catch (err) {
|
|
75
|
-
throw new CliError(
|
|
83
|
+
throw new CliError(`${emitted} is not valid JSON: ${(err as Error).message}`, {
|
|
76
84
|
code: 'VALIDATION_ERROR',
|
|
77
|
-
hint: '
|
|
85
|
+
hint: 'fix the emitted JSON, or delete it to use frontera.config.json',
|
|
78
86
|
})
|
|
79
87
|
}
|
|
88
|
+
return normalizeManifest(parsed)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MANIFEST_HINT = 'fix frontera.manifest.json, then run `frontera app deploy` again'
|
|
92
|
+
|
|
93
|
+
function stringArray(value: unknown, field: string): string[] {
|
|
94
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
95
|
+
throw new CliError(`${field} must be an array of strings`, {
|
|
96
|
+
code: 'VALIDATION_ERROR',
|
|
97
|
+
hint: MANIFEST_HINT,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
return assertDomains(field, value as string[])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Validate emitted manifests before they cross the wire. */
|
|
104
|
+
export function normalizeManifest(value: unknown): AppManifest {
|
|
105
|
+
if (!value || typeof value !== 'object') {
|
|
106
|
+
throw new CliError('frontera manifest must be an object', {
|
|
107
|
+
code: 'VALIDATION_ERROR', hint: MANIFEST_HINT,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
const raw = value as Record<string, unknown>
|
|
111
|
+
if (raw.schemaVersion !== 1 && raw.schemaVersion !== 2) {
|
|
112
|
+
throw new CliError('unsupported frontera manifest schemaVersion', {
|
|
113
|
+
code: 'VALIDATION_ERROR', hint: 'emit schemaVersion 2, then run `frontera app deploy` again',
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
if (typeof raw.displayName !== 'string' || raw.displayName.length === 0) {
|
|
117
|
+
throw new CliError('manifest displayName must be a non-empty string', {
|
|
118
|
+
code: 'VALIDATION_ERROR', hint: MANIFEST_HINT,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
const connectDomains = stringArray(raw.connectDomains, 'connectDomains')
|
|
122
|
+
const resourceDomains = stringArray(raw.resourceDomains, 'resourceDomains')
|
|
123
|
+
|
|
124
|
+
if (raw.schemaVersion === 1) {
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: 2,
|
|
127
|
+
runtime: 'static',
|
|
128
|
+
routing: 'spa',
|
|
129
|
+
entrypoint: 'index.html',
|
|
130
|
+
displayName: raw.displayName,
|
|
131
|
+
connectDomains,
|
|
132
|
+
resourceDomains,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (raw.runtime !== 'static') {
|
|
136
|
+
throw new CliError('manifest runtime must be "static"', {
|
|
137
|
+
code: 'VALIDATION_ERROR', hint: 'build a static artifact; server runtimes are not supported',
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
if (raw.routing !== 'spa' && raw.routing !== 'filesystem') {
|
|
141
|
+
throw new CliError('manifest routing must be "spa" or "filesystem"', {
|
|
142
|
+
code: 'VALIDATION_ERROR',
|
|
143
|
+
hint: MANIFEST_HINT,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
if (raw.entrypoint !== 'index.html') {
|
|
147
|
+
throw new CliError('manifest entrypoint must be "index.html"', {
|
|
148
|
+
code: 'VALIDATION_ERROR',
|
|
149
|
+
hint: MANIFEST_HINT,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
schemaVersion: 2,
|
|
154
|
+
runtime: 'static',
|
|
155
|
+
routing: raw.routing,
|
|
156
|
+
entrypoint: 'index.html',
|
|
157
|
+
displayName: raw.displayName,
|
|
158
|
+
connectDomains,
|
|
159
|
+
resourceDomains,
|
|
160
|
+
}
|
|
80
161
|
}
|
package/src/packaging.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { join, relative, sep } from 'node:path'
|
|
|
17
17
|
export const HARD_EXCLUDED = Object.freeze([
|
|
18
18
|
'node_modules',
|
|
19
19
|
'dist',
|
|
20
|
+
'out',
|
|
20
21
|
'build',
|
|
21
22
|
'.next',
|
|
22
23
|
'.git',
|
|
@@ -39,6 +40,11 @@ export function isExcluded(rel: string): boolean {
|
|
|
39
40
|
if (seg === '.DS_Store') return true
|
|
40
41
|
if (seg.endsWith('.log')) return true
|
|
41
42
|
}
|
|
43
|
+
// The render screenshot. It is written INSIDE the project so a deploy from a
|
|
44
|
+
// bare directory can find it, but it is a build-time by-product of this
|
|
45
|
+
// machine, not source: packaging it would push a few hundred KB of PNG into
|
|
46
|
+
// every `app pull` for a picture the platform already stores per version.
|
|
47
|
+
if (rel === '.frontera/last-render.png') return true
|
|
42
48
|
return false
|
|
43
49
|
}
|
|
44
50
|
|