@frontera-sdk/cli 1.52.38 → 1.52.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { CliError, UsageError } from '../../errors'
|
|
5
5
|
import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
|
|
6
6
|
import { assetFilename } from '../skill/bundle-commands'
|
|
7
|
+
import { outsideImports } from '../../function-outside-imports'
|
|
7
8
|
import { packDirectory } from '../../pack'
|
|
8
9
|
import { createTarGz } from '../../tar'
|
|
9
10
|
import { functionEntry } from '../../local/local-dev-sync'
|
|
@@ -13,6 +14,11 @@ import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
|
|
|
13
14
|
import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
|
|
14
15
|
import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
|
|
15
16
|
import { itemTitle } from '../../local/local-sync-names'
|
|
17
|
+
import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
|
|
18
|
+
import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
|
|
19
|
+
import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
|
|
20
|
+
import { findAppProjects } from '../../mcp/mcp-apps'
|
|
21
|
+
import { runtimeLink } from '../../mcp/mcp-runtime'
|
|
16
22
|
import { readLock, type SyncKind } from '../../local/local-sync-plan'
|
|
17
23
|
import {
|
|
18
24
|
adoptApplied,
|
|
@@ -55,7 +61,10 @@ import { fetchFor, planFor, sourceFor } from './sync'
|
|
|
55
61
|
* request [show] the current request: its items, each against its base, and what happened
|
|
56
62
|
* request apply apply it: each step moves a live version, and a failure puts every earlier one back;
|
|
57
63
|
* then fetch, so the applied items read as up to date
|
|
64
|
+
* request evidence attach evidence: each Agent's gated evals against its last applied run,
|
|
65
|
+
* each released App's last render, a Forge session's screenshots, a trace link
|
|
58
66
|
* request close close it without applying
|
|
67
|
+
* push --release desk=1.4.0 also release a Custom App: Apply promotes that deployment, last
|
|
59
68
|
*
|
|
60
69
|
* The request's id is kept in `.frontera/sync/requests.json` (not
|
|
61
70
|
* committed). Keys come from the keychain, as for `sync fetch`; the
|
|
@@ -81,6 +90,8 @@ interface Platform {
|
|
|
81
90
|
call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts?: { organizationKey?: string | null; timeoutMs?: number }): Promise<T>
|
|
82
91
|
/** A body sent as it is (a document's bytes, a multipart upload), not as JSON. */
|
|
83
92
|
send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts?: { timeoutMs?: number }): Promise<T>
|
|
93
|
+
/** A body read as it is (an evidence picture). */
|
|
94
|
+
bytes(path: string): Promise<{ bytes: Uint8Array; mime: string }>
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/**
|
|
@@ -109,6 +120,11 @@ function platform(link: SyncLink, key: string): Platform {
|
|
|
109
120
|
async send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts: { timeoutMs?: number } = {}): Promise<T> {
|
|
110
121
|
return answer<T>(await request(method, path, contentType ? { 'content-type': contentType } : {}, body, opts.timeoutMs ?? 300_000))
|
|
111
122
|
},
|
|
123
|
+
async bytes(path: string) {
|
|
124
|
+
const res = await request('GET', path, {}, undefined, 60_000)
|
|
125
|
+
if (!res.ok) return answer<never>(res)
|
|
126
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), mime: res.headers.get('content-type') ?? 'application/octet-stream' }
|
|
127
|
+
},
|
|
112
128
|
}
|
|
113
129
|
}
|
|
114
130
|
|
|
@@ -125,6 +141,10 @@ async function answer<T>(res: Response): Promise<T> {
|
|
|
125
141
|
const message = String(parsed?.message ?? (typeof parsed?.error === 'string' ? parsed.error : '') ?? '').trim() || text.slice(0, 300) || `the platform answered ${res.status}`
|
|
126
142
|
const checks = checksOf(parsed?.details)
|
|
127
143
|
if (checks.length) throw new CliError(message, { code: 'SYNC_CHECKS', hint: 'fix the items the checks name, then push again', details: { checks } })
|
|
144
|
+
const details = (parsed?.details ?? null) as { code?: string; gate?: Gate } | null
|
|
145
|
+
if (details?.code === 'GATE_FAILED' && details.gate) {
|
|
146
|
+
throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint: 'make the checks pass (`frontera local sync request evidence` attaches evals and renders), or a workspace admin applies with `--force --reason "<why>"`', details: { gate: details.gate } })
|
|
147
|
+
}
|
|
128
148
|
if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint: 'check it is a current workspace key (sk-ws-…) for this platform; `frontera local sync connect` stores a new one' })
|
|
129
149
|
if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: 'the key’s role decides what it may propose and apply; ask a workspace admin' })
|
|
130
150
|
if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: 'the change request is gone from the workspace; propose again with `frontera local sync push`' })
|
|
@@ -145,6 +165,8 @@ interface ViewItem {
|
|
|
145
165
|
id: string
|
|
146
166
|
ref: string
|
|
147
167
|
kind: SyncKind
|
|
168
|
+
/** What the item's candidate is: evidence taken for it is bound to it. */
|
|
169
|
+
candidateDigest?: string
|
|
148
170
|
name: string
|
|
149
171
|
baseRevision: string | null
|
|
150
172
|
state: string
|
|
@@ -156,15 +178,107 @@ interface ViewItem {
|
|
|
156
178
|
deleted?: boolean
|
|
157
179
|
assets?: Array<{ path: string; bytes: number; sha256: string }>
|
|
158
180
|
documents?: { upload: Array<{ fileName: string; folder?: string | null; sha256: string; bytes: number; replaces?: string | null; staged?: boolean }>; remove: Array<{ fileName: string; sourceId: string }> }
|
|
181
|
+
/**
|
|
182
|
+
* `blueprint/workspace-grants`, while the request is still to be applied:
|
|
183
|
+
* each type its file moves, against what the workspace holds now, and the
|
|
184
|
+
* changes that give the workspace `read_sensitive`, in words.
|
|
185
|
+
*/
|
|
186
|
+
grantChanges?: GrantChange[]
|
|
187
|
+
disclosures?: string[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One type's workspace grant as the request moves it (`from` null: the workspace holds none on it). */
|
|
191
|
+
export interface GrantChange {
|
|
192
|
+
objectType: string
|
|
193
|
+
from: string[] | null
|
|
194
|
+
to: string[]
|
|
195
|
+
disclosure: boolean
|
|
196
|
+
summary?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* What a `blueprint/workspace-grants` item changes, under its line: each
|
|
201
|
+
* disclosure first (the workspace gains `read_sensitive`), then the other
|
|
202
|
+
* moves. Nothing when the view carries none (a platform before it, or a
|
|
203
|
+
* request already applied).
|
|
204
|
+
*/
|
|
205
|
+
export function grantChangeLines(item: Pick<ViewItem, 'grantChanges' | 'disclosures'>): string[] {
|
|
206
|
+
const changes = item.grantChanges ?? []
|
|
207
|
+
const disclosures = item.disclosures ?? changes.filter((g) => g.disclosure).map((g) => g.summary ?? g.objectType)
|
|
208
|
+
const said = (g: GrantChange) => g.summary ?? `${g.objectType}: ${g.from ? g.from.join(', ') : 'no grant'} → ${g.to.join(', ')}`
|
|
209
|
+
if (!changes.length && !disclosures.length) return item.grantChanges ? [' · changes no grant: the workspace holds each as listed'] : []
|
|
210
|
+
return [
|
|
211
|
+
...disclosures.map((d) => ` ! discloses: ${d}`),
|
|
212
|
+
...changes.filter((g) => !g.disclosure).map((g) => ` · ${said(g)}`),
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The release gate as the platform reads it (design 2026-10-18 §6); absent from a platform before it. */
|
|
217
|
+
export interface Gate {
|
|
218
|
+
required: string[]
|
|
219
|
+
checks: Array<{ id: string; kind: 'evals' | 'app'; ref: string; label: string; status: 'passed' | 'failed' | 'missing'; detail: string; required: boolean; reported?: string | null; evidenceId?: string | null }>
|
|
220
|
+
blocking: number
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface Evidence {
|
|
224
|
+
id: string
|
|
225
|
+
kind: string
|
|
226
|
+
ref: string | null
|
|
227
|
+
title: string
|
|
228
|
+
detail: Record<string, unknown>
|
|
229
|
+
mime: string | null
|
|
230
|
+
bytes: number
|
|
231
|
+
hasImage: boolean
|
|
232
|
+
createdBy: string
|
|
233
|
+
createdAt: string
|
|
234
|
+
stale: boolean
|
|
159
235
|
}
|
|
160
236
|
|
|
161
237
|
interface View {
|
|
162
238
|
request: { id: string; title: string; description: string | null; state: string; authorId: string; createdAt: string; updatedAt: string; mergedAt: string | null; closedAt?: string | null }
|
|
163
239
|
items: ViewItem[]
|
|
164
240
|
events: Array<{ id: string; kind: string; actorId: string; detail: Record<string, unknown> | null; createdAt: string }>
|
|
241
|
+
gate?: Gate
|
|
242
|
+
evidence?: Evidence[]
|
|
165
243
|
outcome?: unknown
|
|
166
244
|
}
|
|
167
245
|
|
|
246
|
+
/** A Custom App release as a change request carries it: `app/<slug>`, naming the deployment Apply promotes. */
|
|
247
|
+
export function releaseItem(slug: string, deployment: string): { ref: string; files: Record<string, string> } {
|
|
248
|
+
return { ref: `app/${slug}`, files: { [`apps/${slug}.release.json`]: `${JSON.stringify({ deployment }, null, 2)}\n` } }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The deployment a release item names. */
|
|
252
|
+
export function releasedDeployment(item: Pick<ViewItem, 'name' | 'files'>): string | null {
|
|
253
|
+
try {
|
|
254
|
+
const doc = JSON.parse(item.files[`apps/${item.name}.release.json`] ?? 'null') as { deployment?: unknown } | null
|
|
255
|
+
return typeof doc?.deployment === 'string' ? doc.deployment : null
|
|
256
|
+
} catch {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `--release desk=1.4.0,portal`: each App's slug and the deployment named (none: the newest). */
|
|
262
|
+
export function parseReleases(flag: string | undefined): Array<{ slug: string; deployment: string | null }> {
|
|
263
|
+
if (!flag?.trim()) return []
|
|
264
|
+
return flag.split(',').map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
265
|
+
const [slug = '', deployment = ''] = part.split('=')
|
|
266
|
+
if (!/^[A-Za-z0-9][\w.-]*$/.test(slug.trim())) throw new UsageError(`--release: ${part} is not an App slug`, '--release desk=1.4.0 (its deployment id or release label), or --release desk for its newest deployment')
|
|
267
|
+
return { slug: slug.trim(), deployment: deployment.trim() || null }
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The words for a gate check's state. */
|
|
272
|
+
const GATE_WORD = { passed: 'passed', failed: 'failed', missing: 'no evidence' } as const
|
|
273
|
+
|
|
274
|
+
export function gateLines(gate: Gate | undefined): string[] {
|
|
275
|
+
if (!gate?.checks.length) return []
|
|
276
|
+
return [
|
|
277
|
+
gate.blocking ? `Checks: ${plural(gate.blocking, 'required check')} not passed — Apply waits for them` : 'Checks: every required check passed',
|
|
278
|
+
...gate.checks.map((c) => ` ${c.status === 'passed' ? '✓' : c.required ? '✗' : '·'} ${c.label}: ${c.detail}${c.required ? '' : ' (not required)'}${c.status === 'passed' ? '' : ` [${GATE_WORD[c.status]}]`}${c.reported ? `\n ${c.reported}` : ''}`),
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
|
|
168
282
|
const STATE_WORD: Record<string, string> = {
|
|
169
283
|
draft: 'Draft',
|
|
170
284
|
open: 'Proposed',
|
|
@@ -198,7 +312,7 @@ export function functionBuilder(project: Pick<LocalProject, 'root'>, ctx: Pick<C
|
|
|
198
312
|
const at = findAutomationProjectRoot(resolve(root, entry))
|
|
199
313
|
const inside = at !== null && (at === root || at.startsWith(`${root}/`))
|
|
200
314
|
const tgz = inside
|
|
201
|
-
? packDirectory(at!, ctx.output, `source of ${item.name}
|
|
315
|
+
? packDirectory(at!, ctx.output, `source of ${item.name}`, outsideImports(at!, resolve(root, entry))).tgz
|
|
202
316
|
: createTarGz(Object.entries(item.files).map(([rel, text]) => ({ rel, bytes: new TextEncoder().encode(text) })))
|
|
203
317
|
const source = Buffer.from(tgz).toString('base64')
|
|
204
318
|
return { manifest, bundle: Buffer.from(code, 'utf8').toString('base64'), source }
|
|
@@ -306,6 +420,29 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
|
|
|
306
420
|
return lines.join('\n')
|
|
307
421
|
}
|
|
308
422
|
|
|
423
|
+
/**
|
|
424
|
+
* The release items asked for: each App resolved in the workspace, and the
|
|
425
|
+
* deployment named, or its newest one (what was deployed last, previews
|
|
426
|
+
* included) when only the slug is given.
|
|
427
|
+
*/
|
|
428
|
+
async function releaseItems(api: Platform, asked: Array<{ slug: string; deployment: string | null }>) {
|
|
429
|
+
const out: Array<{ ref: string; files: Record<string, string>; slug: string; deployment: string; chosen: boolean }> = []
|
|
430
|
+
for (const r of asked) {
|
|
431
|
+
let deployment = r.deployment
|
|
432
|
+
if (!deployment) {
|
|
433
|
+
const app = await api.call<{ id: string }>('GET', `/v1/platform-apps/resolve/${encodeURIComponent(r.slug)}`).catch((err: CliError) => {
|
|
434
|
+
throw err.code === 'NOT_FOUND' ? new UsageError(`the workspace has no Custom App ${r.slug}`, 'deploy it once with `frontera app deploy` in its folder, then release it') : err
|
|
435
|
+
})
|
|
436
|
+
const ledger = await api.call<{ deployments: Array<{ id: string; label: string | null; pruned: unknown }> }>('GET', `/v1/platform-apps/${app.id}/deployments`)
|
|
437
|
+
const newest = ledger.deployments.find((d) => !d.pruned)
|
|
438
|
+
if (!newest) throw new UsageError(`${r.slug} has no deployment to release`, 'deploy one with `frontera app deploy` in its folder first')
|
|
439
|
+
deployment = newest.label ?? newest.id
|
|
440
|
+
}
|
|
441
|
+
out.push({ ...releaseItem(r.slug, deployment), slug: r.slug, deployment, chosen: !r.deployment })
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
309
446
|
export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
310
447
|
const project = localProject(ctx.cwd)
|
|
311
448
|
const link = needLink(project)
|
|
@@ -367,7 +504,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
367
504
|
})
|
|
368
505
|
}
|
|
369
506
|
const going = chosen.filter((i) => i.proposable)
|
|
370
|
-
|
|
507
|
+
const releaseAsks = parseReleases(flagString(ctx, 'release'))
|
|
508
|
+
if (!going.length && !releaseAsks.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
|
|
371
509
|
const title = flagString(ctx, 'title')?.trim()
|
|
372
510
|
const description = flagString(ctx, 'description')
|
|
373
511
|
const api = platformFor(project, link)
|
|
@@ -382,7 +520,10 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
382
520
|
}
|
|
383
521
|
if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
|
|
384
522
|
|
|
385
|
-
|
|
523
|
+
// Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
|
|
524
|
+
const releases = await releaseItems(api, releaseAsks)
|
|
525
|
+
const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref)).map((i) => ({ ref: i.ref, files: i.files })) : []
|
|
526
|
+
const items = [...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases]
|
|
386
527
|
let view: View
|
|
387
528
|
try {
|
|
388
529
|
view = target
|
|
@@ -411,13 +552,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
|
|
|
411
552
|
proposed: true,
|
|
412
553
|
created: !target,
|
|
413
554
|
request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
|
|
414
|
-
pushed: going.map((i) => i.ref),
|
|
555
|
+
pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
|
|
556
|
+
releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
|
|
415
557
|
notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
|
|
416
558
|
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
417
559
|
},
|
|
418
560
|
text: [
|
|
419
561
|
`${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
|
|
420
562
|
...going.map((i) => ` ${pushItemLine(i)}`),
|
|
563
|
+
...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
|
|
421
564
|
...(notProposed.length ? ['', 'Not proposed:', ...notProposed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)] : []),
|
|
422
565
|
...(orgNote ? ['', orgNote] : []),
|
|
423
566
|
'',
|
|
@@ -462,7 +605,11 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
|
|
|
462
605
|
const source = sourceFor(project, link)
|
|
463
606
|
const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
|
|
464
607
|
const answer = await source.workspace.find<{ ref: string; kind: string; revision: string; content: Record<string, unknown> }>(`/v1/sync/item?ref=${encodeURIComponent(asked)}&revision=${encodeURIComponent(item.baseRevision)}`)
|
|
465
|
-
if (answer)
|
|
608
|
+
if (answer) {
|
|
609
|
+
const files = await projectAtRevision(source, item.ref, answer, Object.keys(item.files), directoryOf(snapshot))
|
|
610
|
+
// A Blueprint revision is the whole model: the item's base is its own file of it (`blueprint/<name>.<ext>`).
|
|
611
|
+
out[item.ref] = { files: item.kind === 'blueprint' ? Object.fromEntries(Object.entries(files).filter(([p]) => p.startsWith(`blueprint/${item.name}.`))) : files, known: true }
|
|
612
|
+
}
|
|
466
613
|
} catch {
|
|
467
614
|
// Shown without its base; the page says so.
|
|
468
615
|
}
|
|
@@ -511,7 +658,7 @@ function activityOf(view: View, titles: Record<string, string>) {
|
|
|
511
658
|
text = 'Apply started'
|
|
512
659
|
break
|
|
513
660
|
case 'apply_refused':
|
|
514
|
-
text = `Apply refused: ${plural(checksOf(d).length, 'check')} failed`
|
|
661
|
+
text = Array.isArray(d.gate) ? `Apply waited: ${plural(d.gate.length, 'required check')} not passed` : `Apply refused: ${plural(checksOf(d).length, 'check')} failed`
|
|
515
662
|
break
|
|
516
663
|
case 'apply_failed': {
|
|
517
664
|
outcome = parseOutcome({ ok: false, steps: d.steps, failure: d.failure, compensation: d.compensation })
|
|
@@ -529,6 +676,15 @@ function activityOf(view: View, titles: Record<string, string>) {
|
|
|
529
676
|
case 'closed':
|
|
530
677
|
text = 'Closed'
|
|
531
678
|
break
|
|
679
|
+
case 'gate_forced':
|
|
680
|
+
text = `Applied with ${plural(Array.isArray(d.skipped) ? d.skipped.length : 0, 'required check')} skipped (Force Apply): ${typeof d.reason === 'string' ? d.reason : ''}`
|
|
681
|
+
break
|
|
682
|
+
case 'evidence_attached':
|
|
683
|
+
text = `Attached ${typeof d.title === 'string' ? d.title : 'evidence'}`
|
|
684
|
+
break
|
|
685
|
+
case 'evidence_removed':
|
|
686
|
+
text = 'Removed a piece of evidence'
|
|
687
|
+
break
|
|
532
688
|
default:
|
|
533
689
|
text = e.kind.replace(/_/g, ' ')
|
|
534
690
|
}
|
|
@@ -551,6 +707,7 @@ async function currentView(project: LocalProject, link: SyncLink): Promise<{ vie
|
|
|
551
707
|
* then what its proposed files declare. The kept titles are not rewritten.
|
|
552
708
|
*/
|
|
553
709
|
function titleOf(item: ViewItem, titles: Record<string, string>): string {
|
|
710
|
+
if (item.kind === 'app') return `${item.name} ${releasedDeployment(item) ?? ''}`.trim()
|
|
554
711
|
return itemTitle(item.kind, item.name, item.files, titles[item.ref])
|
|
555
712
|
}
|
|
556
713
|
|
|
@@ -577,7 +734,9 @@ async function show(ctx: CommandContext) {
|
|
|
577
734
|
name: item.name,
|
|
578
735
|
title,
|
|
579
736
|
change,
|
|
580
|
-
line:
|
|
737
|
+
line: item.kind === 'app'
|
|
738
|
+
? `Custom App ${item.name}: release ${releasedDeployment(item) ?? '(no deployment)'}`
|
|
739
|
+
: itemLine({ kind: item.kind, name: item.name, title, change, changedPaths: base.known ? files.map((f) => f.path) : [], assetsChanged: Boolean(item.assets?.length) && base.known && !files.length, documentsChanged }),
|
|
581
740
|
state: item.state,
|
|
582
741
|
stateLabel: ITEM_STATE_WORD[item.state] ?? item.state,
|
|
583
742
|
error: item.error,
|
|
@@ -586,6 +745,7 @@ async function show(ctx: CommandContext) {
|
|
|
586
745
|
files,
|
|
587
746
|
build: item.build ? { hasSource: item.build.hasSource } : null,
|
|
588
747
|
...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes })) } : {}),
|
|
748
|
+
...(item.grantChanges || item.disclosures ? { grantChanges: item.grantChanges ?? [], disclosures: item.disclosures ?? [] } : {}),
|
|
589
749
|
...(item.documents
|
|
590
750
|
? {
|
|
591
751
|
documents: {
|
|
@@ -608,17 +768,22 @@ async function show(ctx: CommandContext) {
|
|
|
608
768
|
activity,
|
|
609
769
|
checks: checks.filter((c) => !items.some((i) => i.ref === c.ref)).map((c) => ({ ref: c.ref, code: c.code, message: c.message })),
|
|
610
770
|
organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
|
|
771
|
+
gate: view.gate ?? null,
|
|
772
|
+
evidence: view.evidence ?? [],
|
|
611
773
|
},
|
|
612
774
|
text: [
|
|
613
775
|
`${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${link.workspaceName ?? link.workspace}`,
|
|
614
776
|
...(r.description ? [r.description] : []),
|
|
615
777
|
'',
|
|
616
|
-
...items.flatMap((i) => [
|
|
778
|
+
...items.flatMap((i, n) => [
|
|
617
779
|
` ${i.line} (${i.stateLabel})`,
|
|
618
780
|
...(i.error ? [` ! ${i.error}`] : []),
|
|
619
781
|
...i.checks.map((c) => ` ! ${c.message}`),
|
|
782
|
+
...grantChangeLines(view.items[n]!),
|
|
620
783
|
...(i.baseKnown ? [] : [' · its base isn’t on this machine; its files are shown whole']),
|
|
621
784
|
]),
|
|
785
|
+
...(view.gate?.checks.length ? ['', ...gateLines(view.gate)] : []),
|
|
786
|
+
...(view.evidence?.length ? ['', 'Evidence:', ...view.evidence.map((e) => ` ${e.title}${e.stale ? ' (before the last push)' : ''}`)] : []),
|
|
622
787
|
'',
|
|
623
788
|
'Activity:',
|
|
624
789
|
...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
|
|
@@ -642,10 +807,17 @@ async function apply(ctx: CommandContext) {
|
|
|
642
807
|
throw new CliError('this change request changes the Blueprint, and applying it needs the organization key; none is stored for this project', { code: 'SYNC_NO_ORG_KEY', hint: 'connect again with `frontera local sync connect --workspace-key - --org-key -` (both keys on stdin), then apply' })
|
|
643
808
|
}
|
|
644
809
|
const migration = flagString(ctx, 'migration')?.trim()
|
|
810
|
+
const force = flagBool(ctx, 'force')
|
|
811
|
+
const reason = flagString(ctx, 'reason')?.trim()
|
|
812
|
+
if (force && !reason) throw new UsageError('--force needs --reason', 'frontera local sync request apply --force --reason "why it applies without its required checks"')
|
|
645
813
|
let answer: View
|
|
646
814
|
try {
|
|
647
|
-
answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, migration ? { migrationInstruction: migration } : {}, { organizationKey, timeoutMs: 15 * 60_000 })
|
|
815
|
+
answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
|
|
648
816
|
} catch (err) {
|
|
817
|
+
if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
|
|
818
|
+
const gate = (err.details as { gate?: Gate } | undefined)?.gate
|
|
819
|
+
throw new CliError(['not applied: required checks have not passed', ...gateLines(gate).slice(1).filter((l) => l.includes('✗'))].join('\n'), { code: err.code, hint: err.hint, details: err.details })
|
|
820
|
+
}
|
|
649
821
|
if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
|
|
650
822
|
const checks = checksOf(err.details)
|
|
651
823
|
throw new CliError(`not applied: ${plural(checks.length, 'check')} failed\n${checkLines(checks, allTitles).join('\n')}`, {
|
|
@@ -676,7 +848,9 @@ async function apply(ctx: CommandContext) {
|
|
|
676
848
|
// A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
|
|
677
849
|
const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
|
|
678
850
|
const result = await fetchFor(project, link, { full: deleted.length > 0 })
|
|
679
|
-
|
|
851
|
+
// A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
|
|
852
|
+
const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
|
|
853
|
+
const { adopted } = adoptApplied(project.root, link.workspace, adoptable, snapshotItems(project.root, result.snapshot), readLock(project.root), new Date(), { deleted })
|
|
680
854
|
// An Agent's lessons were proposed for review: not proposed again; the next pull brings what reviewers approve.
|
|
681
855
|
adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
|
|
682
856
|
fetched = { changed: result.changed.length, adopted, error: null }
|
|
@@ -685,6 +859,8 @@ async function apply(ctx: CommandContext) {
|
|
|
685
859
|
}
|
|
686
860
|
// Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
|
|
687
861
|
await recordAppliedDocuments(project, link, answer).catch(() => null)
|
|
862
|
+
// The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
|
|
863
|
+
recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
|
|
688
864
|
return {
|
|
689
865
|
data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched },
|
|
690
866
|
text: [
|
|
@@ -740,11 +916,142 @@ async function close(ctx: CommandContext) {
|
|
|
740
916
|
return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”. The project’s files are unchanged; the next push opens a new change request.` }
|
|
741
917
|
}
|
|
742
918
|
|
|
743
|
-
|
|
919
|
+
/**
|
|
920
|
+
* Attach evidence to the current request (design 2026-10-18 §6): each Agent
|
|
921
|
+
* it changes, run on its gated cases against the local runtime and compared
|
|
922
|
+
* with its last applied run; each App it releases, by its last render; the
|
|
923
|
+
* newest screenshots of a Forge session; a trace link. With no flag, all
|
|
924
|
+
* that applies: evals and renders, and this session's screenshots inside
|
|
925
|
+
* Forge.
|
|
926
|
+
*/
|
|
927
|
+
async function evidence(ctx: CommandContext) {
|
|
928
|
+
const project = localProject(ctx.cwd)
|
|
929
|
+
const link = needLink(project)
|
|
930
|
+
const current = await currentView(project, link)
|
|
931
|
+
if (!current) throw new UsageError('no change request to attach evidence to', 'propose one with `frontera local sync push -t "<title>"`')
|
|
932
|
+
const { view, titles } = current
|
|
933
|
+
if (!isOpen(view.request.state)) throw new CliError(`“${view.request.title}” is ${STATE_WORD[view.request.state] ?? view.request.state}; it takes no evidence`, { code: 'CONFLICT', hint: 'propose new changes with `frontera local sync push -t "<title>"`' })
|
|
934
|
+
const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
|
|
935
|
+
const trace = flagString(ctx, 'trace')
|
|
936
|
+
const any = flagBool(ctx, 'evals') || flagBool(ctx, 'renders') || flagString(ctx, 'screenshots') !== undefined || trace !== undefined
|
|
937
|
+
const want = { evals: any ? flagBool(ctx, 'evals') : true, renders: any ? flagBool(ctx, 'renders') : true, screenshots: any ? flagString(ctx, 'screenshots') !== undefined : !!session }
|
|
938
|
+
const api = platformFor(project, link)
|
|
939
|
+
const attach = (body: Record<string, unknown>) => api.call<View>('POST', `/v1/change-requests/${view.request.id}/evidence`, body, { timeoutMs: 120_000 })
|
|
940
|
+
const lines: string[] = []
|
|
941
|
+
const attached: Array<{ kind: string; ref: string | null; title: string }> = []
|
|
942
|
+
const skipped: string[] = []
|
|
943
|
+
|
|
944
|
+
if (want.evals) {
|
|
945
|
+
const agents = view.items.filter((i) => i.kind === 'agent' && !i.deleted)
|
|
946
|
+
if (agents.length) {
|
|
947
|
+
const runtime = runtimeLink(project)
|
|
948
|
+
if (typeof runtime === 'string') throw new CliError(`the evals run against the local runtime: ${runtime}`, { code: 'LOCAL_NOT_RUNNING', hint: 'start it with `frontera local start`, or attach only renders and screenshots with `--renders --screenshots <session>`' })
|
|
949
|
+
const applied = readAppliedRun(project)
|
|
950
|
+
const last = readLastRun(project)
|
|
951
|
+
for (const item of agents) {
|
|
952
|
+
// Chosen as `local test` and MCP `test_run { gate: true }` choose them, and run on what the files say:
|
|
953
|
+
// a draft that isn't fails its cases with what to run, as there.
|
|
954
|
+
const files = selectCaseFiles(project.root, { agent: item.name, gate: true })
|
|
955
|
+
const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
|
|
956
|
+
// Compared with the run when the Agent was last applied from here; never applied from here, with the last run.
|
|
957
|
+
const baseline = Object.keys(applied).some((k) => k.startsWith(`${item.name}\u0000`)) ? applied : last
|
|
958
|
+
const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim() })
|
|
959
|
+
if (results.length) recordRun(project, results)
|
|
960
|
+
// Cases that couldn't run (the provider refused them) prove nothing either way: attached, they would read as none newly failing.
|
|
961
|
+
if (detail.couldNotRun.length) {
|
|
962
|
+
skipped.push(evalsLine(`Evals · ${titleOf(item, titles)}`, detail))
|
|
963
|
+
continue
|
|
964
|
+
}
|
|
965
|
+
const title = `Evals · ${titleOf(item, titles)}`
|
|
966
|
+
await attach({ kind: 'evals', ref: item.ref, candidateDigest: item.candidateDigest ?? null, title, detail })
|
|
967
|
+
attached.push({ kind: 'evals', ref: item.ref, title })
|
|
968
|
+
lines.push(` ${evalsLine(titleOf(item, titles), detail)}`)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (want.renders) {
|
|
974
|
+
const apps = findAppProjects(project.root)
|
|
975
|
+
for (const item of view.items.filter((i) => i.kind === 'app')) {
|
|
976
|
+
const app = apps.find((a) => a.slug === item.name)
|
|
977
|
+
const render = app ? lastRender(app.root) : null
|
|
978
|
+
if (!render) {
|
|
979
|
+
skipped.push(`${item.name}: no render in this project yet (render it with the coding agent's app_render, or open it in Forge's Browser)`)
|
|
980
|
+
continue
|
|
981
|
+
}
|
|
982
|
+
const big = render.image && render.image.byteLength > 2 * 1024 * 1024
|
|
983
|
+
const title = `Render · ${item.name}${typeof render.detail.path === 'string' ? ` ${render.detail.path}` : ''}`
|
|
984
|
+
await attach({ kind: 'render', ref: item.ref, title, detail: { ...render.detail, deployment: releasedDeployment(item) }, ...(render.image && !big ? { image: render.image.toString('base64') } : {}) })
|
|
985
|
+
attached.push({ kind: 'render', ref: item.ref, title })
|
|
986
|
+
lines.push(` ${title}${big ? ' (its picture is over 2 MB and stays here)' : ''}`)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (want.screenshots && session) {
|
|
991
|
+
const shots = sessionScreenshots(project.root, session)
|
|
992
|
+
if (!shots.length) skipped.push(`session ${session}: no screenshots saved`)
|
|
993
|
+
for (const shot of shots) {
|
|
994
|
+
if (shot.bytes > 2 * 1024 * 1024) {
|
|
995
|
+
skipped.push(`${shot.name}: over 2 MB`)
|
|
996
|
+
continue
|
|
997
|
+
}
|
|
998
|
+
const title = shot.name.replace(/\.(png|jpe?g|webp)$/i, '').replace(/[-_]+/g, ' ')
|
|
999
|
+
await attach({ kind: 'screenshot', title, detail: { session, file: shot.name }, image: readFileSync(shot.path).toString('base64') })
|
|
1000
|
+
attached.push({ kind: 'screenshot', ref: null, title })
|
|
1001
|
+
lines.push(` ${title}`)
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (trace !== undefined) {
|
|
1006
|
+
if (!/^https?:\/\//.test(trace)) throw new UsageError('--trace takes an http(s) address', 'frontera local sync request evidence --trace https://…/traces?trace=<id>')
|
|
1007
|
+
const title = flagString(ctx, 'title')?.trim() || 'Trace'
|
|
1008
|
+
await attach({ kind: 'trace', title, detail: { url: trace } })
|
|
1009
|
+
attached.push({ kind: 'trace', ref: null, title })
|
|
1010
|
+
lines.push(` ${title}: ${trace}`)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const after = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
|
|
1014
|
+
recordRequest(project.root, link.workspace, after)
|
|
1015
|
+
return {
|
|
1016
|
+
data: { attached, skipped, gate: after.gate ?? null },
|
|
1017
|
+
text: [
|
|
1018
|
+
attached.length ? `Attached ${plural(attached.length, 'piece')} of evidence to “${view.request.title}”:` : `Nothing attached to “${view.request.title}”.`,
|
|
1019
|
+
...lines,
|
|
1020
|
+
...(skipped.length ? ['', 'Not attached:', ...skipped.map((l) => ` ${l}`)] : []),
|
|
1021
|
+
...(after.gate?.checks.length ? ['', ...gateLines(after.gate)] : []),
|
|
1022
|
+
].join('\n'),
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const PICTURE_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }
|
|
1027
|
+
|
|
1028
|
+
/** One evidence picture, kept on this machine (in the runtime's state folder) for Forge to show. */
|
|
1029
|
+
async function evidenceImage(ctx: CommandContext) {
|
|
1030
|
+
const project = localProject(ctx.cwd)
|
|
1031
|
+
const link = needLink(project)
|
|
1032
|
+
const id = ctx.positional[2]
|
|
1033
|
+
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw new UsageError('say which evidence picture', 'frontera local sync request evidence-image <evidence id>')
|
|
1034
|
+
const kept = currentRequest(readRequests(project.root, link.workspace))
|
|
1035
|
+
if (!kept) throw new UsageError('no change request', 'propose one with `frontera local sync push -t "<title>"`')
|
|
1036
|
+
const dir = join(project.stateDir, 'evidence', kept.id)
|
|
1037
|
+
for (const ext of Object.values(PICTURE_EXT)) {
|
|
1038
|
+
const path = join(dir, `${id}.${ext}`)
|
|
1039
|
+
if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
|
|
1040
|
+
}
|
|
1041
|
+
const { bytes, mime } = await platformFor(project, link).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
|
|
1042
|
+
const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
|
|
1043
|
+
if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
|
|
1044
|
+
mkdirSync(dir, { recursive: true })
|
|
1045
|
+
const path = join(dir, `${id}.${ext}`)
|
|
1046
|
+
writeFileSync(path, bytes)
|
|
1047
|
+
return { data: { path, mime }, text: path }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const REQUEST_ACTIONS: Record<string, (ctx: CommandContext) => Promise<{ data: unknown; text: string }>> = { show, apply, close, evidence, 'evidence-image': evidenceImage }
|
|
744
1051
|
|
|
745
1052
|
export async function syncRequest(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
|
|
746
1053
|
const action = ctx.positional[1] ?? 'show'
|
|
747
1054
|
const run = REQUEST_ACTIONS[action]
|
|
748
|
-
if (!run) throw new UsageError(`unknown request action \`${action}\``, 'use show, apply or close')
|
|
1055
|
+
if (!run) throw new UsageError(`unknown request action \`${action}\``, 'use show, evidence, apply or close')
|
|
749
1056
|
return run(ctx)
|
|
750
1057
|
}
|