@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
|
@@ -5,14 +5,20 @@ import type { LocalCredentials } from './local-state'
|
|
|
5
5
|
* An Agent's Context and data on the local runtime: the object types it may
|
|
6
6
|
* read through its Blueprint tools, from the `blueprint` list in its
|
|
7
7
|
* `agents/<slug>/bindings.json` (`{ "objectType": "objectType/Incident",
|
|
8
|
-
* "permissions": ["read"] }`)
|
|
8
|
+
* "permissions": ["read"] }`), and the types it may submit governed Actions
|
|
9
|
+
* on (`"act"`, which implies read). On the platform these are the Agent's
|
|
9
10
|
* Blueprint grants; without them it has no Blueprint tools at all.
|
|
10
11
|
*
|
|
11
12
|
* Granting is a Steward's call on the organization's Blueprint, so it is made
|
|
12
13
|
* with the runtime's organization key, after the Blueprint is applied and
|
|
13
14
|
* published (bindings apply after it). The runtime ends up with the file's
|
|
14
15
|
* grants: missing ones are granted, changed permissions replaced, and grants
|
|
15
|
-
* the file does not list revoked. An
|
|
16
|
+
* the file does not list revoked. An entry that names no `permissions` (and
|
|
17
|
+
* every grant assumed when the pull couldn't read them, `keepHeld`) is read
|
|
18
|
+
* plus what the runtime's grant already holds, so `act` or `read_sensitive`
|
|
19
|
+
* is taken away only by a file that lists the type's permissions without it
|
|
20
|
+
* (for `act`, the grant is sent with `removePermissions`: a re-grant on the
|
|
21
|
+
* runtime otherwise keeps it). An object type the runtime leaves off
|
|
16
22
|
* (media files) is skipped with a warning; one it does not hold yet waits
|
|
17
23
|
* for the Blueprint to build, so the Agent's tools read as blocked on it.
|
|
18
24
|
*/
|
|
@@ -41,6 +47,8 @@ export async function applyAgentGrants(input: {
|
|
|
41
47
|
leftOff: LeftOff
|
|
42
48
|
/** Whether the project's Blueprint has a file for this object type. */
|
|
43
49
|
inProject: (apiName: string) => boolean
|
|
50
|
+
/** Every grant keeps what the runtime's grant holds beyond read (the file's permissions are assumed, not read). */
|
|
51
|
+
keepHeld?: boolean
|
|
44
52
|
fetch?: typeof fetch
|
|
45
53
|
}): Promise<GrantOutcome> {
|
|
46
54
|
const { slug, creds } = input
|
|
@@ -71,13 +79,17 @@ export async function applyAgentGrants(input: {
|
|
|
71
79
|
|
|
72
80
|
// Wanted: by API name, the file's permissions (read when it names none).
|
|
73
81
|
const wanted = new Map<string, string[]>()
|
|
82
|
+
// The types whose permissions the file doesn't state: read, plus what the runtime's grant holds.
|
|
83
|
+
const keep = new Set<string>()
|
|
74
84
|
for (const g of input.grants) {
|
|
75
85
|
const ref = typeof g?.objectType === 'string' ? parseRef(g.objectType) : null
|
|
76
86
|
if (!ref || ref.kind !== 'objectType') {
|
|
77
87
|
failures.push(` ${String(g?.objectType)}: not an object type reference (objectType/<apiName>)`)
|
|
78
88
|
continue
|
|
79
89
|
}
|
|
80
|
-
|
|
90
|
+
// `read` is implied by the others (`read_sensitive`, `act`), as the runtime stores the grant.
|
|
91
|
+
wanted.set(ref.name, [...new Set(['read', ...(Array.isArray(g.permissions) ? g.permissions : [])])].sort())
|
|
92
|
+
if (input.keepHeld || !Array.isArray(g.permissions)) keep.add(ref.name)
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
// The workspace's own list, which holds Agents never published too; the organization's
|
|
@@ -91,10 +103,15 @@ export async function applyAgentGrants(input: {
|
|
|
91
103
|
const onRuntime = new Set(held.ok ? list<{ apiName?: string }>(held.data).flatMap((t) => (t.apiName ? [t.apiName] : [])) : [])
|
|
92
104
|
const current = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}`)
|
|
93
105
|
if (!current.ok) return { failures: [...failures, ` agent/${slug}: its Blueprint access could not be read: ${brief(current)}`], notes }
|
|
94
|
-
const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...(g.permissions ?? [
|
|
106
|
+
const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...new Set(['read', ...(g.permissions ?? [])])].sort()]))
|
|
95
107
|
|
|
96
108
|
let granted = 0
|
|
97
|
-
|
|
109
|
+
const acting: string[] = []
|
|
110
|
+
const kept: string[] = []
|
|
111
|
+
for (const [apiName, stated] of wanted) {
|
|
112
|
+
const held = has.get(apiName) ?? []
|
|
113
|
+
const extras = keep.has(apiName) ? held.filter((p) => !stated.includes(p)) : []
|
|
114
|
+
const permissions = extras.length ? [...stated, ...extras].sort() : stated
|
|
98
115
|
const why = input.leftOff(apiName)
|
|
99
116
|
if (why) {
|
|
100
117
|
notes.push(` warning: Context and data: ${apiName} is left off here (${why}), so ${slug} can’t read it locally`)
|
|
@@ -106,13 +123,17 @@ export async function applyAgentGrants(input: {
|
|
|
106
123
|
continue
|
|
107
124
|
}
|
|
108
125
|
if (has.get(apiName)?.join(',') !== permissions.join(',')) {
|
|
109
|
-
|
|
126
|
+
// A re-grant keeps a held `act` unless it is named as removed, and here the file's list is the whole grant.
|
|
127
|
+
const removePermissions = held.includes('act') && !permissions.includes('act') ? ['act'] : []
|
|
128
|
+
const res = await call(creds.orgKey, '/v1/blueprint/grants/agent', 'POST', { agentId: agent.id, objectTypeApiName: apiName, permissions, ...(removePermissions.length ? { removePermissions } : {}) })
|
|
110
129
|
if (!res.ok) {
|
|
111
130
|
failures.push(` objectType/${apiName}: ${brief(res)}`)
|
|
112
131
|
continue
|
|
113
132
|
}
|
|
114
133
|
}
|
|
115
134
|
granted += 1
|
|
135
|
+
if (extras.length) kept.push(`${extras.join(' and ')} on ${apiName}`)
|
|
136
|
+
if (permissions.includes('act')) acting.push(apiName)
|
|
116
137
|
}
|
|
117
138
|
// The file is what the Agent may read: a grant it does not list is withdrawn.
|
|
118
139
|
for (const apiName of has.keys()) {
|
|
@@ -120,6 +141,7 @@ export async function applyAgentGrants(input: {
|
|
|
120
141
|
const res = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}/${encodeURIComponent(apiName)}`, 'DELETE')
|
|
121
142
|
if (!res.ok) failures.push(` objectType/${apiName}: could not be revoked: ${brief(res)}`)
|
|
122
143
|
}
|
|
123
|
-
if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}`)
|
|
144
|
+
if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}${acting.length ? `; ${slug} can submit Actions on ${acting.sort().join(', ')} (act)` : ''}`)
|
|
145
|
+
if (kept.length) notes.push(` Context and data: kept what ${slug}'s grants already held where its bindings.json names no permissions: ${kept.join('; ')}`)
|
|
124
146
|
return { failures, notes }
|
|
125
147
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { unchangedSincePull } from './local-check-inherited'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Agents whose Plugins are not offered: an Agent with behavior set up
|
|
8
|
+
* (`agents/<slug>/operating.json`) and no computer runs on the lean profile,
|
|
9
|
+
* which leaves out the platform tools and `activate_capability`, so the
|
|
10
|
+
* Plugins its agent.json lists (`config.plugins`: artifacts, image, charts…)
|
|
11
|
+
* are never offered, here and on the platform alike. By design, but nothing
|
|
12
|
+
* in the apply says so: said as a warning on the Agent, with the fix.
|
|
13
|
+
*
|
|
14
|
+
* Only when all three hold: behavior exists, the Agent has no computer
|
|
15
|
+
* (`config.features.computerAvailability` is not `on-demand` or `eager`; an
|
|
16
|
+
* Agent with behavior and no setting of its own gets none), and Plugins are
|
|
17
|
+
* listed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AgentLeanPlugins {
|
|
21
|
+
agent: string
|
|
22
|
+
plugins: string[]
|
|
23
|
+
/** agent.json and operating.json are as the last pull wrote them: the workspace's Agent is the same. */
|
|
24
|
+
inherited: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The Computer availability values that give an Agent a computer (the service's `AGENT_COMPUTER_AVAILABILITY`, less `none`). */
|
|
28
|
+
const WITH_COMPUTER = new Set(['on-demand', 'eager'])
|
|
29
|
+
|
|
30
|
+
function readJson(path: string): Record<string, unknown> | null {
|
|
31
|
+
try {
|
|
32
|
+
const v = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
33
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function agentLeanPlugins(root: string): AgentLeanPlugins[] {
|
|
40
|
+
let slugs: string[] = []
|
|
41
|
+
try {
|
|
42
|
+
slugs = readdirSync(join(root, 'agents')).filter((n) => !n.startsWith('.')).sort()
|
|
43
|
+
} catch {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
const out: AgentLeanPlugins[] = []
|
|
47
|
+
let unchanged: ReturnType<typeof unchangedSincePull> | null = null
|
|
48
|
+
for (const slug of slugs) {
|
|
49
|
+
const operating = join(root, 'agents', slug, 'operating.json')
|
|
50
|
+
if (!existsSync(operating) || !readJson(operating)) continue
|
|
51
|
+
const agent = readJson(join(root, 'agents', slug, 'agent.json'))
|
|
52
|
+
const config = (agent?.config ?? null) as { plugins?: unknown; features?: { computerAvailability?: unknown } } | null
|
|
53
|
+
if (!config) continue
|
|
54
|
+
const plugins = Array.isArray(config.plugins) ? config.plugins.filter((p): p is string => typeof p === 'string' && !!p) : []
|
|
55
|
+
if (!plugins.length) continue
|
|
56
|
+
if (WITH_COMPUTER.has(String(config.features?.computerAvailability ?? ''))) continue
|
|
57
|
+
unchanged ??= unchangedSincePull(root)
|
|
58
|
+
out.push({ agent: slug, plugins, inherited: [`agents/${slug}/agent.json`, `agents/${slug}/operating.json`].every((f) => unchanged!.files.has(f)) })
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const list = (names: string[]) => (names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
|
|
64
|
+
|
|
65
|
+
/** One Agent in a line, with its fix: what `local dev`, `local status`, the MCP status and Forge's builds say. */
|
|
66
|
+
export function agentLeanPluginsLine(g: AgentLeanPlugins): string {
|
|
67
|
+
return `agent/${g.agent}: its Plugins (${list(g.plugins)}) aren’t offered once behavior is set up (operating.json) without a computer, so it runs without them, here and on the platform. Fix: give it a computer ("computerAvailability": "on-demand" under config.features in agents/${g.agent}/agent.json), or remove the Plugins from agent.json${g.inherited ? ' (inherited: these files are as the pull wrote them, so the workspace’s Agent is the same)' : ''}`
|
|
68
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ItemBuild } from './local-applied'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An Agent whose tools didn't build: its `bindings.json` names Functions or
|
|
8
|
+
* workflows whose apply failed (or that wait on something), so the runtime
|
|
9
|
+
* gives it the rest and it runs without those. That is not an adaptation
|
|
10
|
+
* for local, it is a gap: said as a warning on the Agent's row, naming what
|
|
11
|
+
* failed, on every surface that lists it (`local status`, Forge's home and
|
|
12
|
+
* Activity, Studio's Agents page).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface MissingTools {
|
|
16
|
+
agent: string
|
|
17
|
+
/** `function/dispatch-request`, `workflow/intake`: what it names that isn't on the runtime. */
|
|
18
|
+
refs: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The tools an Agent's bindings name and enable: Functions and workflows, by reference. */
|
|
22
|
+
function boundTools(root: string, slug: string): string[] {
|
|
23
|
+
try {
|
|
24
|
+
const doc = JSON.parse(readFileSync(join(root, 'agents', slug, 'bindings.json'), 'utf8')) as { functions?: unknown; workflows?: unknown }
|
|
25
|
+
const refs: string[] = []
|
|
26
|
+
for (const list of [doc.functions, doc.workflows]) {
|
|
27
|
+
if (!Array.isArray(list)) continue
|
|
28
|
+
for (const b of list) {
|
|
29
|
+
if (!b || typeof b !== 'object' || (b as { enabled?: unknown }).enabled === false) continue
|
|
30
|
+
const ref = (b as { function?: unknown; workflow?: unknown }).function ?? (b as { workflow?: unknown }).workflow
|
|
31
|
+
if (typeof ref === 'string' && /^(function|workflow)\/[\w.-]+$/.test(ref)) refs.push(ref)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return refs
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Each Agent whose bound Functions or workflows failed to apply (or wait on what they need), by its slug. */
|
|
41
|
+
export function agentMissingTools(root: string, agents: string[], builds: Readonly<Record<string, ItemBuild>>): MissingTools[] {
|
|
42
|
+
const out: MissingTools[] = []
|
|
43
|
+
for (const agent of agents) {
|
|
44
|
+
const refs = [...new Set(boundTools(root, agent))].filter((ref) => {
|
|
45
|
+
const state = builds[ref]?.state
|
|
46
|
+
return state === 'failed' || state === 'blocked'
|
|
47
|
+
})
|
|
48
|
+
if (refs.length) out.push({ agent, refs: refs.sort() })
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const list = (names: string[]) => (names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`)
|
|
54
|
+
|
|
55
|
+
/** The warning on the Agent's row: "agent/nova: missing tools: … failed to apply, … Fix: …". */
|
|
56
|
+
export function missingToolsLine(m: MissingTools): string {
|
|
57
|
+
const names = m.refs.map((r) => r.slice(r.indexOf('/') + 1))
|
|
58
|
+
const one = names.length === 1
|
|
59
|
+
const kinds = new Set(m.refs.map((r) => r.slice(0, r.indexOf('/'))))
|
|
60
|
+
const noun = kinds.size === 1 ? (kinds.has('function') ? 'Function' : 'workflow') : 'tool'
|
|
61
|
+
const what = one ? noun : `${noun}s`
|
|
62
|
+
return `agent/${m.agent}: missing tools: the ${what} ${list(names)} failed to apply, so it runs without ${one ? 'it' : 'them'} here. Fix: fix ${one ? `that ${noun}` : `those ${what}`} (${one ? 'its' : 'each one’s'} row says why); the Agent gets ${one ? 'it' : 'them'} on the next apply after ${one ? 'it builds' : 'they build'}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An adaptation the tools' apply recorded for the same gap ("nova goes without the Function x here until it builds"): said as the warning instead. */
|
|
66
|
+
export function isMissingToolAdaptation(line: string): boolean {
|
|
67
|
+
return /\bgoes without the (Function|workflow) [\w.-]+ here until it builds\b/i.test(line)
|
|
68
|
+
}
|
|
@@ -69,3 +69,30 @@ export async function syncAgentName(input: { slug: string; identity: AgentIdenti
|
|
|
69
69
|
return ` warning: ${input.slug}'s name could not be set: ${(err as Error).message}`
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether the runtime holds a draft of this Agent: true or false, or null when
|
|
75
|
+
* it could not be told (no such Agent yet, the runtime did not answer). Its
|
|
76
|
+
* files may be unchanged since they were applied while the draft is gone (a
|
|
77
|
+
* publish turns the draft into the live version), and the Agent's test cases
|
|
78
|
+
* and Studio's Draft chat run the draft, so `local dev` stages it again.
|
|
79
|
+
*/
|
|
80
|
+
export async function hasAgentDraft(input: { slug: string; serviceUrl: string; creds: LocalCredentials; fetch?: typeof fetch }): Promise<boolean | null> {
|
|
81
|
+
const doFetch = input.fetch ?? fetch
|
|
82
|
+
const headers = { authorization: `Bearer ${input.creds.workspaceKey ?? ''}` }
|
|
83
|
+
try {
|
|
84
|
+
const found = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(input.slug)}`, input.serviceUrl), { headers })
|
|
85
|
+
if (!found.ok) return null
|
|
86
|
+
const row = (await found.json()) as { data?: { id?: string }; id?: string }
|
|
87
|
+
const id = row.data?.id ?? row.id
|
|
88
|
+
if (!id) return null
|
|
89
|
+
const res = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(id)}/draft-v2`, input.serviceUrl), { headers })
|
|
90
|
+
if (!res.ok) return null
|
|
91
|
+
// Wrapped twice: `ok({ data: … })` around the draft, which is null when nothing is staged.
|
|
92
|
+
const body = (await res.json()) as { data?: { data?: unknown } | null } | null
|
|
93
|
+
const inner = body?.data && typeof body.data === 'object' && 'data' in body.data ? body.data.data : body?.data
|
|
94
|
+
return inner !== null && inner !== undefined
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CliError } from '../errors'
|
|
5
|
+
import { healProject } from '../heal'
|
|
6
|
+
import { writeState } from '../project'
|
|
7
|
+
import { unpackTo } from '../untar'
|
|
8
|
+
import { appPath } from './local-apps'
|
|
9
|
+
import { vendoredShadows } from './local-app-sdk'
|
|
10
|
+
import { appPulledAsIs, recordPulledFiles } from './local-app-pulled'
|
|
11
|
+
import type { SyncClient } from './local-sync-client'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A pulled Custom App's code, brought beside its file: the source of the
|
|
15
|
+
* version the workspace deployed, unpacked into `apps/<slug>/`, as
|
|
16
|
+
* `frontera app pull` does for a folder of its own. `local dev` then builds
|
|
17
|
+
* it like any App folder.
|
|
18
|
+
*
|
|
19
|
+
* Never over a folder that is there: an `apps/<slug>/` is the project's own
|
|
20
|
+
* code, edited or not. No `.env.local` is written: a dev host given the
|
|
21
|
+
* workspace's credential would read the workspace's records, and `app_dev`
|
|
22
|
+
* (or `frontera app dev`) points the App at the local runtime when it runs.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Each `@frontera-sdk` package an App's package.json takes from the copy under
|
|
26
|
+
* src/frontera/ while its bun.lock installed a published release
|
|
27
|
+
* (`vendoredShadows`): a frozen install refuses that App until one of the two
|
|
28
|
+
* files changes.
|
|
29
|
+
*/
|
|
30
|
+
export type SdkShadow = { pkg: string; version: string }
|
|
31
|
+
|
|
32
|
+
export type AppCodePulled =
|
|
33
|
+
| { slug: string; version: string; files: number; repairs: string[]; shadows?: SdkShadow[]; replaced?: SdkShadow[] }
|
|
34
|
+
| { slug: string; version: string; kept: true; shadows?: SdkShadow[] }
|
|
35
|
+
| { slug: string; version: string; missing: true }
|
|
36
|
+
| { slug: string; version: string; failed: string }
|
|
37
|
+
|
|
38
|
+
/** The version an App's file names, else the pulled item's (`v1.4.0`). */
|
|
39
|
+
export function appCodeVersion(root: string, slug: string, itemVersion?: string | null): string | null {
|
|
40
|
+
try {
|
|
41
|
+
const doc = JSON.parse(readFileSync(join(root, appPath(slug)), 'utf8')) as { version?: unknown }
|
|
42
|
+
if (typeof doc.version === 'string' && doc.version) return doc.version
|
|
43
|
+
} catch {
|
|
44
|
+
// No file: the item's version, below.
|
|
45
|
+
}
|
|
46
|
+
return itemVersion ? itemVersion.replace(/^v/, '') : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The SDK packages an App folder's package.json takes from the copy under
|
|
51
|
+
* src/frontera/ while its bun.lock installed a published release, when the
|
|
52
|
+
* folder is otherwise all as an earlier pull left it ([] when it isn't, or
|
|
53
|
+
* nothing disagrees). That is an older CLI's repair, not the person's work:
|
|
54
|
+
* before pull run 10 the pull vendored the CLI's SDK copy over the published
|
|
55
|
+
* range the workspace's source declared and left its bun.lock as it was, so
|
|
56
|
+
* the two disagree and a frozen install refuses the folder. A pull brings the
|
|
57
|
+
* deployed source again in its place. Read it before the pull writes its
|
|
58
|
+
* frontera.lock entry: an older pull's folder is told from that entry
|
|
59
|
+
* (`appPulledAsIs`).
|
|
60
|
+
*/
|
|
61
|
+
export function earlierPullShadows(root: string, slug: string): SdkShadow[] {
|
|
62
|
+
const dir = join(root, 'apps', slug)
|
|
63
|
+
if (!existsSync(dir)) return []
|
|
64
|
+
const shadows = vendoredShadows(dir)
|
|
65
|
+
return shadows.length && appPulledAsIs(root, join('apps', slug)) === true ? shadows : []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Unpack an App's source into `dir`, record its lineage, repair it, and record it as pulled. */
|
|
69
|
+
function unpackPulled(dir: string, bytes: Uint8Array, app: { id: string; version: string }): { files: number; repairs: string[] } {
|
|
70
|
+
const files = unpackTo(dir, bytes)
|
|
71
|
+
// Its lineage: the workspace's App and the version this code is. Not a deployment from here
|
|
72
|
+
// (`parentVersion`), so status says it was pulled, not deployed; a deploy still names it as its parent.
|
|
73
|
+
writeState(dir, { pulledFrom: app.id, pulledVersion: app.version })
|
|
74
|
+
const { repairs } = healProject(dir)
|
|
75
|
+
// Its source as pulled (repairs included): `local check` counts a failure while it is still so as inherited.
|
|
76
|
+
recordPulledFiles(dir)
|
|
77
|
+
return { files, repairs }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `replaceEarlierPull`: the folder is there, as an earlier pull left it, with
|
|
82
|
+
* its package.json and bun.lock disagreeing (`earlierPullShadows`, read by the
|
|
83
|
+
* caller before the pull's frontera.lock entry changes). Its source is
|
|
84
|
+
* brought again and takes the folder's place, so the folder is the deployed
|
|
85
|
+
* code with the package.json and bun.lock the workspace deployed, which
|
|
86
|
+
* agree; the folder's own `.frontera/` (its state and render evidence) is
|
|
87
|
+
* kept. Without the flag a folder that is there is always kept.
|
|
88
|
+
*/
|
|
89
|
+
export async function pullAppCode(root: string, client: SyncClient, app: { slug: string; id: string; version: string; replaceEarlierPull?: boolean }): Promise<AppCodePulled> {
|
|
90
|
+
const dir = join(root, 'apps', app.slug)
|
|
91
|
+
const base = { slug: app.slug, version: app.version }
|
|
92
|
+
const replacing = existsSync(dir) ? (app.replaceEarlierPull ? vendoredShadows(dir) : []) : null
|
|
93
|
+
// The project's own code, kept; but a package.json and bun.lock that disagree (an earlier pull, before the
|
|
94
|
+
// pull went back to the release its bun.lock installed) are said now, with the fix, not at the first render.
|
|
95
|
+
if (replacing && !replacing.length) {
|
|
96
|
+
const shadows = vendoredShadows(dir)
|
|
97
|
+
return { ...base, kept: true, ...(shadows.length ? { shadows } : {}) }
|
|
98
|
+
}
|
|
99
|
+
let bytes: Uint8Array
|
|
100
|
+
try {
|
|
101
|
+
bytes = await client.getBytes(`/v1/platform-apps/${encodeURIComponent(app.id)}/versions/${encodeURIComponent(app.version)}/source`)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// The earlier pull's folder stays as it is, said with the fix that still applies.
|
|
104
|
+
if (replacing) return { ...base, kept: true, shadows: replacing }
|
|
105
|
+
if (err instanceof CliError && err.code === 'NOT_FOUND') return { ...base, missing: true }
|
|
106
|
+
return { ...base, failed: (err as Error).message.slice(0, 200) }
|
|
107
|
+
}
|
|
108
|
+
// Into a folder of its own first: the earlier pull's folder goes only once the source is unpacked and repaired.
|
|
109
|
+
const target = replacing ? join(root, 'apps', `.${app.slug}.pulling-${process.pid}-${Date.now()}`) : dir
|
|
110
|
+
try {
|
|
111
|
+
const { files, repairs } = unpackPulled(target, bytes, app)
|
|
112
|
+
if (replacing) {
|
|
113
|
+
// The folder's state (its local deployment, its render evidence) stays with it; the pull's lineage and record win.
|
|
114
|
+
const own = join(dir, '.frontera')
|
|
115
|
+
const pulledState = readFileSync(join(target, '.frontera', 'state.json'), 'utf8')
|
|
116
|
+
rmSync(join(target, '.frontera'), { recursive: true, force: true })
|
|
117
|
+
if (existsSync(own)) renameSync(own, join(target, '.frontera'))
|
|
118
|
+
writeState(target, JSON.parse(pulledState) as Parameters<typeof writeState>[1])
|
|
119
|
+
rmSync(dir, { recursive: true, force: true })
|
|
120
|
+
renameSync(target, dir)
|
|
121
|
+
}
|
|
122
|
+
// The repair prefers what the workspace stored: its bun.lock's published release over a copy package.json
|
|
123
|
+
// names. One it couldn't make leaves them disagreeing, said with the command that settles it.
|
|
124
|
+
const shadows = vendoredShadows(dir)
|
|
125
|
+
return { ...base, files, repairs, ...(shadows.length ? { shadows } : {}), ...(replacing ? { replaced: replacing } : {}) }
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// A half-unpacked folder would read as the project's own code next time: it goes.
|
|
128
|
+
rmSync(target, { recursive: true, force: true })
|
|
129
|
+
if (replacing) return { ...base, kept: true, shadows: vendoredShadows(dir) }
|
|
130
|
+
return { ...base, failed: `its source could not be unpacked (${(err as Error).message.slice(0, 160)})` }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Why an App folder won't install as it is, and the command that settles it:
|
|
136
|
+
* its package.json names the SDK copy under src/frontera/ where its bun.lock
|
|
137
|
+
* installed a published release, the one its code was written against.
|
|
138
|
+
*/
|
|
139
|
+
export function sdkShadowLine(dir: string, shadows: readonly SdkShadow[], opts: { repull?: string } = {}): string {
|
|
140
|
+
const pkgs = shadows.map((s) => s.pkg).join(' and ')
|
|
141
|
+
const released = shadows.map((s) => `${s.pkg} ${s.version}`).join(' and ')
|
|
142
|
+
const why = `its package.json names the SDK copy under src/frontera/ for ${pkgs}, where its bun.lock installed the published ${released}, so an install from its lockfile refuses it`
|
|
143
|
+
// An earlier pull's folder, unchanged since: pulling it again brings the deployed source, whose two files agree.
|
|
144
|
+
if (opts.repull) return `${why}. An earlier pull left it so, and nothing in it has changed since: \`frontera local sync pull ${opts.repull}\` brings the deployed source again in its place, or \`frontera app sdk use-local --npm\` in ${dir} goes back to that release where it is, deleting the copy (review and commit either change)`
|
|
145
|
+
return `${why}: run \`frontera app sdk use-local --npm\` in ${dir} to go back to that release (review and commit the change)`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The pull that brings an App folder's deployed source back, when it is an
|
|
150
|
+
* earlier pull's folder still as that pull left it (`apps/<slug>/` of a
|
|
151
|
+
* project, `earlierPullShadows`): `app/<slug>`, else null.
|
|
152
|
+
*/
|
|
153
|
+
export function repullRefFor(appRoot: string): string | null {
|
|
154
|
+
const parent = dirname(appRoot)
|
|
155
|
+
if (basename(parent) !== 'apps') return null
|
|
156
|
+
const slug = basename(appRoot)
|
|
157
|
+
return earlierPullShadows(dirname(parent), slug).length ? `app/${slug}` : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What an install from the App's own SDK copy did (`bun install --no-save`),
|
|
162
|
+
* for `app dev`, `app_render`/`app_dev` and `local check`: installed as its
|
|
163
|
+
* package.json says, the SDK from its copy under src/frontera/; bun.lock kept
|
|
164
|
+
* as pulled (it still records the published release) and package.json
|
|
165
|
+
* unchanged; and what brings the deployed source back.
|
|
166
|
+
*/
|
|
167
|
+
export function copyInstallLine(shadows: readonly SdkShadow[], summary: string, opts: { repull?: string | null } = {}): string {
|
|
168
|
+
const pkgs = shadows.map((s) => s.pkg).join(' and ')
|
|
169
|
+
const released = shadows.map((s) => `${s.pkg} ${s.version}`).join(' and ')
|
|
170
|
+
const back = opts.repull
|
|
171
|
+
? `An earlier pull left package.json and bun.lock disagreeing: \`frontera local sync pull ${opts.repull}\` brings the deployed source back`
|
|
172
|
+
: '`frontera app sdk use-local --npm` in the App goes back to that release (it rewrites package.json and deletes the copy)'
|
|
173
|
+
return `installed its packages from the App’s own SDK copy under src/frontera/ for ${pkgs}, as its package.json says, with \`bun install --no-save\` (${summary}); bun.lock was kept as pulled (it records the published ${released}) and package.json is unchanged. ${back}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One line per App, for the pull's text. */
|
|
177
|
+
export function appCodeLine(p: AppCodePulled): string {
|
|
178
|
+
const shadows = 'shadows' in p ? p.shadows : undefined
|
|
179
|
+
const shadow = shadows?.length ? `; ${sdkShadowLine(`apps/${p.slug}`, shadows)}` : ''
|
|
180
|
+
if ('files' in p) {
|
|
181
|
+
// A repair back to the bun.lock's release means the workspace's source disagreed with itself: said as that.
|
|
182
|
+
const restored = p.repairs.some((r) => r.startsWith('restored '))
|
|
183
|
+
const instead = p.replaced?.length
|
|
184
|
+
? `, in place of the folder an earlier pull left unchanged, whose package.json named the SDK copy under src/frontera/ for ${p.replaced.map((s) => s.pkg).join(' and ')} where its bun.lock installed the published ${p.replaced.map((s) => `${s.pkg} ${s.version}`).join(' and ')} (that copy is gone; its package.json and bun.lock are the workspace’s again; review the change before you commit)`
|
|
185
|
+
: ''
|
|
186
|
+
return ` ${p.slug}: version ${p.version} into apps/${p.slug}/ (${p.files} files${p.repairs.length ? `; ${restored ? 'the workspace’s source has package.json and bun.lock disagreeing, so it was repaired to what its bun.lock installed' : 'repaired legacy packaging'}: ${p.repairs.join('; ')}` : ''})${instead}${shadow}`
|
|
187
|
+
}
|
|
188
|
+
if ('kept' in p) return ` ${p.slug}: apps/${p.slug}/ is already here, so it was kept as it is${shadow}`
|
|
189
|
+
if ('missing' in p) return ` ${p.slug}: the platform holds no source for version ${p.version}; apps/${p.slug}.json is its description only`
|
|
190
|
+
return ` ${p.slug}: its code could not be brought (${p.failed}); \`frontera local sync pull app/${p.slug}\` tries again`
|
|
191
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What an App's dev server writes into its folder, put back.
|
|
7
|
+
*
|
|
8
|
+
* `next dev` rewrites `next-env.d.ts` when it starts (Next 16 points it at
|
|
9
|
+
* `.next/dev/types/routes.d.ts` where `next build` wrote `.next/types/…`) and
|
|
10
|
+
* may reconfigure `tsconfig.json`. A pulled App tracks both, so a preview
|
|
11
|
+
* would leave them modified. `frontera app dev` (which `app_dev`,
|
|
12
|
+
* `app_render` and Forge start) puts each back once the server is ready, and
|
|
13
|
+
* again when it stops.
|
|
14
|
+
*
|
|
15
|
+
* Only files that are the project's, not the person's work in progress: a
|
|
16
|
+
* file git tracks, or one the pull recorded (`pulledFiles` in the App's
|
|
17
|
+
* `.frontera/state.json`), that the dev server changed. A change made while
|
|
18
|
+
* the server runs is put back only when it is what the dev server wrote (the
|
|
19
|
+
* content it wrote at startup, or for `next-env.d.ts` and build info, their
|
|
20
|
+
* generated form); anything else is left as it is, and named.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A file as it was: its bytes and its times, for putting it back as if nothing had touched it. */
|
|
24
|
+
export interface FileAsWas {
|
|
25
|
+
bytes: Buffer
|
|
26
|
+
atimeMs: number
|
|
27
|
+
mtimeMs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A file's bytes and times now; null when it can't be read. */
|
|
31
|
+
export function fileAsWas(path: string): FileAsWas | null {
|
|
32
|
+
try {
|
|
33
|
+
const stat = statSync(path)
|
|
34
|
+
return { bytes: readFileSync(path), atimeMs: stat.atimeMs, mtimeMs: stat.mtimeMs }
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Put a file back as it was, its modification time too. A file the tooling
|
|
42
|
+
* rewrote for a run and put back byte for byte is no change, and whether a
|
|
43
|
+
* render is fresh (`render-evidence.ts`) is read from the source's mtimes: a
|
|
44
|
+
* put-back with a new mtime made the last render stale although nothing in
|
|
45
|
+
* the App had changed. Returns whether the bytes had to be written (a file
|
|
46
|
+
* the tool rewrote with the same bytes only gets its time back).
|
|
47
|
+
*/
|
|
48
|
+
export function putBackAsWas(path: string, was: FileAsWas): boolean {
|
|
49
|
+
const now = existsSync(path) ? readFileSync(path) : null
|
|
50
|
+
const rewrite = !now || !now.equals(was.bytes)
|
|
51
|
+
if (rewrite) writeFileSync(path, was.bytes)
|
|
52
|
+
try {
|
|
53
|
+
if (rewrite || statSync(path).mtimeMs !== was.mtimeMs) utimesSync(path, was.atimeMs / 1000, was.mtimeMs / 1000)
|
|
54
|
+
} catch {
|
|
55
|
+
// The bytes are back; a time that can't be set only makes a render look stale.
|
|
56
|
+
}
|
|
57
|
+
return rewrite
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Written by a framework's dev server or a typecheck into the App folder's top. */
|
|
61
|
+
const isDevWritten = (name: string) => name === 'next-env.d.ts' || name === 'tsconfig.json' || name.endsWith('.tsbuildinfo')
|
|
62
|
+
|
|
63
|
+
/** `next-env.d.ts` as Next writes it: references, an import of its route types, comments. Never a person's code. */
|
|
64
|
+
export function isNextGenerated(name: string, text: string): boolean {
|
|
65
|
+
if (name.endsWith('.tsbuildinfo')) return true
|
|
66
|
+
if (name !== 'next-env.d.ts') return false
|
|
67
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
68
|
+
return lines.length > 0 && lines.every((l) => /^\/\/\/ <reference (types|path)=".*" \/>$/.test(l) || /^import ["']\.\/\.next\/[^"']+["'];?$/.test(l) || l.startsWith('//'))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The names in `names` that git tracks in `dir`; none outside a repository. */
|
|
72
|
+
function gitTracked(dir: string, names: string[]): Set<string> {
|
|
73
|
+
if (!names.length) return new Set()
|
|
74
|
+
const res = spawnSync('git', ['ls-files', '-z', '--', ...names], { cwd: dir, encoding: 'utf8' })
|
|
75
|
+
if (res.status !== 0 || res.error) return new Set()
|
|
76
|
+
return new Set(res.stdout.split('\0').filter(Boolean))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The files the pull recorded in the App folder (`pulledFiles`), by path in the folder. */
|
|
80
|
+
function pulledRecorded(dir: string): Set<string> {
|
|
81
|
+
try {
|
|
82
|
+
const state = JSON.parse(readFileSync(join(dir, '.frontera', 'state.json'), 'utf8')) as { pulledFiles?: Record<string, string> }
|
|
83
|
+
return new Set(Object.keys(state.pulledFiles ?? {}))
|
|
84
|
+
} catch {
|
|
85
|
+
return new Set()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The App folder's files a dev server may write that are the project's: tracked by git, or recorded by the pull. */
|
|
90
|
+
export function trackedDevWrittenFiles(dir: string): string[] {
|
|
91
|
+
if (!existsSync(dir)) return []
|
|
92
|
+
const present = readdirSync(dir).filter(isDevWritten).sort()
|
|
93
|
+
const tracked = gitTracked(dir, present)
|
|
94
|
+
const pulled = pulledRecorded(dir)
|
|
95
|
+
return present.filter((name) => tracked.has(name) || pulled.has(name))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DevWrittenFiles {
|
|
99
|
+
/** The files watched, as they were before the dev server started. */
|
|
100
|
+
readonly watched: string[]
|
|
101
|
+
/** Once the server is ready: each it changed since it started, put back. The names put back. */
|
|
102
|
+
afterReady(): string[]
|
|
103
|
+
/** When it has stopped: each it changed again, put back; the names put back, and those changed otherwise (left). */
|
|
104
|
+
atStop(): { putBack: string[]; left: string[] }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keep the App folder's tracked files a dev server writes as they were before it started (call before starting it). */
|
|
108
|
+
export function keepDevWrittenFiles(dir: string): DevWrittenFiles {
|
|
109
|
+
const before = new Map<string, FileAsWas>()
|
|
110
|
+
for (const name of trackedDevWrittenFiles(dir)) {
|
|
111
|
+
const was = fileAsWas(join(dir, name))
|
|
112
|
+
// Unreadable: left as the server leaves it.
|
|
113
|
+
if (was) before.set(name, was)
|
|
114
|
+
}
|
|
115
|
+
// What the server wrote at startup: the same content again later is its doing too.
|
|
116
|
+
const wrote = new Map<string, Buffer>()
|
|
117
|
+
const read = (name: string) => {
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(join(dir, name))
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const putBack = (name: string): boolean => {
|
|
125
|
+
try {
|
|
126
|
+
putBackAsWas(join(dir, name), before.get(name)!)
|
|
127
|
+
return true
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Rewritten with the same bytes: only its time goes back, and nothing is said.
|
|
133
|
+
const retime = (name: string) => {
|
|
134
|
+
try {
|
|
135
|
+
putBackAsWas(join(dir, name), before.get(name)!)
|
|
136
|
+
} catch {
|
|
137
|
+
// Only a render's freshness reads the time.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
watched: [...before.keys()],
|
|
142
|
+
afterReady() {
|
|
143
|
+
const back: string[] = []
|
|
144
|
+
for (const [name, was] of before) {
|
|
145
|
+
const now = read(name)
|
|
146
|
+
if (!now) continue
|
|
147
|
+
if (now.equals(was.bytes)) {
|
|
148
|
+
retime(name)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
wrote.set(name, now)
|
|
152
|
+
if (putBack(name)) back.push(name)
|
|
153
|
+
}
|
|
154
|
+
return back
|
|
155
|
+
},
|
|
156
|
+
atStop() {
|
|
157
|
+
const back: string[] = []
|
|
158
|
+
const left: string[] = []
|
|
159
|
+
for (const [name, was] of before) {
|
|
160
|
+
const now = read(name)
|
|
161
|
+
if (!now) continue
|
|
162
|
+
if (now.equals(was.bytes)) {
|
|
163
|
+
retime(name)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const theServers = wrote.get(name)?.equals(now) || isNextGenerated(name, now.toString('utf8'))
|
|
167
|
+
if (theServers && putBack(name)) back.push(name)
|
|
168
|
+
else left.push(name)
|
|
169
|
+
}
|
|
170
|
+
return { putBack: back, left }
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One line for what was put back, for the dev server's output (`[dev] …`), or null when nothing was. */
|
|
176
|
+
export function putBackLine(names: string[], when: 'started' | 'stopped'): string | null {
|
|
177
|
+
if (!names.length) return null
|
|
178
|
+
const files = names.join(' and ')
|
|
179
|
+
return `put back ${files} as tracked: the dev server rewrote ${names.length === 1 ? 'it' : 'them'} ${when === 'started' ? 'when it started' : 'while it ran'}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One line for tracked files changed while the server ran that weren't its doing, or null. */
|
|
183
|
+
export function leftLine(names: string[]): string | null {
|
|
184
|
+
if (!names.length) return null
|
|
185
|
+
return `left ${names.join(' and ')} as ${names.length === 1 ? 'it is' : 'they are'}: changed while the dev server ran, not to what it writes (an edit of yours is never put back)`
|
|
186
|
+
}
|