@frontera-sdk/cli 1.52.39 → 1.52.41
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 +49 -5
- 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/draft-changes.ts +1 -1
- 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 +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- 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 +876 -89
- 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 +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- 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/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- 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 +113 -2
- package/src/kit.ts +1 -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 +212 -22
- 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 +181 -8
- 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 +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- 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-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- 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 +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- 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 +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- 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-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- 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/scopes.ts +1 -1
- 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 +17 -14
package/src/main.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { parseArgs } from './args'
|
|
|
3
3
|
import { resolveCredential } from './config'
|
|
4
4
|
import { findProjectRoot } from './context'
|
|
5
5
|
import { CliError, UsageError } from './errors'
|
|
6
|
-
import { EXIT, sessionLaneFailure, sessionLaneRefusal } from './exit'
|
|
6
|
+
import { EXIT, sessionLaneFailure, sessionLaneRefusal, type ExitCode } from './exit'
|
|
7
|
+
import { actAsLocalPersona, listPersonas, localPersonaHint, redact } from './local/local-personas'
|
|
8
|
+
import { localRuntimeAt } from './local/local-runtime-target'
|
|
7
9
|
import { resolveOrganization, setSelectedOrganization } from './organization'
|
|
8
10
|
import { createOutput, type OutputMode } from './output'
|
|
9
11
|
import { readProject } from './project'
|
|
@@ -45,6 +47,20 @@ import type { AppProject } from './context'
|
|
|
45
47
|
* on top — so it gets `-dev`, which is what stops a bug report from naming a
|
|
46
48
|
* version whose code the reporter is not running.
|
|
47
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* A key refused on a command that acts as a person, against the project's
|
|
52
|
+
* own local runtime: told to act as a local persona (`--as`), with the
|
|
53
|
+
* personas it has, never to fetch a person's session token. Null anywhere
|
|
54
|
+
* else, or for any other failure, to keep it as it is.
|
|
55
|
+
*/
|
|
56
|
+
async function onLocalRuntime(failure: CliError, meta: { noun: string; verb: string; authLane?: string }, cwd: string, credential: { apiUrl: string; token: string }): Promise<CliError | null> {
|
|
57
|
+
if (meta.authLane !== 'session' || !credential.token.startsWith('sk-') || !credential.apiUrl) return null
|
|
58
|
+
const local = localRuntimeAt(cwd, credential.apiUrl)
|
|
59
|
+
if (!local) return null
|
|
60
|
+
const personas = await listPersonas(local.serviceUrl, local.creds)
|
|
61
|
+
return new CliError(failure.message, { code: failure.code, hint: localPersonaHint(meta, personas), cause: failure })
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
async function resolveVersion(): Promise<string> {
|
|
49
65
|
const stamped = process.env.FRONTERA_CLI_VERSION
|
|
50
66
|
if (stamped) return stamped
|
|
@@ -134,7 +150,7 @@ async function main(): Promise<number> {
|
|
|
134
150
|
|
|
135
151
|
const args = direct ? [maybeVerb, ...rest].filter((a): a is string => a !== undefined) : rest
|
|
136
152
|
const spec = flagsFor(command.meta)
|
|
137
|
-
const { positional, flags } = parseArgs(args, spec, aliasesFor(command.meta))
|
|
153
|
+
const { positional, flags, repeated } = parseArgs(args, spec, aliasesFor(command.meta))
|
|
138
154
|
|
|
139
155
|
if (flags.help === true) {
|
|
140
156
|
return emit(describeCommand(command.meta), () => renderCommandHelp(command.meta))
|
|
@@ -144,6 +160,10 @@ async function main(): Promise<number> {
|
|
|
144
160
|
const output = createOutput(mode, { quiet: flags.quiet === true })
|
|
145
161
|
// Read by the failure path: which credential was refused decides the hint.
|
|
146
162
|
let token = ''
|
|
163
|
+
// Where that credential was sent: the project's own local runtime words a refusal differently.
|
|
164
|
+
let apiUrlUsed = ''
|
|
165
|
+
// A local persona's session (`--as`): redacted from anything a failure says.
|
|
166
|
+
let personaToken = ''
|
|
147
167
|
|
|
148
168
|
try {
|
|
149
169
|
// Reserved commands fail BEFORE anything else — no credential is
|
|
@@ -201,19 +221,37 @@ async function main(): Promise<number> {
|
|
|
201
221
|
// project are always read from one place. Two directories would mean
|
|
202
222
|
// `--dir ../other-customer` could edit one customer's app with another
|
|
203
223
|
// customer's key.
|
|
204
|
-
|
|
224
|
+
let credential = command.meta.offline
|
|
205
225
|
? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
|
|
206
226
|
: await resolveCredential(
|
|
207
227
|
{
|
|
208
228
|
apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined,
|
|
209
229
|
profile: typeof flags.profile === 'string' ? flags.profile : undefined,
|
|
230
|
+
// In a local project, an organization command takes the runtime's organization key.
|
|
231
|
+
organization: commandScope(command.meta) === 'organization' && command.meta.noun !== 'auth' && command.meta.noun !== 'kit',
|
|
210
232
|
},
|
|
211
233
|
{ cwd },
|
|
212
234
|
)
|
|
213
235
|
token = credential.token
|
|
236
|
+
apiUrlUsed = credential.apiUrl
|
|
214
237
|
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
let workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
|
|
239
|
+
let orgFlag = typeof flags.org === 'string' ? flags.org.trim() : undefined
|
|
240
|
+
|
|
241
|
+
// `--as <persona>`: a local persona's session for this one command, on the project's
|
|
242
|
+
// own local runtime only (`local-personas.ts`). Its token is never printed.
|
|
243
|
+
if (typeof flags.as === 'string' && !command.meta.offline) {
|
|
244
|
+
const acting = await actAsLocalPersona({ meta: command.meta, cwd, apiUrl: credential.apiUrl, wanted: flags.as })
|
|
245
|
+
if ((workspaceFlag && workspaceFlag !== acting.workspaceId) || (orgFlag && orgFlag !== acting.organizationId)) {
|
|
246
|
+
throw new UsageError('--as acts in the local runtime\'s own workspace', 'drop --workspace and --org')
|
|
247
|
+
}
|
|
248
|
+
personaToken = acting.token
|
|
249
|
+
credential = { apiUrl: credential.apiUrl, token: acting.token }
|
|
250
|
+
token = acting.token
|
|
251
|
+
workspaceFlag = acting.workspaceId
|
|
252
|
+
orgFlag = acting.organizationId
|
|
253
|
+
output.note(`acting as ${acting.persona.displayName} (${acting.persona.key}), a local persona`)
|
|
254
|
+
}
|
|
217
255
|
|
|
218
256
|
/**
|
|
219
257
|
* `--workspace` is global — same reasoning as `--profile`: an escape hatch
|
|
@@ -258,7 +296,7 @@ async function main(): Promise<number> {
|
|
|
258
296
|
?? (missingArg
|
|
259
297
|
? null
|
|
260
298
|
: unaddressedWorkspaceRefusal({ meta: command.meta, token: credential.token, workspace: workspaceFlag }))
|
|
261
|
-
if (refusal) throw refusal
|
|
299
|
+
if (refusal) throw (await onLocalRuntime(refusal, command.meta, cwd, credential)) ?? refusal
|
|
262
300
|
}
|
|
263
301
|
|
|
264
302
|
/**
|
|
@@ -294,17 +332,20 @@ async function main(): Promise<number> {
|
|
|
294
332
|
token: credential.token,
|
|
295
333
|
positional,
|
|
296
334
|
flags,
|
|
335
|
+
repeated,
|
|
297
336
|
output,
|
|
298
337
|
project,
|
|
299
338
|
...(workspaceFlag ? { workspaceId: workspaceFlag } : {}),
|
|
300
339
|
})
|
|
301
340
|
|
|
302
|
-
output.render(result.data, () => result.text)
|
|
303
|
-
return EXIT.OK
|
|
341
|
+
output.render(personaToken ? redact(result.data, personaToken) : result.data, () => (personaToken ? redact(result.text, personaToken) : result.text))
|
|
342
|
+
return (result.exitCode ?? EXIT.OK) as ExitCode
|
|
304
343
|
} catch (err) {
|
|
305
344
|
// A key on a session-lane command: one recovery for every such noun,
|
|
306
345
|
// rather than each API client rewording its own 401/403.
|
|
307
|
-
|
|
346
|
+
const laneFailure = sessionLaneFailure(command.meta, token, err)
|
|
347
|
+
const failure = (laneFailure && (await onLocalRuntime(laneFailure, command.meta, typeof flags.dir === 'string' ? flags.dir : process.cwd(), { apiUrl: apiUrlUsed, token }).catch(() => null))) ?? laneFailure ?? err
|
|
348
|
+
return output.fail(personaToken ? redact(failure, personaToken) : failure)
|
|
308
349
|
}
|
|
309
350
|
}
|
|
310
351
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A basic accessibility scan of a rendered page (design 2026-10-18 §6):
|
|
6
|
+
* axe-core's WCAG 2 A and AA rules, run in the page `app_render` opened, in
|
|
7
|
+
* Forge's tab or headless. What it finds is kept with the render evidence,
|
|
8
|
+
* and a deployment's Accessibility check reads it: serious and critical
|
|
9
|
+
* findings fail it; the rest are listed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface A11yFinding {
|
|
13
|
+
id: string
|
|
14
|
+
impact: 'minor' | 'moderate' | 'serious' | 'critical' | null
|
|
15
|
+
help: string
|
|
16
|
+
nodes: number
|
|
17
|
+
/** The first element it found, as a selector. */
|
|
18
|
+
target: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface A11yResult {
|
|
22
|
+
/** Serious and critical findings: what fails the check. */
|
|
23
|
+
violations: number
|
|
24
|
+
findings: A11yFinding[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let source: string | null | undefined
|
|
28
|
+
|
|
29
|
+
/** axe-core's script, read once; null when the CLI was installed without it. */
|
|
30
|
+
export function axeSource(): string | null {
|
|
31
|
+
if (source !== undefined) return source
|
|
32
|
+
try {
|
|
33
|
+
source = readFileSync(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8')
|
|
34
|
+
} catch {
|
|
35
|
+
source = null
|
|
36
|
+
}
|
|
37
|
+
return source
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Run in the page after axe's script: the violations, in plain data. */
|
|
41
|
+
export const AXE_RUN = `(async () => {
|
|
42
|
+
const r = await axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] }, resultTypes: ['violations'] })
|
|
43
|
+
return r.violations.map((v) => ({ id: v.id, impact: v.impact || null, help: v.help, nodes: v.nodes.length, target: v.nodes[0] && v.nodes[0].target ? v.nodes[0].target.join(' ') : null }))
|
|
44
|
+
})()`
|
|
45
|
+
|
|
46
|
+
/** The scan's findings as the evidence keeps them: worst first, at most 20. */
|
|
47
|
+
export function summarizeA11y(raw: unknown): A11yResult | null {
|
|
48
|
+
if (!Array.isArray(raw)) return null
|
|
49
|
+
const rank = { critical: 0, serious: 1, moderate: 2, minor: 3 } as const
|
|
50
|
+
const findings = raw
|
|
51
|
+
.filter((f): f is A11yFinding => !!f && typeof (f as A11yFinding).id === 'string')
|
|
52
|
+
.map((f) => ({ id: f.id, impact: f.impact ?? null, help: String(f.help ?? '').slice(0, 200), nodes: Number(f.nodes) || 0, target: f.target ? String(f.target).slice(0, 200) : null }))
|
|
53
|
+
.sort((a, b) => (rank[a.impact ?? 'minor'] ?? 4) - (rank[b.impact ?? 'minor'] ?? 4))
|
|
54
|
+
return { violations: findings.filter((f) => f.impact === 'serious' || f.impact === 'critical').length, findings: findings.slice(0, 20) }
|
|
55
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's accessibility tree as the coding agent reads it, in the shape
|
|
3
|
+
* Playwright MCP's snapshot uses: one line per meaningful node, indented by
|
|
4
|
+
* depth, with a ref for each element the agent can click or type into.
|
|
5
|
+
* Built from Chrome's `Accessibility.getFullAXTree`.
|
|
6
|
+
*
|
|
7
|
+
* Shared by both browsers the coding agent drives: Forge's (which re-exports
|
|
8
|
+
* this module) and the headless one `frontera mcp` runs without Forge, so a
|
|
9
|
+
* snapshot and its refs read the same in either.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AxValue {
|
|
13
|
+
type?: string
|
|
14
|
+
value?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AxNode {
|
|
18
|
+
nodeId: string
|
|
19
|
+
ignored?: boolean
|
|
20
|
+
role?: AxValue
|
|
21
|
+
name?: AxValue
|
|
22
|
+
value?: AxValue
|
|
23
|
+
description?: AxValue
|
|
24
|
+
properties?: Array<{ name: string; value: AxValue }>
|
|
25
|
+
childIds?: string[]
|
|
26
|
+
parentId?: string
|
|
27
|
+
backendDOMNodeId?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Roles that only group: their children are shown in their place. */
|
|
31
|
+
const TRANSPARENT = new Set(['generic', 'none', 'presentation', 'InlineTextBox', 'LineBreak', 'RootWebArea', 'WebArea', 'Section', 'group'])
|
|
32
|
+
|
|
33
|
+
/** Roles an agent acts on; they get a ref. */
|
|
34
|
+
const ACTIONABLE = new Set([
|
|
35
|
+
'button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton',
|
|
36
|
+
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'tab', 'treeitem', 'listbox', 'gridcell', 'row', 'columnheader',
|
|
37
|
+
'textarea', 'select', 'summary', 'disclosure triangle',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const SHOWN_PROPERTIES = ['level', 'checked', 'pressed', 'expanded', 'selected', 'disabled', 'required', 'invalid', 'readonly'] as const
|
|
41
|
+
|
|
42
|
+
const MAX_LINES = 1_200
|
|
43
|
+
|
|
44
|
+
function text(v: AxValue | undefined): string {
|
|
45
|
+
if (v?.value === undefined || v.value === null) return ''
|
|
46
|
+
return String(v.value).replace(/\s+/g, ' ').trim()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A frame's tree read into the page's (an App embedded in Studio): shown under
|
|
51
|
+
* its <iframe> element, keyed there by that element's accessibility node id.
|
|
52
|
+
*/
|
|
53
|
+
export interface FrameContent {
|
|
54
|
+
nodes: AxNode[]
|
|
55
|
+
/** The frame's address, without its query. */
|
|
56
|
+
url: string
|
|
57
|
+
/** The number an element of this frame is named by (its handle), from its DOM node. */
|
|
58
|
+
handleOf: (backendDOMNodeId: number) => number
|
|
59
|
+
frames?: Map<string, FrameContent | { error: string }>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Snapshot {
|
|
63
|
+
/** The tree as text. */
|
|
64
|
+
text: string
|
|
65
|
+
/** Each ref's DOM node (a handle, for an element in a frame), for the actions. */
|
|
66
|
+
refs: Map<string, number>
|
|
67
|
+
/** Each ref's role and name, to find the element again after the page changes. */
|
|
68
|
+
labels: Map<string, { role: string; name: string }>
|
|
69
|
+
truncated: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `refFor` keeps refs stable: the tab hands out one ref per DOM node for as
|
|
74
|
+
* long as the document lives, so e1 stays the same element across snapshots
|
|
75
|
+
* and actions until the page navigates. Without it, refs count from e1.
|
|
76
|
+
*/
|
|
77
|
+
export function formatSnapshot(
|
|
78
|
+
nodes: AxNode[],
|
|
79
|
+
opts: { url: string; title: string; refPrefix?: string; refFor?: (backendDOMNodeId: number) => string; frames?: Map<string, FrameContent | { error: string }> },
|
|
80
|
+
): Snapshot {
|
|
81
|
+
const refs = new Map<string, number>()
|
|
82
|
+
const labels = new Map<string, { role: string; name: string }>()
|
|
83
|
+
const lines: string[] = [`- Page URL: ${opts.url}`, `- Page title: ${opts.title}`, '- Snapshot:']
|
|
84
|
+
let counter = 0
|
|
85
|
+
let truncated = false
|
|
86
|
+
|
|
87
|
+
interface Tree {
|
|
88
|
+
byId: Map<string, AxNode>
|
|
89
|
+
handleOf: (backendDOMNodeId: number) => number
|
|
90
|
+
frames: Map<string, FrameContent | { error: string }> | undefined
|
|
91
|
+
}
|
|
92
|
+
const treeOf = (ns: AxNode[], handleOf: Tree['handleOf'], frames: Tree['frames']): { tree: Tree; root: AxNode | undefined } => {
|
|
93
|
+
const byId = new Map(ns.map((n) => [n.nodeId, n]))
|
|
94
|
+
return { tree: { byId, handleOf, frames }, root: ns.find((n) => !n.parentId || !byId.has(n.parentId)) ?? ns[0] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const visit = (tree: Tree, node: AxNode, depth: number, parentName: string) => {
|
|
98
|
+
if (lines.length >= MAX_LINES) {
|
|
99
|
+
truncated = true
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const role = text(node.role)
|
|
103
|
+
const name = text(node.name)
|
|
104
|
+
const kids = (node.childIds ?? []).map((id) => tree.byId.get(id)).filter((n): n is AxNode => !!n)
|
|
105
|
+
// Text that only repeats its parent's name adds nothing.
|
|
106
|
+
if (role === 'StaticText') {
|
|
107
|
+
if (name && name !== parentName) lines.push(`${' '.repeat(depth)}- text: ${JSON.stringify(name)}`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (node.ignored || TRANSPARENT.has(role) || !role) {
|
|
111
|
+
for (const k of kids) visit(tree, k, depth, parentName)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const props = (node.properties ?? [])
|
|
115
|
+
.filter((p) => (SHOWN_PROPERTIES as readonly string[]).includes(p.name) && p.value.value !== false && p.value.value !== 'false' && p.value.value !== undefined)
|
|
116
|
+
.map((p) => (p.value.value === true || p.value.value === 'true' ? p.name : `${p.name}=${String(p.value.value)}`))
|
|
117
|
+
let ref = ''
|
|
118
|
+
if (node.backendDOMNodeId !== undefined && (ACTIONABLE.has(role) || props.includes('focusable'))) {
|
|
119
|
+
const handle = tree.handleOf(node.backendDOMNodeId)
|
|
120
|
+
const id = opts.refFor ? opts.refFor(handle) : `${opts.refPrefix ?? 'e'}${++counter}`
|
|
121
|
+
refs.set(id, handle)
|
|
122
|
+
labels.set(id, { role, name })
|
|
123
|
+
ref = id
|
|
124
|
+
}
|
|
125
|
+
const value = text(node.value)
|
|
126
|
+
const frame = tree.frames?.get(node.nodeId)
|
|
127
|
+
const bits = [`- ${role}`, name ? JSON.stringify(name) : '', ...props.map((p) => `[${p}]`), ref ? `[ref=${ref}]` : '', frame && 'url' in frame ? `[frame=${frame.url}]` : ''].filter(Boolean).join(' ')
|
|
128
|
+
lines.push(`${' '.repeat(depth)}${bits}${value && value !== name ? `: ${JSON.stringify(value.slice(0, 200))}` : ''}`)
|
|
129
|
+
for (const k of kids) visit(tree, k, depth + 1, name)
|
|
130
|
+
// An embedded frame (an App in Studio): its page, under its <iframe>, with refs that act inside it.
|
|
131
|
+
if (frame && 'error' in frame) lines.push(`${' '.repeat(depth + 1)}- text: ${JSON.stringify(`(the frame could not be read: ${frame.error})`)}`)
|
|
132
|
+
else if (frame) {
|
|
133
|
+
const inner = treeOf(frame.nodes, frame.handleOf, frame.frames)
|
|
134
|
+
if (inner.root) visit(inner.tree, inner.root, depth + 1, name)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const top = treeOf(nodes, (n) => n, opts.frames)
|
|
139
|
+
if (top.root) visit(top.tree, top.root, 0, '')
|
|
140
|
+
if (truncated) lines.push(`- … the page has more; the snapshot stops at ${MAX_LINES} lines`)
|
|
141
|
+
return { text: lines.join('\n'), refs, labels, truncated }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** After the page changed, the ref in a new snapshot for the same element: same role and name, the first one. */
|
|
145
|
+
export function findAgain(label: { role: string; name: string }, snapshot: Snapshot): string | null {
|
|
146
|
+
for (const [ref, l] of snapshot.labels) if (l.role === label.role && l.name === label.name) return ref
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One document's refs: a DOM node keeps the ref it first got for as long as
|
|
152
|
+
* the document lives, so a ref from any snapshot of the page still names the
|
|
153
|
+
* same element after the agent's actions. Cleared when the tab navigates to
|
|
154
|
+
* another document (the nodes are gone with it).
|
|
155
|
+
*/
|
|
156
|
+
export class RefBook {
|
|
157
|
+
private byNode = new Map<number, string>()
|
|
158
|
+
private byRef = new Map<string, number>()
|
|
159
|
+
private counter = 0
|
|
160
|
+
|
|
161
|
+
constructor(private readonly prefix = 'e') {}
|
|
162
|
+
|
|
163
|
+
readonly refFor = (backendDOMNodeId: number): string => {
|
|
164
|
+
const known = this.byNode.get(backendDOMNodeId)
|
|
165
|
+
if (known) return known
|
|
166
|
+
const ref = `${this.prefix}${++this.counter}`
|
|
167
|
+
this.byNode.set(backendDOMNodeId, ref)
|
|
168
|
+
this.byRef.set(ref, backendDOMNodeId)
|
|
169
|
+
return ref
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
node(ref: string): number | undefined {
|
|
173
|
+
return this.byRef.get(ref)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
reset(): void {
|
|
177
|
+
this.byNode.clear()
|
|
178
|
+
this.byRef.clear()
|
|
179
|
+
this.counter = 0
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials that ride in a local address, which the coding agent's browser
|
|
3
|
+
* keeps out of everything it reports (tab addresses, the network list, the
|
|
4
|
+
* console, snapshots, script results, render observations), in Forge's
|
|
5
|
+
* Browser and the headless one alike:
|
|
6
|
+
*
|
|
7
|
+
* - Studio's sign-in link, `?token=`: swapped for a cookie, then redirected away from.
|
|
8
|
+
* - An App's launch token, `?t=` on the App's frame address: a signed asset
|
|
9
|
+
* token (its app, user and version, then a signature) that loads that App's
|
|
10
|
+
* build as that person until it expires. The App's first page hands it to
|
|
11
|
+
* its worker and drops it from the address, but the first request carries it.
|
|
12
|
+
* - The usual names an address carries a credential under (`access_token`,
|
|
13
|
+
* `code`, `session`, `handoff`…), in the query or the fragment.
|
|
14
|
+
*
|
|
15
|
+
* Only on this machine's addresses: a site elsewhere is the person's own
|
|
16
|
+
* business, and its parameters are not this project's credentials.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What a credential is shown as instead. */
|
|
20
|
+
export const STUDIO_SESSION = '[studio-session]'
|
|
21
|
+
export const APP_LAUNCH_TOKEN = '[app-launch-token]'
|
|
22
|
+
export const ADDRESS_CREDENTIAL = '[credential]'
|
|
23
|
+
|
|
24
|
+
const NAMED = ['access_token', 'id_token', 'refresh_token', 'code', 'session', 'session_token', 'handoff', 'auth', 'key', 'api_key'] as const
|
|
25
|
+
|
|
26
|
+
/** A signed token's shape (`<base64url body>.<base64url signature>`), so `t` as a cache-busting timestamp (Vite's HMR) is not taken for one. */
|
|
27
|
+
const SIGNED = /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}$/
|
|
28
|
+
|
|
29
|
+
/** A loopback address: where the project's own pages live. */
|
|
30
|
+
function local(url: URL): boolean {
|
|
31
|
+
const h = url.hostname
|
|
32
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The credentials an address carries, each with what it is shown as instead; none for an address elsewhere. */
|
|
36
|
+
export function credentialsIn(address: string): Array<{ secret: string; label: string }> {
|
|
37
|
+
let url: URL
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(address)
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
if (!local(url)) return []
|
|
44
|
+
const out: Array<{ secret: string; label: string }> = []
|
|
45
|
+
const lists = [url.searchParams]
|
|
46
|
+
if (url.hash.length > 1) lists.push(new URLSearchParams(url.hash.slice(1)))
|
|
47
|
+
for (const params of lists) {
|
|
48
|
+
const token = params.get('token')
|
|
49
|
+
if (token) out.push({ secret: token, label: STUDIO_SESSION })
|
|
50
|
+
const launch = params.get('t')
|
|
51
|
+
if (launch && SIGNED.test(launch)) out.push({ secret: launch, label: APP_LAUNCH_TOKEN })
|
|
52
|
+
for (const name of NAMED) {
|
|
53
|
+
const value = params.get(name)
|
|
54
|
+
if (value) out.push({ secret: value, label: ADDRESS_CREDENTIAL })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Too short to be a secret, and too likely to match ordinary words.
|
|
58
|
+
return out.filter((c) => c.secret.length >= 8)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Text with each secret blanked out, as typed and as an address encodes it. */
|
|
62
|
+
export function redactWith(text: string, secrets: ReadonlyMap<string, string>): string {
|
|
63
|
+
let out = text
|
|
64
|
+
for (const [secret, label] of secrets) out = out.split(secret).join(label).split(encodeURIComponent(secret)).join(label)
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every string in a value with the secrets blanked out. */
|
|
69
|
+
export function redactDeep<T>(value: T, secrets: ReadonlyMap<string, string>): T {
|
|
70
|
+
if (!secrets.size) return value
|
|
71
|
+
const walk = (v: unknown): unknown => {
|
|
72
|
+
if (typeof v === 'string') return redactWith(v, secrets)
|
|
73
|
+
if (Array.isArray(v)) return v.map(walk)
|
|
74
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x)]))
|
|
75
|
+
return v
|
|
76
|
+
}
|
|
77
|
+
return walk(value) as T
|
|
78
|
+
}
|