@frontera-sdk/cli 1.52.39 → 1.52.41
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 +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { APP_SDK_PACKAGES } from '../template'
|
|
6
|
+
import { fileAsWas, putBackAsWas } from './local-app-dev-files'
|
|
7
|
+
import { installedVersion } from './local-app-lock-install'
|
|
8
|
+
import { vendoredShadows } from './local-app-sdk'
|
|
9
|
+
import { isAlive } from './local-state'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A pulled App whose node_modules holds the SDK release its bun.lock records
|
|
13
|
+
* (`installFromLock`), while its tsconfig.json still sends the SDK's
|
|
14
|
+
* subpaths (`@frontera-sdk/blueprint/*`) to the older copy under
|
|
15
|
+
* src/frontera/: TypeScript, `bun test` and Next.js follow `paths` before
|
|
16
|
+
* node_modules, so the code would still build against the copy.
|
|
17
|
+
*
|
|
18
|
+
* Its own check and its dev server run with those paths set aside: tsconfig.json
|
|
19
|
+
* without them for as long as they run, put back byte for byte after, with
|
|
20
|
+
* its modification time, so the put-back is no change to the App (a render
|
|
21
|
+
* made meanwhile stays fresh). The original is kept in the App's
|
|
22
|
+
* `.frontera/` (never tracked) until it is put
|
|
23
|
+
* back, so a run that was killed is put back by the next one. Only those
|
|
24
|
+
* `paths` entries change, and a tsconfig.json edited meanwhile is never
|
|
25
|
+
* overwritten.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const RECORD = 'tsconfig.set-aside.json'
|
|
29
|
+
const recordPath = (appRoot: string) => join(appRoot, '.frontera', RECORD)
|
|
30
|
+
const hash = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex')
|
|
31
|
+
|
|
32
|
+
interface SetAsideRecord {
|
|
33
|
+
/** tsconfig.json as it was, base64. */
|
|
34
|
+
original: string
|
|
35
|
+
/** The hash of tsconfig.json as written without the paths. */
|
|
36
|
+
written: string
|
|
37
|
+
paths: string[]
|
|
38
|
+
/** The process that set them aside: while it runs, another run leaves them set aside. */
|
|
39
|
+
pid: number
|
|
40
|
+
/** tsconfig.json's times as it was, put back with it (absent in a record an older CLI wrote). */
|
|
41
|
+
atimeMs?: number
|
|
42
|
+
mtimeMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readRecord(appRoot: string): SetAsideRecord | null {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(recordPath(appRoot), 'utf8')) as SetAsideRecord
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `compilerOptions.paths` keys that send a package to the SDK copy under
|
|
55
|
+
* src/frontera/ while node_modules holds the release bun.lock records, with
|
|
56
|
+
* that release. Empty when there are none, the install came from the copy,
|
|
57
|
+
* or tsconfig.json isn't plain JSON.
|
|
58
|
+
*/
|
|
59
|
+
export function copyPathsOverRelease(appRoot: string): { keys: string[]; releases: Array<{ pkg: string; version: string }> } {
|
|
60
|
+
const none = { keys: [], releases: [] }
|
|
61
|
+
const releases = vendoredShadows(appRoot).filter((s) => installedVersion(appRoot, s.pkg) === s.version)
|
|
62
|
+
if (!releases.length) return none
|
|
63
|
+
let config: { compilerOptions?: { paths?: Record<string, string[]> } }
|
|
64
|
+
try {
|
|
65
|
+
config = JSON.parse(readFileSync(join(appRoot, 'tsconfig.json'), 'utf8')) as typeof config
|
|
66
|
+
} catch {
|
|
67
|
+
return none
|
|
68
|
+
}
|
|
69
|
+
const dirs = releases.map((r) => (APP_SDK_PACKAGES as Record<string, { dir: string } | undefined>)[r.pkg]?.dir).filter((d): d is string => !!d)
|
|
70
|
+
const keys = Object.entries(config.compilerOptions?.paths ?? {})
|
|
71
|
+
.filter(([, targets]) => Array.isArray(targets) && targets.some((t) => dirs.some((d) => t.replace(/^\.\//, '') === d || t.replace(/^\.\//, '').startsWith(`${d}/`))))
|
|
72
|
+
.map(([key]) => key)
|
|
73
|
+
return keys.length ? { keys, releases } : none
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SetAside {
|
|
77
|
+
/** What was set aside, for the output. */
|
|
78
|
+
line: string
|
|
79
|
+
/** Put tsconfig.json back; what was done, for the output. */
|
|
80
|
+
restore(): string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Set the copy's `paths` aside in tsconfig.json for a run; null when there are none to set aside. */
|
|
84
|
+
export function setAsideCopyPaths(appRoot: string): SetAside | null {
|
|
85
|
+
// Another run that is still going (the App's dev server) set them aside: left to it to put back.
|
|
86
|
+
const running = readRecord(appRoot)
|
|
87
|
+
if (running && running.pid !== process.pid && isAlive(running.pid)) {
|
|
88
|
+
return { line: `tsconfig.json: its path${running.paths.length === 1 ? '' : 's'} ${running.paths.join(', ')} to the SDK copy under src/frontera/ already set aside by a run still going (pid ${running.pid}), which puts ${running.paths.length === 1 ? 'it' : 'them'} back`, restore: () => 'tsconfig.json: left to that run to put back' }
|
|
89
|
+
}
|
|
90
|
+
// A run that was killed left its set-aside behind: put back first.
|
|
91
|
+
restoreSetAsideTsconfig(appRoot)
|
|
92
|
+
const { keys, releases } = copyPathsOverRelease(appRoot)
|
|
93
|
+
if (!keys.length) return null
|
|
94
|
+
const path = join(appRoot, 'tsconfig.json')
|
|
95
|
+
const was = fileAsWas(path)
|
|
96
|
+
if (!was) return null
|
|
97
|
+
const original = was.bytes
|
|
98
|
+
const config = JSON.parse(original.toString('utf8')) as { compilerOptions: { paths: Record<string, string[]> } }
|
|
99
|
+
for (const key of keys) delete config.compilerOptions.paths[key]
|
|
100
|
+
const written = `${JSON.stringify(config, null, 2)}\n`
|
|
101
|
+
mkdirSync(join(appRoot, '.frontera'), { recursive: true })
|
|
102
|
+
const record: SetAsideRecord = { original: original.toString('base64'), written: hash(written), paths: keys, pid: process.pid, atimeMs: was.atimeMs, mtimeMs: was.mtimeMs }
|
|
103
|
+
writeFileSync(recordPath(appRoot), `${JSON.stringify(record, null, 2)}\n`)
|
|
104
|
+
writeFileSync(path, written)
|
|
105
|
+
const released = releases.map((r) => `${r.pkg} ${r.version}`).join(' and ')
|
|
106
|
+
return {
|
|
107
|
+
line: `tsconfig.json: its path${keys.length === 1 ? '' : 's'} ${keys.join(', ')} to the SDK copy under src/frontera/ set aside while it runs, so it builds against the published ${released} that bun.lock records and node_modules holds; put back byte for byte after`,
|
|
108
|
+
restore: () => restoreSetAsideTsconfig(appRoot) ?? 'tsconfig.json: already as it was',
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* tsconfig.json as it is the App's, while a run has its copy paths set aside:
|
|
114
|
+
* the original kept in `.frontera/`, byte for byte, when tsconfig.json is
|
|
115
|
+
* still as the set-aside wrote it. Null when nothing is set aside, or
|
|
116
|
+
* tsconfig.json was edited since (the edit is the App's). What a deploy or a
|
|
117
|
+
* save packages while the App's dev server runs, so the source it uploads is
|
|
118
|
+
* never the set-aside.
|
|
119
|
+
*/
|
|
120
|
+
export function tsconfigAsKept(appRoot: string): Buffer | null {
|
|
121
|
+
const record = readRecord(appRoot)
|
|
122
|
+
if (!record || typeof record.original !== 'string') return null
|
|
123
|
+
try {
|
|
124
|
+
return hash(readFileSync(join(appRoot, 'tsconfig.json'))) === record.written ? Buffer.from(record.original, 'base64') : null
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Put back a tsconfig.json whose copy paths were set aside: when it is still
|
|
132
|
+
* as the set-aside wrote it. Edited since, it is left as it is (and said). Null
|
|
133
|
+
* when nothing was set aside.
|
|
134
|
+
*/
|
|
135
|
+
export function restoreSetAsideTsconfig(appRoot: string): string | null {
|
|
136
|
+
const record = readRecord(appRoot)
|
|
137
|
+
if (!record) return null
|
|
138
|
+
const path = join(appRoot, 'tsconfig.json')
|
|
139
|
+
const original = Buffer.from(record.original, 'base64')
|
|
140
|
+
const now = existsSync(path) ? readFileSync(path) : null
|
|
141
|
+
rmSync(recordPath(appRoot), { force: true })
|
|
142
|
+
if (now && hash(now) !== record.written && !now.equals(original)) return `tsconfig.json: changed while it ran, so left as it is (its paths ${record.paths.join(', ')} were set aside; put them back if you need them)`
|
|
143
|
+
// With its modification time as it was: a render made while it was set aside is still of this source (`render-evidence.ts`).
|
|
144
|
+
const times = typeof record.mtimeMs === 'number' ? { atimeMs: record.atimeMs ?? record.mtimeMs, mtimeMs: record.mtimeMs } : null
|
|
145
|
+
const already = !!now && now.equals(original)
|
|
146
|
+
if (times) putBackAsWas(path, { bytes: original, ...times })
|
|
147
|
+
else if (!already) writeFileSync(path, original)
|
|
148
|
+
return already ? 'tsconfig.json: already as it was' : 'tsconfig.json: put back byte for byte'
|
|
149
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join, relative } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import vendored from '../vendor/sdk-sources.json'
|
|
8
|
+
import { isRegistryRange, lockedSdkVersions, lockedSpec, recordLockedRanges } from '../app-lock'
|
|
9
|
+
import { unvendorSdkPackageDetail, viteAliasesSdk, type Unvendored } from '../heal'
|
|
10
|
+
import { APP_SDK_PACKAGES, sdkPackageFiles, type AppSdkPackage } from '../template'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An App's `@frontera-sdk/*` packages against the SDK the runtime it is built
|
|
14
|
+
* for has.
|
|
15
|
+
*
|
|
16
|
+
* In image mode that is the CLI's own SDK (the one its skills and scaffold
|
|
17
|
+
* describe; a scaffold pins `^<sdkVersion>` from npm). In source mode the
|
|
18
|
+
* local runtime runs from a platform checkout, whose SDK can be ahead of
|
|
19
|
+
* every npm release: the App then installs the checkout's packages instead.
|
|
20
|
+
*
|
|
21
|
+
* How the checkout's SDK reaches an App: each package is packed (`bun pm
|
|
22
|
+
* pack`, deterministic) into a tarball in the App's own `vendor/frontera-sdk/`,
|
|
23
|
+
* and the App depends on `file:./vendor/frontera-sdk/<tarball>`. Chosen over
|
|
24
|
+
* the alternatives because:
|
|
25
|
+
* - a `file:`/`link:` dependency on the checkout's folder writes a personal
|
|
26
|
+
* absolute path into package.json, and Bun links a folder dependency file
|
|
27
|
+
* by file, so imports resolve back into the checkout, outside the App:
|
|
28
|
+
* TypeScript and Turbopack (rooted at the App) then can't find the App's
|
|
29
|
+
* react or the SDK's sibling package;
|
|
30
|
+
* - an overlay copied into node_modules leaves package.json alone, but the
|
|
31
|
+
* next `bun install` puts the npm package back;
|
|
32
|
+
* - a tarball is extracted into node_modules as a plain copy inside the App,
|
|
33
|
+
* survives `bun install`, has a relative path, and is committed with the
|
|
34
|
+
* App, so a clone, CI and `app deploy` build the same SDK. Its file name
|
|
35
|
+
* carries a hash of its content, so a changed checkout is a new file the
|
|
36
|
+
* lockfile notices, and the same checkout packs to the same file.
|
|
37
|
+
* The packed package.json drops the checkout's devDependencies and scripts,
|
|
38
|
+
* and names a sibling `@frontera-sdk` package as a peer (the App depends on it
|
|
39
|
+
* directly), so the App holds one copy of each and one React.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Where an App keeps the checkout's SDK tarballs, relative to the App. */
|
|
43
|
+
export const LOCAL_SDK_DIR = join('vendor', 'frontera-sdk')
|
|
44
|
+
|
|
45
|
+
export interface SdkDrift {
|
|
46
|
+
pkg: string
|
|
47
|
+
installed: string
|
|
48
|
+
/** The version the App should have: the checkout's in source mode, else the CLI's. */
|
|
49
|
+
cli: string
|
|
50
|
+
/** The installed version is older than that. */
|
|
51
|
+
older: boolean
|
|
52
|
+
/** Names that SDK exports which the installed package does not. */
|
|
53
|
+
missing: string[]
|
|
54
|
+
/** Where the reference SDK is: the platform checkout (source mode) or the CLI's own (npm). */
|
|
55
|
+
source: 'checkout' | 'npm'
|
|
56
|
+
/**
|
|
57
|
+
* The published version the App's bun.lock installed, when package.json
|
|
58
|
+
* names the SDK copy under src/frontera/ in its place: the copy shadows the
|
|
59
|
+
* release the code was written against, and `use-local --npm` goes back.
|
|
60
|
+
*/
|
|
61
|
+
shadowsLocked?: string
|
|
62
|
+
/**
|
|
63
|
+
* The App deliberately builds against a published release: package.json
|
|
64
|
+
* names a registry range and its lockfile installed exactly the version in
|
|
65
|
+
* node_modules (as `app sdk use-local --npm` leaves it). Said as a note,
|
|
66
|
+
* never a warning: the App is consistent with itself.
|
|
67
|
+
*/
|
|
68
|
+
published?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The version a CLI's SDK is: `1.52.28` of `1.52.28-dev`. */
|
|
72
|
+
export const CLI_SDK_VERSION: string = vendored.sdkVersion
|
|
73
|
+
|
|
74
|
+
/** The local runtime's platform checkout, when it runs in source mode. */
|
|
75
|
+
export interface CheckoutSdk {
|
|
76
|
+
platformSource: string
|
|
77
|
+
/** The checkout's SDK version (sdk-core's package.json). */
|
|
78
|
+
version: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** -1, 0 or 1: `a` against `b`, by their numbers (a pre-release tag sorts before its release). */
|
|
82
|
+
export function compareVersions(a: string, b: string): number {
|
|
83
|
+
const parse = (v: string) => {
|
|
84
|
+
const [core, pre] = v.trim().replace(/^v/, '').split('-', 2) as [string, string | undefined]
|
|
85
|
+
return { nums: core.split('.').map((n) => Number.parseInt(n, 10) || 0), pre }
|
|
86
|
+
}
|
|
87
|
+
const x = parse(a)
|
|
88
|
+
const y = parse(b)
|
|
89
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
|
|
90
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0)
|
|
91
|
+
if (d) return d < 0 ? -1 : 1
|
|
92
|
+
}
|
|
93
|
+
if (x.pre && !y.pre) return -1
|
|
94
|
+
if (!x.pre && y.pre) return 1
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The names a TypeScript module exports: declarations and `export { a, b as c }` lists. */
|
|
99
|
+
export function exportedNames(source: string): Set<string> {
|
|
100
|
+
const out = new Set<string>()
|
|
101
|
+
for (const m of source.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|var|class|interface|type|enum|abstract\s+class)\s+([A-Za-z_$][\w$]*)/gm)) out.add(m[1]!)
|
|
102
|
+
for (const m of source.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
|
|
103
|
+
for (const part of m[1]!.split(',')) {
|
|
104
|
+
const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()?.trim()
|
|
105
|
+
if (name) out.add(name)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const readJson = (path: string): Record<string, unknown> | null => {
|
|
112
|
+
try {
|
|
113
|
+
const doc = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
114
|
+
return doc && typeof doc === 'object' && !Array.isArray(doc) ? (doc as Record<string, unknown>) : null
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The platform checkout an App's runtime runs from: the nearest
|
|
122
|
+
* `.frontera/local/config.json` above the App that says `mode: source` and
|
|
123
|
+
* names a `platformSource` with the SDK packages. Null in image mode, or
|
|
124
|
+
* outside a solution.
|
|
125
|
+
*/
|
|
126
|
+
export function checkoutSdkFor(dir: string): CheckoutSdk | null {
|
|
127
|
+
let at = dir
|
|
128
|
+
for (;;) {
|
|
129
|
+
const config = readJson(join(at, '.frontera', 'local', 'config.json'))
|
|
130
|
+
if (config) {
|
|
131
|
+
const source = typeof config.platformSource === 'string' ? config.platformSource : ''
|
|
132
|
+
if (config.mode !== 'source' || !source) return null
|
|
133
|
+
// The runtime as it last started, when it has: a runtime started from release images has no checkout SDK,
|
|
134
|
+
// whatever the config now says, so neither the advice nor `app sdk use-local` reaches for one.
|
|
135
|
+
const state = readJson(join(at, '.frontera', 'local', 'state.json'))
|
|
136
|
+
if (state && typeof state.mode === 'string' && state.mode !== 'source') return null
|
|
137
|
+
const core = readJson(join(source, 'packages', 'sdk-core', 'package.json'))
|
|
138
|
+
return core && typeof core.version === 'string' ? { platformSource: source, version: core.version } : null
|
|
139
|
+
}
|
|
140
|
+
const up = dirname(at)
|
|
141
|
+
if (up === at) return null
|
|
142
|
+
at = up
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `@frontera-sdk/blueprint` → the checkout's `packages/sdk-blueprint`, when it has it. */
|
|
147
|
+
export function checkoutPackageDir(checkout: CheckoutSdk, pkg: string): string | null {
|
|
148
|
+
const m = /^@frontera-sdk\/([a-z0-9-]+)$/.exec(pkg)
|
|
149
|
+
if (!m) return null
|
|
150
|
+
const dir = join(checkout.platformSource, 'packages', `sdk-${m[1]}`)
|
|
151
|
+
return existsSync(join(dir, 'package.json')) ? dir : null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A package's TypeScript sources, by path relative to its `src/` (tests left out). */
|
|
155
|
+
function sourceFiles(srcDir: string): Record<string, string> {
|
|
156
|
+
const out: Record<string, string> = {}
|
|
157
|
+
const walk = (dir: string, rel: string) => {
|
|
158
|
+
if (!existsSync(dir)) return
|
|
159
|
+
for (const name of readdirSync(dir).sort()) {
|
|
160
|
+
const full = join(dir, name)
|
|
161
|
+
const r = rel ? `${rel}/${name}` : name
|
|
162
|
+
if (statSync(full).isDirectory()) {
|
|
163
|
+
if (name !== '__tests__' && name !== 'node_modules') walk(full, r)
|
|
164
|
+
} else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) out[r] = readFileSync(full, 'utf8')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
walk(srcDir, '')
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The SDK an App should have, per package: the checkout's sources in source mode, else the CLI's vendored copy. */
|
|
172
|
+
function referenceFiles(pkg: string, checkout: CheckoutSdk | null): Record<string, string> | null {
|
|
173
|
+
if (checkout) {
|
|
174
|
+
const dir = checkoutPackageDir(checkout, pkg)
|
|
175
|
+
return dir ? sourceFiles(join(dir, 'src')) : null
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return sdkPackageFiles(pkg as AppSdkPackage)
|
|
179
|
+
} catch {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Where a package is installed for an App: its own node_modules, or one above it (a hoisted workspace install). */
|
|
185
|
+
function installedDir(appRoot: string, pkg: string): string | null {
|
|
186
|
+
let dir = appRoot
|
|
187
|
+
for (;;) {
|
|
188
|
+
const candidate = join(dir, 'node_modules', pkg)
|
|
189
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
190
|
+
const up = dirname(dir)
|
|
191
|
+
if (up === dir) return null
|
|
192
|
+
dir = up
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function appDependencies(appRoot: string): Record<string, string> | null {
|
|
197
|
+
const manifest = readJson(join(appRoot, 'package.json')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null
|
|
198
|
+
return manifest ? { ...manifest.devDependencies, ...manifest.dependencies } : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Each `@frontera-sdk/*` the App depends on and has installed, where it
|
|
203
|
+
* differs from the SDK its runtime has (`checkout` in source mode, else the
|
|
204
|
+
* CLI's); [] when none does.
|
|
205
|
+
*/
|
|
206
|
+
export function appSdkDrift(appRoot: string, checkout: CheckoutSdk | null = checkoutSdkFor(appRoot)): SdkDrift[] {
|
|
207
|
+
const deps = appDependencies(appRoot)
|
|
208
|
+
if (!deps) return []
|
|
209
|
+
const want = checkout?.version ?? CLI_SDK_VERSION
|
|
210
|
+
const shadowed = new Map(vendoredShadows(appRoot).map((v) => [v.pkg, v.version] as const))
|
|
211
|
+
const locked = lockedSdkVersions(appRoot)
|
|
212
|
+
const out: SdkDrift[] = []
|
|
213
|
+
for (const pkg of Object.keys(deps).filter((d) => d.startsWith('@frontera-sdk/')).sort()) {
|
|
214
|
+
const reference = referenceFiles(pkg, checkout)
|
|
215
|
+
if (!reference) continue
|
|
216
|
+
const dir = installedDir(appRoot, pkg)
|
|
217
|
+
if (!dir) continue
|
|
218
|
+
const installed = String(readJson(join(dir, 'package.json'))?.version ?? '')
|
|
219
|
+
if (!installed) continue
|
|
220
|
+
const missing: string[] = []
|
|
221
|
+
for (const [rel, content] of Object.entries(reference)) {
|
|
222
|
+
if (!/\.tsx?$/.test(rel)) continue
|
|
223
|
+
const theirs = [join(dir, 'src', rel), join(dir, rel)].find((p) => existsSync(p))
|
|
224
|
+
const have = theirs ? exportedNames(readFileSync(theirs, 'utf8')) : new Set<string>()
|
|
225
|
+
for (const name of exportedNames(content)) if (!have.has(name)) missing.push(name)
|
|
226
|
+
}
|
|
227
|
+
const older = compareVersions(installed, want) < 0
|
|
228
|
+
const shadows = shadowed.get(pkg)
|
|
229
|
+
const published = !shadows && isRegistryRange(deps[pkg]) && locked.get(pkg)?.version === installed
|
|
230
|
+
if (older || missing.length) out.push({ pkg, installed, cli: want, older, missing: [...new Set(missing)].sort(), source: checkout ? 'checkout' : 'npm', ...(shadows ? { shadowsLocked: shadows } : {}), ...(published ? { published: true } : {}) })
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What `local check` and `app_dev` say about an App's SDK, one line each; []
|
|
237
|
+
* when it matches. A warning (`warning: …`) when the App's SDK is a copy or a
|
|
238
|
+
* `file:` path that is not what its own lockfile installed, or a release its
|
|
239
|
+
* lockfile does not record; a note (`note: …`) when the App deliberately
|
|
240
|
+
* builds against a published release its lockfile installed, so the line
|
|
241
|
+
* never asks to undo `frontera app sdk use-local --npm`.
|
|
242
|
+
*/
|
|
243
|
+
export function sdkDriftLines(app: string, drift: SdkDrift[]): string[] {
|
|
244
|
+
return drift.map((d) => {
|
|
245
|
+
if (d.published) {
|
|
246
|
+
const runs = d.source === 'checkout' ? `the local runtime runs ${d.cli} from the platform checkout` : `this CLI's SDK is ${d.cli}`
|
|
247
|
+
const switchTo = d.source === 'checkout' ? `\`frontera app sdk use-local\` in ${app} builds against the checkout` : `\`bun add ${d.pkg}@^${d.cli}\` in ${app} moves to it`
|
|
248
|
+
const extra = d.missing.length ? ` (${d.source === 'checkout' ? 'the checkout' : 'that SDK'} adds ${d.missing.slice(0, 4).join(', ')}${d.missing.length > 4 ? ` and ${d.missing.length - 4} more` : ''})` : ''
|
|
249
|
+
return `note: ${app} is built against the published ${d.pkg} ${d.installed} (its package.json and lockfile agree); ${runs}${extra}. ${switchTo} if you want that.`
|
|
250
|
+
}
|
|
251
|
+
const names = d.missing.length ? ` It lacks ${d.missing.slice(0, 6).join(', ')}${d.missing.length > 6 ? ` and ${d.missing.length - 6} more` : ''}, which ${d.source === 'checkout' ? 'the platform checkout’s SDK' : 'this CLI’s SDK'} and its skills use.` : ''
|
|
252
|
+
// A copy under src/frontera/ in place of the release its bun.lock installed: back to that release, whatever the runtime.
|
|
253
|
+
if (d.shadowsLocked) {
|
|
254
|
+
return `warning: ${app} uses the SDK copy under src/frontera/ for ${d.pkg} (${d.installed}), where its bun.lock installed the published ${d.pkg} ${d.shadowsLocked} its code was written against. To fix, run \`frontera app sdk use-local --npm\` in ${app}: it goes back to that release.`
|
|
255
|
+
}
|
|
256
|
+
if (d.source === 'checkout') {
|
|
257
|
+
// npm has nothing newer than the checkout: the App builds against the checkout's own packages.
|
|
258
|
+
return `warning: ${app} has ${d.pkg} ${d.installed} installed, not the SDK of the platform checkout the local runtime runs from (${d.cli}).${names} To fix, run \`frontera app sdk use-local\` in ${app}: it packs the checkout's SDK into ${app}/${LOCAL_SDK_DIR} and installs it (\`--npm\` switches back).`
|
|
259
|
+
}
|
|
260
|
+
const fix = d.older
|
|
261
|
+
? `upgrade it: \`bun add ${d.pkg}@^${d.cli}\` in ${app}`
|
|
262
|
+
: `install a release that has them (this CLI's is ${d.cli}: \`bun add ${d.pkg}@${d.cli}\` in ${app}), or write against the installed API`
|
|
263
|
+
return `warning: ${app} has ${d.pkg} ${d.installed} installed, ${d.older ? 'older than' : 'not the same as'} this CLI's SDK ${d.cli}.${names} To fix, ${fix}.`
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Why an App's frozen install refused (package.json and its bun.lock
|
|
269
|
+
* disagree), and what fixes it, for `app_render`, `app_dev` and `app dev`.
|
|
270
|
+
* Nothing was installed and the lockfile was left as it is. When package.json
|
|
271
|
+
* names the SDK copy under src/frontera/ where the lockfile installed a
|
|
272
|
+
* published release (a pull's repair, or an older scaffold), the copy shadows
|
|
273
|
+
* the release the code was written against: `app sdk use-local --npm` goes
|
|
274
|
+
* back to it. Otherwise the person decides which of the two files is right.
|
|
275
|
+
*/
|
|
276
|
+
export function lockMismatchAdvice(appRoot: string): string {
|
|
277
|
+
const shadowed = vendoredShadows(appRoot)
|
|
278
|
+
const kept = 'package.json and bun.lock disagree, so nothing was installed and bun.lock was left as it is'
|
|
279
|
+
if (shadowed.length) {
|
|
280
|
+
return `${kept}: package.json names the SDK copy under src/frontera/ for ${shadowed.map((v) => v.pkg).join(' and ')}, where bun.lock installed the published ${shadowed.map((v) => `${v.pkg} ${v.version}`).join(' and ')} its code was written against. Run \`frontera app sdk use-local --npm\` in the App to go back to the published SDK, then try again`
|
|
281
|
+
}
|
|
282
|
+
return `${kept}. If package.json is right, run \`bun install\` in the App to update bun.lock; if bun.lock is, put package.json back as it was`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Each `@frontera-sdk` package an App's package.json takes from the copy
|
|
287
|
+
* under src/frontera/ while its lockfile installed a published release, with
|
|
288
|
+
* that release: the copy shadows what the code was written against.
|
|
289
|
+
*/
|
|
290
|
+
export function vendoredShadows(appRoot: string): Array<{ pkg: string; version: string }> {
|
|
291
|
+
const deps = (readJson(join(appRoot, 'package.json')) as { dependencies?: Record<string, string> } | null)?.dependencies ?? {}
|
|
292
|
+
const locked = lockedSdkVersions(appRoot)
|
|
293
|
+
return Object.entries(deps).flatMap(([pkg, spec]) => (isVendoredSpec(pkg, spec) && locked.has(pkg) ? [{ pkg, version: locked.get(pkg)!.version }] : []))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The shadows an install can take from the App's own SDK copy, as its
|
|
298
|
+
* package.json says (`bun install --no-save`, which writes neither
|
|
299
|
+
* package.json nor bun.lock): each copy's folder under src/frontera/ still
|
|
300
|
+
* holds its package.json. [] when nothing shadows a locked release, or a copy
|
|
301
|
+
* is gone (only `frontera app sdk use-local --npm` or a pull settles that).
|
|
302
|
+
*/
|
|
303
|
+
export function copyInstallShadows(appRoot: string): Array<{ pkg: string; version: string }> {
|
|
304
|
+
const shadowed = vendoredShadows(appRoot)
|
|
305
|
+
if (!shadowed.length) return []
|
|
306
|
+
const deps = (readJson(join(appRoot, 'package.json')) as { dependencies?: Record<string, string> } | null)?.dependencies ?? {}
|
|
307
|
+
const present = shadowed.every((s) => existsSync(join(appRoot, String(deps[s.pkg]).replace(/^file:(\.\/)?/, ''), 'package.json')))
|
|
308
|
+
return present ? shadowed : []
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Switching an App to the checkout's SDK, and back ───────────────────────
|
|
312
|
+
|
|
313
|
+
/** Each `@frontera-sdk` package a package.json names in its dependencies. */
|
|
314
|
+
const sdkDepsOf = (manifest: Record<string, unknown>): string[] => Object.keys((manifest.dependencies as Record<string, string> | undefined) ?? {}).filter((d) => d.startsWith('@frontera-sdk/'))
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Pack one checkout package into `<appRoot>/vendor/frontera-sdk/`: its
|
|
318
|
+
* sources (tests left out), README and LICENSE, and a package.json without
|
|
319
|
+
* devDependencies or scripts that names its sibling SDK packages as peers.
|
|
320
|
+
* Returns the tarball's path relative to the App. Older tarballs of the same
|
|
321
|
+
* package are removed.
|
|
322
|
+
*/
|
|
323
|
+
export function packCheckoutPackage(appRoot: string, checkout: CheckoutSdk, pkg: string): string {
|
|
324
|
+
const dir = checkoutPackageDir(checkout, pkg)
|
|
325
|
+
if (!dir) throw new Error(`the platform checkout at ${checkout.platformSource} has no ${pkg} (packages/sdk-…)`)
|
|
326
|
+
const manifest = readJson(join(dir, 'package.json'))!
|
|
327
|
+
const stage = mkdtempSync(join(tmpdir(), 'frontera-sdk-pack-'))
|
|
328
|
+
try {
|
|
329
|
+
const files = sourceFiles(join(dir, 'src'))
|
|
330
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
331
|
+
mkdirSync(dirname(join(stage, 'src', rel)), { recursive: true })
|
|
332
|
+
writeFileSync(join(stage, 'src', rel), content)
|
|
333
|
+
}
|
|
334
|
+
for (const extra of ['README.md', 'LICENSE']) if (existsSync(join(dir, extra))) copyFileSync(join(dir, extra), join(stage, extra))
|
|
335
|
+
const { devDependencies: _dev, scripts: _scripts, dependencies, peerDependencies, ...rest } = manifest as Record<string, unknown> & { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> }
|
|
336
|
+
const deps: Record<string, string> = {}
|
|
337
|
+
const peers: Record<string, string> = { ...(peerDependencies ?? {}) }
|
|
338
|
+
for (const [name, range] of Object.entries(dependencies ?? {})) {
|
|
339
|
+
if (name.startsWith('@frontera-sdk/')) peers[name] = '*'
|
|
340
|
+
else deps[name] = range.startsWith('workspace:') ? String(checkout.version) : range
|
|
341
|
+
}
|
|
342
|
+
const packed = { ...rest, ...(Object.keys(deps).length ? { dependencies: deps } : {}), ...(Object.keys(peers).length ? { peerDependencies: peers } : {}), files: ['src', 'README.md', 'LICENSE'] }
|
|
343
|
+
writeFileSync(join(stage, 'package.json'), `${JSON.stringify(packed, null, 2)}\n`)
|
|
344
|
+
const hash = createHash('sha256')
|
|
345
|
+
for (const rel of Object.keys(files).sort()) hash.update(rel).update('\0').update(files[rel]!).update('\0')
|
|
346
|
+
hash.update(JSON.stringify(packed))
|
|
347
|
+
const short = pkg.replace(/^@frontera-sdk\//, '')
|
|
348
|
+
const name = `frontera-sdk-${short}-${String(manifest.version)}-${hash.digest('hex').slice(0, 8)}.tgz`
|
|
349
|
+
const outDir = join(appRoot, LOCAL_SDK_DIR)
|
|
350
|
+
mkdirSync(outDir, { recursive: true })
|
|
351
|
+
// The App's own package manager packs it, as it installs it.
|
|
352
|
+
const res = spawnSync('bun', ['pm', 'pack', '--ignore-scripts', '--quiet', '--filename', join(outDir, name)], { cwd: stage, encoding: 'utf8' })
|
|
353
|
+
if (res.status !== 0 || !existsSync(join(outDir, name))) throw new Error(`packing ${pkg} failed: ${(res.stderr || res.stdout || '').trim().split('\n').pop() ?? `exit ${res.status}`}`)
|
|
354
|
+
for (const old of readdirSync(outDir)) if (old !== name && old.startsWith(`frontera-sdk-${short}-`) && old.endsWith('.tgz')) rmSync(join(outDir, old))
|
|
355
|
+
return relative(appRoot, join(outDir, name)).split('\\').join('/')
|
|
356
|
+
} finally {
|
|
357
|
+
rmSync(stage, { recursive: true, force: true })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Is a package.json dependency spec one of this App's checkout tarballs? */
|
|
362
|
+
export const isCheckoutSpec = (spec: string | undefined) => !!spec && /^file:(\.\/)?vendor\/frontera-sdk\/frontera-sdk-[A-Za-z0-9.-]+\.tgz$/.test(spec)
|
|
363
|
+
|
|
364
|
+
export interface SdkSwitch {
|
|
365
|
+
/** Each SDK package and the dependency spec now in package.json; `locked`: the release its lockfile installed, when the spec pins it. */
|
|
366
|
+
packages: Array<{ pkg: string; spec: string; locked?: string }>
|
|
367
|
+
/**
|
|
368
|
+
* What the switch itself changed in the App, by path relative to it, before
|
|
369
|
+
* the caller's install: so the command can say every file it wrote or deleted.
|
|
370
|
+
*/
|
|
371
|
+
changes: SdkSwitchChanges
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface SdkSwitchChanges {
|
|
375
|
+
/** package.json, when its @frontera-sdk dependencies were rewritten. */
|
|
376
|
+
manifest: boolean
|
|
377
|
+
/** The lockfile whose root workspace ranges were rewritten to match (resolutions untouched), when one was. */
|
|
378
|
+
lockfile?: string
|
|
379
|
+
/** Each copy under src/frontera/ deleted, with its files and the tsconfig.json paths to it that went too. */
|
|
380
|
+
unvendored: Unvendored[]
|
|
381
|
+
/** Each checkout tarball written under vendor/frontera-sdk/ (`use-local`). */
|
|
382
|
+
tarballsWritten: string[]
|
|
383
|
+
/** Each checkout tarball deleted from vendor/frontera-sdk/: an older pack (`use-local`), or all of them (`--npm`). */
|
|
384
|
+
tarballsRemoved: string[]
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The checkout tarballs an App holds, by path relative to it. */
|
|
388
|
+
function tarballsIn(appRoot: string): string[] {
|
|
389
|
+
const dir = join(appRoot, LOCAL_SDK_DIR)
|
|
390
|
+
if (!existsSync(dir)) return []
|
|
391
|
+
return readdirSync(dir).filter((n) => n.endsWith('.tgz')).sort().map((n) => `${LOCAL_SDK_DIR.split('\\').join('/')}/${n}`)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* One line per change, for the command's text: what was written, and every
|
|
396
|
+
* file deleted, by name. [] when nothing changed.
|
|
397
|
+
*/
|
|
398
|
+
export function sdkSwitchChangeLines(changes: SdkSwitchChanges): string[] {
|
|
399
|
+
const lines: string[] = []
|
|
400
|
+
if (changes.manifest) lines.push('package.json: the @frontera-sdk dependencies above')
|
|
401
|
+
if (changes.lockfile) lines.push(`${changes.lockfile}: the root workspace's ranges for them, to match (resolutions untouched)`)
|
|
402
|
+
for (const u of changes.unvendored) {
|
|
403
|
+
if (u.tsconfigPaths.length) lines.push(`tsconfig.json: removed the path${u.tsconfigPaths.length === 1 ? '' : 's'} ${u.tsconfigPaths.join(', ')} (pointing at ${u.dir}/)`)
|
|
404
|
+
}
|
|
405
|
+
for (const t of changes.tarballsWritten) lines.push(`wrote ${t}`)
|
|
406
|
+
for (const u of changes.unvendored) {
|
|
407
|
+
if (u.files.length) lines.push(`deleted ${u.dir}/, the SDK copy (${u.files.length} file${u.files.length === 1 ? '' : 's'}: ${u.files.join(', ')})`)
|
|
408
|
+
}
|
|
409
|
+
const emptied = [...new Set(changes.unvendored.flatMap((u) => (u.parentRemoved ? [u.parentRemoved] : [])))]
|
|
410
|
+
for (const p of emptied) lines.push(`deleted ${p}/, left empty`)
|
|
411
|
+
for (const t of changes.tarballsRemoved) lines.push(`deleted ${t}`)
|
|
412
|
+
return lines
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function writeDependencies(appRoot: string, specs: Record<string, string>): void {
|
|
416
|
+
const path = join(appRoot, 'package.json')
|
|
417
|
+
const text = readFileSync(path, 'utf8')
|
|
418
|
+
const manifest = JSON.parse(text) as Record<string, unknown> & { dependencies?: Record<string, string> }
|
|
419
|
+
manifest.dependencies = { ...(manifest.dependencies ?? {}), ...specs }
|
|
420
|
+
const indent = /^(\s+)"/m.exec(text)?.[1] ?? ' '
|
|
421
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, indent)}\n`)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Point the App's `@frontera-sdk` dependencies at the checkout's packages:
|
|
426
|
+
* each one it names (and each sibling those need, sdk-core for blueprint) is
|
|
427
|
+
* packed into `vendor/frontera-sdk/` and its spec becomes
|
|
428
|
+
* `file:./vendor/frontera-sdk/<tarball>`. Writes package.json; the caller
|
|
429
|
+
* installs.
|
|
430
|
+
*/
|
|
431
|
+
export function useCheckoutSdk(appRoot: string, checkout: CheckoutSdk): SdkSwitch {
|
|
432
|
+
const manifest = readJson(join(appRoot, 'package.json'))
|
|
433
|
+
if (!manifest) throw new Error(`${appRoot} has no package.json`)
|
|
434
|
+
const wanted = new Set(sdkDepsOf(manifest).filter((pkg) => checkoutPackageDir(checkout, pkg)))
|
|
435
|
+
// A package the App's SDK packages need from each other, held by the App itself so there is one copy.
|
|
436
|
+
for (const pkg of [...wanted]) {
|
|
437
|
+
const own = readJson(join(checkoutPackageDir(checkout, pkg)!, 'package.json'))
|
|
438
|
+
for (const dep of own ? sdkDepsOf(own) : []) if (checkoutPackageDir(checkout, dep)) wanted.add(dep)
|
|
439
|
+
}
|
|
440
|
+
if (!wanted.size) throw new Error('the App depends on no @frontera-sdk package the checkout has')
|
|
441
|
+
const before = new Set(tarballsIn(appRoot))
|
|
442
|
+
const specs: Record<string, string> = {}
|
|
443
|
+
for (const pkg of [...wanted].sort()) specs[pkg] = `file:./${packCheckoutPackage(appRoot, checkout, pkg)}`
|
|
444
|
+
const manifestBefore = readFileSync(join(appRoot, 'package.json'), 'utf8')
|
|
445
|
+
writeDependencies(appRoot, specs)
|
|
446
|
+
const after = tarballsIn(appRoot)
|
|
447
|
+
return {
|
|
448
|
+
packages: Object.entries(specs).map(([pkg, spec]) => ({ pkg, spec })),
|
|
449
|
+
changes: {
|
|
450
|
+
manifest: readFileSync(join(appRoot, 'package.json'), 'utf8') !== manifestBefore,
|
|
451
|
+
unvendored: [],
|
|
452
|
+
// The same checkout packs to the same file: only a new name is a write.
|
|
453
|
+
tarballsWritten: after.filter((t) => !before.has(t)),
|
|
454
|
+
tarballsRemoved: [...before].filter((t) => !after.includes(t)),
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Is a spec the copy a pull's repair (or an old scaffold) vendored under src/frontera/? */
|
|
460
|
+
export const isVendoredSpec = (pkg: string, spec: string | undefined) => {
|
|
461
|
+
const dir = (APP_SDK_PACKAGES as Record<string, { dir: string } | undefined>)[pkg]?.dir
|
|
462
|
+
return !!spec && !!dir && spec.replace(/^file:(\.\/)?/, '') === dir && spec.startsWith('file:')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Back to npm: every checkout tarball spec, and every copy vendored under
|
|
467
|
+
* src/frontera/ (a pulled App's repair), becomes a published range: the one
|
|
468
|
+
* the App's own lockfile installed when it has one (the SDK its code was
|
|
469
|
+
* written against), else `^<version>` (the CLI's SDK range, as the scaffold
|
|
470
|
+
* pins). `vendor/frontera-sdk/` and the vendored copies (with their tsconfig
|
|
471
|
+
* paths) are removed. Writes package.json; the caller installs.
|
|
472
|
+
*/
|
|
473
|
+
export function useNpmSdk(appRoot: string, version: string = CLI_SDK_VERSION): SdkSwitch {
|
|
474
|
+
const manifest = readJson(join(appRoot, 'package.json')) as { dependencies?: Record<string, string> } | null
|
|
475
|
+
if (!manifest) throw new Error(`${appRoot} has no package.json`)
|
|
476
|
+
const locked = lockedSdkVersions(appRoot)
|
|
477
|
+
const specs: Record<string, string> = {}
|
|
478
|
+
const fromLock: Record<string, string> = {}
|
|
479
|
+
const unvendored: Unvendored[] = []
|
|
480
|
+
for (const [pkg, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
481
|
+
if (!pkg.startsWith('@frontera-sdk/') || !(isCheckoutSpec(spec) || isVendoredSpec(pkg, spec))) continue
|
|
482
|
+
// A Vite App aliases its vendored copy: that is how it resolves the SDK (`frontera app sdk sync` refreshes it).
|
|
483
|
+
if (isVendoredSpec(pkg, spec) && viteAliasesSdk(appRoot, pkg as AppSdkPackage)) continue
|
|
484
|
+
const lockedAt = locked.get(pkg)
|
|
485
|
+
specs[pkg] = lockedAt ? lockedSpec(lockedAt) : `^${version}`
|
|
486
|
+
if (lockedAt) fromLock[pkg] = lockedAt.version
|
|
487
|
+
if (isVendoredSpec(pkg, spec)) {
|
|
488
|
+
const removed = unvendorSdkPackageDetail(appRoot, pkg as AppSdkPackage)
|
|
489
|
+
if (removed && (removed.files.length || removed.tsconfigPaths.length)) unvendored.push(removed)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (Object.keys(specs).length) writeDependencies(appRoot, specs)
|
|
493
|
+
// The lockfile's own record of the ranges follows, so it and package.json agree before the install.
|
|
494
|
+
const lockRecorded = recordLockedRanges(appRoot, Object.fromEntries(Object.keys(fromLock).map((pkg) => [pkg, specs[pkg]!])))
|
|
495
|
+
const tarballsRemoved = tarballsIn(appRoot)
|
|
496
|
+
rmSync(join(appRoot, LOCAL_SDK_DIR), { recursive: true, force: true })
|
|
497
|
+
const vendorDir = join(appRoot, 'vendor')
|
|
498
|
+
if (existsSync(vendorDir) && readdirSync(vendorDir).length === 0) rmSync(vendorDir, { recursive: true })
|
|
499
|
+
return {
|
|
500
|
+
packages: Object.entries(specs).map(([pkg, spec]) => ({ pkg, spec, ...(fromLock[pkg] ? { locked: fromLock[pkg] } : {}) })),
|
|
501
|
+
changes: {
|
|
502
|
+
manifest: Object.keys(specs).length > 0,
|
|
503
|
+
...(lockRecorded ? { lockfile: existsSync(join(appRoot, 'bun.lock')) ? 'bun.lock' : 'package-lock.json' } : {}),
|
|
504
|
+
unvendored,
|
|
505
|
+
tarballsWritten: [],
|
|
506
|
+
tarballsRemoved,
|
|
507
|
+
},
|
|
508
|
+
}
|
|
509
|
+
}
|