@frontera-sdk/cli 1.52.39 → 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,88 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * How a running `frontera app dev` last fared minting the browser's App
6
+ * session, kept beside its server record (`.frontera/local/app-dev/<slug>.session`)
7
+ * so `frontera local status` can say a dev server needs a restart, which only
8
+ * the dev server itself can see.
9
+ *
10
+ * A session is minted for the App's id on the runtime. After `frontera local
11
+ * snapshot restore` (or a `local check` rollback) to a baseline taken before
12
+ * the App existed, that id is gone: the dev server registers the App again by
13
+ * its slug and mints for the new id (`mintDevSession`). When that fails too,
14
+ * the record says so, with the platform's words.
15
+ */
16
+ export interface DevSessionState {
17
+ ok: boolean
18
+ at: string
19
+ /** The platform's refusal, when the last mint failed. */
20
+ error?: string
21
+ /** The App was registered again on the runtime, under this id, before the session could be minted. */
22
+ reRegistered?: string
23
+ }
24
+
25
+ const sessionFile = (solutionStateDir: string, slug: string) => join(solutionStateDir, 'app-dev', `${slug.replace(/[^a-z0-9-]+/gi, '-')}.session`)
26
+
27
+ export function writeDevSessionState(solutionStateDir: string, slug: string, state: DevSessionState): void {
28
+ try {
29
+ mkdirSync(join(solutionStateDir, 'app-dev'), { recursive: true })
30
+ writeFileSync(sessionFile(solutionStateDir, slug), `${JSON.stringify(state, null, 2)}\n`)
31
+ } catch {
32
+ // Only status reads it; the session itself does not need it.
33
+ }
34
+ }
35
+
36
+ export function readDevSessionState(solutionStateDir: string, slug: string): DevSessionState | null {
37
+ try {
38
+ const state = JSON.parse(readFileSync(sessionFile(solutionStateDir, slug), 'utf8')) as Partial<DevSessionState>
39
+ return typeof state.ok === 'boolean' && typeof state.at === 'string' ? (state as DevSessionState) : null
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ /** A platform answer that means "no such App here" or "this App's credential is no longer good": re-registering may fix it. */
46
+ export function isAppGone(error: unknown): boolean {
47
+ const status = (error as { status?: unknown } | null)?.status
48
+ const code = (error as { code?: unknown } | null)?.code
49
+ return status === 401 || status === 403 || status === 404 || code === 'NOT_FOUND' || code === 'UNAUTHORIZED'
50
+ }
51
+
52
+ /**
53
+ * Mint a session for the App, registering it again first when the runtime no
54
+ * longer has it. `appId` is the current id; `reRegister` asks the runtime for
55
+ * the App by its slug (creating it), and returns the id to mint for.
56
+ */
57
+ export async function mintDevSession<T>(args: {
58
+ appId: string
59
+ mint: (appId: string) => Promise<T>
60
+ reRegister: () => Promise<string>
61
+ /** Told the id the App has now, after a re-registration. */
62
+ onReRegistered?: (appId: string) => void
63
+ record?: (state: DevSessionState) => void
64
+ now?: () => Date
65
+ }): Promise<{ session: T; appId: string }> {
66
+ const at = () => (args.now ?? (() => new Date()))().toISOString()
67
+ try {
68
+ const session = await args.mint(args.appId)
69
+ args.record?.({ ok: true, at: at() })
70
+ return { session, appId: args.appId }
71
+ } catch (first) {
72
+ if (!isAppGone(first)) {
73
+ args.record?.({ ok: false, at: at(), error: (first as Error)?.message ?? String(first) })
74
+ throw first
75
+ }
76
+ try {
77
+ const appId = await args.reRegister()
78
+ args.onReRegistered?.(appId)
79
+ const session = await args.mint(appId)
80
+ args.record?.({ ok: true, at: at(), ...(appId !== args.appId ? { reRegistered: appId } : {}) })
81
+ return { session, appId }
82
+ } catch (second) {
83
+ const error = `${(second as Error)?.message ?? String(second)} (the runtime no longer has this App or its credential, and registering it again failed: stop this dev server and start it again)`
84
+ args.record?.({ ok: false, at: at(), error })
85
+ throw new Error(error)
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,114 @@
1
+ import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { lockedSdkVersions } from '../app-lock'
6
+ import type { InstallOptions, InstallResult } from '../project-bootstrap'
7
+
8
+ /**
9
+ * A pulled App's packages, installed as its `bun.lock` records them: the
10
+ * deployed App's actual dependencies, from the npm registry.
11
+ *
12
+ * An older pull left such an App with package.json naming the SDK copy
13
+ * under src/frontera/ (`file:./src/frontera/core`) where bun.lock installed a
14
+ * published release (`@frontera-sdk/core@1.50.61`), the one its code was
15
+ * written against. A frozen install refuses the two as they disagree, and an
16
+ * install from the copy builds the code against an older SDK than it was
17
+ * written for.
18
+ *
19
+ * So the install runs in a temporary folder holding a copy of package.json
20
+ * whose SDK entries name the ranges bun.lock records for them (`^1.43.10`),
21
+ * and bun.lock as it is: `bun install --frozen-lockfile` there installs
22
+ * exactly what the lockfile says, and its node_modules is moved into the App.
23
+ * Neither the App's package.json nor its bun.lock is opened for writing.
24
+ *
25
+ * Null when it can't be done that way: bun.lock records no range for one of
26
+ * the packages, or package.json takes another package from a local path the
27
+ * temporary folder doesn't have. The caller then installs from the copy.
28
+ */
29
+
30
+ /** Files beside package.json that change how an install resolves: copied to the temporary folder too. */
31
+ const INSTALL_CONFIG = ['.npmrc', 'bunfig.toml']
32
+
33
+ const LOCAL_SPEC = /^(file|link|workspace|portal):/
34
+
35
+ export interface LockInstall extends InstallResult {
36
+ /** The packages installed as the lockfile records them, with the release it installed. */
37
+ fromLock: Array<{ pkg: string; version: string }>
38
+ }
39
+
40
+ export async function installFromLock(
41
+ appRoot: string,
42
+ shadows: ReadonlyArray<{ pkg: string; version: string }>,
43
+ install: (dir: string, options?: InstallOptions) => Promise<InstallResult>,
44
+ ): Promise<LockInstall | null> {
45
+ if (!shadows.length || !existsSync(join(appRoot, 'bun.lock'))) return null
46
+ const locked = lockedSdkVersions(appRoot)
47
+ const ranges = new Map<string, string>()
48
+ for (const s of shadows) {
49
+ const range = locked.get(s.pkg)?.range
50
+ if (!range) return null
51
+ ranges.set(s.pkg, range)
52
+ }
53
+ let manifest: Record<string, unknown>
54
+ try {
55
+ manifest = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')) as Record<string, unknown>
56
+ } catch {
57
+ return null
58
+ }
59
+ const sections = ['dependencies', 'devDependencies', 'optionalDependencies'] as const
60
+ // Another package from a local path would not resolve from the temporary folder: installed from the copy instead.
61
+ for (const section of sections) {
62
+ for (const [pkg, spec] of Object.entries((manifest[section] as Record<string, string> | undefined) ?? {})) {
63
+ if (!ranges.has(pkg) && typeof spec === 'string' && LOCAL_SPEC.test(spec)) return null
64
+ }
65
+ }
66
+ const asLocked = { ...manifest }
67
+ for (const section of sections) {
68
+ const deps = manifest[section] as Record<string, string> | undefined
69
+ if (!deps) continue
70
+ asLocked[section] = Object.fromEntries(Object.entries(deps).map(([pkg, spec]) => [pkg, ranges.get(pkg) ?? spec]))
71
+ }
72
+ const stage = mkdtempSync(join(tmpdir(), 'frontera-lock-install-'))
73
+ try {
74
+ writeFileSync(join(stage, 'package.json'), `${JSON.stringify(asLocked, null, 2)}\n`)
75
+ copyFileSync(join(appRoot, 'bun.lock'), join(stage, 'bun.lock'))
76
+ for (const name of INSTALL_CONFIG) if (existsSync(join(appRoot, name))) copyFileSync(join(appRoot, name), join(stage, name))
77
+ const result = await install(stage, { frozenLockfile: true })
78
+ const fromLock = shadows.map((s) => ({ pkg: s.pkg, version: locked.get(s.pkg)!.version }))
79
+ if (!result.installed || !existsSync(join(stage, 'node_modules'))) {
80
+ return { installed: false, summary: result.installed ? 'it wrote no node_modules' : result.summary, fromLock, ...(result.lockMismatch ? { lockMismatch: true } : {}) }
81
+ }
82
+ // The release the lockfile records, or the install did something else: not moved in.
83
+ const wrong = fromLock.find((s) => installedVersion(stage, s.pkg) !== s.version)
84
+ if (wrong) return { installed: false, summary: `it installed ${wrong.pkg} ${installedVersion(stage, wrong.pkg) ?? '(none)'}, not the ${wrong.version} bun.lock records`, fromLock }
85
+ moveInto(join(stage, 'node_modules'), join(appRoot, 'node_modules'))
86
+ return { installed: true, summary: result.summary, fromLock }
87
+ } finally {
88
+ rmSync(stage, { recursive: true, force: true })
89
+ }
90
+ }
91
+
92
+ /** The version of `pkg` a folder's node_modules holds, or null. */
93
+ export function installedVersion(dir: string, pkg: string): string | null {
94
+ try {
95
+ return (JSON.parse(readFileSync(join(dir, 'node_modules', pkg, 'package.json'), 'utf8')) as { version?: string }).version ?? null
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ /** Move a folder, copying it (links kept as they are) where a rename can't cross file systems. */
102
+ function moveInto(from: string, to: string): void {
103
+ try {
104
+ renameSync(from, to)
105
+ } catch {
106
+ cpSync(from, to, { recursive: true, verbatimSymlinks: true })
107
+ }
108
+ }
109
+
110
+ /** What the install did, in words: the releases installed and that neither file changed. */
111
+ export function lockInstallLine(fromLock: LockInstall['fromLock'], summary: string): string {
112
+ const released = fromLock.map((s) => `${s.pkg} ${s.version}`).join(' and ')
113
+ return `installed what its bun.lock records from npm (${summary}): the published ${released} its code was written against, where package.json names the SDK copy under src/frontera/. The install ran in a temporary folder whose copy of package.json names those releases, with \`bun install --frozen-lockfile\`; the App's package.json and bun.lock are unchanged`
114
+ }
@@ -0,0 +1,167 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { basename, join } from 'node:path'
4
+
5
+ import { writeState } from '../project'
6
+ import { fileAsWas, putBackAsWas, type FileAsWas } from './local-app-dev-files'
7
+ import { readLock } from './local-sync-plan'
8
+
9
+ /**
10
+ * A pulled Custom App's code as the pull left it: each source file's hash,
11
+ * kept in the App folder's state (`.frontera/state.json`, `pulledFiles`) when
12
+ * its code is pulled. `local check` reads it the way it reads `frontera.lock`
13
+ * for the rest of the project: an App whose source is all as pulled, with no
14
+ * file added or removed, fails its check through no change of the person's,
15
+ * so the failure is inherited from the workspace (`--strict` still fails it).
16
+ *
17
+ * What a build or an install writes is not source and is left out of both
18
+ * sides: `node_modules/`, `.next/`, build output, `*.tsbuildinfo`,
19
+ * `next-env.d.ts`, `.env*`, the folder's own `.frontera/`.
20
+ */
21
+
22
+ /** Generated wherever they are. */
23
+ const GENERATED_DIRS = new Set(['node_modules', '.next', '.turbo', '.vercel', '.git'])
24
+ /** Generated at the folder's top only: a `src/build/` may be source. */
25
+ const GENERATED_TOP = new Set(['.frontera', 'dist', 'build', 'out', 'coverage'])
26
+ /** Written by a build, an install or a dev host (`.env.local`, which `app dev` points at the local runtime), never source. */
27
+ const isGenerated = (name: string) => name.endsWith('.tsbuildinfo') || name === 'next-env.d.ts' || name === '.DS_Store' || name.endsWith('.log') || name === '.env' || name.startsWith('.env.')
28
+
29
+ const hashBytes = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex').slice(0, 16)
30
+
31
+ /** Each source file of an App folder: its path in the folder and when it was last written. */
32
+ function appSourceFiles(dir: string): Array<{ rel: string; path: string; mtimeMs: number }> {
33
+ const out: Array<{ rel: string; path: string; mtimeMs: number }> = []
34
+ const walk = (at: string, rel: string) => {
35
+ for (const name of readdirSync(at).sort()) {
36
+ const path = join(at, name)
37
+ const st = statSync(path)
38
+ if (st.isDirectory()) {
39
+ if (!GENERATED_DIRS.has(name) && !(rel === '' && GENERATED_TOP.has(name))) walk(path, rel ? `${rel}/${name}` : name)
40
+ } else if (st.isFile() && !isGenerated(name)) {
41
+ out.push({ rel: rel ? `${rel}/${name}` : name, path, mtimeMs: st.mtimeMs })
42
+ }
43
+ }
44
+ }
45
+ if (existsSync(dir)) walk(dir, '')
46
+ return out
47
+ }
48
+
49
+ /** Each source file of an App folder, by its path in the folder, with its hash. */
50
+ export function appSourceHashes(dir: string): Record<string, string> {
51
+ return Object.fromEntries(appSourceFiles(dir).map((f) => [f.rel, hashBytes(readFileSync(f.path))]))
52
+ }
53
+
54
+ /** Record the folder's source as pulled (after the pull's own repairs): the baseline `local check` compares with. */
55
+ export function recordPulledFiles(dir: string): void {
56
+ writeState(dir, { pulledFiles: appSourceHashes(dir) })
57
+ }
58
+
59
+ /**
60
+ * Whether an App folder's source is all as its pull left it: true, false, or
61
+ * null when the folder was never pulled with a record (an older CLI's pull, or
62
+ * the project's own App), which counts as the person's.
63
+ */
64
+ export function appUnchangedSincePull(dir: string): boolean | null {
65
+ let recorded: Record<string, string> | undefined
66
+ try {
67
+ recorded = (JSON.parse(readFileSync(join(dir, '.frontera', 'state.json'), 'utf8')) as { pulledFiles?: Record<string, string> }).pulledFiles
68
+ } catch {
69
+ return null
70
+ }
71
+ if (!recorded || typeof recorded !== 'object' || !Object.keys(recorded).length) return null
72
+ const now = appSourceHashes(dir)
73
+ const paths = new Set([...Object.keys(recorded), ...Object.keys(now)])
74
+ for (const p of paths) if (recorded[p] !== now[p]) return false
75
+ return true
76
+ }
77
+
78
+ /** How long one App's unpack (and the repairs right after it) takes at most. */
79
+ const UNPACK_SPAN_MS = 60_000
80
+ /** How long after the lock's `pulledAt` a pull can still be bringing App code (documents come first). */
81
+ const UNPACK_AFTER_PULL_MS = 30 * 60_000
82
+ /** Clock slack between the lock's timestamp and the file system's. */
83
+ const CLOCK_SLACK_MS = 5_000
84
+
85
+ const bare = (v: unknown) => (typeof v === 'string' ? v.replace(/^v/, '') : undefined)
86
+
87
+ /**
88
+ * Whether a project's App folder (`apps/<slug>`) is all as the pull left it.
89
+ * The folder's own record (`pulledFiles`) decides when there is one. An App
90
+ * pulled before pulls kept that record has only its lineage (`pulledFrom`,
91
+ * `pulledVersion`), and the project's `frontera.lock` entry for `app/<slug>`:
92
+ * when that entry names the same App and version, and every source file was
93
+ * written in the one unpack (within a minute of each other) that began at
94
+ * that pull, nothing has been written since and it is as pulled. A file
95
+ * written later is a change (false). Anything that can't be told (no
96
+ * lineage, no lock entry, another App or version, code older or much newer
97
+ * than the pull, as a copy of the folder would be) is null: the person's.
98
+ */
99
+ export function appPulledAsIs(root: string, appDir: string): boolean | null {
100
+ const dir = join(root, appDir)
101
+ const recorded = appUnchangedSincePull(dir)
102
+ if (recorded !== null) return recorded
103
+ let state: { pulledFrom?: unknown; pulledVersion?: unknown }
104
+ try {
105
+ state = JSON.parse(readFileSync(join(dir, '.frontera', 'state.json'), 'utf8')) as typeof state
106
+ } catch {
107
+ return null
108
+ }
109
+ if (typeof state.pulledFrom !== 'string' || !state.pulledFrom) return null
110
+ const entry = readLock(root)?.items[`app/${basename(dir)}`]
111
+ if (!entry) return null
112
+ if (entry.id && entry.id !== state.pulledFrom) return null
113
+ if (entry.version && bare(state.pulledVersion) && bare(entry.version) !== bare(state.pulledVersion)) return null
114
+ const pulledAt = Date.parse(entry.pulledAt)
115
+ const files = appSourceFiles(dir)
116
+ if (!files.length || !Number.isFinite(pulledAt)) return null
117
+ const first = Math.min(...files.map((f) => f.mtimeMs))
118
+ const last = Math.max(...files.map((f) => f.mtimeMs))
119
+ if (first < pulledAt - CLOCK_SLACK_MS || first > pulledAt + UNPACK_AFTER_PULL_MS) return null
120
+ return last - first <= UNPACK_SPAN_MS
121
+ }
122
+
123
+ /**
124
+ * Written into an App folder's top by a typecheck or a build: `tsc
125
+ * --incremental`'s build info, and what `next build` writes: `next-env.d.ts`,
126
+ * and `tsconfig.json`, which it reconfigures (its `include` and options) to
127
+ * what it wants.
128
+ */
129
+ const isBuildWritten = (name: string) => name.endsWith('.tsbuildinfo') || name === 'next-env.d.ts' || name === 'tsconfig.json'
130
+
131
+ /**
132
+ * Keep what an App's own check writes into its folder's top out of the
133
+ * tree: the build info (`tsconfig.tsbuildinfo`), `next-env.d.ts` and
134
+ * `tsconfig.json` as they were before it ran. The returned function puts
135
+ * each back (removes one the check made) and names what it put back. A
136
+ * pulled App tracks them, and a check is not a change. Only these: a
137
+ * snapshot of the whole folder would also undo an edit made while the check
138
+ * ran.
139
+ */
140
+ export function keepBuildWrittenFiles(dir: string): () => string[] {
141
+ const before = new Map<string, FileAsWas>()
142
+ const names = () => (existsSync(dir) ? readdirSync(dir).filter(isBuildWritten) : [])
143
+ for (const name of names()) {
144
+ const was = fileAsWas(join(dir, name))
145
+ // Unreadable: left as the check leaves it.
146
+ if (was) before.set(name, was)
147
+ }
148
+ return () => {
149
+ const restored: string[] = []
150
+ for (const name of new Set([...names(), ...before.keys()])) {
151
+ const path = join(dir, name)
152
+ const was = before.get(name)
153
+ try {
154
+ if (!was) {
155
+ rmSync(path, { force: true })
156
+ restored.push(name)
157
+ } else if (putBackAsWas(path, was)) {
158
+ // Its time goes back too, so a render before the check is still fresh after it.
159
+ restored.push(name)
160
+ }
161
+ } catch {
162
+ // Best effort: a file that can't be put back stays as the check left it.
163
+ }
164
+ }
165
+ return restored
166
+ }
167
+ }
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import { readState, writeState } from '../project'
5
+ import { customAppFolders } from './local-apps'
6
+
7
+ /**
8
+ * Custom Apps and the database baseline (`local-db-snapshot.ts`).
9
+ *
10
+ * `frontera app deploy` writes the App and its deployment into the runtime's
11
+ * database, and records the deployment in the App folder
12
+ * (`.frontera/state.json`). A restore puts back the database as the baseline
13
+ * holds it, so a deployment made after the baseline is gone from the runtime
14
+ * while the folder still names it. The baseline keeps the deployment each App
15
+ * folder named when it was taken; a restore puts that back in the folder, marks
16
+ * what it lost (`restoredAway`), and the next `frontera local dev` deploys
17
+ * the App again.
18
+ */
19
+
20
+ const STATE = join('.frontera', 'state.json')
21
+
22
+ /** Each Custom App folder's last deployment from here, by folder (relative to the project): what the runtime should hold. */
23
+ export function appDeployments(root: string): Record<string, string | null> {
24
+ const out: Record<string, string | null> = {}
25
+ for (const dir of customAppFolders(root)) {
26
+ const state = readState(join(root, dir))
27
+ out[dir] = typeof state.parentVersion === 'string' && state.parentVersion ? state.parentVersion : null
28
+ }
29
+ return out
30
+ }
31
+
32
+ const CROCKFORD = '0123456789abcdefghjkmnpqrstvwxyz'
33
+
34
+ /** When a deployment was made, from its id (`dpl_` and a ULID, whose first ten characters are the time); null for any other id. */
35
+ export function deploymentTime(id: string): number | null {
36
+ const m = /^dpl_([0-9a-hjkmnp-tv-z]{26})$/i.exec(id)
37
+ if (!m) return null
38
+ let ms = 0
39
+ for (const ch of m[1]!.slice(0, 10).toLowerCase()) ms = ms * 32 + CROCKFORD.indexOf(ch)
40
+ return ms
41
+ }
42
+
43
+ export interface AppLostByRestore {
44
+ dir: string
45
+ name: string
46
+ /** The deployment the folder named, which the restored database does not have. */
47
+ lost: string
48
+ /** The deployment the baseline holds for it, when it holds one. */
49
+ back: string | null
50
+ }
51
+
52
+ /**
53
+ * The App folders whose deployment the database being restored does not
54
+ * have. With the deployments the baseline recorded (`atBaseline`), any folder
55
+ * naming another one; for a baseline recorded before they were kept, any
56
+ * deployment made after it was taken (by the time in its id, else by when
57
+ * the folder recorded it).
58
+ */
59
+ export function appsLostByRestore(root: string, baseline: { atBaseline?: Record<string, string | null> | null; savedAt: string }): AppLostByRestore[] {
60
+ const savedAt = Date.parse(baseline.savedAt)
61
+ const out: AppLostByRestore[] = []
62
+ for (const [dir, now] of Object.entries(appDeployments(root))) {
63
+ if (!now) continue
64
+ if (baseline.atBaseline) {
65
+ const was = baseline.atBaseline[dir] ?? null
66
+ if (was !== now) out.push({ dir, name: basename(dir), lost: now, back: was })
67
+ continue
68
+ }
69
+ let at = deploymentTime(now)
70
+ if (at === null) {
71
+ try {
72
+ at = statSync(join(root, dir, STATE)).mtimeMs
73
+ } catch {
74
+ at = null
75
+ }
76
+ }
77
+ if (at !== null && Number.isFinite(savedAt) && at > savedAt) out.push({ dir, name: basename(dir), lost: now, back: null })
78
+ }
79
+ return out
80
+ }
81
+
82
+ /** Put the baseline's deployment back in each folder, and mark what the restore took away. */
83
+ export function markAppsLostByRestore(root: string, lost: readonly AppLostByRestore[], restoredAt: string): void {
84
+ for (const app of lost) {
85
+ // Deployed again and lost again: the newest deployment lost is the one named.
86
+ writeState(join(root, app.dir), { parentVersion: app.back, restoredAway: { deployment: app.lost, restoredAt } })
87
+ }
88
+ }
89
+
90
+ /** The App folders a restore took a deployment from, still waiting to be deployed again. */
91
+ export function appsAwayByRestore(root: string): Array<{ dir: string; name: string; deployment: string }> {
92
+ const out: Array<{ dir: string; name: string; deployment: string }> = []
93
+ for (const dir of customAppFolders(root)) {
94
+ const path = join(root, dir, STATE)
95
+ if (!existsSync(path)) continue
96
+ try {
97
+ const away = (JSON.parse(readFileSync(path, 'utf8')) as { restoredAway?: { deployment?: unknown } }).restoredAway
98
+ if (away && typeof away.deployment === 'string') out.push({ dir, name: basename(dir), deployment: away.deployment })
99
+ } catch {
100
+ // Unreadable: nothing to say about it.
101
+ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** A restore's line about the Apps it took deployments from; empty when there are none. */
107
+ export function appsLostWords(lost: readonly AppLostByRestore[]): string {
108
+ if (!lost.length) return ''
109
+ const names = lost.map((a) => `${a.name} (${a.lost})`).join(', ')
110
+ return `${lost.length === 1 ? 'the Custom App' : `${lost.length} Custom Apps`} ${names} ${lost.length === 1 ? 'was' : 'were'} deployed after the baseline and ${lost.length === 1 ? 'is' : 'are'} not on the runtime now: the next \`frontera local dev\` deploys ${lost.length === 1 ? 'it' : 'them'} again (or \`frontera app deploy\` in ${lost.map((a) => a.dir).join(', ')})`
111
+ }
@@ -0,0 +1,68 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { readState } from '../project'
4
+ import { customAppFolders } from './local-apps'
5
+ import type { LocalCredentials } from './local-state'
6
+
7
+ /**
8
+ * Whether the runtime has each Custom App's deployment, as the App folder
9
+ * names it. The folder's record (`.frontera/state.json`) says what was
10
+ * deployed from here; only the runtime says whether it is still there (a
11
+ * restore of its database, a reset, a deployment made to another runtime).
12
+ * `frontera local status` reads it, so a status that says "Deployed to this
13
+ * runtime" is one the runtime agrees with.
14
+ */
15
+ export interface LiveApp {
16
+ /** The runtime has an App by the folder's id. */
17
+ app: boolean
18
+ /** The runtime has the deployment the folder names; null when it was not asked (no App). */
19
+ deployment: boolean | null
20
+ }
21
+
22
+ type Get = (path: string) => Promise<{ status: number; body: unknown }>
23
+
24
+ /** GET with the workspace key, answering status and body; never throws on an answer. */
25
+ export function workspaceGet(serviceUrl: string, creds: Pick<LocalCredentials, 'workspaceKey' | 'organizationId'>, fetcher: typeof fetch = fetch): Get | null {
26
+ if (!creds.workspaceKey) return null
27
+ return async (path) => {
28
+ const res = await fetcher(new URL(path, serviceUrl), { headers: { authorization: `Bearer ${creds.workspaceKey}`, 'x-org-id': creds.organizationId }, signal: AbortSignal.timeout(5_000) })
29
+ const text = await res.text()
30
+ let body: unknown = text
31
+ try {
32
+ body = text ? JSON.parse(text) : null
33
+ } catch {
34
+ // Keep the text.
35
+ }
36
+ return { status: res.status, body }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Each App folder that names a deployment, by folder name, as the runtime
42
+ * holds it. Null when the runtime could not be asked; a folder the runtime
43
+ * could not answer for is left out (the folder's record speaks for it).
44
+ */
45
+ export async function liveAppDeployments(root: string, get: Get | null): Promise<Map<string, LiveApp> | null> {
46
+ if (!get) return null
47
+ const out = new Map<string, LiveApp>()
48
+ for (const dir of customAppFolders(root)) {
49
+ const state = readState(join(root, dir))
50
+ const deployment = typeof state.parentVersion === 'string' && state.parentVersion ? state.parentVersion : null
51
+ if (!deployment || !state.appId) continue
52
+ const name = dir.split('/').pop()!
53
+ try {
54
+ const res = await get(`/v1/platform-apps/${encodeURIComponent(state.appId)}/deployments`)
55
+ if (res.status === 404) {
56
+ out.set(name, { app: false, deployment: null })
57
+ continue
58
+ }
59
+ if (res.status !== 200) continue
60
+ const data = ((res.body as { data?: unknown })?.data ?? res.body) as { deployments?: Array<{ id?: unknown }> } | null
61
+ const ids = (data?.deployments ?? []).map((d) => d.id)
62
+ out.set(name, { app: true, deployment: ids.includes(deployment) })
63
+ } catch {
64
+ // Not answered: the folder's record speaks for this one.
65
+ }
66
+ }
67
+ return out
68
+ }