@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -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
+ }