@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.
- 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 +48 -4
- 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/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 +331 -31
- 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 +875 -88
- 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 +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- 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/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -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 +211 -21
- 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 +180 -7
- 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 +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- 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-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- 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 +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- 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 +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- 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-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- 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/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 +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
|
+
}
|