@wenathlan/devthink 2.0.13 → 2.0.15
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/CHANGELOG.md +27 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/cli.ts
ADDED
|
@@ -0,0 +1,3500 @@
|
|
|
1
|
+
/** The cli module of the 1.1.88 consolidation: the terminal entry and the cli tools interned in this one file, so the cli surface carries one module without duplicate variations. */
|
|
2
|
+
|
|
3
|
+
/* ── Merged from cli/devthink.ts: the terminal entry of the cli. ── */
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
formatdiagnostics,
|
|
10
|
+
lintplanfile,
|
|
11
|
+
parseplanfile,
|
|
12
|
+
planlintsummary,
|
|
13
|
+
rulesetcachekey,
|
|
14
|
+
plansteprisk,
|
|
15
|
+
} from "./plan.js";
|
|
16
|
+
import {
|
|
17
|
+
dryflowdriver,
|
|
18
|
+
flowrunreport,
|
|
19
|
+
parseflowrunrequest,
|
|
20
|
+
parsegrantsfile,
|
|
21
|
+
renderflowevents,
|
|
22
|
+
renderflowtimeline,
|
|
23
|
+
runflow,
|
|
24
|
+
} from "./flow.js";
|
|
25
|
+
import type { flowdriver } from "./flow.js";
|
|
26
|
+
import {
|
|
27
|
+
exportchain,
|
|
28
|
+
exportdescriptorof,
|
|
29
|
+
exportextractions,
|
|
30
|
+
exportnotes,
|
|
31
|
+
exportrecords,
|
|
32
|
+
secretfieldshapes,
|
|
33
|
+
} from "./export.js";
|
|
34
|
+
import { openheadlesssession, openlibraryrun, remoteattachframes } from "./headless.js";
|
|
35
|
+
import {
|
|
36
|
+
dryrunworkflow,
|
|
37
|
+
newworkflowrun,
|
|
38
|
+
runworkflow,
|
|
39
|
+
composeworkflow,
|
|
40
|
+
workflowblockof,
|
|
41
|
+
workflowstepof,
|
|
42
|
+
} from "./workflow.js";
|
|
43
|
+
import { platformtargets } from "./runtime.js";
|
|
44
|
+
import {
|
|
45
|
+
adapterdeclarationsof,
|
|
46
|
+
capabilityprobeof,
|
|
47
|
+
filesystemstorageadapter,
|
|
48
|
+
portablecapabilityset,
|
|
49
|
+
} from "./runtime.js";
|
|
50
|
+
import {
|
|
51
|
+
actionkindcatalog,
|
|
52
|
+
actionrisk,
|
|
53
|
+
dryrunprojection,
|
|
54
|
+
portablerulesetof,
|
|
55
|
+
mcpmodegate,
|
|
56
|
+
nativeinstallconsentgate,
|
|
57
|
+
cssselectorvalid,
|
|
58
|
+
fixtureconsentgate,
|
|
59
|
+
kindoptionfields,
|
|
60
|
+
validatetargetref,
|
|
61
|
+
} from "./policy.js";
|
|
62
|
+
import { clicommands, surfacepalette } from "./views.js";
|
|
63
|
+
import {
|
|
64
|
+
apifreezedate,
|
|
65
|
+
apifreezerelease,
|
|
66
|
+
capmanifestof,
|
|
67
|
+
deprecationwindow,
|
|
68
|
+
protocolsupported,
|
|
69
|
+
sharedprotocolversion,
|
|
70
|
+
type capmanifestsurface,
|
|
71
|
+
} from "./apifreeze.js";
|
|
72
|
+
import { toolcallframe } from "./protocol.js";
|
|
73
|
+
import { defaultmaskshapes, maskmarker } from "./security.js";
|
|
74
|
+
import { sessionmemory } from "./memory.js";
|
|
75
|
+
import { acquirerunlock, releaserunlock } from "./run.js";
|
|
76
|
+
import { exportlogchain } from "./security.js";
|
|
77
|
+
import { packageversion } from "./version.js";
|
|
78
|
+
import {
|
|
79
|
+
createservesession,
|
|
80
|
+
routeserveframe,
|
|
81
|
+
shutdowndrain,
|
|
82
|
+
startserve,
|
|
83
|
+
transportendpoints,
|
|
84
|
+
defaultmcpport,
|
|
85
|
+
localhostbind,
|
|
86
|
+
} from "./mcp.js";
|
|
87
|
+
import { createlinepump, httpanswer, httpendpoint, parsepost, routepath } from "./http.js";
|
|
88
|
+
import {
|
|
89
|
+
hostmanifestdestination,
|
|
90
|
+
installnativehost,
|
|
91
|
+
nativedefaultstate,
|
|
92
|
+
nativediagnostics,
|
|
93
|
+
nativehostmanifesttemplate,
|
|
94
|
+
uninstallnativehost,
|
|
95
|
+
} from "./bridge.js";
|
|
96
|
+
import type {
|
|
97
|
+
auditevent,
|
|
98
|
+
cliconfiguration,
|
|
99
|
+
clientrecord,
|
|
100
|
+
consentprovider,
|
|
101
|
+
headlessfixture,
|
|
102
|
+
immutablelogentry,
|
|
103
|
+
mcpserverconfig,
|
|
104
|
+
planfile,
|
|
105
|
+
planfilestep,
|
|
106
|
+
planlintdiagnostic,
|
|
107
|
+
portableruleset,
|
|
108
|
+
runlogentry,
|
|
109
|
+
sitenote,
|
|
110
|
+
stepoutcome,
|
|
111
|
+
storedrunlog,
|
|
112
|
+
toolmock,
|
|
113
|
+
transportkind,
|
|
114
|
+
variablescope,
|
|
115
|
+
workflowstep,
|
|
116
|
+
exportdescriptor,
|
|
117
|
+
exportresult,
|
|
118
|
+
headlessfixtureoutcome,
|
|
119
|
+
manifestcheckfinding,
|
|
120
|
+
planprogress,
|
|
121
|
+
planrisksummary,
|
|
122
|
+
runworkflowoutcome,
|
|
123
|
+
workflowdocument,
|
|
124
|
+
workflowrecord,
|
|
125
|
+
workflowrun,
|
|
126
|
+
} from "./types.js";
|
|
127
|
+
|
|
128
|
+
const forbidden = new Set([
|
|
129
|
+
"debugger",
|
|
130
|
+
"cookies",
|
|
131
|
+
"webRequest",
|
|
132
|
+
"history",
|
|
133
|
+
"bookmarks",
|
|
134
|
+
"nativeMessaging",
|
|
135
|
+
"proxy",
|
|
136
|
+
"management",
|
|
137
|
+
]);
|
|
138
|
+
const allowedoptional = new Set([
|
|
139
|
+
"tabs",
|
|
140
|
+
"downloads",
|
|
141
|
+
"clipboardRead",
|
|
142
|
+
"clipboardWrite",
|
|
143
|
+
"offscreen",
|
|
144
|
+
"nativeMessaging",
|
|
145
|
+
]);
|
|
146
|
+
/** The extension surfaces that must never become sandbox pages because they hold extension privileges. */
|
|
147
|
+
const privilegedpages = new Set(["background.html", "popup.html", "sidepanel.html", "offscreen.html"]);
|
|
148
|
+
/** The configuration file location the cli reads when no --config flag rides the invocation; every value inside stays the user's choice. */
|
|
149
|
+
const defaultconfigfile = ".devthink/config.json";
|
|
150
|
+
/** Decodes the manifest identity key strictly so a corrupted key cannot reach the packaged extension. */
|
|
151
|
+
function decodeidentitykey(key: string): Buffer {
|
|
152
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key))
|
|
153
|
+
throw new Error("The manifest key must be strict base64 without whitespace.");
|
|
154
|
+
if (key.length % 4 !== 0)
|
|
155
|
+
throw new Error(`The manifest key length ${key.length} is not a multiple of four, so it is not valid base64.`);
|
|
156
|
+
const der = Buffer.from(key, "base64");
|
|
157
|
+
if (der.length < 150 || der.length > 600)
|
|
158
|
+
throw new Error(`The manifest key decodes to ${der.length} bytes, outside the RSA subject public key range.`);
|
|
159
|
+
if (!der.subarray(0, 2).equals(Buffer.from([0x30, 0x82])))
|
|
160
|
+
throw new Error("The manifest key does not decode to a DER subject public key sequence.");
|
|
161
|
+
const declared = der.readUInt16BE(2);
|
|
162
|
+
if (declared !== der.length - 4)
|
|
163
|
+
throw new Error(`The manifest key DER length ${declared} does not match the decoded ${der.length - 4} bytes.`);
|
|
164
|
+
return der;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Reads the recorded digest of the published identity key from the build artifact checksums: the build writes the manifest key digest beside the bundle checksums, so the identity pin rides a generated artifact of the shipped package set instead of a hard coded constant — the internal cryptographic digests of the bundles, the packages and the identity never stay baked inside the sources, they are generated by every build and verified against the artifact that shipped them. */
|
|
168
|
+
async function recordedidentitydigest(): Promise<string | undefined> {
|
|
169
|
+
/* the installed package carries its scoped checksums at the package root and the repository build carries the full set under dist: the identity pin rides whichever artifact set the running tree owns, so the digest reads the package copy first and the build copy second */
|
|
170
|
+
for (const candidate of ["checksums.txt", "dist/checksums.txt"]) {
|
|
171
|
+
try {
|
|
172
|
+
const text = await readFile(resolve(candidate), "utf8");
|
|
173
|
+
for (const line of text.split("\n")) {
|
|
174
|
+
const match = /^([0-9a-f]{64}) manifest\.json key$/.exec(line.trim());
|
|
175
|
+
if (match !== null) return match[1];
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
/* an unbuilt tree carries no artifact record: the structural identity checks alone stand, and the build regenerates the record on every run */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Parses loose terminal arguments into positional values and --flags, with --name=value and --name value both accepted. */
|
|
185
|
+
function parseflags(args: string[]): { positional: string[]; flags: Map<string, string> } {
|
|
186
|
+
const positional: string[] = [];
|
|
187
|
+
const flags = new Map<string, string>();
|
|
188
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
189
|
+
const arg = args[index];
|
|
190
|
+
if (arg === undefined) continue;
|
|
191
|
+
if (arg.startsWith("--")) {
|
|
192
|
+
const [name, inline] = arg.slice(2).split("=", 2);
|
|
193
|
+
if (name === undefined || name.trim() === "") throw new Error(`The argument ${arg} carries no flag name.`);
|
|
194
|
+
const next = args[index + 1];
|
|
195
|
+
if (inline !== undefined) flags.set(name, inline);
|
|
196
|
+
else if (next !== undefined && !next.startsWith("--")) {
|
|
197
|
+
flags.set(name, next);
|
|
198
|
+
index += 1;
|
|
199
|
+
} else flags.set(name, "true");
|
|
200
|
+
} else positional.push(arg);
|
|
201
|
+
}
|
|
202
|
+
return { positional, flags };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reads the global invocation state the commands share: the parsed configuration, the verbosity and the json output marker, all resolved before any command dispatches. */
|
|
206
|
+
async function globalstate(
|
|
207
|
+
args: string[],
|
|
208
|
+
): Promise<{
|
|
209
|
+
config: cliconfiguration;
|
|
210
|
+
verbosity: "quiet" | "normal" | "verbose";
|
|
211
|
+
json: boolean;
|
|
212
|
+
flags: Map<string, string>;
|
|
213
|
+
positional: string[];
|
|
214
|
+
}> {
|
|
215
|
+
const { positional, flags } = parseflags(args);
|
|
216
|
+
const configpath = flags.get("config") ?? defaultconfigfile;
|
|
217
|
+
let raw: unknown = {};
|
|
218
|
+
try {
|
|
219
|
+
raw = JSON.parse(await readFile(configpath, "utf8"));
|
|
220
|
+
} catch {
|
|
221
|
+
/* an absent or unreadable configuration keeps every default off; a present but malformed one refuses below */
|
|
222
|
+
}
|
|
223
|
+
const config = parsecliconfig(raw);
|
|
224
|
+
const verbosityflag =
|
|
225
|
+
flags.get("verbose") === "true" ? "verbose" : flags.get("quiet") === "true" ? "quiet" : undefined;
|
|
226
|
+
const verbosity = verbosityflag ?? config.verbosity ?? "normal";
|
|
227
|
+
const json = flags.get("json") === "true" || config.format === "json";
|
|
228
|
+
return { config, verbosity, json, flags, positional };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Asks one terminal question and returns the trimmed answer. */
|
|
232
|
+
async function ask(question: string): Promise<string> {
|
|
233
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
234
|
+
try {
|
|
235
|
+
return (await rl.question(question)).trim();
|
|
236
|
+
} finally {
|
|
237
|
+
rl.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Reads the rule cache of one compiled portable rule set: a matching cache returns the parsed set and a stale or absent cache recompiles and rewrites the file. */
|
|
242
|
+
async function cachedruleset(cachedir: string): Promise<portableruleset> {
|
|
243
|
+
const ruleset = portablerulesetof(Date.now());
|
|
244
|
+
const key = rulesetcachekey(ruleset);
|
|
245
|
+
const cachefile = join(cachedir, `${key}.json`);
|
|
246
|
+
try {
|
|
247
|
+
const cached = JSON.parse(await readFile(cachefile, "utf8")) as portableruleset;
|
|
248
|
+
if (rulesetcachekey(cached) === key) return cached;
|
|
249
|
+
} catch {
|
|
250
|
+
/* a missing or unreadable cache recompiles below */
|
|
251
|
+
}
|
|
252
|
+
await mkdir(cachedir, { recursive: true });
|
|
253
|
+
await writeFile(cachefile, JSON.stringify(ruleset, null, 2), "utf8");
|
|
254
|
+
return ruleset;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Reads the plan file targets of one path: a file lints alone and a directory lints every json file it holds. */
|
|
258
|
+
async function plantargets(target: string): Promise<string[]> {
|
|
259
|
+
const info = await stat(target);
|
|
260
|
+
if (info.isFile()) return [target];
|
|
261
|
+
const entries = await readdir(target);
|
|
262
|
+
return entries.filter((entry) => entry.endsWith(".json")).map((entry) => join(target, entry));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Reads the read only kinds of the reviewed vocabulary: a runtime without a dom executes the read only vocabulary only. */
|
|
266
|
+
function readonlykinds(): string[] {
|
|
267
|
+
return actionkindcatalog().filter((kind) => {
|
|
268
|
+
try {
|
|
269
|
+
return actionrisk(kind as never) === "read";
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Lints plan files against the portable rule set with the 1.1.80 findings beside it: the selector grammar, the required option fields, the origin allowlist and the risk summary ride every lint, the lines format prints one stable line per finding for editors, and error diagnostics exit non zero. */
|
|
277
|
+
async function cmdplanlint(
|
|
278
|
+
args: string[],
|
|
279
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const { positional, flags } = parseflags(args);
|
|
282
|
+
const target = positional[0] ?? state.config.defaultplan;
|
|
283
|
+
if (target === undefined || target.trim() === "")
|
|
284
|
+
throw new Error(
|
|
285
|
+
"The planlint command needs the plan file or directory it lints, or the default plan location in the configuration.",
|
|
286
|
+
);
|
|
287
|
+
const format = flags.get("format") ?? (state.json ? "json" : "human");
|
|
288
|
+
if (format !== "human" && format !== "json" && format !== "lines")
|
|
289
|
+
throw new Error("The planlint format must stay human, json or lines.");
|
|
290
|
+
const ruleset = await cachedruleset(flags.get("cache") ?? ".devthink");
|
|
291
|
+
const capabilities = flags.has("capabilities")
|
|
292
|
+
? (JSON.parse(await readFile(flags.get("capabilities") as string, "utf8")) as string[])
|
|
293
|
+
: portablecapabilityset({
|
|
294
|
+
vocabulary: actionkindcatalog(),
|
|
295
|
+
probes: capabilityprobeof({ dom: flags.get("domless") !== "true", storage: true, network: true, worker: true }),
|
|
296
|
+
domlesskinds: readonlykinds(),
|
|
297
|
+
});
|
|
298
|
+
const allowlist = flags.has("allowlist")
|
|
299
|
+
? (flags.get("allowlist") as string)
|
|
300
|
+
.split(",")
|
|
301
|
+
.map((origin) => origin.trim())
|
|
302
|
+
.filter((origin) => origin !== "")
|
|
303
|
+
: state.config.allowlist;
|
|
304
|
+
const files = await plantargets(target);
|
|
305
|
+
if (files.length === 0) throw new Error(`The target ${target} carries no plan file to lint.`);
|
|
306
|
+
const report: Array<{
|
|
307
|
+
file: string;
|
|
308
|
+
diagnostics: planlintdiagnostic[];
|
|
309
|
+
risk: ReturnType<typeof planrisksummaryof>;
|
|
310
|
+
exitcode: number;
|
|
311
|
+
}> = [];
|
|
312
|
+
let exitcode = 0;
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
let diagnostics: planlintdiagnostic[];
|
|
315
|
+
let plan: planfile;
|
|
316
|
+
try {
|
|
317
|
+
plan = parseplanfile(JSON.parse(await readFile(file, "utf8")));
|
|
318
|
+
diagnostics = [
|
|
319
|
+
...lintplanfile({ file: plan, ruleset, capabilities, now: Date.now() }),
|
|
320
|
+
...planlintfindings({ file: plan, capabilities, ...(allowlist !== undefined ? { allowlist } : {}) }),
|
|
321
|
+
];
|
|
322
|
+
} catch (error) {
|
|
323
|
+
diagnostics = [
|
|
324
|
+
{
|
|
325
|
+
code: "plan.schema.file",
|
|
326
|
+
path: file,
|
|
327
|
+
severity: "error",
|
|
328
|
+
message: error instanceof Error ? error.message : String(error),
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
plan = { version: packageversion, goal: "", origin: "https://invalid.example", steps: [] };
|
|
332
|
+
}
|
|
333
|
+
const summary = planlintsummary(diagnostics);
|
|
334
|
+
if (summary.exitcode !== 0) exitcode = 1;
|
|
335
|
+
report.push({ file, diagnostics, risk: planrisksummaryof(plan), exitcode: summary.exitcode });
|
|
336
|
+
}
|
|
337
|
+
if (state.verbosity !== "quiet" && state.verbosity === "verbose" && format === "human")
|
|
338
|
+
console.log(
|
|
339
|
+
`The planlint ran ${ruleset.rules.length} portable rules over ${files.length} plan file${files.length === 1 ? "" : "s"} with ${capabilities.length} declared capabilities${allowlist !== undefined ? ` and the consent allowlist of ${allowlist.length} origin${allowlist.length === 1 ? "" : "s"}` : ""}.`,
|
|
340
|
+
);
|
|
341
|
+
if (format === "json") console.log(JSON.stringify(report, null, 2));
|
|
342
|
+
else if (format === "lines")
|
|
343
|
+
for (const entry of report)
|
|
344
|
+
for (const diagnostic of entry.diagnostics)
|
|
345
|
+
console.log(
|
|
346
|
+
`${entry.file}:${diagnostic.severity}:${diagnostic.code}:${diagnostic.path}: ${diagnostic.message}`,
|
|
347
|
+
);
|
|
348
|
+
else
|
|
349
|
+
for (const entry of report) {
|
|
350
|
+
console.log(`== ${entry.file}\n${formatdiagnostics(entry.diagnostics, "human")}`);
|
|
351
|
+
if (state.verbosity !== "quiet") console.log(`risk: ${entry.risk.reason}`);
|
|
352
|
+
}
|
|
353
|
+
process.exitCode = exitcode;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Converts one foreign plan source into the reviewed plan file grammar: the migrateplan command of the 2.0.0 migration bridge reads the source file in its --format dialect (a version one devthink plan, an automa workflow, a selenium side file, a ui vision macro or a tabular csv), refuses every entry the importer cannot map with the source entry named, verifies the converted plan against the same frozen grammar and the same lint engine planlint runs before anything is written, prints the conversion report with its provenance on stderr because the plan file grammar carries no metadata field, and writes the --out file or prints the plan json to stdout. The importer converts only: nothing executes here, and every imported plan enters the same review flow a hand authored plan enters — no imported plan ever inherits a consent grant. */
|
|
357
|
+
async function cmdmigrateplan(
|
|
358
|
+
args: string[],
|
|
359
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const { positional, flags } = parseflags(args);
|
|
362
|
+
const source = positional[0];
|
|
363
|
+
if (source === undefined || source.trim() === "")
|
|
364
|
+
throw new Error("The migrateplan command needs the source file it converts.");
|
|
365
|
+
const format = flags.get("format") ?? "";
|
|
366
|
+
if (format.trim() === "")
|
|
367
|
+
throw new Error(
|
|
368
|
+
"The migrateplan command needs the --format flag of v1, automa, selenium, uivision or tabular; the converter never guesses the source format.",
|
|
369
|
+
);
|
|
370
|
+
const text = await readFile(source, "utf8");
|
|
371
|
+
const conversion = migrateplanconversion({ format, text, now: Date.now() });
|
|
372
|
+
const serialized = JSON.stringify(conversion.file, null, 2);
|
|
373
|
+
const parsed = parseplanfile(
|
|
374
|
+
JSON.parse(serialized),
|
|
375
|
+
); /* the frozen grammar verifies the exact bytes the command writes */
|
|
376
|
+
const ruleset = await cachedruleset(flags.get("cache") ?? ".devthink");
|
|
377
|
+
const capabilities = flags.has("capabilities")
|
|
378
|
+
? (JSON.parse(await readFile(flags.get("capabilities") as string, "utf8")) as string[])
|
|
379
|
+
: portablecapabilityset({
|
|
380
|
+
vocabulary: actionkindcatalog(),
|
|
381
|
+
probes: capabilityprobeof({ dom: flags.get("domless") !== "true", storage: true, network: true, worker: true }),
|
|
382
|
+
domlesskinds: readonlykinds(),
|
|
383
|
+
});
|
|
384
|
+
const diagnostics = [
|
|
385
|
+
...lintplanfile({ file: parsed, ruleset, capabilities, now: Date.now() }),
|
|
386
|
+
...planlintfindings({ file: parsed, capabilities }),
|
|
387
|
+
];
|
|
388
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
389
|
+
if (errors.length > 0) {
|
|
390
|
+
console.error(formatdiagnostics(errors, "human"));
|
|
391
|
+
console.error(
|
|
392
|
+
"The migrateplan command refuses to emit a converted plan that fails its own lint; the importers mark every entry they cannot map, so this refusal names a conversion gap the importer should have caught.",
|
|
393
|
+
);
|
|
394
|
+
process.exitCode = exitcodeof("schemaerror");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const out = flags.get("out");
|
|
398
|
+
if (out !== undefined) {
|
|
399
|
+
await mkdir(resolve(out, ".."), { recursive: true });
|
|
400
|
+
await writeFile(out, serialized + "\n", "utf8");
|
|
401
|
+
if (state.verbosity !== "quiet") console.error(`The migrateplan command wrote the converted plan to ${out}.`);
|
|
402
|
+
} else {
|
|
403
|
+
console.log(serialized);
|
|
404
|
+
}
|
|
405
|
+
for (const note of conversion.notes) console.error(note);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Lists the example gallery of the 2.0.0 platform release and validates one entry: the recipes command reads the gallery index the packages ship (gallery.json at the installed package root, the dist build copy second, the repository source fallback tests/code/recipes/gallery.json last), prints every entry with its category, difficulty, fixture page and description, and validates one named entry by loading its plan through the same frozen grammar and the same lint engine the planlint command runs. The --dryrun flag walks the entry through the same dry flow driver the extension runs with a recording consent provider, so a gallery recipe imports, lints and dry runs through the terminal exactly as the ci runner walks it — the gallery never drifts from the code. */
|
|
409
|
+
async function cmdrecipes(
|
|
410
|
+
args: string[],
|
|
411
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
const { positional, flags } = parseflags(args);
|
|
414
|
+
const candidates = ["gallery.json", "dist/gallery.json", "tests/code/recipes/gallery.json"];
|
|
415
|
+
let gallery:
|
|
416
|
+
| {
|
|
417
|
+
entries: Array<{
|
|
418
|
+
id: string;
|
|
419
|
+
category: string;
|
|
420
|
+
difficulty: string;
|
|
421
|
+
description: string;
|
|
422
|
+
fixture: string;
|
|
423
|
+
origin: string;
|
|
424
|
+
expecteddurationms: number;
|
|
425
|
+
}>;
|
|
426
|
+
fixturepages: Array<{ name: string }>;
|
|
427
|
+
}
|
|
428
|
+
| undefined;
|
|
429
|
+
let gallerypath = "";
|
|
430
|
+
for (const candidate of candidates) {
|
|
431
|
+
try {
|
|
432
|
+
gallery = JSON.parse(await readFile(candidate, "utf8"));
|
|
433
|
+
gallerypath = candidate;
|
|
434
|
+
break;
|
|
435
|
+
} catch {
|
|
436
|
+
/* the built index first, the repository source second, and neither found refuses below */
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (gallery === undefined)
|
|
440
|
+
throw new Error(
|
|
441
|
+
"The recipes command needs the gallery index the installed package ships (gallery.json at the package root), the build copy (dist/gallery.json) or the repository source (tests/code/recipes/gallery.json); run from the package root or run pnpm build first.",
|
|
442
|
+
);
|
|
443
|
+
const id = positional[0];
|
|
444
|
+
if (id === undefined) {
|
|
445
|
+
if (state.json || flags.get("format") === "json") {
|
|
446
|
+
console.log(JSON.stringify(gallery, null, 2));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
for (const entry of gallery.entries)
|
|
450
|
+
console.log(
|
|
451
|
+
`${entry.id.padEnd(24)} ${entry.category.padEnd(11)} ${entry.difficulty.padEnd(12)} ${entry.fixture.padEnd(17)} ${entry.description}`,
|
|
452
|
+
);
|
|
453
|
+
console.log(
|
|
454
|
+
`The gallery carries ${gallery.entries.length} recipes over ${gallery.fixturepages.length} fixture pages; run devthink recipes <id> for one entry, with --dryrun to walk its flow.`,
|
|
455
|
+
);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const entry = gallery.entries.find((candidate) => candidate.id === id);
|
|
459
|
+
if (entry === undefined) throw new Error(`The gallery carries no entry ${id}; run devthink recipes for the list.`);
|
|
460
|
+
const recipecandidates = [
|
|
461
|
+
gallerypath === "gallery.json"
|
|
462
|
+
? `fixtures/recipes/${id}.json`
|
|
463
|
+
: gallerypath.startsWith("dist/")
|
|
464
|
+
? `dist/fixtures/recipes/${id}.json`
|
|
465
|
+
: `tests/code/recipes/${id}.json`,
|
|
466
|
+
`fixtures/recipes/${id}.json`,
|
|
467
|
+
`dist/fixtures/recipes/${id}.json`,
|
|
468
|
+
];
|
|
469
|
+
let recipetext: string | undefined;
|
|
470
|
+
for (const candidate of recipecandidates) {
|
|
471
|
+
try {
|
|
472
|
+
recipetext = await readFile(candidate, "utf8");
|
|
473
|
+
break;
|
|
474
|
+
} catch {
|
|
475
|
+
/* the index sibling first, the built copy second */
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (recipetext === undefined)
|
|
479
|
+
throw new Error(
|
|
480
|
+
`The recipe file of ${id} sits beside neither the gallery index nor the shipped fixture set; run from the package root or run pnpm build first.`,
|
|
481
|
+
);
|
|
482
|
+
const file = parseplanfile(JSON.parse(recipetext));
|
|
483
|
+
const ruleset = await cachedruleset(flags.get("cache") ?? ".devthink");
|
|
484
|
+
const capabilities = portablecapabilityset({
|
|
485
|
+
vocabulary: actionkindcatalog(),
|
|
486
|
+
probes: capabilityprobeof({ dom: flags.get("domless") !== "true", storage: true, network: true, worker: true }),
|
|
487
|
+
domlesskinds: readonlykinds(),
|
|
488
|
+
});
|
|
489
|
+
const diagnostics = [
|
|
490
|
+
...lintplanfile({ file, ruleset, capabilities, now: Date.now() }),
|
|
491
|
+
...planlintfindings({ file, capabilities }),
|
|
492
|
+
];
|
|
493
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
494
|
+
if (state.json || flags.get("format") === "json") {
|
|
495
|
+
console.log(
|
|
496
|
+
JSON.stringify(
|
|
497
|
+
{ entry, plan: file, diagnostics, ...(flags.get("dryrun") === "true" ? { dryrun: "requested" } : {}) },
|
|
498
|
+
null,
|
|
499
|
+
2,
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
console.log(`${entry.id} — ${entry.category} — ${entry.difficulty} — fixture ${entry.fixture} of ${entry.origin}`);
|
|
504
|
+
console.log(entry.description);
|
|
505
|
+
console.log(
|
|
506
|
+
`The plan carries ${file.steps.length} step${file.steps.length === 1 ? "" : "s"} over the origin ${file.origin}.`,
|
|
507
|
+
);
|
|
508
|
+
if (diagnostics.length > 0) console.log(formatdiagnostics(diagnostics, "human"));
|
|
509
|
+
else console.log("No planlint diagnostics.");
|
|
510
|
+
}
|
|
511
|
+
if (errors.length > 0) {
|
|
512
|
+
process.exitCode = exitcodeof("schemaerror");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (flags.get("dryrun") === "true") {
|
|
516
|
+
const resolvedgates: string[] = [];
|
|
517
|
+
const provider: consentprovider = {
|
|
518
|
+
async resolvegate(gate) {
|
|
519
|
+
resolvedgates.push(gate.id);
|
|
520
|
+
return "approve";
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
const planpath = recipecandidates[0] ?? `dist/fixtures/recipes/${id}.json`;
|
|
524
|
+
const run = await runflow({
|
|
525
|
+
request: {
|
|
526
|
+
planpath,
|
|
527
|
+
options: {
|
|
528
|
+
format: "human",
|
|
529
|
+
outputdir: "tests/artifacts",
|
|
530
|
+
interactive: false,
|
|
531
|
+
dryrun: true,
|
|
532
|
+
grantspath: "gallery",
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
file,
|
|
536
|
+
grants: [entry.origin],
|
|
537
|
+
provider,
|
|
538
|
+
driver: dryflowdriver(),
|
|
539
|
+
now: Date.now(),
|
|
540
|
+
clock: () => Date.now(),
|
|
541
|
+
});
|
|
542
|
+
console.log(
|
|
543
|
+
`The dry run walked ${run.outcome.steps} of ${file.steps.length} steps, waited at ${resolvedgates.length} consent gate${resolvedgates.length === 1 ? "" : "s"} and ended ${run.outcome.state} with exit code ${run.outcome.exitcode}.`,
|
|
544
|
+
);
|
|
545
|
+
process.exitCode = run.outcome.exitcode;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Builds the terminal consent provider: every gate wait prints its reason and asks the human for one approve or refuse answer. */
|
|
550
|
+
function terminalprovider(): consentprovider {
|
|
551
|
+
return {
|
|
552
|
+
async resolvegate(gate) {
|
|
553
|
+
const answer = await ask(
|
|
554
|
+
`${gate.reason}\nApprove the gate ${gate.id} of kind ${gate.kind} on ${gate.origin}? [y/N] `,
|
|
555
|
+
);
|
|
556
|
+
return answer.toLowerCase() === "y" ? "approve" : "refuse";
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Attaches one remote driver through the same agent protocol frames the extension speaks, then executes every step as one tools/call frame. */
|
|
562
|
+
async function attachremotedriver(endpoint: string, origin: string): Promise<flowdriver> {
|
|
563
|
+
const session = openheadlesssession({ origin, now: Date.now() });
|
|
564
|
+
const frames = remoteattachframes({ id: 1, session, runtime: "node" });
|
|
565
|
+
const attach = await fetch(endpoint, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { "content-type": "application/json" },
|
|
568
|
+
body: JSON.stringify(frames.initialize),
|
|
569
|
+
});
|
|
570
|
+
const attached = (await attach.json()) as { result?: { attached?: boolean }; error?: { message?: string } };
|
|
571
|
+
if (attached.error !== undefined || attached.result?.attached !== true)
|
|
572
|
+
throw new Error(
|
|
573
|
+
`The remote session at ${endpoint} refused the attach: ${attached.error?.message ?? "the endpoint returned no attached result."}`,
|
|
574
|
+
);
|
|
575
|
+
let sequence = 1;
|
|
576
|
+
return {
|
|
577
|
+
async execute(step) {
|
|
578
|
+
sequence += 1;
|
|
579
|
+
const started = Date.now();
|
|
580
|
+
const frame = toolcallframe({
|
|
581
|
+
id: sequence,
|
|
582
|
+
name: `browser.${step.kind}`,
|
|
583
|
+
params: {
|
|
584
|
+
id: step.id,
|
|
585
|
+
kind: step.kind,
|
|
586
|
+
label: step.label,
|
|
587
|
+
...(step.target !== undefined ? { target: step.target } : {}),
|
|
588
|
+
...(step.value !== undefined ? { value: step.value } : {}),
|
|
589
|
+
...(step.options !== undefined ? { options: step.options } : {}),
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const response = await fetch(endpoint, {
|
|
593
|
+
method: "POST",
|
|
594
|
+
headers: { "content-type": "application/json" },
|
|
595
|
+
body: JSON.stringify(frame),
|
|
596
|
+
});
|
|
597
|
+
const body = (await response.json()) as { result?: unknown; error?: { message?: string } };
|
|
598
|
+
const duration = Date.now() - started;
|
|
599
|
+
if (body.error !== undefined)
|
|
600
|
+
return {
|
|
601
|
+
state: "failed",
|
|
602
|
+
summary: `The remote session refused the step ${step.id}: ${body.error.message ?? "the endpoint returned an error."}`,
|
|
603
|
+
duration,
|
|
604
|
+
};
|
|
605
|
+
return {
|
|
606
|
+
state: "done",
|
|
607
|
+
summary: `The remote session executed the step ${step.id} of kind ${step.kind}.`,
|
|
608
|
+
duration,
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Runs one plan file from the terminal: the grants gate, the consent prompts, the driver, the sealed chain in the output directory and the exit code all ride the same pipeline the extension runs. */
|
|
615
|
+
async function cmdflowrun(args: string[]): Promise<void> {
|
|
616
|
+
const request = parseflowrunrequest(args);
|
|
617
|
+
const { flags } = parseflags(args);
|
|
618
|
+
const file = parseplanfile(JSON.parse(await readFile(request.planpath, "utf8")));
|
|
619
|
+
const grants =
|
|
620
|
+
request.options.grantspath !== undefined
|
|
621
|
+
? parsegrantsfile(JSON.parse(await readFile(request.options.grantspath, "utf8")))
|
|
622
|
+
: [];
|
|
623
|
+
if (request.options.grantspath === undefined && request.options.interactive) {
|
|
624
|
+
const answer = await ask(`Grant the origin ${file.origin} for this run? [y/N] `);
|
|
625
|
+
if (answer.toLowerCase() !== "y")
|
|
626
|
+
throw new Error("The interactive origin grant was refused; the flowrun never starts.");
|
|
627
|
+
grants.push(file.origin);
|
|
628
|
+
}
|
|
629
|
+
const provider = request.options.interactive ? terminalprovider() : undefined;
|
|
630
|
+
const endpoint = flags.get("endpoint");
|
|
631
|
+
const driver =
|
|
632
|
+
endpoint !== undefined
|
|
633
|
+
? await attachremotedriver(endpoint, file.origin)
|
|
634
|
+
: request.options.dryrun
|
|
635
|
+
? dryflowdriver()
|
|
636
|
+
: undefined;
|
|
637
|
+
if (driver === undefined)
|
|
638
|
+
throw new Error(
|
|
639
|
+
"The flowrun needs a remote endpoint (--endpoint) or the dry run flag (--dryrun); the cli drives no local browser itself.",
|
|
640
|
+
);
|
|
641
|
+
const profiledir = flags.get("profile") ?? ".devthink/profile";
|
|
642
|
+
const store = new sessionmemory(
|
|
643
|
+
filesystemstorageadapter(
|
|
644
|
+
{
|
|
645
|
+
readfile: (path) => readFile(path, "utf8"),
|
|
646
|
+
writefile: (path, data) => writeFile(path, data, "utf8"),
|
|
647
|
+
mkdir: (path) => mkdir(path, { recursive: true }),
|
|
648
|
+
join: (...parts: string[]) => join(...parts),
|
|
649
|
+
},
|
|
650
|
+
profiledir,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
const runid = `flowrun:${Date.now()}`;
|
|
654
|
+
const lock = acquirerunlock({
|
|
655
|
+
locks: await store.getrunlocks(),
|
|
656
|
+
sessionid: "flowrun",
|
|
657
|
+
runid,
|
|
658
|
+
holder: "devthink-cli",
|
|
659
|
+
now: Date.now(),
|
|
660
|
+
});
|
|
661
|
+
await store.setrunlocks(lock.locks);
|
|
662
|
+
if (!lock.acquired) throw new Error(lock.reason);
|
|
663
|
+
try {
|
|
664
|
+
const run = await runflow({
|
|
665
|
+
request,
|
|
666
|
+
file,
|
|
667
|
+
grants,
|
|
668
|
+
...(provider !== undefined ? { provider } : {}),
|
|
669
|
+
driver,
|
|
670
|
+
now: Date.now(),
|
|
671
|
+
clock: () => Date.now(),
|
|
672
|
+
emit: (event) => {
|
|
673
|
+
if (request.options.format === "human") console.log(renderflowevents([event], "human"));
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
await mkdir(request.options.outputdir, { recursive: true });
|
|
677
|
+
const chain = await exportlogchain(run.log);
|
|
678
|
+
const stem = join(request.options.outputdir, runid.replace(/:/g, "-"));
|
|
679
|
+
await writeFile(`${stem}.chain.json`, JSON.stringify(chain, null, 2), "utf8");
|
|
680
|
+
await writeFile(
|
|
681
|
+
`${stem}.report.json`,
|
|
682
|
+
JSON.stringify(flowrunreport({ outcome: run.outcome, events: run.events }), null, 2),
|
|
683
|
+
"utf8",
|
|
684
|
+
);
|
|
685
|
+
await store.addaudi({
|
|
686
|
+
id: createHash("sha256").update(runid).digest("hex").slice(0, 16),
|
|
687
|
+
kind: "flowrun",
|
|
688
|
+
at: Date.now(),
|
|
689
|
+
summary: `The flowrun of ${file.origin} ended ${run.outcome.state} after ${run.outcome.steps} step${run.outcome.steps === 1 ? "" : "s"}.`,
|
|
690
|
+
sessionid: "flowrun",
|
|
691
|
+
});
|
|
692
|
+
if (request.options.format === "json")
|
|
693
|
+
console.log(JSON.stringify(flowrunreport({ outcome: run.outcome, events: run.events }), null, 2));
|
|
694
|
+
else console.log(renderflowtimeline(run.events));
|
|
695
|
+
process.exitCode = run.outcome.exitcode;
|
|
696
|
+
} finally {
|
|
697
|
+
const released = releaserunlock({ locks: await store.getrunlocks(), sessionid: "flowrun", runid, now: Date.now() });
|
|
698
|
+
await store.setrunlocks(released.locks);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Runs one saved workflow from the terminal through the library engine: the review gate pauses for the terminal approval before the run and before every sensitive step, the checkpoints persist beside the workflow so a resume continues from its checkpoint id, the audit trail file writes beside the workflow after each run, the progress streams one line per completed step and the structured outcome summary maps onto the documented exit code. */
|
|
703
|
+
async function cmdrunworkflow(
|
|
704
|
+
args: string[],
|
|
705
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
706
|
+
): Promise<void> {
|
|
707
|
+
const { positional, flags } = parseflags(args);
|
|
708
|
+
const workflowpath = positional[0];
|
|
709
|
+
if (workflowpath === undefined || workflowpath.trim() === "")
|
|
710
|
+
throw new Error("The runworkflow command needs the workflow file it runs.");
|
|
711
|
+
const dryrun = flags.get("dryrun") === "true";
|
|
712
|
+
const format = flags.get("format") ?? (state.json ? "json" : "human");
|
|
713
|
+
if (format !== "human" && format !== "json") throw new Error("The runworkflow format must stay human or json.");
|
|
714
|
+
const document = parseworkflowdocument(JSON.parse(await readFile(workflowpath, "utf8")));
|
|
715
|
+
const record = composeworkflowdocument(document, Date.now());
|
|
716
|
+
const runid = `workflow:${record.id}:${Date.now()}`;
|
|
717
|
+
const checkpointfile = `${workflowpath}.checkpoint.json`;
|
|
718
|
+
let resumed = false;
|
|
719
|
+
let run = newworkflowrun({ id: runid, workflowid: record.id, ...(dryrun ? { dryrun: true } : {}), now: Date.now() });
|
|
720
|
+
let scopes: variablescope[] | undefined;
|
|
721
|
+
let log: runlogentry[] | undefined;
|
|
722
|
+
let outputs: Record<string, stepoutcome> | undefined;
|
|
723
|
+
const resumeid = flags.get("resume");
|
|
724
|
+
if (resumeid !== undefined) {
|
|
725
|
+
const checkpoints = JSON.parse(await readFile(checkpointfile, "utf8")) as Array<{
|
|
726
|
+
runid: string;
|
|
727
|
+
cursor: number;
|
|
728
|
+
scopes: variablescope[];
|
|
729
|
+
log: runlogentry[];
|
|
730
|
+
outputs: Record<string, stepoutcome>;
|
|
731
|
+
}>;
|
|
732
|
+
const checkpoint = [...checkpoints].reverse().find((entry) => entry.runid === resumeid);
|
|
733
|
+
if (checkpoint === undefined)
|
|
734
|
+
throw new Error(
|
|
735
|
+
`No checkpoint of the run ${resumeid} sits beside ${workflowpath}; the resume needs its checkpoint id.`,
|
|
736
|
+
);
|
|
737
|
+
run = { id: resumeid, workflowid: record.id, state: "paused", cursor: checkpoint.cursor, startedat: Date.now() };
|
|
738
|
+
scopes = checkpoint.scopes;
|
|
739
|
+
log = checkpoint.log;
|
|
740
|
+
outputs = checkpoint.outputs;
|
|
741
|
+
resumed = true;
|
|
742
|
+
}
|
|
743
|
+
if (state.verbosity !== "quiet") {
|
|
744
|
+
const approval = await ask(
|
|
745
|
+
`Run the workflow ${record.name} of ${record.steps.length} step${record.steps.length === 1 ? "" : "s"} on ${record.origins.join(", ")}${resumed ? ` resuming from the checkpoint ${run.cursor}` : ""}${dryrun ? " as a dry run" : ""}? [y/N] `,
|
|
746
|
+
);
|
|
747
|
+
if (approval.toLowerCase() !== "y") {
|
|
748
|
+
console.log(
|
|
749
|
+
format === "json"
|
|
750
|
+
? JSON.stringify(
|
|
751
|
+
{
|
|
752
|
+
runid,
|
|
753
|
+
state: "cancelled",
|
|
754
|
+
exitclass: "consentrefused",
|
|
755
|
+
exitcode: exitcodeof("consentrefused"),
|
|
756
|
+
reason: "The terminal review refused the workflow before the first step.",
|
|
757
|
+
},
|
|
758
|
+
null,
|
|
759
|
+
2,
|
|
760
|
+
)
|
|
761
|
+
: "The terminal review refused the workflow; nothing ran.",
|
|
762
|
+
);
|
|
763
|
+
process.exitCode = exitcodeof("consentrefused");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let summary: ReturnType<typeof runworkflowsummaryof>;
|
|
768
|
+
if (dryrun) {
|
|
769
|
+
const dried = dryrunworkflow({
|
|
770
|
+
record,
|
|
771
|
+
run,
|
|
772
|
+
...(scopes !== undefined ? { scopes } : {}),
|
|
773
|
+
...(log !== undefined ? { log } : {}),
|
|
774
|
+
now: Date.now(),
|
|
775
|
+
projection: dryrunprojection,
|
|
776
|
+
});
|
|
777
|
+
summary = runworkflowsummaryof({ runid: dried.run.id, run: dried.run, log: dried.log });
|
|
778
|
+
} else {
|
|
779
|
+
const endpoint = flags.get("endpoint");
|
|
780
|
+
if (endpoint === undefined)
|
|
781
|
+
throw new Error(
|
|
782
|
+
"The runworkflow command needs a remote endpoint (--endpoint) or the dry run flag (--dryrun); the cli drives no local browser itself.",
|
|
783
|
+
);
|
|
784
|
+
const driver = await attachremotedriver(endpoint, record.origins[0] as string);
|
|
785
|
+
const checkpoints = [] as Array<{
|
|
786
|
+
runid: string;
|
|
787
|
+
cursor: number;
|
|
788
|
+
scopes: variablescope[];
|
|
789
|
+
log: runlogentry[];
|
|
790
|
+
outputs: Record<string, stepoutcome>;
|
|
791
|
+
}>;
|
|
792
|
+
const executed = await runworkflow({
|
|
793
|
+
record,
|
|
794
|
+
run,
|
|
795
|
+
...(scopes !== undefined ? { scopes } : {}),
|
|
796
|
+
...(log !== undefined ? { log } : {}),
|
|
797
|
+
...(outputs !== undefined ? { outputs } : {}),
|
|
798
|
+
execute: async (step: workflowstep, context) => {
|
|
799
|
+
let risk: "read" | "interaction" | "sensitive" = "sensitive";
|
|
800
|
+
try {
|
|
801
|
+
risk = actionrisk(step.kind as never);
|
|
802
|
+
} catch {
|
|
803
|
+
/* an unknown kind already refused at composition */
|
|
804
|
+
}
|
|
805
|
+
if (risk === "sensitive" && state.verbosity !== "quiet") {
|
|
806
|
+
const answer = await ask(
|
|
807
|
+
`The step ${step.id} of kind ${step.kind} grades sensitive on ${record.origins.join(", ")}.\nApprove the step before it runs? [y/N] `,
|
|
808
|
+
);
|
|
809
|
+
if (answer.toLowerCase() !== "y")
|
|
810
|
+
return {
|
|
811
|
+
ok: false,
|
|
812
|
+
summary: `The terminal review refused the sensitive step ${step.id} of kind ${step.kind}.`,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const result = await driver.execute(step as planfilestep, Date.now());
|
|
816
|
+
return {
|
|
817
|
+
ok: result.state === "done",
|
|
818
|
+
summary: result.summary,
|
|
819
|
+
...(context.block !== undefined ? { details: { block: context.block } } : {}),
|
|
820
|
+
};
|
|
821
|
+
},
|
|
822
|
+
now: Date.now(),
|
|
823
|
+
gates: { sessionactive: true, planapproved: true, origingranted: (origin) => record.origins.includes(origin) },
|
|
824
|
+
oncheckpoint: async (checkpoint) => {
|
|
825
|
+
if (state.verbosity !== "quiet" && format === "human")
|
|
826
|
+
console.log(`step ${checkpoint.run.cursor}/${record.steps.length}: ${checkpoint.log.at(-1)?.summary ?? ""}`);
|
|
827
|
+
checkpoints.push({
|
|
828
|
+
runid: checkpoint.run.id,
|
|
829
|
+
cursor: checkpoint.run.cursor,
|
|
830
|
+
scopes: checkpoint.scopes,
|
|
831
|
+
log: checkpoint.log,
|
|
832
|
+
outputs: {},
|
|
833
|
+
});
|
|
834
|
+
await writeFile(checkpointfile, JSON.stringify(checkpoints, null, 2), "utf8");
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
summary = runworkflowsummaryof({ runid: executed.run.id, run: executed.run, log: executed.log });
|
|
838
|
+
}
|
|
839
|
+
await writeFile(
|
|
840
|
+
`${workflowpath}.audit.jsonl`,
|
|
841
|
+
workflowauditlines({
|
|
842
|
+
runid: summary.runid,
|
|
843
|
+
workflow: record.name,
|
|
844
|
+
log: summary.steps.map((entry) => ({
|
|
845
|
+
stepid: entry.stepid,
|
|
846
|
+
label: entry.label,
|
|
847
|
+
state: entry.state as "done",
|
|
848
|
+
startedat: 0,
|
|
849
|
+
duration: entry.duration,
|
|
850
|
+
summary: entry.summary,
|
|
851
|
+
})),
|
|
852
|
+
now: Date.now(),
|
|
853
|
+
}) + "\n",
|
|
854
|
+
"utf8",
|
|
855
|
+
);
|
|
856
|
+
if (format === "json") console.log(JSON.stringify(summary, null, 2));
|
|
857
|
+
else {
|
|
858
|
+
for (const step of summary.steps)
|
|
859
|
+
console.log(
|
|
860
|
+
`${step.state === "done" ? "done" : step.state} ${step.stepid} ${step.label} (${step.duration} ms): ${step.summary}`,
|
|
861
|
+
);
|
|
862
|
+
console.log(
|
|
863
|
+
`The workflow run ${summary.runid} ended ${summary.state} at checkpoint ${summary.checkpoint} with the exit class ${summary.exitclass} (${summary.exitcode}).${summary.reason !== undefined ? ` ${summary.reason}` : ""}`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
process.exitCode = summary.exitcode;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Exports session, audit or extraction data from the profile store or an input file: the serializers ride the shared export menu, the from and to flags window the records, an absent output path writes to stdout and the secret store material refuses the export in full. */
|
|
870
|
+
async function cmdexportdata(
|
|
871
|
+
args: string[],
|
|
872
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
873
|
+
): Promise<void> {
|
|
874
|
+
const { positional, flags } = parseflags(args);
|
|
875
|
+
const scope = positional[0];
|
|
876
|
+
if (scope === undefined) throw new Error("The exportdata command needs its scope of session, audit or extraction.");
|
|
877
|
+
if (scope !== "session" && scope !== "audit" && scope !== "extraction")
|
|
878
|
+
throw new Error("The exportdata scope stays session, audit or extraction.");
|
|
879
|
+
const format = flags.get("format") ?? (scope === "session" ? "json" : scope === "audit" ? "jsonl" : "csv");
|
|
880
|
+
const timebound = (name: string): number | undefined => {
|
|
881
|
+
const value = flags.get(name);
|
|
882
|
+
if (value === undefined) return undefined;
|
|
883
|
+
if (/^-?\d+$/.test(value)) return Number(value);
|
|
884
|
+
const parsed = Date.parse(value);
|
|
885
|
+
if (Number.isNaN(parsed))
|
|
886
|
+
throw new Error(`The ${name} flag must carry an epoch millisecond number or an ISO date.`);
|
|
887
|
+
return parsed;
|
|
888
|
+
};
|
|
889
|
+
const from = timebound("from");
|
|
890
|
+
const to = timebound("to");
|
|
891
|
+
const input = flags.get("input");
|
|
892
|
+
const storedir = flags.get("store");
|
|
893
|
+
let records: Array<Record<string, unknown>>;
|
|
894
|
+
if (input !== undefined) {
|
|
895
|
+
records = JSON.parse(await readFile(input, "utf8")) as Array<Record<string, unknown>>;
|
|
896
|
+
} else if (storedir !== undefined) {
|
|
897
|
+
const store = new sessionmemory(
|
|
898
|
+
filesystemstorageadapter(
|
|
899
|
+
{
|
|
900
|
+
readfile: (path) => readFile(path, "utf8"),
|
|
901
|
+
writefile: (path, data) => writeFile(path, data, "utf8"),
|
|
902
|
+
mkdir: (path) => mkdir(path, { recursive: true }),
|
|
903
|
+
join: (...parts: string[]) => join(...parts),
|
|
904
|
+
},
|
|
905
|
+
storedir,
|
|
906
|
+
),
|
|
907
|
+
);
|
|
908
|
+
if (scope === "session") records = (await store.gethistory()) as unknown as Array<Record<string, unknown>>;
|
|
909
|
+
else if (scope === "audit") records = (await store.getaudit()) as unknown as Array<Record<string, unknown>>;
|
|
910
|
+
else
|
|
911
|
+
records = (await store.getdatasets()).flatMap((dataset) =>
|
|
912
|
+
dataset.rows.map((row) => ({ ...row, dataset: dataset.name, at: dataset.at })),
|
|
913
|
+
);
|
|
914
|
+
} else {
|
|
915
|
+
throw new Error("The exportdata command needs its records from the --input file or the --store profile directory.");
|
|
916
|
+
}
|
|
917
|
+
if (!Array.isArray(records)) throw new Error("The exportdata records must be a json array.");
|
|
918
|
+
const shapes = flags.has("shapes")
|
|
919
|
+
? (flags.get("shapes") as string)
|
|
920
|
+
.split(",")
|
|
921
|
+
.map((shape) => shape.trim())
|
|
922
|
+
.filter((shape) => shape !== "")
|
|
923
|
+
: [...defaultmaskshapes];
|
|
924
|
+
const out = flags.get("out");
|
|
925
|
+
const exported = exportdatacontent({
|
|
926
|
+
scope,
|
|
927
|
+
format,
|
|
928
|
+
records,
|
|
929
|
+
shapes,
|
|
930
|
+
...(from !== undefined ? { from } : {}),
|
|
931
|
+
...(to !== undefined ? { to } : {}),
|
|
932
|
+
...(out !== undefined ? { path: out } : {}),
|
|
933
|
+
});
|
|
934
|
+
if (exported.result.reason !== undefined) {
|
|
935
|
+
console.error(exported.result.reason);
|
|
936
|
+
process.exitCode = exitcodeof("consentrefused");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (state.verbosity === "verbose" && state.json !== true)
|
|
940
|
+
console.log(
|
|
941
|
+
`The exportdata wrote ${exported.result.rows} record${exported.result.rows === 1 ? "" : "s"} of ${exported.result.bytes} bytes in ${format}.`,
|
|
942
|
+
);
|
|
943
|
+
if (out !== undefined) {
|
|
944
|
+
await mkdir(resolve(out, ".."), { recursive: true });
|
|
945
|
+
await writeFile(out, exported.content + "\n", "utf8");
|
|
946
|
+
if (state.verbosity !== "quiet") console.log(`The exportdata wrote ${out}.`);
|
|
947
|
+
} else console.log(exported.content);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Replays one plan against recorded page state fixtures through the library run handle: the fixture consent gates hold against the recorded grants, the read only kinds execute against the observation, the unsupported kinds report honestly and the outcomes record through the progress model the live runs share. */
|
|
951
|
+
async function cmdheadless(
|
|
952
|
+
args: string[],
|
|
953
|
+
state: { config: cliconfiguration; verbosity: "quiet" | "normal" | "verbose"; json: boolean },
|
|
954
|
+
): Promise<void> {
|
|
955
|
+
const { positional, flags } = parseflags(args);
|
|
956
|
+
const planpath = positional[0] ?? state.config.defaultplan;
|
|
957
|
+
if (planpath === undefined || planpath.trim() === "")
|
|
958
|
+
throw new Error(
|
|
959
|
+
"The headless command needs the plan file it replays, or the default plan location in the configuration.",
|
|
960
|
+
);
|
|
961
|
+
const fixturesdir = flags.get("fixtures") ?? state.config.fixturesdir;
|
|
962
|
+
if (fixturesdir === undefined || fixturesdir.trim() === "")
|
|
963
|
+
throw new Error(
|
|
964
|
+
"The headless command needs the fixtures directory (--fixtures) or the fixturesdir location in the configuration.",
|
|
965
|
+
);
|
|
966
|
+
const entries = (await readdir(fixturesdir)).filter((entry) => entry.endsWith(".json"));
|
|
967
|
+
if (entries.length === 0) throw new Error(`The fixtures directory ${fixturesdir} carries no fixture file.`);
|
|
968
|
+
const fixtures: headlessfixture[] = [];
|
|
969
|
+
for (const entry of entries)
|
|
970
|
+
fixtures.push(parseheadlessfixture(JSON.parse(await readFile(join(fixturesdir, entry), "utf8"))));
|
|
971
|
+
const plan = parseplanfile(JSON.parse(await readFile(planpath, "utf8")));
|
|
972
|
+
const fixture = resolvefixture(fixtures, plan.origin);
|
|
973
|
+
const handle = openlibraryrun({
|
|
974
|
+
plan,
|
|
975
|
+
statesource: (origin) => fixtures.find((candidate) => candidate.origin === origin),
|
|
976
|
+
...(flags.has("grants")
|
|
977
|
+
? {
|
|
978
|
+
policy: {
|
|
979
|
+
grants: (flags.get("grants") as string)
|
|
980
|
+
.split(",")
|
|
981
|
+
.map((grant) => grant.trim())
|
|
982
|
+
.filter((grant) => grant !== ""),
|
|
983
|
+
},
|
|
984
|
+
}
|
|
985
|
+
: {}),
|
|
986
|
+
now: Date.now(),
|
|
987
|
+
});
|
|
988
|
+
const outcomes: Array<{
|
|
989
|
+
stepid: string;
|
|
990
|
+
kind: string;
|
|
991
|
+
state: "done" | "refused" | "unsupported";
|
|
992
|
+
summary: string;
|
|
993
|
+
details?: Record<string, unknown>;
|
|
994
|
+
}> = [];
|
|
995
|
+
for (const step of plan.steps as planfilestep[]) {
|
|
996
|
+
const outcome = handle.step(step, Date.now());
|
|
997
|
+
outcomes.push(outcome);
|
|
998
|
+
if (state.verbosity !== "quiet" && state.json !== true)
|
|
999
|
+
console.log(`${outcome.state} ${outcome.stepid} of kind ${outcome.kind}: ${outcome.summary}`);
|
|
1000
|
+
}
|
|
1001
|
+
const exitclass = outcomes.some((outcome) => outcome.state === "unsupported")
|
|
1002
|
+
? "unsupported"
|
|
1003
|
+
: outcomes.some((outcome) => outcome.state === "refused")
|
|
1004
|
+
? "consentrefused"
|
|
1005
|
+
: "ok";
|
|
1006
|
+
if (state.json || flags.get("format") === "json")
|
|
1007
|
+
console.log(
|
|
1008
|
+
JSON.stringify(
|
|
1009
|
+
{
|
|
1010
|
+
plan: planpath,
|
|
1011
|
+
fixture: fixture.id,
|
|
1012
|
+
origin: fixture.origin,
|
|
1013
|
+
outcomes,
|
|
1014
|
+
progress: { completedsteps: handle.handle().progress.completedsteps.length },
|
|
1015
|
+
exitclass,
|
|
1016
|
+
exitcode: exitcodeof(exitclass),
|
|
1017
|
+
},
|
|
1018
|
+
null,
|
|
1019
|
+
2,
|
|
1020
|
+
),
|
|
1021
|
+
);
|
|
1022
|
+
else
|
|
1023
|
+
console.log(
|
|
1024
|
+
`The headless replay of ${plan.goal} against the fixture ${fixture.id} of ${fixture.origin} completed ${handle.handle().progress.completedsteps.length} step${handle.handle().progress.completedsteps.length === 1 ? "" : "s"} with the exit class ${exitclass} (${exitcodeof(exitclass)}).`,
|
|
1025
|
+
);
|
|
1026
|
+
process.exitCode = exitcodeof(exitclass);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Serves the mcp server mode of the 1.1.84 family: the serve command validates the mcpmode gate over the user chosen transports, bind, port, frame size, queue depth, drain window and tls material, then speaks newline delimited json rpc over its own stdin and stdout while the optional http listener serves the streamable endpoint on its own localhost port — both transports may run at the same time — with every log line on stderr so the stdio wire stays clean. The standalone serve answers the protocol surfaces with the same gates the extension surfaces run (unpaired clients dispatch nothing, sensitive tools raise the human gate, the serve lists only the tools the grants cover) and executes no page step because the real execution rides the paired extension engine; the tool mocks of the --mocks flag let a client exercise the catalog without a browser. */
|
|
1030
|
+
async function cmdserve(args: string[]): Promise<void> {
|
|
1031
|
+
const { flags } = parseflags(args);
|
|
1032
|
+
const transportflag = flags.get("transport");
|
|
1033
|
+
const transports: transportkind[] =
|
|
1034
|
+
transportflag === "stdio" ? ["stdio"] : transportflag === "http" ? ["http"] : ["stdio", "http"];
|
|
1035
|
+
const portflag = flags.get("port");
|
|
1036
|
+
const port =
|
|
1037
|
+
portflag !== undefined && Number.isFinite(Number(portflag)) && Number(portflag) > 0 && Number(portflag) <= 65535
|
|
1038
|
+
? Math.floor(Number(portflag))
|
|
1039
|
+
: defaultmcpport;
|
|
1040
|
+
const bind = flags.get("bind") ?? localhostbind;
|
|
1041
|
+
const localbind = bind === "127.0.0.1" || bind === "localhost" || bind === "::1";
|
|
1042
|
+
const framesizeflag = flags.get("framesize");
|
|
1043
|
+
const queuedepthflag = flags.get("queuedepth");
|
|
1044
|
+
const drainwindowflag = flags.get("drainwindow");
|
|
1045
|
+
const config: mcpserverconfig = {
|
|
1046
|
+
bind,
|
|
1047
|
+
port,
|
|
1048
|
+
transports,
|
|
1049
|
+
enabled: true,
|
|
1050
|
+
...(localbind ? {} : { remote: true }),
|
|
1051
|
+
...(framesizeflag !== undefined && Number.isFinite(Number(framesizeflag))
|
|
1052
|
+
? { framesize: Math.floor(Number(framesizeflag)) }
|
|
1053
|
+
: {}),
|
|
1054
|
+
...(queuedepthflag !== undefined && Number.isFinite(Number(queuedepthflag))
|
|
1055
|
+
? { queuedepth: Math.floor(Number(queuedepthflag)) }
|
|
1056
|
+
: {}),
|
|
1057
|
+
...(drainwindowflag !== undefined && Number.isFinite(Number(drainwindowflag))
|
|
1058
|
+
? { drainwindow: Math.floor(Number(drainwindowflag)) }
|
|
1059
|
+
: {}),
|
|
1060
|
+
};
|
|
1061
|
+
const gate = mcpmodegate(config);
|
|
1062
|
+
if (!gate.allowed) throw new Error(gate.reason ?? "The serve command failed its gate.");
|
|
1063
|
+
const startedat = Date.now();
|
|
1064
|
+
const started = startserve({ config, now: startedat });
|
|
1065
|
+
if (started.state === undefined) throw new Error(started.reason ?? "The serve command did not start.");
|
|
1066
|
+
const mocknames = (flags.get("mocks") ?? "")
|
|
1067
|
+
.split(",")
|
|
1068
|
+
.map((name) => name.trim())
|
|
1069
|
+
.filter((name) => name !== "");
|
|
1070
|
+
const mocks: toolmock[] = mocknames.map((tool) => ({
|
|
1071
|
+
tool,
|
|
1072
|
+
result: {
|
|
1073
|
+
content: `The ${tool} mock answered from the serve test context; no browser was touched.`,
|
|
1074
|
+
iserror: false,
|
|
1075
|
+
},
|
|
1076
|
+
testcontext: true,
|
|
1077
|
+
createdat: startedat,
|
|
1078
|
+
}));
|
|
1079
|
+
const client: clientrecord = {
|
|
1080
|
+
id: "serve-stdio",
|
|
1081
|
+
transport: "stdio",
|
|
1082
|
+
paired: flags.get("pair-client") === "true",
|
|
1083
|
+
connectedat: startedat,
|
|
1084
|
+
...(flags.get("pair-client") === "true" ? { pairedat: startedat } : {}),
|
|
1085
|
+
};
|
|
1086
|
+
const holder: { session: ReturnType<typeof createservesession> } = {
|
|
1087
|
+
session: createservesession({ config, client, origin: "stdio", now: startedat }),
|
|
1088
|
+
};
|
|
1089
|
+
holder.session.mocks = mocks;
|
|
1090
|
+
const execute = async (step: { kind: string; id: string }): Promise<never> => {
|
|
1091
|
+
throw new Error(
|
|
1092
|
+
`The standalone serve executes the ${step.kind} step ${step.id} through the paired extension engine; run the extension for the real execution.`,
|
|
1093
|
+
);
|
|
1094
|
+
};
|
|
1095
|
+
const handle = async (
|
|
1096
|
+
frame: import("./types.js").jsonrpcframe,
|
|
1097
|
+
): Promise<import("./types.js").jsonrpcframe | undefined> => {
|
|
1098
|
+
const outcome = await routeserveframe({ frame, state: holder.session, now: Date.now(), execute: execute as never });
|
|
1099
|
+
holder.session = outcome.state;
|
|
1100
|
+
return outcome.response;
|
|
1101
|
+
};
|
|
1102
|
+
const pump = createlinepump({
|
|
1103
|
+
write: (line) => {
|
|
1104
|
+
process.stdout.write(line);
|
|
1105
|
+
},
|
|
1106
|
+
handle,
|
|
1107
|
+
now: startedat,
|
|
1108
|
+
});
|
|
1109
|
+
const endpoints = transportendpoints({ config, now: startedat });
|
|
1110
|
+
process.stderr.write(
|
|
1111
|
+
`The mcp serve started ${endpoints.map((transport) => `${transport.kind} on ${transport.endpoint}`).join(" and ")}${started.state.degraded ? " degraded to the read only tools because no origin grant covers the serve" : ""}; ${mocks.length} tool mock${mocks.length === 1 ? "" : "s"} registered; the client ${client.paired ? "stays paired through the explicit launch flag" : "waits for the pairing approval"}. Every log rides stderr so the stdio wire stays clean.\n`,
|
|
1112
|
+
);
|
|
1113
|
+
let stopping = false;
|
|
1114
|
+
const stop = (): void => {
|
|
1115
|
+
if (stopping) return;
|
|
1116
|
+
stopping = true;
|
|
1117
|
+
const drain = shutdowndrain({
|
|
1118
|
+
inflight: holder.session.contexts,
|
|
1119
|
+
now: Date.now(),
|
|
1120
|
+
...(config.drainwindow !== undefined ? { window: config.drainwindow, drainstart: startedat } : {}),
|
|
1121
|
+
});
|
|
1122
|
+
process.stderr.write(
|
|
1123
|
+
`The serve shutdown ${drain.phase} with ${drain.waiting} in flight call${drain.waiting === 1 ? "" : "s"}; the drain waits for every call before the exit.\n`,
|
|
1124
|
+
);
|
|
1125
|
+
process.exit(0);
|
|
1126
|
+
};
|
|
1127
|
+
if (transports.includes("stdio")) {
|
|
1128
|
+
process.stdin.setEncoding("utf8");
|
|
1129
|
+
process.stdin.on("data", (chunk) => {
|
|
1130
|
+
void pump.feed(typeof chunk === "string" ? chunk : String(chunk)).catch((error) => {
|
|
1131
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
process.stdin.on("end", stop);
|
|
1135
|
+
}
|
|
1136
|
+
if (transports.includes("http")) {
|
|
1137
|
+
const endpoint = httpendpoint(config);
|
|
1138
|
+
const { createServer } = await import("node:http");
|
|
1139
|
+
const { createServer: createsecure } = await import("node:https");
|
|
1140
|
+
const readbody = async (request: import("node:http").IncomingMessage): Promise<string> => {
|
|
1141
|
+
const chunks: Buffer[] = [];
|
|
1142
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
1143
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1144
|
+
};
|
|
1145
|
+
const handler = async (
|
|
1146
|
+
request: import("node:http").IncomingMessage,
|
|
1147
|
+
response: import("node:http").ServerResponse,
|
|
1148
|
+
): Promise<void> => {
|
|
1149
|
+
const route = routepath(config, request.url ?? "/");
|
|
1150
|
+
if (route !== "endpoint") {
|
|
1151
|
+
response.writeHead(404, { "content-type": "application/json" });
|
|
1152
|
+
response.end(
|
|
1153
|
+
httpanswer({
|
|
1154
|
+
jsonrpc: "2.0",
|
|
1155
|
+
id: null,
|
|
1156
|
+
error: { code: "method", message: `The serve routes no http path ${request.url ?? "/"}.` },
|
|
1157
|
+
}),
|
|
1158
|
+
);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (request.method !== "POST") {
|
|
1162
|
+
response.writeHead(405, { "content-type": "application/json", allow: "POST" });
|
|
1163
|
+
response.end(
|
|
1164
|
+
httpanswer({
|
|
1165
|
+
jsonrpc: "2.0",
|
|
1166
|
+
id: null,
|
|
1167
|
+
error: { code: "method", message: "The streamable endpoint accepts posted json rpc messages only." },
|
|
1168
|
+
}),
|
|
1169
|
+
);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
const parsed = parsepost(await readbody(request));
|
|
1173
|
+
if (parsed.message === undefined) {
|
|
1174
|
+
response.writeHead(400, { "content-type": "application/json" });
|
|
1175
|
+
response.end(
|
|
1176
|
+
httpanswer({
|
|
1177
|
+
jsonrpc: "2.0",
|
|
1178
|
+
id: null,
|
|
1179
|
+
error: {
|
|
1180
|
+
code: "parse",
|
|
1181
|
+
message: parsed.error?.message ?? "The posted body does not parse as one json rpc message.",
|
|
1182
|
+
},
|
|
1183
|
+
}),
|
|
1184
|
+
);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const frames = Array.isArray(parsed.message) ? parsed.message : [parsed.message];
|
|
1188
|
+
const answers: import("./types.js").jsonrpcframe[] = [];
|
|
1189
|
+
for (const frame of frames) {
|
|
1190
|
+
if (frame.id === undefined) continue;
|
|
1191
|
+
const answer = await handle(frame);
|
|
1192
|
+
if (answer !== undefined) answers.push(answer);
|
|
1193
|
+
}
|
|
1194
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
1195
|
+
response.end(
|
|
1196
|
+
answers.length === 1
|
|
1197
|
+
? httpanswer(answers[0] as import("./types.js").jsonrpcframe)
|
|
1198
|
+
: httpanswer({
|
|
1199
|
+
jsonrpc: "2.0",
|
|
1200
|
+
id: null,
|
|
1201
|
+
error: { code: "internal", message: "The posted message produced no answer." },
|
|
1202
|
+
}),
|
|
1203
|
+
);
|
|
1204
|
+
};
|
|
1205
|
+
let server: import("node:http").Server | import("node:https").Server;
|
|
1206
|
+
const certpath = flags.get("tlscert");
|
|
1207
|
+
const keypath = flags.get("tlskey");
|
|
1208
|
+
if (certpath !== undefined && keypath !== undefined) {
|
|
1209
|
+
const { readFile } = await import("node:fs/promises");
|
|
1210
|
+
server = createsecure(
|
|
1211
|
+
{ cert: await readFile(certpath, "utf8"), key: await readFile(keypath, "utf8") },
|
|
1212
|
+
handler as never,
|
|
1213
|
+
);
|
|
1214
|
+
process.stderr.write(`The http listener terminates tls with the user provided certificate ${certpath}.\n`);
|
|
1215
|
+
} else {
|
|
1216
|
+
server = createServer(handler as never);
|
|
1217
|
+
}
|
|
1218
|
+
await new Promise<void>((resolve) => {
|
|
1219
|
+
server.listen(endpoint.port, endpoint.bind, () => {
|
|
1220
|
+
resolve();
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
process.stderr.write(
|
|
1224
|
+
`The streamable http endpoint listens on ${endpoint.endpoint} (localhost bind${endpoint.localhost ? "" : " behind the explicit remote review"}).\n`,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
await new Promise<void>(() => {
|
|
1228
|
+
/* the serve runs until the stdin closes or the process receives a stop signal */
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Installs, removes or diagnoses the optional native host of the 1.1.85 native host bridge: the install subcommand writes the host manifest into the user profile directory behind the install consent flag that explains the scope, the uninstall subcommand removes the host manifest and its preferences, and the diagnostics subcommand reports the install state the manifest declares — nothing native runs until the user installs it, the companion path stays the user's choice and a system wide install refuses without its explicit flag. */
|
|
1233
|
+
async function cmdnative(args: string[]): Promise<void> {
|
|
1234
|
+
const { flags, positional } = parseflags(args);
|
|
1235
|
+
const subcommand = positional[0] ?? "diagnostics";
|
|
1236
|
+
const profiledir = flags.get("profile") ?? "";
|
|
1237
|
+
const hostname = flags.get("host") ?? "";
|
|
1238
|
+
const platformflag = flags.get("platform");
|
|
1239
|
+
const platform = platformflag === "macos" || platformflag === "windows" ? platformflag : "linux";
|
|
1240
|
+
const systemwide = flags.get("systemwide") === "true";
|
|
1241
|
+
if (subcommand === "install") {
|
|
1242
|
+
const extensionid = flags.get("extension-id") ?? "";
|
|
1243
|
+
const companionpath = flags.get("companion") ?? "";
|
|
1244
|
+
const gate = nativeinstallconsentgate({
|
|
1245
|
+
consent: flags.get("consent") === "true",
|
|
1246
|
+
profiledir: systemwide ? "/system" : profiledir,
|
|
1247
|
+
hostname,
|
|
1248
|
+
});
|
|
1249
|
+
if (!gate.allowed) {
|
|
1250
|
+
console.error(gate.reason);
|
|
1251
|
+
console.error(
|
|
1252
|
+
"The install scope: the companion process the manifest launches, the profile directory the manifest writes into and the extension origins the manifest allows. Pass --consent true once you reviewed the scope.",
|
|
1253
|
+
);
|
|
1254
|
+
process.exitCode = exitcodeof("consentrefused");
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const outcome = await installnativehost({
|
|
1258
|
+
profiledir,
|
|
1259
|
+
hostname,
|
|
1260
|
+
extensionid,
|
|
1261
|
+
companionpath,
|
|
1262
|
+
consent: true,
|
|
1263
|
+
now: Date.now(),
|
|
1264
|
+
...(systemwide ? { systemwide: true } : {}),
|
|
1265
|
+
platform,
|
|
1266
|
+
io: {
|
|
1267
|
+
mkdir: async (dir) => {
|
|
1268
|
+
await mkdir(dir, { recursive: true });
|
|
1269
|
+
},
|
|
1270
|
+
writefile: async (path, text) => {
|
|
1271
|
+
await writeFile(path, text, "utf8");
|
|
1272
|
+
},
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
if (outcome.refused !== undefined) {
|
|
1276
|
+
console.error(outcome.refused);
|
|
1277
|
+
process.exitCode = exitcodeof("schemaerror");
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
console.log(
|
|
1281
|
+
JSON.stringify(
|
|
1282
|
+
{
|
|
1283
|
+
installed: true,
|
|
1284
|
+
manifestpath: outcome.manifestpath,
|
|
1285
|
+
hostname,
|
|
1286
|
+
extensionid,
|
|
1287
|
+
installerversion: outcome.state.installerversion,
|
|
1288
|
+
systemwide,
|
|
1289
|
+
},
|
|
1290
|
+
null,
|
|
1291
|
+
2,
|
|
1292
|
+
),
|
|
1293
|
+
);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (subcommand === "uninstall") {
|
|
1297
|
+
const outcome = await uninstallnativehost({
|
|
1298
|
+
profiledir,
|
|
1299
|
+
hostname,
|
|
1300
|
+
now: Date.now(),
|
|
1301
|
+
...(systemwide ? { systemwide: true } : {}),
|
|
1302
|
+
platform,
|
|
1303
|
+
io: {
|
|
1304
|
+
exists: async (path) => {
|
|
1305
|
+
try {
|
|
1306
|
+
await stat(path);
|
|
1307
|
+
return true;
|
|
1308
|
+
} catch {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
removefile: async (path) => {
|
|
1313
|
+
await (await import("node:fs/promises")).rm(path, { force: true });
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
if (outcome.refused !== undefined) {
|
|
1318
|
+
console.error(outcome.refused);
|
|
1319
|
+
process.exitCode = exitcodeof("schemaerror");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
console.log(
|
|
1323
|
+
JSON.stringify({ installed: false, manifestpath: outcome.manifestpath, removed: outcome.removed }, null, 2),
|
|
1324
|
+
);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (subcommand === "diagnostics") {
|
|
1328
|
+
const companionpath = (flags.get("companion") ?? "").trim();
|
|
1329
|
+
const template =
|
|
1330
|
+
hostname !== "" && companionpath !== "" ? nativehostmanifesttemplate({ hostname, companionpath }) : undefined;
|
|
1331
|
+
const destination =
|
|
1332
|
+
hostname !== ""
|
|
1333
|
+
? hostmanifestdestination({
|
|
1334
|
+
hostname,
|
|
1335
|
+
...(profiledir !== "" ? { profiledir } : {}),
|
|
1336
|
+
...(systemwide ? { systemwide: true } : {}),
|
|
1337
|
+
platform,
|
|
1338
|
+
})
|
|
1339
|
+
: undefined;
|
|
1340
|
+
const report = nativediagnostics({ state: nativedefaultstate(), now: Date.now() });
|
|
1341
|
+
console.log(
|
|
1342
|
+
JSON.stringify(
|
|
1343
|
+
{
|
|
1344
|
+
...report,
|
|
1345
|
+
...(destination !== undefined ? { manifestdestination: destination } : {}),
|
|
1346
|
+
...(template !== undefined ? { manifesttemplate: template.manifest } : {}),
|
|
1347
|
+
},
|
|
1348
|
+
null,
|
|
1349
|
+
2,
|
|
1350
|
+
),
|
|
1351
|
+
);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
throw new Error(
|
|
1355
|
+
`The native subcommand ${subcommand} stays outside the reviewed surface; run devthink help for the command list.`,
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Exports runs, extractions and notes in csv, json or log format: the chain verifies first and an unmasked value refuses the export in full. */
|
|
1360
|
+
async function cmdexport(args: string[]): Promise<void> {
|
|
1361
|
+
const { positional, flags } = parseflags(args);
|
|
1362
|
+
const scope = positional[0];
|
|
1363
|
+
if (scope === undefined) throw new Error("The export command needs its scope of runs, extractions or notes.");
|
|
1364
|
+
const descriptor = exportdescriptorof(flags.get("format") ?? "json", scope);
|
|
1365
|
+
const input = flags.get("input");
|
|
1366
|
+
if (input === undefined) throw new Error("The export command needs the --input file it exports.");
|
|
1367
|
+
const shapes = flags.has("shapes")
|
|
1368
|
+
? (flags.get("shapes") as string)
|
|
1369
|
+
.split(",")
|
|
1370
|
+
.map((shape) => shape.trim())
|
|
1371
|
+
.filter((shape) => shape !== "")
|
|
1372
|
+
: [...defaultmaskshapes];
|
|
1373
|
+
const out = flags.get("out");
|
|
1374
|
+
const payload = JSON.parse(await readFile(input, "utf8"));
|
|
1375
|
+
const exported =
|
|
1376
|
+
scope === "runs"
|
|
1377
|
+
? await exportchain({
|
|
1378
|
+
descriptor,
|
|
1379
|
+
log: {
|
|
1380
|
+
runid:
|
|
1381
|
+
typeof (payload as { runid?: string }).runid === "string" ? (payload as { runid: string }).runid : "run",
|
|
1382
|
+
sessionid: "flowrun",
|
|
1383
|
+
entries: (payload as { log: immutablelogentry[] }).log,
|
|
1384
|
+
updatedat: Date.now(),
|
|
1385
|
+
} as storedrunlog,
|
|
1386
|
+
shapes,
|
|
1387
|
+
...(out !== undefined ? { path: out } : {}),
|
|
1388
|
+
})
|
|
1389
|
+
: scope === "extractions"
|
|
1390
|
+
? exportextractions({
|
|
1391
|
+
descriptor,
|
|
1392
|
+
rows: payload as Array<Record<string, unknown>>,
|
|
1393
|
+
shapes,
|
|
1394
|
+
...(out !== undefined ? { path: out } : {}),
|
|
1395
|
+
})
|
|
1396
|
+
: exportnotes({
|
|
1397
|
+
descriptor,
|
|
1398
|
+
notes: payload as sitenote[],
|
|
1399
|
+
shapes,
|
|
1400
|
+
...(out !== undefined ? { path: out } : {}),
|
|
1401
|
+
});
|
|
1402
|
+
if (exported.result.reason !== undefined) throw new Error(exported.result.reason);
|
|
1403
|
+
if (out !== undefined) {
|
|
1404
|
+
await mkdir(resolve(out, ".."), { recursive: true });
|
|
1405
|
+
await writeFile(out, exported.content + "\n", "utf8");
|
|
1406
|
+
} else console.log(exported.content);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** Scaffolds one plan file: the documented starting point a plan author edits, with read only steps and a placeholder origin the author replaces. */
|
|
1410
|
+
async function cmdinit(args: string[]): Promise<void> {
|
|
1411
|
+
const { positional } = parseflags(args);
|
|
1412
|
+
const target = positional[0];
|
|
1413
|
+
if (target === undefined || target.trim() === "")
|
|
1414
|
+
throw new Error("The init command needs the plan file path it scaffolds.");
|
|
1415
|
+
const scaffold = {
|
|
1416
|
+
version: packageversion,
|
|
1417
|
+
goal: "Describe the objective of the plan",
|
|
1418
|
+
origin: "https://example.org",
|
|
1419
|
+
grants: ["observe", "readtext"],
|
|
1420
|
+
steps: [
|
|
1421
|
+
{ id: "observe", kind: "observe", label: "Observe the page", gate: false },
|
|
1422
|
+
{ id: "read", kind: "readtext", label: "Read the main text", target: "main" },
|
|
1423
|
+
],
|
|
1424
|
+
};
|
|
1425
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
1426
|
+
await writeFile(target, JSON.stringify(scaffold, null, 2) + "\n", "utf8");
|
|
1427
|
+
console.log(
|
|
1428
|
+
`Scaffolded the plan file ${target}; edit the goal, the origin and the steps, then run devthink planlint on it.`,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** Probes the capabilities of the active runtime and prints the capability matrix beside the adapter declarations and the platform targets. */
|
|
1433
|
+
async function cmddoctor(): Promise<void> {
|
|
1434
|
+
const probes = capabilityprobeof({
|
|
1435
|
+
dom: typeof document !== "undefined",
|
|
1436
|
+
storage: true,
|
|
1437
|
+
network: typeof fetch === "function",
|
|
1438
|
+
worker: true,
|
|
1439
|
+
});
|
|
1440
|
+
console.log(
|
|
1441
|
+
JSON.stringify(
|
|
1442
|
+
{
|
|
1443
|
+
version: packageversion,
|
|
1444
|
+
runtime: "node",
|
|
1445
|
+
probes,
|
|
1446
|
+
adapters: adapterdeclarationsof(),
|
|
1447
|
+
platforms: platformtargets(),
|
|
1448
|
+
capabilitycount: portablecapabilityset({
|
|
1449
|
+
vocabulary: actionkindcatalog(),
|
|
1450
|
+
probes,
|
|
1451
|
+
domlesskinds: readonlykinds(),
|
|
1452
|
+
}).length,
|
|
1453
|
+
},
|
|
1454
|
+
null,
|
|
1455
|
+
2,
|
|
1456
|
+
),
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Prints the shared command registry the commandpalette and the cli register together. */
|
|
1461
|
+
async function cmdcommands(args: string[]): Promise<void> {
|
|
1462
|
+
const { flags } = parseflags(args);
|
|
1463
|
+
const registry = clicommands(surfacepalette());
|
|
1464
|
+
if (flags.get("format") === "json") {
|
|
1465
|
+
console.log(JSON.stringify(registry, null, 2));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
for (const command of registry)
|
|
1469
|
+
console.log(`${command.terminal ? "cli " : "ui "} ${command.id}: ${command.label} [${command.surface}]`);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Prints the frozen capability manifest of every surface through the describe command of the 1.1.91 api freeze: one manifest per surface with its message types, action kinds, permissions and permission coverage beside the freeze scope record and the protocolv2 negotiation line — the json format answers the full manifests while the plain format prints one summary line per surface. */
|
|
1473
|
+
async function cmddescribe(args: string[]): Promise<void> {
|
|
1474
|
+
const { flags } = parseflags(args);
|
|
1475
|
+
const surfaces: capmanifestsurface[] = ["background", "pagebridge", "sidepanel", "popup", "cli", "library", "mcp"];
|
|
1476
|
+
const manifests = surfaces.map((surface) => capmanifestof(surface));
|
|
1477
|
+
const negotiation = sharedprotocolversion([1, 2]);
|
|
1478
|
+
if (flags.get("format") === "json") {
|
|
1479
|
+
console.log(
|
|
1480
|
+
JSON.stringify(
|
|
1481
|
+
{
|
|
1482
|
+
freeze: {
|
|
1483
|
+
release: apifreezerelease,
|
|
1484
|
+
date: apifreezedate,
|
|
1485
|
+
scope: surfaces,
|
|
1486
|
+
supported: protocolsupported,
|
|
1487
|
+
deprecation: deprecationwindow,
|
|
1488
|
+
},
|
|
1489
|
+
protocol: negotiation,
|
|
1490
|
+
manifests,
|
|
1491
|
+
},
|
|
1492
|
+
null,
|
|
1493
|
+
2,
|
|
1494
|
+
),
|
|
1495
|
+
);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
console.log(
|
|
1499
|
+
`Devthink ${packageversion} — the protocolv2 api freeze of ${apifreezerelease} pinned on ${apifreezedate}.`,
|
|
1500
|
+
);
|
|
1501
|
+
console.log(
|
|
1502
|
+
`Supported protocol versions ${protocolsupported.minimum} through ${protocolsupported.maximum}; the deprecation window closes at ${deprecationwindow.closes}.`,
|
|
1503
|
+
);
|
|
1504
|
+
for (const manifest of manifests)
|
|
1505
|
+
console.log(
|
|
1506
|
+
` ${manifest.surface.padEnd(12)} ${manifest.messages.length} message types, ${manifest.kinds.length} action kinds, ${manifest.permissions.length} permissions, pinned to release ${manifest.release}.`,
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Prints the version banner from package.json and every command with its one line description, beside the documented exit code classes. */
|
|
1511
|
+
async function cmdhelp(): Promise<void> {
|
|
1512
|
+
console.log(`Devthink ${packageversion} — the consent-first browser agent bridge on the terminal.`);
|
|
1513
|
+
console.log("Usage: devthink <command> [options]");
|
|
1514
|
+
for (const command of clicommands(surfacepalette()).filter((entry) => entry.terminal))
|
|
1515
|
+
console.log(` ${entrypad(command.id)} ${command.label}`);
|
|
1516
|
+
console.log(`Global flags: --config <file> (default ${defaultconfigfile}), --verbose, --quiet, --json.`);
|
|
1517
|
+
console.log(`Exit codes: ${cliexitclasses.map((exitclass) => `${exitclass}=${exitcodeof(exitclass)}`).join(", ")}.`);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Pads one command id so the help lines align. */
|
|
1521
|
+
function entrypad(id: string): string {
|
|
1522
|
+
return id.padEnd(12);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Family entry of the grand merge: the single devthink binary routes the
|
|
1527
|
+
* extension command family here (`devthink ext <command>`); the argv is
|
|
1528
|
+
* spliced into the process position the original entry reading expects,
|
|
1529
|
+
* the family main runs untouched and the caller argv is restored.
|
|
1530
|
+
*
|
|
1531
|
+
* @param argv the family arguments (without the family prefix).
|
|
1532
|
+
*/
|
|
1533
|
+
export async function runclifamily(argv: string[]): Promise<void> {
|
|
1534
|
+
const saved = process.argv.slice();
|
|
1535
|
+
process.argv = [saved[0], saved[1], ...argv];
|
|
1536
|
+
try {
|
|
1537
|
+
await main();
|
|
1538
|
+
} finally {
|
|
1539
|
+
process.argv = saved;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async function main(): Promise<void> {
|
|
1544
|
+
const command = process.argv[2] ?? "help";
|
|
1545
|
+
const args = process.argv.slice(3);
|
|
1546
|
+
if (command === "manifest") {
|
|
1547
|
+
const manifesttext = await readFile(resolve("web/manifest.json"), "utf8");
|
|
1548
|
+
const manifest = JSON.parse(manifesttext) as {
|
|
1549
|
+
version: string;
|
|
1550
|
+
key?: string;
|
|
1551
|
+
permissions?: string[];
|
|
1552
|
+
optional_permissions?: string[];
|
|
1553
|
+
host_permissions?: string[];
|
|
1554
|
+
sandbox?: { pages?: string[] };
|
|
1555
|
+
offscreen?: { document?: string; reasons?: string[]; justification?: string };
|
|
1556
|
+
content_scripts?: Array<{ matches?: string[]; world?: string; js?: string[] }>;
|
|
1557
|
+
};
|
|
1558
|
+
const packagejson = JSON.parse(await readFile(resolve("package.json"), "utf8")) as { version: string };
|
|
1559
|
+
const permissions = manifest.permissions ?? [];
|
|
1560
|
+
const optional = manifest["optional_permissions"] ?? [];
|
|
1561
|
+
const denied = permissions.filter((permission) => forbidden.has(permission));
|
|
1562
|
+
const deniedoptional = optional.filter((permission) => !allowedoptional.has(permission));
|
|
1563
|
+
if (manifest.version !== packagejson.version) throw new Error("Manifest version must match package.json.");
|
|
1564
|
+
if (!manifest.key) throw new Error("A stable manifest key is required for the extension identity.");
|
|
1565
|
+
const identitykey = decodeidentitykey(manifest.key);
|
|
1566
|
+
if (identitykey.length !== 294)
|
|
1567
|
+
throw new Error(
|
|
1568
|
+
`The manifest key decodes to ${identitykey.length} bytes; the published Devthink identity key decodes to 294.`,
|
|
1569
|
+
);
|
|
1570
|
+
const digest = createHash("sha256").update(identitykey).digest("hex");
|
|
1571
|
+
const recorded = await recordedidentitydigest();
|
|
1572
|
+
if (recorded !== undefined && digest !== recorded)
|
|
1573
|
+
throw new Error(
|
|
1574
|
+
`The manifest identity key digest ${digest} does not match the recorded build artifact digest ${recorded}; changing the extension identity must be a reviewed decision through a rebuilt artifact.`,
|
|
1575
|
+
);
|
|
1576
|
+
if ((manifest["host_permissions"] ?? []).length) throw new Error("Mandatory host permissions are not allowed.");
|
|
1577
|
+
if (denied.length) throw new Error(`Forbidden permissions: ${denied.join(", ")}`);
|
|
1578
|
+
if (deniedoptional.length) throw new Error(`Forbidden optional permissions: ${deniedoptional.join(", ")}`);
|
|
1579
|
+
const sandboxpages = manifest.sandbox?.pages ?? [];
|
|
1580
|
+
if (manifest.sandbox !== undefined) {
|
|
1581
|
+
if (sandboxpages.length === 0) throw new Error("The sandbox key needs at least one sandbox page.");
|
|
1582
|
+
if (sandboxpages.some((page) => !/^([a-z0-9-]+)\.html$/.test(page)))
|
|
1583
|
+
throw new Error("Every sandbox page needs its plain html file name.");
|
|
1584
|
+
if (new Set(sandboxpages).size !== sandboxpages.length)
|
|
1585
|
+
throw new Error("The sandbox page names must stay unique.");
|
|
1586
|
+
const privileged = sandboxpages.filter((page) => privilegedpages.has(page));
|
|
1587
|
+
if (privileged.length)
|
|
1588
|
+
throw new Error(
|
|
1589
|
+
`The sandbox pages ${privileged.join(", ")} hold extension privileges and must never run untrusted markup.`,
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
if (manifest.offscreen !== undefined) {
|
|
1593
|
+
if (!manifest.offscreen.document || !/^[a-z0-9-]+\.html$/.test(manifest.offscreen.document))
|
|
1594
|
+
throw new Error("The offscreen declaration needs its document path.");
|
|
1595
|
+
if (!manifest.offscreen.reasons?.length)
|
|
1596
|
+
throw new Error("The offscreen declaration needs the reasons the user reviewed.");
|
|
1597
|
+
if (!manifest.offscreen.justification?.trim())
|
|
1598
|
+
throw new Error("The offscreen declaration needs its justification in plain language.");
|
|
1599
|
+
}
|
|
1600
|
+
const worlds = (manifest["content_scripts"] ?? []).map((script) => script.world ?? "ISOLATED");
|
|
1601
|
+
if (worlds.some((world) => world !== "ISOLATED"))
|
|
1602
|
+
throw new Error(
|
|
1603
|
+
"Content scripts stay registered in the isolated world by default; no main world registration exists.",
|
|
1604
|
+
);
|
|
1605
|
+
if ((manifest["content_scripts"] ?? []).some((script) => (script.matches ?? []).length > 0))
|
|
1606
|
+
throw new Error(
|
|
1607
|
+
"Content script registrations carry no matches because injection stays behind the granted scripting calls on reviewed origins.",
|
|
1608
|
+
);
|
|
1609
|
+
const filebytes: Record<string, Uint8Array> = {};
|
|
1610
|
+
let bundlepresent = false;
|
|
1611
|
+
try {
|
|
1612
|
+
const distdir = resolve("dist/extension");
|
|
1613
|
+
for (const entry of await readdir(distdir))
|
|
1614
|
+
if (entry.endsWith(".js")) filebytes[entry] = new Uint8Array(await readFile(join(distdir, entry)));
|
|
1615
|
+
bundlepresent = true;
|
|
1616
|
+
/* the 2.0.2 icon family: the icon payloads the build materializes under dist/extension/icons join the bundle bytes so the deep manifest check reads their png dimensions against the manifest size keys — the store set provably renders at the required sizes through the cli manifest command too. */
|
|
1617
|
+
const iconsdir = resolve("dist/extension/icons");
|
|
1618
|
+
for (const entry of await readdir(iconsdir))
|
|
1619
|
+
if (entry.endsWith(".png")) filebytes[`icons/${entry}`] = new Uint8Array(await readFile(join(iconsdir, entry)));
|
|
1620
|
+
} catch {
|
|
1621
|
+
/* a manifest check before a build carries no bundled scripts; the deep checks report the absent bytes */
|
|
1622
|
+
}
|
|
1623
|
+
const deep = deepmanifestchecks({
|
|
1624
|
+
manifest,
|
|
1625
|
+
manifesttext,
|
|
1626
|
+
manifestfile: "manifest.json",
|
|
1627
|
+
filebytes,
|
|
1628
|
+
capabilities: capabilityapireport([...permissions, ...optional]),
|
|
1629
|
+
digestof: (bytes) => createHash("sha256").update(bytes).digest("base64"),
|
|
1630
|
+
/* the 2.0.6 fail-soft: a running tree without any dist/extension bundle (an installed library package, an unbuilt checkout) answers the icon family through the release-zip note instead of six absent-bundle errors — the bundle-present error stays for a built tree whose bundle misses a declared icon. */
|
|
1631
|
+
bundlepresent,
|
|
1632
|
+
});
|
|
1633
|
+
if (deep.exitcode !== 0) {
|
|
1634
|
+
for (const finding of deep.findings.filter((entry) => entry.severity === "error"))
|
|
1635
|
+
console.error(
|
|
1636
|
+
`${finding.severity.toUpperCase()} ${finding.rule} at ${finding.path}${finding.source !== undefined ? ` (${finding.source})` : ""}: ${finding.message}`,
|
|
1637
|
+
);
|
|
1638
|
+
process.exitCode = deep.exitcode;
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
/* the 1.1.91 api freeze: the manifest validation verifies the served capability manifest files stay pinned to the release, the frozen protocol major and the frozen surface lists */
|
|
1642
|
+
const capsurfaces: capmanifestsurface[] = [
|
|
1643
|
+
"background",
|
|
1644
|
+
"pagebridge",
|
|
1645
|
+
"sidepanel",
|
|
1646
|
+
"popup",
|
|
1647
|
+
"cli",
|
|
1648
|
+
"library",
|
|
1649
|
+
"mcp",
|
|
1650
|
+
];
|
|
1651
|
+
const capmanifestfiles: Array<{
|
|
1652
|
+
surface: string;
|
|
1653
|
+
release: string;
|
|
1654
|
+
protocolmajor: number;
|
|
1655
|
+
messages: number;
|
|
1656
|
+
permissions: number;
|
|
1657
|
+
}> = [];
|
|
1658
|
+
for (const surface of capsurfaces) {
|
|
1659
|
+
const capstext = await readFile(new URL(`./caps/${surface}.json`, import.meta.url), "utf8");
|
|
1660
|
+
const stored = JSON.parse(capstext) as {
|
|
1661
|
+
surface: string;
|
|
1662
|
+
release: string;
|
|
1663
|
+
protocolmajor: number;
|
|
1664
|
+
messages: string[];
|
|
1665
|
+
kinds: string[];
|
|
1666
|
+
permissions: string[];
|
|
1667
|
+
};
|
|
1668
|
+
if (stored.surface !== surface)
|
|
1669
|
+
throw new Error(`The capmanifest of the ${surface} surface names the surface ${stored.surface}.`);
|
|
1670
|
+
if (stored.release !== packagejson.version)
|
|
1671
|
+
throw new Error(
|
|
1672
|
+
`The capmanifest of the ${surface} surface pins the release ${stored.release} while the package carries ${packagejson.version}.`,
|
|
1673
|
+
);
|
|
1674
|
+
if (stored.protocolmajor !== 2)
|
|
1675
|
+
throw new Error(`The capmanifest of the ${surface} surface must pin the frozen protocol major two.`);
|
|
1676
|
+
const served = capmanifestof(surface);
|
|
1677
|
+
if (
|
|
1678
|
+
JSON.stringify(served.messages) !== JSON.stringify(stored.messages) ||
|
|
1679
|
+
JSON.stringify(served.permissions) !== JSON.stringify(stored.permissions) ||
|
|
1680
|
+
JSON.stringify(served.kinds) !== JSON.stringify(stored.kinds)
|
|
1681
|
+
)
|
|
1682
|
+
throw new Error(
|
|
1683
|
+
`The capmanifest of the ${surface} surface drifted from the frozen surface lists of apifreeze.ts.`,
|
|
1684
|
+
);
|
|
1685
|
+
capmanifestfiles.push({
|
|
1686
|
+
surface,
|
|
1687
|
+
release: stored.release,
|
|
1688
|
+
protocolmajor: stored.protocolmajor,
|
|
1689
|
+
messages: stored.messages.length,
|
|
1690
|
+
permissions: stored.permissions.length,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
console.log(
|
|
1694
|
+
JSON.stringify(
|
|
1695
|
+
{
|
|
1696
|
+
valid: true,
|
|
1697
|
+
version: manifest.version,
|
|
1698
|
+
identitykeybytes: identitykey.length,
|
|
1699
|
+
permissions,
|
|
1700
|
+
optionalpermissions: optional,
|
|
1701
|
+
capmanifests: capmanifestfiles,
|
|
1702
|
+
...(sandboxpages.length > 0 ? { sandboxpages } : {}),
|
|
1703
|
+
...(manifest.offscreen !== undefined
|
|
1704
|
+
? { offscreendocument: manifest.offscreen.document, offscreenreasons: manifest.offscreen.reasons }
|
|
1705
|
+
: {}),
|
|
1706
|
+
deepchecks: {
|
|
1707
|
+
rules: deep.findings.length,
|
|
1708
|
+
findings: deep.findings.map((finding) => ({
|
|
1709
|
+
rule: finding.rule,
|
|
1710
|
+
path: finding.path,
|
|
1711
|
+
severity: finding.severity,
|
|
1712
|
+
...(finding.source !== undefined ? { source: finding.source } : {}),
|
|
1713
|
+
message: finding.message,
|
|
1714
|
+
})),
|
|
1715
|
+
},
|
|
1716
|
+
},
|
|
1717
|
+
null,
|
|
1718
|
+
2,
|
|
1719
|
+
),
|
|
1720
|
+
);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const state = await globalstate(args);
|
|
1724
|
+
if (command === "planlint") {
|
|
1725
|
+
await cmdplanlint(args, state);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (command === "migrateplan") {
|
|
1729
|
+
await cmdmigrateplan(args, state);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (command === "recipes") {
|
|
1733
|
+
await cmdrecipes(args, state);
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
if (command === "flowrun") {
|
|
1737
|
+
await cmdflowrun(args);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (command === "runworkflow") {
|
|
1741
|
+
await cmdrunworkflow(args, state);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
if (command === "exportdata") {
|
|
1745
|
+
await cmdexportdata(args, state);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (command === "headless") {
|
|
1749
|
+
await cmdheadless(args, state);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (command === "serve") {
|
|
1753
|
+
await cmdserve(args);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
if (command === "native") {
|
|
1757
|
+
await cmdnative(args);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (command === "export") {
|
|
1761
|
+
await cmdexport(args);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (command === "init") {
|
|
1765
|
+
await cmdinit(args);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (command === "doctor") {
|
|
1769
|
+
await cmddoctor();
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
if (command === "commands") {
|
|
1773
|
+
await cmdcommands(args);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (command === "describe") {
|
|
1777
|
+
await cmddescribe(args);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
if (command === "help" || command === "--help") {
|
|
1781
|
+
await cmdhelp();
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
throw new Error(
|
|
1785
|
+
`The command ${command} stays outside the reviewed command surface; run devthink help for the command list.`,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/* the grand-merge guard: when this module is imported as the ext family
|
|
1790
|
+
surface by the devthink router (dynamic import), the module-load main must
|
|
1791
|
+
not fire — only a direct `bun cli.ts` (or bun entry) run executes it. */
|
|
1792
|
+
const cliDirectEntry = process.argv[1]?.endsWith("cli.ts") || process.argv[1]?.endsWith("cli");
|
|
1793
|
+
const cliBunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
|
|
1794
|
+
if (cliDirectEntry || cliBunEntry)
|
|
1795
|
+
main().catch((error) => {
|
|
1796
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1797
|
+
process.exitCode = exitcodeof("schemaerror");
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
/* ── Merged from clitools.ts: the cli tool surface. ── */
|
|
1801
|
+
import { recordoutcome, recordstep } from "./progress.js";
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Cli tools logic of the 1.1.80 family.
|
|
1805
|
+
* The pure half of the terminal surface lives in this one module while the impure half (the filesystem reads, the terminal prompts and the process exit) stays in the cli entry that wires it: the deep manifest checks grade the manifest key allowlist, the permission source lines, the content security policy hashes, the web accessible resources, the permission duplication, the minimum chrome version against the capability report and the icon dimensions; the planlint findings add the selector grammar, the required option fields and the origin allowlist to the shared lint engine beside the risk summary; the runworkflow composition loads a workflow document through the composeworkflow engine the extension runs; the exportdata selection routes session, audit and extraction records through the shared export menu serializers with the time window filter and the secret store refusal; the exit code mapping documents the failure classes; and the headless fixture resolution replays read only kinds against recorded page state fixtures under the fixture consent gate.
|
|
1806
|
+
* Every rule shares the modules the extension shares: the kind catalog and the selector grammar come from policy, the risk grades flow through the same resolvedrisk derivation, the workflow steps compose through the same engine, the serializers ride the exporttools descriptors and the fixtures carry the observation schema of the live snapshot pipeline — so the consent gates hold on the terminal exactly as they hold in the browser.
|
|
1807
|
+
*/
|
|
1808
|
+
|
|
1809
|
+
/** Parses the global cli configuration under schemastrict: the default plan location, the fixtures directory, the verbosity, the default format and the consent allowlist; unknown fields and wrong shapes refuse with the field that names them, so a misconfigured terminal never silently widens a default. */
|
|
1810
|
+
export function parsecliconfig(value: unknown): cliconfiguration {
|
|
1811
|
+
if (value === undefined || value === null) return {};
|
|
1812
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
1813
|
+
throw new Error("The cli configuration must be a json object.");
|
|
1814
|
+
const root = value as Record<string, unknown>;
|
|
1815
|
+
const unknownfields = Object.keys(root).filter(
|
|
1816
|
+
(key) => !["defaultplan", "fixturesdir", "verbosity", "format", "allowlist"].includes(key),
|
|
1817
|
+
);
|
|
1818
|
+
if (unknownfields.length > 0)
|
|
1819
|
+
throw new Error(
|
|
1820
|
+
`The cli configuration carries the unknown field${unknownfields.length === 1 ? "" : "s"} ${unknownfields.join(", ")}; schemastrict refuses unknown configuration fields.`,
|
|
1821
|
+
);
|
|
1822
|
+
const config: cliconfiguration = {};
|
|
1823
|
+
if (root.defaultplan !== undefined) {
|
|
1824
|
+
if (typeof root.defaultplan !== "string" || root.defaultplan.trim() === "")
|
|
1825
|
+
throw new Error("The defaultplan configuration must be a non-empty path string.");
|
|
1826
|
+
config.defaultplan = root.defaultplan;
|
|
1827
|
+
}
|
|
1828
|
+
if (root.fixturesdir !== undefined) {
|
|
1829
|
+
if (typeof root.fixturesdir !== "string" || root.fixturesdir.trim() === "")
|
|
1830
|
+
throw new Error("The fixturesdir configuration must be a non-empty directory string.");
|
|
1831
|
+
config.fixturesdir = root.fixturesdir;
|
|
1832
|
+
}
|
|
1833
|
+
if (root.verbosity !== undefined) {
|
|
1834
|
+
if (root.verbosity !== "quiet" && root.verbosity !== "normal" && root.verbosity !== "verbose")
|
|
1835
|
+
throw new Error("The verbosity configuration must stay quiet, normal or verbose.");
|
|
1836
|
+
config.verbosity = root.verbosity;
|
|
1837
|
+
}
|
|
1838
|
+
if (root.format !== undefined) {
|
|
1839
|
+
if (root.format !== "human" && root.format !== "json")
|
|
1840
|
+
throw new Error("The format configuration must stay human or json.");
|
|
1841
|
+
config.format = root.format;
|
|
1842
|
+
}
|
|
1843
|
+
if (root.allowlist !== undefined) {
|
|
1844
|
+
if (
|
|
1845
|
+
!Array.isArray(root.allowlist) ||
|
|
1846
|
+
!root.allowlist.every((origin) => typeof origin === "string" && origin.startsWith("https://"))
|
|
1847
|
+
)
|
|
1848
|
+
throw new Error("The allowlist configuration must be a list of HTTPS origins.");
|
|
1849
|
+
config.allowlist = root.allowlist.filter((origin): origin is string => typeof origin === "string");
|
|
1850
|
+
}
|
|
1851
|
+
return config;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
/** The exit code classes the cli documents in the readme: every command maps its failure onto exactly one class. */
|
|
1855
|
+
export const cliexitclasses: readonly string[] = [
|
|
1856
|
+
"ok",
|
|
1857
|
+
"consentrefused",
|
|
1858
|
+
"stepfailed",
|
|
1859
|
+
"schemaerror",
|
|
1860
|
+
"unsupported",
|
|
1861
|
+
"cancelled",
|
|
1862
|
+
];
|
|
1863
|
+
|
|
1864
|
+
/** Maps one exit class onto its documented exit code; an unknown class refuses as a schema error because the mapping itself is contract. */
|
|
1865
|
+
export function exitcodeof(exitclass: string): number {
|
|
1866
|
+
const codes: Record<string, number> = {
|
|
1867
|
+
ok: 0,
|
|
1868
|
+
consentrefused: 1,
|
|
1869
|
+
stepfailed: 2,
|
|
1870
|
+
schemaerror: 3,
|
|
1871
|
+
unsupported: 4,
|
|
1872
|
+
cancelled: 5,
|
|
1873
|
+
};
|
|
1874
|
+
const code = codes[exitclass];
|
|
1875
|
+
if (code === undefined)
|
|
1876
|
+
throw new Error(`The exit class ${exitclass} stays outside the documented classes ${cliexitclasses.join(", ")}.`);
|
|
1877
|
+
return code;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Reads the exit class of one workflow run state: a done run reports ok, a failed run reports the step failure, a cancelled run reports the cancellation and every other state reports the schema error of an unfinished document. */
|
|
1881
|
+
export function exitclassofrun(run: workflowrun): string {
|
|
1882
|
+
if (run.state === "done") return "ok";
|
|
1883
|
+
if (run.state === "failed") return "stepfailed";
|
|
1884
|
+
if (run.state === "cancelled") return "cancelled";
|
|
1885
|
+
return "schemaerror";
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/** The reviewed manifest key allowlist of the deep manifest check: every key the published extension may declare; a key outside the list is a review miss, never a silent pass. The browsers and vsix keys of the 1.1.93 single manifest design carry the per browser overlays and the vs code packaging data the build reads — reviewed metadata keys the derived browser manifests strip before they ship. */
|
|
1889
|
+
export const manifestkeyallowlist: readonly string[] = [
|
|
1890
|
+
"manifest_version",
|
|
1891
|
+
"key",
|
|
1892
|
+
"name",
|
|
1893
|
+
"version",
|
|
1894
|
+
"description",
|
|
1895
|
+
"default_locale",
|
|
1896
|
+
"permissions",
|
|
1897
|
+
"optional_permissions",
|
|
1898
|
+
"optional_host_permissions",
|
|
1899
|
+
"host_permissions",
|
|
1900
|
+
"content_scripts",
|
|
1901
|
+
"content_security_policy",
|
|
1902
|
+
"web_accessible_resources",
|
|
1903
|
+
"sandbox",
|
|
1904
|
+
"offscreen",
|
|
1905
|
+
"background",
|
|
1906
|
+
"action",
|
|
1907
|
+
"side_panel",
|
|
1908
|
+
"chrome_url_overrides",
|
|
1909
|
+
"options_ui",
|
|
1910
|
+
"icons",
|
|
1911
|
+
"minimum_chrome_version",
|
|
1912
|
+
"browsers",
|
|
1913
|
+
"vsix",
|
|
1914
|
+
];
|
|
1915
|
+
|
|
1916
|
+
/** The reviewed web accessible resource set of the deep manifest check: the pages the extension declares to the store; a resource outside the set is web exposed without review. */
|
|
1917
|
+
export const reviewedwebresources: readonly string[] = ["sandbox.html"];
|
|
1918
|
+
|
|
1919
|
+
/** Reads the capability api report of one permission list: every api the permissions turn on with the minimum chrome version that ships it, so the minimum chrome version check derives its floor from the declared capabilities instead of a hardcoded number. */
|
|
1920
|
+
export function capabilityapireport(permissions: string[]): Array<{ api: string; minchrome: number }> {
|
|
1921
|
+
const floors: Record<string, number> = {
|
|
1922
|
+
activeTab: 88,
|
|
1923
|
+
storage: 88,
|
|
1924
|
+
scripting: 88,
|
|
1925
|
+
sidePanel: 114,
|
|
1926
|
+
tabs: 88,
|
|
1927
|
+
downloads: 88,
|
|
1928
|
+
clipboardRead: 88,
|
|
1929
|
+
clipboardWrite: 88,
|
|
1930
|
+
offscreen: 109,
|
|
1931
|
+
nativeMessaging: 88,
|
|
1932
|
+
};
|
|
1933
|
+
return permissions.flatMap((permission) =>
|
|
1934
|
+
floors[permission] !== undefined ? [{ api: permission, minchrome: floors[permission] as number }] : [],
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/** Reads the png dimensions of one icon payload straight from the ihdr header: the width and height the declared icon size key must match. */
|
|
1939
|
+
export function pngdimensions(bytes: Uint8Array): { width: number; height: number } {
|
|
1940
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
1941
|
+
if (bytes.length < 24 || signature.some((byte, index) => bytes[index] !== byte))
|
|
1942
|
+
throw new Error("The icon payload carries no png signature.");
|
|
1943
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1944
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Reads the source file and line of one manifest declaration: the first text line that carries the value, in the file:line form editors consume. */
|
|
1948
|
+
export function manifestsourceline(manifesttext: string, manifestfile: string, value: string): string | undefined {
|
|
1949
|
+
const index = manifesttext.indexOf(value);
|
|
1950
|
+
if (index < 0) return undefined;
|
|
1951
|
+
const line = manifesttext.slice(0, index).split("\n").length;
|
|
1952
|
+
return `${manifestfile}:${line}`;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/** Reads the sha256 script hashes of one content security policy list: the hash entries the deep check verifies against the bundled script bytes. */
|
|
1956
|
+
export function csphashesof(policy: string): string[] {
|
|
1957
|
+
return [...policy.matchAll(/'sha256-([A-Za-z0-9+/=]+)'/g)].map((match) => `sha256-${match[1]}`);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/** Runs the deep manifest checks of the 1.1.80 family: every manifest key verifies against the runtime policy allowlist, every permission reports its source file and line, the content security policy hashes verify against the bundled scripts through the digest seam, the web accessible resources verify against the reviewed resource set, duplicated permissions across the required and optional sets refuse, the minimum chrome version checks against the capability report and every declared icon verifies its file presence and dimensions. */
|
|
1961
|
+
export function deepmanifestchecks(input: {
|
|
1962
|
+
manifest: unknown;
|
|
1963
|
+
manifesttext: string;
|
|
1964
|
+
manifestfile: string;
|
|
1965
|
+
filebytes: Record<string, Uint8Array>;
|
|
1966
|
+
capabilities: Array<{ api: string; minchrome: number }>;
|
|
1967
|
+
digestof: (bytes: Uint8Array) => string;
|
|
1968
|
+
bundlepresent?: boolean;
|
|
1969
|
+
}): { findings: manifestcheckfinding[]; exitcode: number } {
|
|
1970
|
+
if (!input.manifest || typeof input.manifest !== "object" || Array.isArray(input.manifest))
|
|
1971
|
+
throw new Error("The manifest must be a json object.");
|
|
1972
|
+
const manifest = input.manifest as Record<string, unknown>;
|
|
1973
|
+
const findings: manifestcheckfinding[] = [];
|
|
1974
|
+
for (const key of Object.keys(manifest)) {
|
|
1975
|
+
if (!manifestkeyallowlist.includes(key))
|
|
1976
|
+
findings.push({
|
|
1977
|
+
rule: "manifest.key.allowlist",
|
|
1978
|
+
path: key,
|
|
1979
|
+
...(manifestsourceline(input.manifesttext, input.manifestfile, `"${key}"`) !== undefined
|
|
1980
|
+
? { source: manifestsourceline(input.manifesttext, input.manifestfile, `"${key}"`) as string }
|
|
1981
|
+
: {}),
|
|
1982
|
+
severity: "error",
|
|
1983
|
+
message: `The manifest key ${key} stays outside the runtime policy allowlist; an undeclared key never ships unreviewed.`,
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
const permissions = Array.isArray(manifest.permissions)
|
|
1987
|
+
? manifest.permissions.filter((permission): permission is string => typeof permission === "string")
|
|
1988
|
+
: [];
|
|
1989
|
+
const optional = Array.isArray(manifest["optional_permissions"])
|
|
1990
|
+
? manifest["optional_permissions"].filter((permission): permission is string => typeof permission === "string")
|
|
1991
|
+
: [];
|
|
1992
|
+
for (const permission of permissions) {
|
|
1993
|
+
const source = manifestsourceline(input.manifesttext, input.manifestfile, `"${permission}"`);
|
|
1994
|
+
findings.push({
|
|
1995
|
+
rule: "manifest.permission.source",
|
|
1996
|
+
path: `permissions.${permission}`,
|
|
1997
|
+
...(source !== undefined ? { source } : {}),
|
|
1998
|
+
severity: "info",
|
|
1999
|
+
message: `The required permission ${permission} declares at ${source ?? "an unknown line"}.`,
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
for (const permission of optional) {
|
|
2003
|
+
const source = manifestsourceline(input.manifesttext, input.manifestfile, `"${permission}"`);
|
|
2004
|
+
findings.push({
|
|
2005
|
+
rule: "manifest.permission.source",
|
|
2006
|
+
path: `optional_permissions.${permission}`,
|
|
2007
|
+
...(source !== undefined ? { source } : {}),
|
|
2008
|
+
severity: "info",
|
|
2009
|
+
message: `The optional permission ${permission} declares at ${source ?? "an unknown line"}.`,
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
const duplicated = permissions.filter((permission) => optional.includes(permission));
|
|
2013
|
+
if (duplicated.length > 0)
|
|
2014
|
+
findings.push({
|
|
2015
|
+
rule: "manifest.permission.duplicate",
|
|
2016
|
+
path: "permissions",
|
|
2017
|
+
severity: "error",
|
|
2018
|
+
message: `The permission${duplicated.length === 1 ? "" : "s"} ${duplicated.join(", ")} declare${duplicated.length === 1 ? "s" : ""} in both the required and the optional sets; one permission declares exactly once.`,
|
|
2019
|
+
});
|
|
2020
|
+
if (permissions.includes("nativeMessaging"))
|
|
2021
|
+
findings.push({
|
|
2022
|
+
rule: "manifest.permission.nativemessaging",
|
|
2023
|
+
path: "permissions.nativeMessaging",
|
|
2024
|
+
severity: "error",
|
|
2025
|
+
message:
|
|
2026
|
+
"The native messaging permission declares in the optional set only: a required native messaging permission installs a native host grant the user never reviewed, so the validator refuses it in the required set and the native transport stays a per install user choice.",
|
|
2027
|
+
});
|
|
2028
|
+
if (optional.includes("nativeMessaging"))
|
|
2029
|
+
findings.push({
|
|
2030
|
+
rule: "manifest.permission.nativemessaging",
|
|
2031
|
+
path: "optional_permissions.nativeMessaging",
|
|
2032
|
+
severity: "info",
|
|
2033
|
+
message:
|
|
2034
|
+
"The native messaging permission declares in the optional set: the user grants the native transport per install behind the install consent, the per class gates and the kill switch of the native host bridge.",
|
|
2035
|
+
});
|
|
2036
|
+
const csp = manifest["content_security_policy"];
|
|
2037
|
+
if (csp === undefined) {
|
|
2038
|
+
findings.push({
|
|
2039
|
+
rule: "manifest.csp.hashes",
|
|
2040
|
+
path: "content_security_policy",
|
|
2041
|
+
severity: "info",
|
|
2042
|
+
message:
|
|
2043
|
+
"The manifest declares no content security policy; the store default script-src 'self' applies and no inline script runs.",
|
|
2044
|
+
});
|
|
2045
|
+
} else if (!csp || typeof csp !== "object" || Array.isArray(csp)) {
|
|
2046
|
+
findings.push({
|
|
2047
|
+
rule: "manifest.csp.hashes",
|
|
2048
|
+
path: "content_security_policy",
|
|
2049
|
+
severity: "error",
|
|
2050
|
+
message:
|
|
2051
|
+
"The content security policy must be the object with its extension pages policy; the manifest v3 form the extension pages read.",
|
|
2052
|
+
});
|
|
2053
|
+
} else {
|
|
2054
|
+
/* the 1.1.95 strict csp: the manifest v3 key the root manifest and both overlays declare reads first, the legacy extension scripts key stays a fallback so an old dialect still answers the check */
|
|
2055
|
+
const policy =
|
|
2056
|
+
(csp as Record<string, unknown>)["extension_pages"] ?? (csp as Record<string, unknown>)["extension_scripts"];
|
|
2057
|
+
if (typeof policy !== "string")
|
|
2058
|
+
findings.push({
|
|
2059
|
+
rule: "manifest.csp.hashes",
|
|
2060
|
+
path: "content_security_policy.extension_pages",
|
|
2061
|
+
severity: "error",
|
|
2062
|
+
message: "The extension pages policy needs its string form.",
|
|
2063
|
+
});
|
|
2064
|
+
else {
|
|
2065
|
+
if (/unsafe-inline|unsafe-eval|\*/.test(policy))
|
|
2066
|
+
findings.push({
|
|
2067
|
+
rule: "manifest.csp.hashes",
|
|
2068
|
+
path: "content_security_policy.extension_pages",
|
|
2069
|
+
severity: "error",
|
|
2070
|
+
message:
|
|
2071
|
+
"The extension pages policy widens with a wildcard or an unsafe source; the reviewed policy carries only 'self' and script hashes.",
|
|
2072
|
+
});
|
|
2073
|
+
const hashes = csphashesof(policy);
|
|
2074
|
+
if (hashes.length === 0)
|
|
2075
|
+
findings.push({
|
|
2076
|
+
rule: "manifest.csp.hashes",
|
|
2077
|
+
path: "content_security_policy.extension_pages",
|
|
2078
|
+
severity: "info",
|
|
2079
|
+
message: "The extension pages policy pins no script hash; only the 'self' origin loads scripts.",
|
|
2080
|
+
});
|
|
2081
|
+
for (const hash of hashes) {
|
|
2082
|
+
const digest = hash.slice("sha256-".length);
|
|
2083
|
+
const matches = Object.entries(input.filebytes).filter(([, bytes]) => input.digestof(bytes) === digest);
|
|
2084
|
+
findings.push(
|
|
2085
|
+
matches.length > 0
|
|
2086
|
+
? {
|
|
2087
|
+
rule: "manifest.csp.hashes",
|
|
2088
|
+
path: "content_security_policy.extension_pages",
|
|
2089
|
+
severity: "info",
|
|
2090
|
+
message: `The script hash ${hash} verifies against ${matches.map(([name]) => name).join(", ")}.`,
|
|
2091
|
+
}
|
|
2092
|
+
: {
|
|
2093
|
+
rule: "manifest.csp.hashes",
|
|
2094
|
+
path: "content_security_policy.extension_pages",
|
|
2095
|
+
severity: "error",
|
|
2096
|
+
message: `The script hash ${hash} verifies against no bundled script; every declared hash pins a shipped file.`,
|
|
2097
|
+
},
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
const resources = manifest["web_accessible_resources"];
|
|
2103
|
+
if (resources === undefined) {
|
|
2104
|
+
findings.push({
|
|
2105
|
+
rule: "manifest.war.reviewed",
|
|
2106
|
+
path: "web_accessible_resources",
|
|
2107
|
+
severity: "info",
|
|
2108
|
+
message: "The manifest declares no web accessible resources; nothing the extension ships is web exposed.",
|
|
2109
|
+
});
|
|
2110
|
+
} else {
|
|
2111
|
+
const declared = Array.isArray(resources)
|
|
2112
|
+
? resources.flatMap((entry) =>
|
|
2113
|
+
typeof entry === "string"
|
|
2114
|
+
? [entry]
|
|
2115
|
+
: entry && typeof entry === "object" && Array.isArray((entry as Record<string, unknown>).resources)
|
|
2116
|
+
? ((entry as Record<string, unknown>).resources as unknown[]).filter(
|
|
2117
|
+
(resource): resource is string => typeof resource === "string",
|
|
2118
|
+
)
|
|
2119
|
+
: [],
|
|
2120
|
+
)
|
|
2121
|
+
: [];
|
|
2122
|
+
if (declared.length === 0)
|
|
2123
|
+
findings.push({
|
|
2124
|
+
rule: "manifest.war.reviewed",
|
|
2125
|
+
path: "web_accessible_resources",
|
|
2126
|
+
severity: "error",
|
|
2127
|
+
message: "The web accessible resources declaration carries no resource; an empty declaration declares nothing.",
|
|
2128
|
+
});
|
|
2129
|
+
for (const resource of declared) {
|
|
2130
|
+
const source = manifestsourceline(input.manifesttext, input.manifestfile, resource);
|
|
2131
|
+
if (!reviewedwebresources.includes(resource))
|
|
2132
|
+
findings.push({
|
|
2133
|
+
rule: "manifest.war.reviewed",
|
|
2134
|
+
path: "web_accessible_resources",
|
|
2135
|
+
...(source !== undefined ? { source } : {}),
|
|
2136
|
+
severity: "error",
|
|
2137
|
+
message: `The web accessible resource ${resource} stays outside the reviewed resource set ${reviewedwebresources.join(", ")}.`,
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
const floor = input.capabilities.reduce((highest, entry) => Math.max(highest, entry.minchrome), 0);
|
|
2142
|
+
const minimum = manifest["minimum_chrome_version"];
|
|
2143
|
+
if (minimum === undefined) {
|
|
2144
|
+
findings.push({
|
|
2145
|
+
rule: "manifest.chromeminimum",
|
|
2146
|
+
path: "minimum_chrome_version",
|
|
2147
|
+
severity: "info",
|
|
2148
|
+
message: `The manifest declares no minimum chrome version; the capability report of ${input.capabilities.length} api${input.capabilities.length === 1 ? "" : "s"} derives the floor ${floor}.`,
|
|
2149
|
+
});
|
|
2150
|
+
} else if (typeof minimum !== "number" || !Number.isInteger(minimum) || minimum < 88) {
|
|
2151
|
+
findings.push({
|
|
2152
|
+
rule: "manifest.chromeminimum",
|
|
2153
|
+
path: "minimum_chrome_version",
|
|
2154
|
+
severity: "error",
|
|
2155
|
+
message: "The minimum chrome version must be an integer at or above the manifest v3 floor of 88.",
|
|
2156
|
+
});
|
|
2157
|
+
} else if (minimum < floor) {
|
|
2158
|
+
findings.push({
|
|
2159
|
+
rule: "manifest.chromeminimum",
|
|
2160
|
+
path: "minimum_chrome_version",
|
|
2161
|
+
severity: "error",
|
|
2162
|
+
message: `The declared minimum chrome version ${minimum} sits below the capability floor ${floor} the declared apis require; a browser below the floor installs an extension whose apis miss.`,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
const icons = manifest.icons;
|
|
2166
|
+
if (icons === undefined) {
|
|
2167
|
+
findings.push({
|
|
2168
|
+
rule: "manifest.icons",
|
|
2169
|
+
path: "icons",
|
|
2170
|
+
severity: "info",
|
|
2171
|
+
message: "The manifest declares no icons; the store renders the default puzzle piece beside the extension name.",
|
|
2172
|
+
});
|
|
2173
|
+
} else if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
|
|
2174
|
+
findings.push({
|
|
2175
|
+
rule: "manifest.icons",
|
|
2176
|
+
path: "icons",
|
|
2177
|
+
severity: "error",
|
|
2178
|
+
message: "The icons declaration must be the object of size keys and icon paths.",
|
|
2179
|
+
});
|
|
2180
|
+
} else {
|
|
2181
|
+
for (const [size, path] of Object.entries(icons as Record<string, unknown>)) {
|
|
2182
|
+
const declared = Number(size);
|
|
2183
|
+
if (typeof path !== "string" || path.trim() === "") {
|
|
2184
|
+
findings.push({
|
|
2185
|
+
rule: "manifest.icons",
|
|
2186
|
+
path: `icons.${size}`,
|
|
2187
|
+
severity: "error",
|
|
2188
|
+
message: `The icon of size ${size} needs its file path.`,
|
|
2189
|
+
});
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
const bytes = input.filebytes[path];
|
|
2193
|
+
if (bytes === undefined) {
|
|
2194
|
+
if (input.bundlepresent === false) {
|
|
2195
|
+
findings.push({
|
|
2196
|
+
rule: "manifest.icons",
|
|
2197
|
+
path: `icons.${size}`,
|
|
2198
|
+
severity: "info",
|
|
2199
|
+
message: `The icon ${path} of size ${size} rides the browser bundle of the release zip channel; the running tree carries no bundle to verify against — a repository checkout verifies it after pnpm build and an installed library package carries the manifest structure alone.`,
|
|
2200
|
+
});
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
const source = manifestsourceline(input.manifesttext, input.manifestfile, path);
|
|
2204
|
+
findings.push({
|
|
2205
|
+
rule: "manifest.icons",
|
|
2206
|
+
path: `icons.${size}`,
|
|
2207
|
+
...(source !== undefined ? { source } : {}),
|
|
2208
|
+
severity: "error",
|
|
2209
|
+
message: `The icon ${path} of size ${size} ships no file in the bundle.`,
|
|
2210
|
+
});
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
try {
|
|
2214
|
+
const dimensions = pngdimensions(bytes);
|
|
2215
|
+
if (dimensions.width !== declared || dimensions.height !== declared)
|
|
2216
|
+
findings.push({
|
|
2217
|
+
rule: "manifest.icons",
|
|
2218
|
+
path: `icons.${size}`,
|
|
2219
|
+
severity: "error",
|
|
2220
|
+
message: `The icon ${path} decodes to ${dimensions.width}x${dimensions.height} while its size key declares ${declared}.`,
|
|
2221
|
+
});
|
|
2222
|
+
else
|
|
2223
|
+
findings.push({
|
|
2224
|
+
rule: "manifest.icons",
|
|
2225
|
+
path: `icons.${size}`,
|
|
2226
|
+
severity: "info",
|
|
2227
|
+
message: `The icon ${path} decodes to ${dimensions.width}x${dimensions.height} and matches its size key.`,
|
|
2228
|
+
});
|
|
2229
|
+
} catch (error) {
|
|
2230
|
+
findings.push({
|
|
2231
|
+
rule: "manifest.icons",
|
|
2232
|
+
path: `icons.${size}`,
|
|
2233
|
+
severity: "error",
|
|
2234
|
+
message: `The icon ${path} fails its png read: ${error instanceof Error ? error.message : String(error)}`,
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const exitcode = findings.some((finding) => finding.severity === "error") ? 1 : 0;
|
|
2240
|
+
return { findings, exitcode };
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/** Runs the 1.1.80 planlint findings the cli adds to the shared lint engine: the kind catalog check that refuses forbidden kinds, the selector grammar check shared with policy, the required option fields check the executor enforces and the origin allowlist check against the consent allowlist the user configured. */
|
|
2244
|
+
export function planlintfindings(input: {
|
|
2245
|
+
file: planfile;
|
|
2246
|
+
capabilities?: string[];
|
|
2247
|
+
allowlist?: string[];
|
|
2248
|
+
}): planlintdiagnostic[] {
|
|
2249
|
+
const diagnostics: planlintdiagnostic[] = [];
|
|
2250
|
+
const catalog = new Set(actionkindcatalog());
|
|
2251
|
+
const capabilities = input.capabilities === undefined ? undefined : new Set(input.capabilities);
|
|
2252
|
+
input.file.steps.forEach((step, index) => {
|
|
2253
|
+
const path = `steps[${index}]`;
|
|
2254
|
+
if (!catalog.has(step.kind))
|
|
2255
|
+
diagnostics.push({
|
|
2256
|
+
code: "plan.kind.catalog",
|
|
2257
|
+
path: `${path}.kind`,
|
|
2258
|
+
severity: "error",
|
|
2259
|
+
message: `The step ${step.id} carries the kind ${step.kind} outside the reviewed catalog of ${catalog.size} kinds; a forbidden kind refuses the plan before any run starts.`,
|
|
2260
|
+
});
|
|
2261
|
+
if (step.target !== undefined) {
|
|
2262
|
+
const trimmed = step.target.trim();
|
|
2263
|
+
if (trimmed.startsWith("{")) {
|
|
2264
|
+
try {
|
|
2265
|
+
const reference = validatetargetref(JSON.parse(trimmed));
|
|
2266
|
+
if (!reference.allowed)
|
|
2267
|
+
diagnostics.push({
|
|
2268
|
+
code: "plan.selector.grammar",
|
|
2269
|
+
path: `${path}.target`,
|
|
2270
|
+
severity: "error",
|
|
2271
|
+
message: `The target reference of the step ${step.id} fails the shared target reference grammar: ${reference.reason}`,
|
|
2272
|
+
});
|
|
2273
|
+
} catch (error) {
|
|
2274
|
+
diagnostics.push({
|
|
2275
|
+
code: "plan.selector.grammar",
|
|
2276
|
+
path: `${path}.target`,
|
|
2277
|
+
severity: "error",
|
|
2278
|
+
message: `The target of the step ${step.id} parses neither as a selector nor as a target reference: ${error instanceof Error ? error.message : String(error)}`,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
} else {
|
|
2282
|
+
const selector = cssselectorvalid(trimmed);
|
|
2283
|
+
if (!selector.allowed)
|
|
2284
|
+
diagnostics.push({
|
|
2285
|
+
code: "plan.selector.grammar",
|
|
2286
|
+
path: `${path}.target`,
|
|
2287
|
+
severity: "error",
|
|
2288
|
+
message: `The selector of the step ${step.id} fails the shared selector grammar: ${selector.reason}`,
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const required = kindoptionfields(step.kind);
|
|
2293
|
+
if (required.length > 0) {
|
|
2294
|
+
let options: Record<string, unknown> = {};
|
|
2295
|
+
if (step.options !== undefined) {
|
|
2296
|
+
try {
|
|
2297
|
+
const parsed = JSON.parse(step.options);
|
|
2298
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2299
|
+
throw new Error("The options payload must be a json object.");
|
|
2300
|
+
options = parsed as Record<string, unknown>;
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
diagnostics.push({
|
|
2303
|
+
code: "plan.options.required",
|
|
2304
|
+
path: `${path}.options`,
|
|
2305
|
+
severity: "error",
|
|
2306
|
+
message: `The step ${step.id} of kind ${step.kind} carries options that parse no json object: ${error instanceof Error ? error.message : String(error)}`,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
const missing = required.filter((field) => options[field] === undefined);
|
|
2311
|
+
if (missing.length > 0)
|
|
2312
|
+
diagnostics.push({
|
|
2313
|
+
code: "plan.options.required",
|
|
2314
|
+
path: `${path}.options`,
|
|
2315
|
+
severity: "error",
|
|
2316
|
+
message: `The step ${step.id} of kind ${step.kind} misses the required option field${missing.length === 1 ? "" : "s"} ${missing.join(", ")}; the executor refuses the same payload at run time.`,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
if (capabilities !== undefined && !capabilities.has(step.kind))
|
|
2320
|
+
diagnostics.push({
|
|
2321
|
+
code: "plan.capability.catalog",
|
|
2322
|
+
path: `${path}.kind`,
|
|
2323
|
+
severity: "error",
|
|
2324
|
+
message: `The step ${step.id} carries the kind ${step.kind} outside the declared capability set; the target runtime never executes it.`,
|
|
2325
|
+
});
|
|
2326
|
+
});
|
|
2327
|
+
if (input.allowlist !== undefined && input.allowlist.length > 0 && !input.allowlist.includes(input.file.origin))
|
|
2328
|
+
diagnostics.push({
|
|
2329
|
+
code: "plan.origin.allowlist",
|
|
2330
|
+
path: "origin",
|
|
2331
|
+
severity: "error",
|
|
2332
|
+
message: `The plan origin ${input.file.origin} stays outside the consent allowlist of ${input.allowlist.length} origin${input.allowlist.length === 1 ? "" : "s"}; the extension demands the same grant before any step runs.`,
|
|
2333
|
+
});
|
|
2334
|
+
return diagnostics;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** Reads the risk summary of one plan file: the step counts per risk class beside the total, so the review reads the plan risk shape at a glance. */
|
|
2338
|
+
export function planrisksummaryof(file: planfile): planrisksummary {
|
|
2339
|
+
let read = 0;
|
|
2340
|
+
let interaction = 0;
|
|
2341
|
+
let sensitive = 0;
|
|
2342
|
+
for (const step of file.steps) {
|
|
2343
|
+
const risk = plansteprisk(step);
|
|
2344
|
+
if (risk === "read") read += 1;
|
|
2345
|
+
else if (risk === "interaction") interaction += 1;
|
|
2346
|
+
else sensitive += 1;
|
|
2347
|
+
}
|
|
2348
|
+
return {
|
|
2349
|
+
read,
|
|
2350
|
+
interaction,
|
|
2351
|
+
sensitive,
|
|
2352
|
+
steps: file.steps.length,
|
|
2353
|
+
reason: `The plan carries ${file.steps.length} step${file.steps.length === 1 ? "" : "s"}: ${read} read, ${interaction} interaction and ${sensitive} sensitive; the sensitive steps gate on the human review before any run starts.`,
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** Parses one workflow document under schemastrict: the version, the workflow payload with its name, version, origins and steps; unknown fields refuse with the path that names them. */
|
|
2358
|
+
export function parseworkflowdocument(value: unknown): workflowdocument {
|
|
2359
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
2360
|
+
throw new Error("The workflow document must be a json object.");
|
|
2361
|
+
const root = value as Record<string, unknown>;
|
|
2362
|
+
const unknownroot = Object.keys(root).filter((key) => !["version", "workflow"].includes(key));
|
|
2363
|
+
if (unknownroot.length > 0)
|
|
2364
|
+
throw new Error(
|
|
2365
|
+
`The workflow document carries the unknown field${unknownroot.length === 1 ? "" : "s"} ${unknownroot.join(", ")}; schemastrict refuses unknown workflow document fields.`,
|
|
2366
|
+
);
|
|
2367
|
+
if (typeof root.version !== "string" || root.version.trim() === "")
|
|
2368
|
+
throw new Error("The workflow document needs its version as a non-empty string.");
|
|
2369
|
+
const payload = root.workflow;
|
|
2370
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
2371
|
+
throw new Error("The workflow document needs its workflow payload object.");
|
|
2372
|
+
const candidate = payload as Record<string, unknown>;
|
|
2373
|
+
const unknownpayload = Object.keys(candidate).filter(
|
|
2374
|
+
(key) => !["name", "version", "origins", "steps", "blocks"].includes(key),
|
|
2375
|
+
);
|
|
2376
|
+
if (unknownpayload.length > 0)
|
|
2377
|
+
throw new Error(
|
|
2378
|
+
`The workflow payload carries the unknown field${unknownpayload.length === 1 ? "" : "s"} ${unknownpayload.join(", ")}; schemastrict refuses unknown workflow payload fields.`,
|
|
2379
|
+
);
|
|
2380
|
+
if (typeof candidate.name !== "string" || candidate.name.trim() === "")
|
|
2381
|
+
throw new Error("The workflow needs its name as a non-empty string.");
|
|
2382
|
+
if (typeof candidate.version !== "number" || !Number.isInteger(candidate.version) || candidate.version < 1)
|
|
2383
|
+
throw new Error("The workflow version must be a positive integer.");
|
|
2384
|
+
if (
|
|
2385
|
+
!Array.isArray(candidate.origins) ||
|
|
2386
|
+
candidate.origins.length === 0 ||
|
|
2387
|
+
!candidate.origins.every((origin) => typeof origin === "string" && origin.startsWith("https://"))
|
|
2388
|
+
)
|
|
2389
|
+
throw new Error("The workflow needs at least one granted HTTPS origin.");
|
|
2390
|
+
if (!Array.isArray(candidate.steps) || candidate.steps.length === 0)
|
|
2391
|
+
throw new Error("The workflow needs at least one step.");
|
|
2392
|
+
const steps = candidate.steps.map((step, index) => {
|
|
2393
|
+
const parsed = workflowstepof(step);
|
|
2394
|
+
if (parsed === undefined) throw new Error(`The workflow step at index ${index} fails the workflow step grammar.`);
|
|
2395
|
+
return parsed as unknown as Record<string, unknown>;
|
|
2396
|
+
});
|
|
2397
|
+
const blocks =
|
|
2398
|
+
candidate.blocks === undefined
|
|
2399
|
+
? undefined
|
|
2400
|
+
: (candidate.blocks as unknown[]).map((block, index) => {
|
|
2401
|
+
if (!block || typeof block !== "object")
|
|
2402
|
+
throw new Error(`The workflow block at index ${index} must be an object.`);
|
|
2403
|
+
return block as Record<string, unknown>;
|
|
2404
|
+
});
|
|
2405
|
+
return {
|
|
2406
|
+
version: root.version,
|
|
2407
|
+
workflow: {
|
|
2408
|
+
name: candidate.name,
|
|
2409
|
+
version: candidate.version,
|
|
2410
|
+
origins: candidate.origins as string[],
|
|
2411
|
+
steps,
|
|
2412
|
+
...(blocks !== undefined ? { blocks } : {}),
|
|
2413
|
+
},
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/** Composes one workflow document through the engine the extension runs: the kinds verify against the reviewed catalog and the risk grades flow through the same policy table, so the terminal replay and the browser run grade one workflow identically. */
|
|
2418
|
+
export function composeworkflowdocument(document: workflowdocument, now: number): workflowrecord {
|
|
2419
|
+
const steps = document.workflow.steps.flatMap((step) => {
|
|
2420
|
+
const parsed = workflowstepof(step);
|
|
2421
|
+
return parsed !== undefined ? [parsed] : [];
|
|
2422
|
+
});
|
|
2423
|
+
const blocks = (document.workflow.blocks ?? []).flatMap((block) => {
|
|
2424
|
+
const parsed = workflowblockof(block);
|
|
2425
|
+
return parsed !== undefined ? [parsed] : [];
|
|
2426
|
+
});
|
|
2427
|
+
return composeworkflow({
|
|
2428
|
+
name: document.workflow.name,
|
|
2429
|
+
version: document.workflow.version,
|
|
2430
|
+
origins: document.workflow.origins,
|
|
2431
|
+
steps,
|
|
2432
|
+
blocks,
|
|
2433
|
+
now,
|
|
2434
|
+
kindallowed: (kind) => {
|
|
2435
|
+
try {
|
|
2436
|
+
actionrisk(kind as never);
|
|
2437
|
+
return true;
|
|
2438
|
+
} catch {
|
|
2439
|
+
return false;
|
|
2440
|
+
}
|
|
2441
|
+
},
|
|
2442
|
+
riskof: (kind) => {
|
|
2443
|
+
try {
|
|
2444
|
+
return actionrisk(kind as never);
|
|
2445
|
+
} catch {
|
|
2446
|
+
return "sensitive" as const;
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** Builds the structured outcome summary of one runworkflow execution: the run state beside the per step durations, the checkpoint the run reached and the exit class the terminal maps onto its exit code. */
|
|
2453
|
+
export function runworkflowsummaryof(input: {
|
|
2454
|
+
runid: string;
|
|
2455
|
+
run: workflowrun;
|
|
2456
|
+
log: runlogentry[];
|
|
2457
|
+
}): runworkflowoutcome {
|
|
2458
|
+
const exitclass = exitclassofrun(input.run);
|
|
2459
|
+
return {
|
|
2460
|
+
runid: input.runid,
|
|
2461
|
+
state: input.run.state,
|
|
2462
|
+
steps: input.log.map((entry) => ({
|
|
2463
|
+
stepid: entry.stepid,
|
|
2464
|
+
label: entry.label,
|
|
2465
|
+
state: entry.state,
|
|
2466
|
+
duration: entry.duration,
|
|
2467
|
+
summary: entry.summary,
|
|
2468
|
+
})),
|
|
2469
|
+
checkpoint: input.run.cursor,
|
|
2470
|
+
exitclass,
|
|
2471
|
+
exitcode: exitcodeof(exitclass),
|
|
2472
|
+
...(input.run.failreason !== undefined ? { reason: input.run.failreason } : {}),
|
|
2473
|
+
...(input.run.cancelreason !== undefined ? { reason: input.run.cancelreason } : {}),
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** Renders the audit trail lines of one runworkflow execution: one json line per run log entry with the sealed header, the file the command writes beside the workflow after each run. */
|
|
2478
|
+
export function workflowauditlines(input: {
|
|
2479
|
+
runid: string;
|
|
2480
|
+
workflow: string;
|
|
2481
|
+
log: runlogentry[];
|
|
2482
|
+
now: number;
|
|
2483
|
+
}): string {
|
|
2484
|
+
const header = JSON.stringify({ runid: input.runid, workflow: input.workflow, state: "sealed", at: input.now });
|
|
2485
|
+
return [header, ...input.log.map((entry) => JSON.stringify(entry))].join("\n");
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
/** Filters records through the exportdata time window: a record enters the window when its timestamp field sits inside the from and to bounds the flags declared; an absent bound stays open and a record without a timestamp never drops on a window it cannot answer. */
|
|
2489
|
+
export function exportdatawindow(
|
|
2490
|
+
records: Array<Record<string, unknown>>,
|
|
2491
|
+
from?: number,
|
|
2492
|
+
to?: number,
|
|
2493
|
+
timefield = "at",
|
|
2494
|
+
): Array<Record<string, unknown>> {
|
|
2495
|
+
return records.filter((record) => {
|
|
2496
|
+
const at = record[timefield];
|
|
2497
|
+
if (typeof at !== "number" || !Number.isFinite(at)) return true;
|
|
2498
|
+
if (from !== undefined && at < from) return false;
|
|
2499
|
+
if (to !== undefined && at > to) return false;
|
|
2500
|
+
return true;
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/** Refuses secret store material before any serialization: a record that carries a secret shaped field without its mask marker, or a vault entry marker at all, refuses the export in full because the secret store never ships through an export. */
|
|
2505
|
+
export function secretstorerefusal(records: Array<Record<string, unknown>>): {
|
|
2506
|
+
refused: boolean;
|
|
2507
|
+
fields: string[];
|
|
2508
|
+
reason?: string;
|
|
2509
|
+
} {
|
|
2510
|
+
const fields = new Set<string>();
|
|
2511
|
+
for (const record of records) {
|
|
2512
|
+
for (const [name, value] of Object.entries(record)) {
|
|
2513
|
+
const lower = name.toLowerCase();
|
|
2514
|
+
if (lower === "vaultid" || lower === "vaultentry" || lower === "secretstore") fields.add(name);
|
|
2515
|
+
if (
|
|
2516
|
+
typeof value === "string" &&
|
|
2517
|
+
value.trim() !== "" &&
|
|
2518
|
+
secretfieldshapes.some((shape) => lower.includes(shape)) &&
|
|
2519
|
+
!value.includes(maskmarker)
|
|
2520
|
+
)
|
|
2521
|
+
fields.add(name);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
if (fields.size === 0) return { refused: false, fields: [] };
|
|
2525
|
+
const listed = [...fields].sort();
|
|
2526
|
+
return {
|
|
2527
|
+
refused: true,
|
|
2528
|
+
fields: listed,
|
|
2529
|
+
reason: `The exportdata command refuses the secret store material of ${listed.join(", ")}; the vault never ships through an export and an unmasked secret value refuses the export in full.`,
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** Exports session, audit or extraction data through the shared export menu serializers: the descriptor routes through exporttools, the mask verdicts honor in every format and a refused export writes nothing. */
|
|
2534
|
+
export function exportdatacontent(input: {
|
|
2535
|
+
scope: "session" | "audit" | "extraction";
|
|
2536
|
+
format: string;
|
|
2537
|
+
records: Array<Record<string, unknown>>;
|
|
2538
|
+
shapes: string[];
|
|
2539
|
+
from?: number;
|
|
2540
|
+
to?: number;
|
|
2541
|
+
path?: string;
|
|
2542
|
+
}): { result: exportresult; content: string } {
|
|
2543
|
+
const refusal = secretstorerefusal(input.records);
|
|
2544
|
+
const descriptor: exportdescriptor = exportdescriptorof(input.format, input.scope);
|
|
2545
|
+
if (refusal.refused && refusal.reason !== undefined)
|
|
2546
|
+
return {
|
|
2547
|
+
result: {
|
|
2548
|
+
descriptor,
|
|
2549
|
+
bytes: 0,
|
|
2550
|
+
rows: 0,
|
|
2551
|
+
...(input.path !== undefined ? { path: input.path } : {}),
|
|
2552
|
+
reason: refusal.reason,
|
|
2553
|
+
},
|
|
2554
|
+
content: "",
|
|
2555
|
+
};
|
|
2556
|
+
const windowed = exportdatawindow(input.records, input.from, input.to);
|
|
2557
|
+
return exportrecords({
|
|
2558
|
+
descriptor,
|
|
2559
|
+
records: windowed,
|
|
2560
|
+
shapes: input.shapes,
|
|
2561
|
+
...(input.path !== undefined ? { path: input.path } : {}),
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** Parses one recorded page state fixture under schemastrict: the identity, the HTTPS origin, the observation payload of the live snapshot schema, the fixture scoped grants and the recording time; unknown fields refuse with the path that names them. */
|
|
2566
|
+
export function parseheadlessfixture(value: unknown): headlessfixture {
|
|
2567
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
2568
|
+
throw new Error("The fixture file must be a json object.");
|
|
2569
|
+
const root = value as Record<string, unknown>;
|
|
2570
|
+
const unknownfields = Object.keys(root).filter(
|
|
2571
|
+
(key) => !["id", "origin", "observation", "grants", "recordedat"].includes(key),
|
|
2572
|
+
);
|
|
2573
|
+
if (unknownfields.length > 0)
|
|
2574
|
+
throw new Error(
|
|
2575
|
+
`The fixture carries the unknown field${unknownfields.length === 1 ? "" : "s"} ${unknownfields.join(", ")}; schemastrict refuses unknown fixture fields.`,
|
|
2576
|
+
);
|
|
2577
|
+
if (typeof root.id !== "string" || root.id.trim() === "")
|
|
2578
|
+
throw new Error("The fixture needs its id as a non-empty string.");
|
|
2579
|
+
if (typeof root.origin !== "string" || !root.origin.startsWith("https://"))
|
|
2580
|
+
throw new Error("The fixture needs the HTTPS origin it recorded.");
|
|
2581
|
+
if (!root.observation || typeof root.observation !== "object" || Array.isArray(root.observation))
|
|
2582
|
+
throw new Error("The fixture needs its observation payload of the live snapshot schema.");
|
|
2583
|
+
const observation = root.observation as Record<string, unknown>;
|
|
2584
|
+
for (const field of [
|
|
2585
|
+
"schemaversion",
|
|
2586
|
+
"url",
|
|
2587
|
+
"title",
|
|
2588
|
+
"textpreview",
|
|
2589
|
+
"textlength",
|
|
2590
|
+
"forms",
|
|
2591
|
+
"interactive",
|
|
2592
|
+
"capturedat",
|
|
2593
|
+
]) {
|
|
2594
|
+
if (observation[field] === undefined)
|
|
2595
|
+
throw new Error(`The fixture observation misses its ${field} field of the live snapshot schema.`);
|
|
2596
|
+
}
|
|
2597
|
+
if (!Array.isArray(observation.forms) || !Array.isArray(observation.interactive))
|
|
2598
|
+
throw new Error("The fixture observation needs its forms and interactive lists.");
|
|
2599
|
+
if (!Array.isArray(root.grants) || !root.grants.every((grant) => typeof grant === "string" && grant.trim() !== ""))
|
|
2600
|
+
throw new Error("The fixture grants must be a list of non-empty action kind names.");
|
|
2601
|
+
if (typeof root.recordedat !== "number" || !Number.isFinite(root.recordedat))
|
|
2602
|
+
throw new Error("The fixture needs its recording time.");
|
|
2603
|
+
return {
|
|
2604
|
+
id: root.id,
|
|
2605
|
+
origin: root.origin,
|
|
2606
|
+
observation: root.observation as headlessfixture["observation"],
|
|
2607
|
+
grants: root.grants.filter((grant): grant is string => typeof grant === "string"),
|
|
2608
|
+
recordedat: root.recordedat,
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** Resolves the fixture of one origin: the recorded page state whose origin matches exactly, so a plan replays against the state its review recorded. */
|
|
2613
|
+
export function resolvefixture(fixtures: headlessfixture[], origin: string): headlessfixture {
|
|
2614
|
+
const resolved = fixtures.find((fixture) => fixture.origin === origin);
|
|
2615
|
+
if (resolved === undefined)
|
|
2616
|
+
throw new Error(
|
|
2617
|
+
`No recorded fixture covers the origin ${origin}; headlessmode replays only against recorded page state.`,
|
|
2618
|
+
);
|
|
2619
|
+
return resolved;
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
/* ── The migrateplan importers of the 2.0.0 migration bridge: the conversion of foreign plan sources into the reviewed plan file grammar. ── */
|
|
2623
|
+
|
|
2624
|
+
/**
|
|
2625
|
+
* Conversion logic of the 2.0.0 migration bridge, promised by the roadmap 1.1.92 migration tools and carried by this release.
|
|
2626
|
+
* Every importer is a pure conversion: it reads one foreign plan source (a version one devthink plan, an automa workflow, a selenium ide side file, a ui vision macro or a tabular csv), maps every entry onto the reviewed plan file step grammar — the kind from the actionkindcatalog, the target as a reviewed css selector, the value and the reviewed json options — and refuses every entry it cannot map with the source entry named, because an importer never guesses a kind, a selector or a value. The converted plan carries no metadata (the frozen plan file grammar allows no provenance field), so the provenance, the field mapping notes and the import date ride the conversion report the terminal prints on stderr. The importers never execute anything and never touch a page: an imported plan enters exactly the same review flow, consent gates and origin grants a hand authored plan enters, and no imported plan inherits a consent grant from its source format.
|
|
2627
|
+
*/
|
|
2628
|
+
|
|
2629
|
+
/** One conversion record of the migration bridge: the converted plan file beside the provenance notes the terminal report prints, because the frozen plan file grammar carries no metadata field — the source format, the source version, the import date and every field mapping note ride this record on stderr and never the plan json an importer produced. */
|
|
2630
|
+
export interface conversionrecord {
|
|
2631
|
+
file: planfile;
|
|
2632
|
+
format: string;
|
|
2633
|
+
sourceversion: string;
|
|
2634
|
+
mapped: number;
|
|
2635
|
+
notes: string[];
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
/** Reads one required string field of an imported source record or refuses with the path that names it. */
|
|
2639
|
+
function requiredtext(record: Record<string, unknown>, field: string, path: string): string {
|
|
2640
|
+
const value = record[field];
|
|
2641
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
2642
|
+
throw new Error(`${path} needs its ${field} as a non-empty string.`);
|
|
2643
|
+
return value;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/** Reads one optional string field of an imported source record; an absent field stays absent while a present field must stay a string (an empty one reads as absent). */
|
|
2647
|
+
function optionaltext(record: Record<string, unknown>, field: string, path: string): string | undefined {
|
|
2648
|
+
const value = record[field];
|
|
2649
|
+
if (value === undefined) return undefined;
|
|
2650
|
+
if (typeof value !== "string")
|
|
2651
|
+
throw new Error(`${path} carries its ${field} as neither a string nor an absent field.`);
|
|
2652
|
+
return value.trim() === "" ? undefined : value;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/** Reads one optional value field of an imported source record: a string or a number stringifies onto the reviewed value field while every other shape refuses with the path that names it. */
|
|
2656
|
+
function optionalvalue(record: Record<string, unknown>, field: string, path: string): string | undefined {
|
|
2657
|
+
const value = record[field];
|
|
2658
|
+
if (value === undefined) return undefined;
|
|
2659
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
2660
|
+
throw new Error(`${path} carries its ${field} as neither a string nor a number.`);
|
|
2661
|
+
return String(value).trim() === "" ? undefined : String(value);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/** Builds one reviewed plan file step from a converted source entry: the kind verifies against the reviewed catalog, the target verifies against the shared selector grammar, and every kind that grades sensitive carries the explicit gate declaration the review demands — the importer marks converted sensitive steps for consent review instead of letting them pass ungated. */
|
|
2665
|
+
function convertedstep(input: {
|
|
2666
|
+
entry: string;
|
|
2667
|
+
id: string;
|
|
2668
|
+
kind: string;
|
|
2669
|
+
label: string;
|
|
2670
|
+
target?: string;
|
|
2671
|
+
value?: string;
|
|
2672
|
+
options?: string;
|
|
2673
|
+
}): planfilestep {
|
|
2674
|
+
const catalog = new Set(actionkindcatalog());
|
|
2675
|
+
if (!catalog.has(input.kind))
|
|
2676
|
+
throw new Error(
|
|
2677
|
+
`The ${input.entry} carries the action ${input.kind} outside the reviewed catalog of ${catalog.size} kinds; the importer refuses to guess a kind.`,
|
|
2678
|
+
);
|
|
2679
|
+
const step: planfilestep = { id: input.id, kind: input.kind, label: input.label };
|
|
2680
|
+
if (input.target !== undefined) {
|
|
2681
|
+
const selector = cssselectorvalid(input.target);
|
|
2682
|
+
if (!selector.allowed)
|
|
2683
|
+
throw new Error(
|
|
2684
|
+
`The ${input.entry} carries the selector ${input.target} the reviewed selector grammar refuses: ${selector.reason}`,
|
|
2685
|
+
);
|
|
2686
|
+
step.target = input.target;
|
|
2687
|
+
}
|
|
2688
|
+
if (input.value !== undefined) step.value = input.value;
|
|
2689
|
+
if (input.options !== undefined) step.options = input.options;
|
|
2690
|
+
let risk: "read" | "interaction" | "sensitive" = "sensitive";
|
|
2691
|
+
try {
|
|
2692
|
+
risk = actionrisk(input.kind as never);
|
|
2693
|
+
} catch {
|
|
2694
|
+
/* the catalog check above already refused the kind */
|
|
2695
|
+
}
|
|
2696
|
+
if (risk === "sensitive") step.gate = true;
|
|
2697
|
+
return step;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/** Reads one derived label for a converted step whose source format carries no label of its own: the kind and the target or value compose the plain language line the review reads. */
|
|
2701
|
+
function derivedlabel(kind: string, detail?: string): string {
|
|
2702
|
+
return detail === undefined ? `Run the ${kind} step` : `Run the ${kind} step on ${detail}`;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/** Reads the provenance note every conversion record carries first: the source format, the source version, the import date and the mapped step count, because the frozen plan file grammar carries no metadata field of its own. */
|
|
2706
|
+
function provenancenote(input: { format: string; sourceversion: string; mapped: number; now: number }): string {
|
|
2707
|
+
return `The ${input.format} importer converted the source of version ${input.sourceversion} into ${input.mapped} reviewed step${input.mapped === 1 ? "" : "s"} on ${new Date(input.now).toISOString()}; the provenance rides this conversion report on stderr because the frozen plan file grammar carries no metadata field, and the converted plan enters the same review flow a hand authored plan enters.`;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* Converts one version one era devthink plan into the reviewed plan file grammar of the migration bridge.
|
|
2712
|
+
* The legacy grammar this importer reads (documented here because the version one era predates the frozen grammar): the root carries its version, goal, origin, the optional grants and denials and its steps; every step carries its id, its action, its label and the optional selector, value and milliseconds. The action names the reviewed kind directly — the version one vocabulary and the frozen vocabulary share their kind names, so a legacy action outside the reviewed catalog refuses with the step named instead of guessing. The selector becomes the reviewed target, the value becomes the reviewed value, the milliseconds field of a delay action lifts its hardcoded wait onto the reviewed delay option grammar, the sensitive kinds gain their explicit gate declaration, the step identifiers pass through unchanged, and the grants and denials of the devthink source pass through because they are devthink origin declarations rather than foreign consent grants.
|
|
2713
|
+
* A source that already parses as the current plan file grammar passes through unchanged, so converting an already converted plan is the identity and the migration is idempotent.
|
|
2714
|
+
*/
|
|
2715
|
+
export function importv1plan(input: { source: unknown; now: number }): conversionrecord {
|
|
2716
|
+
try {
|
|
2717
|
+
const current = parseplanfile(input.source);
|
|
2718
|
+
const passthrough: conversionrecord = {
|
|
2719
|
+
file: current,
|
|
2720
|
+
format: "v1",
|
|
2721
|
+
sourceversion: current.version,
|
|
2722
|
+
mapped: current.steps.length,
|
|
2723
|
+
notes: [
|
|
2724
|
+
provenancenote({ format: "v1", sourceversion: current.version, mapped: current.steps.length, now: input.now }),
|
|
2725
|
+
"The source already parses as the reviewed plan file grammar, so the conversion passes it through unchanged; the migration is idempotent.",
|
|
2726
|
+
],
|
|
2727
|
+
};
|
|
2728
|
+
return passthrough;
|
|
2729
|
+
} catch {
|
|
2730
|
+
/* the source is not the current grammar: the legacy grammar below reads it */
|
|
2731
|
+
}
|
|
2732
|
+
if (typeof input.source !== "object" || input.source === null || Array.isArray(input.source))
|
|
2733
|
+
throw new Error("The version one plan must be a json object.");
|
|
2734
|
+
const root = input.source as Record<string, unknown>;
|
|
2735
|
+
const unknownroot = Object.keys(root).filter(
|
|
2736
|
+
(key) => !["version", "goal", "origin", "steps", "grants", "denials"].includes(key),
|
|
2737
|
+
);
|
|
2738
|
+
if (unknownroot.length > 0)
|
|
2739
|
+
throw new Error(
|
|
2740
|
+
`The version one plan carries the unknown field${unknownroot.length === 1 ? "" : "s"} ${unknownroot.join(", ")}; the migration reports fields without a destination as refusals, never as silent drops.`,
|
|
2741
|
+
);
|
|
2742
|
+
const sourceversion = requiredtext(root, "version", "The version one plan");
|
|
2743
|
+
const goal = requiredtext(root, "goal", "The version one plan");
|
|
2744
|
+
const origin = requiredtext(root, "origin", "The version one plan");
|
|
2745
|
+
if (!origin.startsWith("https://")) throw new Error("The version one plan origin must be an HTTPS origin.");
|
|
2746
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0)
|
|
2747
|
+
throw new Error("The version one plan needs at least one step.");
|
|
2748
|
+
const notes: string[] = [];
|
|
2749
|
+
const steps: planfilestep[] = [];
|
|
2750
|
+
let lifteddelays = 0;
|
|
2751
|
+
root.steps.forEach((rawstep, index) => {
|
|
2752
|
+
const path = `The version one step at index ${index}`;
|
|
2753
|
+
if (typeof rawstep !== "object" || rawstep === null || Array.isArray(rawstep))
|
|
2754
|
+
throw new Error(`${path} must be an object.`);
|
|
2755
|
+
const step = rawstep as Record<string, unknown>;
|
|
2756
|
+
const unknownfields = Object.keys(step).filter(
|
|
2757
|
+
(key) => !["id", "action", "label", "selector", "value", "milliseconds"].includes(key),
|
|
2758
|
+
);
|
|
2759
|
+
if (unknownfields.length > 0)
|
|
2760
|
+
throw new Error(
|
|
2761
|
+
`${path} carries the unknown field${unknownfields.length === 1 ? "" : "s"} ${unknownfields.join(", ")}; the migration reports fields without a destination as refusals, never as silent drops.`,
|
|
2762
|
+
);
|
|
2763
|
+
const id = requiredtext(step, "id", path);
|
|
2764
|
+
const action = requiredtext(step, "action", path);
|
|
2765
|
+
const label = requiredtext(step, "label", path);
|
|
2766
|
+
const selector = optionaltext(step, "selector", path);
|
|
2767
|
+
const value = optionalvalue(step, "value", path);
|
|
2768
|
+
const milliseconds = step.milliseconds;
|
|
2769
|
+
if (milliseconds !== undefined) {
|
|
2770
|
+
if (action !== "delay")
|
|
2771
|
+
throw new Error(
|
|
2772
|
+
`The version one step ${id} carries its milliseconds field on the action ${action}; only the delay action lifts a hardcoded wait onto the reviewed option grammar.`,
|
|
2773
|
+
);
|
|
2774
|
+
if (typeof milliseconds !== "number" || !Number.isInteger(milliseconds) || milliseconds < 1)
|
|
2775
|
+
throw new Error(
|
|
2776
|
+
`The version one step ${id} carries its milliseconds as a number the reviewed delay grammar refuses; the wait needs a positive integer of milliseconds.`,
|
|
2777
|
+
);
|
|
2778
|
+
lifteddelays += 1;
|
|
2779
|
+
}
|
|
2780
|
+
steps.push(
|
|
2781
|
+
convertedstep({
|
|
2782
|
+
entry: `version one step ${id}`,
|
|
2783
|
+
id,
|
|
2784
|
+
kind: action,
|
|
2785
|
+
label,
|
|
2786
|
+
...(selector !== undefined ? { target: selector } : {}),
|
|
2787
|
+
...(value !== undefined ? { value } : {}),
|
|
2788
|
+
...(milliseconds !== undefined ? { options: JSON.stringify({ delay: milliseconds }) } : {}),
|
|
2789
|
+
}),
|
|
2790
|
+
);
|
|
2791
|
+
});
|
|
2792
|
+
const file: planfile = { version: packageversion, goal, origin, steps };
|
|
2793
|
+
if (root.grants !== undefined) {
|
|
2794
|
+
if (!Array.isArray(root.grants) || !root.grants.every((grant) => typeof grant === "string" && grant.trim() !== ""))
|
|
2795
|
+
throw new Error("The version one plan grants must be a list of non-empty action kind names.");
|
|
2796
|
+
file.grants = root.grants.filter((grant): grant is string => typeof grant === "string");
|
|
2797
|
+
}
|
|
2798
|
+
if (root.denials !== undefined) {
|
|
2799
|
+
if (
|
|
2800
|
+
!Array.isArray(root.denials) ||
|
|
2801
|
+
!root.denials.every((denial) => typeof denial === "string" && denial.trim() !== "")
|
|
2802
|
+
)
|
|
2803
|
+
throw new Error("The version one plan denials must be a list of non-empty action kind names.");
|
|
2804
|
+
file.denials = root.denials.filter((denial): denial is string => typeof denial === "string");
|
|
2805
|
+
}
|
|
2806
|
+
const gated = steps.filter((step) => step.gate === true).map((step) => step.id);
|
|
2807
|
+
notes.push(
|
|
2808
|
+
`The version one field action of every step moved onto the kind field of the reviewed step grammar, the selector field onto the target field and the value field onto the value field; the step identifiers pass through unchanged.`,
|
|
2809
|
+
lifteddelays > 0
|
|
2810
|
+
? `The milliseconds field of ${lifteddelays} delay step${lifteddelays === 1 ? "" : "s"} lifted onto the reviewed delay option grammar as the delay option the executor reads.`
|
|
2811
|
+
: "The source carries no hardcoded delay to lift.",
|
|
2812
|
+
gated.length > 0
|
|
2813
|
+
? `The converted sensitive step${gated.length === 1 ? "" : "s"} ${gated.join(", ")} carr${gated.length === 1 ? "ies" : "ry"} the explicit gate declaration the review demands; the sensitive steps wait for the human review exactly as a hand authored plan waits.`
|
|
2814
|
+
: "The source converts to read only steps that carry no gate declaration.",
|
|
2815
|
+
file.grants !== undefined
|
|
2816
|
+
? "The grants and denials of the devthink source pass through because they are devthink origin declarations of the same grammar family, never foreign consent grants; the foreign importers contribute none."
|
|
2817
|
+
: "The source declares no grants, so the converted plan inherits no consent grant from its source format.",
|
|
2818
|
+
);
|
|
2819
|
+
return {
|
|
2820
|
+
file,
|
|
2821
|
+
format: "v1",
|
|
2822
|
+
sourceversion,
|
|
2823
|
+
mapped: steps.length,
|
|
2824
|
+
notes: [provenancenote({ format: "v1", sourceversion, mapped: steps.length, now: input.now }), ...notes],
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* Converts one automa workflow into the reviewed plan file grammar of the migration bridge.
|
|
2830
|
+
* The automa grammar this importer reads: the root carries the workflow name, the optional version and its blocks object keyed by block id; every block carries its type and its optional data. The conversion table: newtab becomes tabcreate with its url as the value, click-element becomes click, forms becomes fillform with its field list as the reviewed options, link becomes click, go-back becomes back, close-tab becomes tabclose, wait becomes delay with its milliseconds lifted onto the reviewed delay option grammar, scroll becomes scroll and screenshot becomes shotview. The trigger block never becomes a plan step: it lands in the conversion report as the paused trigger the scheduler arms only after the plan review. Every other block type refuses with the block named. The plan origin derives from the first newtab url — one plan addresses exactly one origin, so a navigation away from that origin refuses with the block named — the steps follow the block order the workflow file carries, and the block ids become the step ids so the provenance stays traceable.
|
|
2831
|
+
* The importer converts only: it opens no tab, fills no form and never executes anything outside the normal consent gates.
|
|
2832
|
+
*/
|
|
2833
|
+
export function importautoma(input: { source: unknown; now: number }): conversionrecord {
|
|
2834
|
+
if (typeof input.source !== "object" || input.source === null || Array.isArray(input.source))
|
|
2835
|
+
throw new Error("The automa workflow must be a json object.");
|
|
2836
|
+
const root = input.source as Record<string, unknown>;
|
|
2837
|
+
const unknownroot = Object.keys(root).filter((key) => !["name", "version", "blocks"].includes(key));
|
|
2838
|
+
if (unknownroot.length > 0)
|
|
2839
|
+
throw new Error(
|
|
2840
|
+
`The automa workflow carries the unknown field${unknownroot.length === 1 ? "" : "s"} ${unknownroot.join(", ")}; the migration reports fields without a destination as refusals, never as silent drops.`,
|
|
2841
|
+
);
|
|
2842
|
+
const goal = requiredtext(root, "name", "The automa workflow");
|
|
2843
|
+
const sourceversion = optionaltext(root, "version", "The automa workflow") ?? "unrecorded";
|
|
2844
|
+
const blocks = root.blocks;
|
|
2845
|
+
if (typeof blocks !== "object" || blocks === null || Array.isArray(blocks))
|
|
2846
|
+
throw new Error("The automa workflow carries its blocks as the object keyed by block id.");
|
|
2847
|
+
const notes: string[] = [];
|
|
2848
|
+
const steps: planfilestep[] = [];
|
|
2849
|
+
let origin: string | undefined;
|
|
2850
|
+
let pausedtriggers = 0;
|
|
2851
|
+
for (const [key, rawblock] of Object.entries(blocks)) {
|
|
2852
|
+
if (key.trim() === "")
|
|
2853
|
+
throw new Error("The automa workflow carries a block under an empty id; every block names its id.");
|
|
2854
|
+
if (typeof rawblock !== "object" || rawblock === null || Array.isArray(rawblock))
|
|
2855
|
+
throw new Error(`The automa block ${key} must be an object.`);
|
|
2856
|
+
const block = rawblock as Record<string, unknown>;
|
|
2857
|
+
const type = requiredtext(block, "type", `The automa block ${key}`);
|
|
2858
|
+
const data: Record<string, unknown> =
|
|
2859
|
+
typeof block.data === "object" && block.data !== null && !Array.isArray(block.data)
|
|
2860
|
+
? (block.data as Record<string, unknown>)
|
|
2861
|
+
: {};
|
|
2862
|
+
if (type === "trigger") {
|
|
2863
|
+
pausedtriggers += 1;
|
|
2864
|
+
continue; /* the trigger block lands in the report as the paused trigger, never as a plan step */
|
|
2865
|
+
}
|
|
2866
|
+
if (type === "newtab") {
|
|
2867
|
+
const url = requiredtext(data, "url", `The automa block ${key} of type newtab`);
|
|
2868
|
+
if (!url.startsWith("https://"))
|
|
2869
|
+
throw new Error(
|
|
2870
|
+
`The automa block ${key} of type newtab carries the url ${url} the reviewed grammar refuses; the plan origin must be an HTTPS origin.`,
|
|
2871
|
+
);
|
|
2872
|
+
const blockorigin = new URL(url).origin;
|
|
2873
|
+
if (origin === undefined) origin = blockorigin;
|
|
2874
|
+
else if (origin !== blockorigin)
|
|
2875
|
+
throw new Error(
|
|
2876
|
+
`The automa block ${key} of type newtab navigates to ${blockorigin} while the plan addresses ${origin}; one plan addresses exactly one origin, so the importer refuses the navigation instead of guessing.`,
|
|
2877
|
+
);
|
|
2878
|
+
steps.push(
|
|
2879
|
+
convertedstep({
|
|
2880
|
+
entry: `automa block ${key} of type newtab`,
|
|
2881
|
+
id: key,
|
|
2882
|
+
kind: "tabcreate",
|
|
2883
|
+
label: derivedlabel("tabcreate", url),
|
|
2884
|
+
value: url,
|
|
2885
|
+
}),
|
|
2886
|
+
);
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
if (type === "click-element" || type === "link") {
|
|
2890
|
+
const selector = requiredtext(data, "selector", `The automa block ${key} of type ${type}`);
|
|
2891
|
+
steps.push(
|
|
2892
|
+
convertedstep({
|
|
2893
|
+
entry: `automa block ${key} of type ${type}`,
|
|
2894
|
+
id: key,
|
|
2895
|
+
kind: "click",
|
|
2896
|
+
label: derivedlabel("click", selector),
|
|
2897
|
+
target: selector,
|
|
2898
|
+
}),
|
|
2899
|
+
);
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
if (type === "forms") {
|
|
2903
|
+
const fields = data.fields;
|
|
2904
|
+
if (!Array.isArray(fields) || fields.length === 0)
|
|
2905
|
+
throw new Error(
|
|
2906
|
+
`The automa block ${key} of type forms carries no field list; a form block without fields fills nothing.`,
|
|
2907
|
+
);
|
|
2908
|
+
const reviewed: Array<Record<string, string>> = [];
|
|
2909
|
+
fields.forEach((rawfield, fieldindex) => {
|
|
2910
|
+
if (typeof rawfield !== "object" || rawfield === null || Array.isArray(rawfield))
|
|
2911
|
+
throw new Error(`The field at index ${fieldindex} of the automa block ${key} must be an object.`);
|
|
2912
|
+
const field = rawfield as Record<string, unknown>;
|
|
2913
|
+
const name = requiredtext(field, "name", `The field at index ${fieldindex} of the automa block ${key}`);
|
|
2914
|
+
const value =
|
|
2915
|
+
optionalvalue(field, "value", `The field at index ${fieldindex} of the automa block ${key}`) ?? "";
|
|
2916
|
+
const selector = requiredtext(field, "selector", `The field at index ${fieldindex} of the automa block ${key}`);
|
|
2917
|
+
const checked = cssselectorvalid(selector);
|
|
2918
|
+
if (!checked.allowed)
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
`The field at index ${fieldindex} of the automa block ${key} carries the selector ${selector} the reviewed selector grammar refuses: ${checked.reason}`,
|
|
2921
|
+
);
|
|
2922
|
+
reviewed.push({ name, value, selector });
|
|
2923
|
+
});
|
|
2924
|
+
steps.push(
|
|
2925
|
+
convertedstep({
|
|
2926
|
+
entry: `automa block ${key} of type forms`,
|
|
2927
|
+
id: key,
|
|
2928
|
+
kind: "fillform",
|
|
2929
|
+
label: derivedlabel("fillform", `${reviewed.length} form fields`),
|
|
2930
|
+
options: JSON.stringify({ fields: reviewed }),
|
|
2931
|
+
}),
|
|
2932
|
+
);
|
|
2933
|
+
continue;
|
|
2934
|
+
}
|
|
2935
|
+
if (type === "go-back") {
|
|
2936
|
+
steps.push(
|
|
2937
|
+
convertedstep({
|
|
2938
|
+
entry: `automa block ${key} of type go-back`,
|
|
2939
|
+
id: key,
|
|
2940
|
+
kind: "back",
|
|
2941
|
+
label: derivedlabel("back"),
|
|
2942
|
+
}),
|
|
2943
|
+
);
|
|
2944
|
+
continue;
|
|
2945
|
+
}
|
|
2946
|
+
if (type === "close-tab") {
|
|
2947
|
+
steps.push(
|
|
2948
|
+
convertedstep({
|
|
2949
|
+
entry: `automa block ${key} of type close-tab`,
|
|
2950
|
+
id: key,
|
|
2951
|
+
kind: "tabclose",
|
|
2952
|
+
label: derivedlabel("tabclose"),
|
|
2953
|
+
}),
|
|
2954
|
+
);
|
|
2955
|
+
continue;
|
|
2956
|
+
}
|
|
2957
|
+
if (type === "wait") {
|
|
2958
|
+
const ms = data.ms;
|
|
2959
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 1)
|
|
2960
|
+
throw new Error(
|
|
2961
|
+
`The automa block ${key} of type wait carries its ms as a number the reviewed delay grammar refuses; the wait needs a positive integer of milliseconds.`,
|
|
2962
|
+
);
|
|
2963
|
+
steps.push(
|
|
2964
|
+
convertedstep({
|
|
2965
|
+
entry: `automa block ${key} of type wait`,
|
|
2966
|
+
id: key,
|
|
2967
|
+
kind: "delay",
|
|
2968
|
+
label: derivedlabel("delay", `${ms} milliseconds`),
|
|
2969
|
+
options: JSON.stringify({ delay: ms }),
|
|
2970
|
+
}),
|
|
2971
|
+
);
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
if (type === "scroll") {
|
|
2975
|
+
const selector = requiredtext(data, "selector", `The automa block ${key} of type scroll`);
|
|
2976
|
+
steps.push(
|
|
2977
|
+
convertedstep({
|
|
2978
|
+
entry: `automa block ${key} of type scroll`,
|
|
2979
|
+
id: key,
|
|
2980
|
+
kind: "scroll",
|
|
2981
|
+
label: derivedlabel("scroll", selector),
|
|
2982
|
+
target: selector,
|
|
2983
|
+
}),
|
|
2984
|
+
);
|
|
2985
|
+
continue;
|
|
2986
|
+
}
|
|
2987
|
+
if (type === "screenshot") {
|
|
2988
|
+
steps.push(
|
|
2989
|
+
convertedstep({
|
|
2990
|
+
entry: `automa block ${key} of type screenshot`,
|
|
2991
|
+
id: key,
|
|
2992
|
+
kind: "shotview",
|
|
2993
|
+
label: derivedlabel("shotview"),
|
|
2994
|
+
}),
|
|
2995
|
+
);
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
throw new Error(
|
|
2999
|
+
`The automa block ${key} of type ${type} stays outside the conversion table of the automa importer; the importer refuses unmapped block types with the block named instead of guessing a kind.`,
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
if (origin === undefined)
|
|
3003
|
+
throw new Error(
|
|
3004
|
+
"The automa workflow carries no newtab block; the plan origin derives from the first newtab url, so a workflow without one refuses instead of guessing an origin.",
|
|
3005
|
+
);
|
|
3006
|
+
if (steps.length === 0)
|
|
3007
|
+
throw new Error("The automa workflow converted to no step; the plan file grammar needs at least one step.");
|
|
3008
|
+
const gated = steps.filter((step) => step.gate === true).map((step) => step.id);
|
|
3009
|
+
notes.push(
|
|
3010
|
+
"The automa block ids became the step ids and the steps follow the block order the workflow file carries, so the provenance of every converted step stays traceable to its block.",
|
|
3011
|
+
pausedtriggers > 0
|
|
3012
|
+
? `The trigger block${pausedtriggers === 1 ? "" : "s"} converted to no plan step: ${pausedtriggers === 1 ? "it lands" : "they land"} in this report as the paused trigger the scheduler arms only after the plan review, exactly the paused state the roadmap promises.`
|
|
3013
|
+
: "The workflow carries no trigger block.",
|
|
3014
|
+
`The converted sensitive step${gated.length === 1 ? "" : "s"} ${gated.join(", ")} carr${gated.length === 1 ? "ies" : "ry"} the explicit gate declaration the review demands, and the converted plan inherits no consent grant from the automa workflow.`,
|
|
3015
|
+
);
|
|
3016
|
+
const file: planfile = { version: packageversion, goal, origin, steps };
|
|
3017
|
+
return {
|
|
3018
|
+
file,
|
|
3019
|
+
format: "automa",
|
|
3020
|
+
sourceversion,
|
|
3021
|
+
mapped: steps.length,
|
|
3022
|
+
notes: [provenancenote({ format: "automa", sourceversion, mapped: steps.length, now: input.now }), ...notes],
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/** Resolves one selenium or ui vision locator target onto the reviewed css selector grammar: the css= prefix passes its selector verbatim, the id= prefix resolves onto the #id form and the name= prefix resolves onto the [name=value] attribute form, while every other locator strategy — an xpath, a link text or a bare locator — refuses with the entry named because the importer never guesses a selector. */
|
|
3027
|
+
function resolvedlocator(entry: string, target: string): string {
|
|
3028
|
+
const trimmed = target.trim();
|
|
3029
|
+
if (trimmed.startsWith("css=")) {
|
|
3030
|
+
const selector = trimmed.slice("css=".length).trim();
|
|
3031
|
+
if (selector === "")
|
|
3032
|
+
throw new Error(`The ${entry} carries an empty css= locator; a step that addresses the page names its selector.`);
|
|
3033
|
+
return selector;
|
|
3034
|
+
}
|
|
3035
|
+
if (trimmed.startsWith("id=")) {
|
|
3036
|
+
const id = trimmed.slice("id=".length).trim();
|
|
3037
|
+
if (id === "")
|
|
3038
|
+
throw new Error(`The ${entry} carries an empty id= locator; a step that addresses the page names its element.`);
|
|
3039
|
+
return `#${id}`;
|
|
3040
|
+
}
|
|
3041
|
+
if (trimmed.startsWith("name=")) {
|
|
3042
|
+
const name = trimmed.slice("name=".length).trim();
|
|
3043
|
+
if (name === "")
|
|
3044
|
+
throw new Error(`The ${entry} carries an empty name= locator; a step that addresses the page names its element.`);
|
|
3045
|
+
return `[name=${name}]`;
|
|
3046
|
+
}
|
|
3047
|
+
throw new Error(
|
|
3048
|
+
`The ${entry} carries the locator ${trimmed} outside the css=, id= and name= locator prefixes the importer resolves; the importer never guesses a selector.`,
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
/** Reads the reviewed step id of one selenium command: the command id the side file carries, because the identifier preservation of the migration bridge keeps the source identity of every step. */
|
|
3053
|
+
function seleniumstepid(command: Record<string, unknown>, index: number, testname: string): string {
|
|
3054
|
+
const id = optionaltext(command, "id", `The selenium command of ${testname} at index ${index}`);
|
|
3055
|
+
return id ?? `${testname}-${index + 1}`;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* Converts one selenium ide side file into the reviewed plan file grammar of the migration bridge.
|
|
3060
|
+
* The selenium grammar this importer reads: the root carries its version, name, the base url every open target resolves against and its tests; every test carries its commands, and every command carries its id, command, target and value. One plan addresses exactly one test, so a side file with several tests refuses with the count named. The command table: open becomes navigate with the resolved absolute url as its value, click becomes click, type becomes type, sendKeys becomes appendtext (both deliver keystrokes to the addressed element — type replaces the content and sendKeys appends), select becomes select and pause becomes delay with its milliseconds lifted onto the reviewed delay option grammar. Every other command refuses with the command named. The target grammar accepts the css=, id= and name= locator prefixes; every other locator strategy refuses with the command named.
|
|
3061
|
+
* The importer converts only: it opens no page, types no text and never executes anything outside the normal consent gates.
|
|
3062
|
+
*/
|
|
3063
|
+
export function importselenium(input: { source: unknown; now: number }): conversionrecord {
|
|
3064
|
+
if (typeof input.source !== "object" || input.source === null || Array.isArray(input.source))
|
|
3065
|
+
throw new Error("The selenium side file must be a json object.");
|
|
3066
|
+
const root = input.source as Record<string, unknown>;
|
|
3067
|
+
const unknownroot = Object.keys(root).filter(
|
|
3068
|
+
(key) => !["id", "version", "name", "url", "tests", "suites"].includes(key),
|
|
3069
|
+
);
|
|
3070
|
+
if (unknownroot.length > 0)
|
|
3071
|
+
throw new Error(
|
|
3072
|
+
`The selenium side file carries the unknown field${unknownroot.length === 1 ? "" : "s"} ${unknownroot.join(", ")}; the migration reports fields without a destination as refusals, never as silent drops.`,
|
|
3073
|
+
);
|
|
3074
|
+
const sourceversion = requiredtext(root, "version", "The selenium side file");
|
|
3075
|
+
const goal = requiredtext(root, "name", "The selenium side file");
|
|
3076
|
+
const base = requiredtext(root, "url", "The selenium side file");
|
|
3077
|
+
if (!base.startsWith("https://"))
|
|
3078
|
+
throw new Error("The selenium side file url must be the HTTPS base origin the plan addresses.");
|
|
3079
|
+
const origin = new URL(base).origin;
|
|
3080
|
+
const tests = root.tests;
|
|
3081
|
+
if (!Array.isArray(tests)) throw new Error("The selenium side file carries its tests as a list.");
|
|
3082
|
+
if (tests.length !== 1)
|
|
3083
|
+
throw new Error(
|
|
3084
|
+
`The selenium side file carries ${tests.length} test${tests.length === 1 ? "" : "s"}; one plan addresses exactly one test, so split the side file or pick the test before the conversion.`,
|
|
3085
|
+
);
|
|
3086
|
+
const rawtest = tests[0];
|
|
3087
|
+
if (typeof rawtest !== "object" || rawtest === null || Array.isArray(rawtest))
|
|
3088
|
+
throw new Error("The selenium test must be an object.");
|
|
3089
|
+
const test = rawtest as Record<string, unknown>;
|
|
3090
|
+
const testname = requiredtext(test, "name", "The selenium test");
|
|
3091
|
+
const commands = test.commands;
|
|
3092
|
+
if (!Array.isArray(commands) || commands.length === 0)
|
|
3093
|
+
throw new Error(`The selenium test ${testname} carries no command; the plan file grammar needs at least one step.`);
|
|
3094
|
+
const notes: string[] = [];
|
|
3095
|
+
const steps: planfilestep[] = [];
|
|
3096
|
+
commands.forEach((rawcommand, index) => {
|
|
3097
|
+
const path = `The selenium command of ${testname} at index ${index}`;
|
|
3098
|
+
if (typeof rawcommand !== "object" || rawcommand === null || Array.isArray(rawcommand))
|
|
3099
|
+
throw new Error(`${path} must be an object.`);
|
|
3100
|
+
const command = rawcommand as Record<string, unknown>;
|
|
3101
|
+
const name = requiredtext(command, "command", path);
|
|
3102
|
+
const id = seleniumstepid(command, index, testname);
|
|
3103
|
+
const target = optionaltext(command, "target", path);
|
|
3104
|
+
const value = optionalvalue(command, "value", path);
|
|
3105
|
+
if (name === "open") {
|
|
3106
|
+
if (target === undefined)
|
|
3107
|
+
throw new Error(
|
|
3108
|
+
`The selenium command ${id} of open carries no target; the open command names the url it resolves against the side file base.`,
|
|
3109
|
+
);
|
|
3110
|
+
let resolved: string;
|
|
3111
|
+
if (target.startsWith("https://")) resolved = target;
|
|
3112
|
+
else if (target.startsWith("/")) resolved = `${origin}${target}`;
|
|
3113
|
+
else
|
|
3114
|
+
throw new Error(
|
|
3115
|
+
`The selenium command ${id} of open carries the target ${target} the importer resolves neither as an absolute url nor as a base relative path; the importer never guesses a url.`,
|
|
3116
|
+
);
|
|
3117
|
+
const commandorigin = new URL(resolved).origin;
|
|
3118
|
+
if (commandorigin !== origin)
|
|
3119
|
+
throw new Error(
|
|
3120
|
+
`The selenium command ${id} of open navigates to ${commandorigin} while the plan addresses ${origin}; one plan addresses exactly one origin, so the importer refuses the navigation instead of guessing.`,
|
|
3121
|
+
);
|
|
3122
|
+
steps.push(
|
|
3123
|
+
convertedstep({
|
|
3124
|
+
entry: `selenium command ${id} of ${name}`,
|
|
3125
|
+
id,
|
|
3126
|
+
kind: "navigate",
|
|
3127
|
+
label: derivedlabel("navigate", resolved),
|
|
3128
|
+
value: resolved,
|
|
3129
|
+
}),
|
|
3130
|
+
);
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
if (name === "click" || name === "type" || name === "sendKeys" || name === "select") {
|
|
3134
|
+
if (target === undefined)
|
|
3135
|
+
throw new Error(
|
|
3136
|
+
`The selenium command ${id} of ${name} carries no target; a step that addresses the page names its selector.`,
|
|
3137
|
+
);
|
|
3138
|
+
const selector = resolvedlocator(`selenium command ${id} of ${name}`, target);
|
|
3139
|
+
const kind = name === "sendKeys" ? "appendtext" : name;
|
|
3140
|
+
steps.push(
|
|
3141
|
+
convertedstep({
|
|
3142
|
+
entry: `selenium command ${id} of ${name}`,
|
|
3143
|
+
id,
|
|
3144
|
+
kind,
|
|
3145
|
+
label: derivedlabel(kind, value !== undefined ? `${selector} with ${value}` : selector),
|
|
3146
|
+
target: selector,
|
|
3147
|
+
...(value !== undefined ? { value } : {}),
|
|
3148
|
+
}),
|
|
3149
|
+
);
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
if (name === "pause") {
|
|
3153
|
+
if (target === undefined || !/^\d+$/.test(target.trim()) || Number(target.trim()) < 1)
|
|
3154
|
+
throw new Error(
|
|
3155
|
+
`The selenium command ${id} of pause carries its target ${target ?? ""} as the milliseconds the reviewed delay grammar refuses; the pause needs a positive integer of milliseconds.`,
|
|
3156
|
+
);
|
|
3157
|
+
const ms = Number(target.trim());
|
|
3158
|
+
steps.push(
|
|
3159
|
+
convertedstep({
|
|
3160
|
+
entry: `selenium command ${id} of ${name}`,
|
|
3161
|
+
id,
|
|
3162
|
+
kind: "delay",
|
|
3163
|
+
label: derivedlabel("delay", `${ms} milliseconds`),
|
|
3164
|
+
options: JSON.stringify({ delay: ms }),
|
|
3165
|
+
}),
|
|
3166
|
+
);
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
throw new Error(
|
|
3170
|
+
`The selenium command ${id} of ${name} stays outside the conversion table of the selenium importer; the importer refuses unmapped commands with the command named instead of guessing a kind.`,
|
|
3171
|
+
);
|
|
3172
|
+
});
|
|
3173
|
+
const gated = steps.filter((step) => step.gate === true).map((step) => step.id);
|
|
3174
|
+
notes.push(
|
|
3175
|
+
`The selenium command ids became the step ids and the command table mapped open onto navigate, click onto click, type onto type, sendKeys onto appendtext, select onto select and pause onto the reviewed delay option grammar; the field mapping notes ride this report because the plan file grammar carries no metadata field.`,
|
|
3176
|
+
`The css=, id= and name= locator prefixes resolved onto the reviewed css selector grammar while the base url ${origin} became the plan origin; the importer resolved no xpath and guessed no selector.`,
|
|
3177
|
+
`The converted sensitive step${gated.length === 1 ? "" : "s"} ${gated.join(", ")} carr${gated.length === 1 ? "ies" : "ry"} the explicit gate declaration the review demands, and the converted plan inherits no consent grant from the selenium side file.`,
|
|
3178
|
+
);
|
|
3179
|
+
const file: planfile = { version: packageversion, goal, origin, steps };
|
|
3180
|
+
return {
|
|
3181
|
+
file,
|
|
3182
|
+
format: "selenium",
|
|
3183
|
+
sourceversion,
|
|
3184
|
+
mapped: steps.length,
|
|
3185
|
+
notes: [provenancenote({ format: "selenium", sourceversion, mapped: steps.length, now: input.now }), ...notes],
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
/**
|
|
3190
|
+
* Converts one ui vision (kantu) macro into the reviewed plan file grammar of the migration bridge.
|
|
3191
|
+
* The ui vision grammar this importer reads: the root carries the macro Name and its Commands list, and every command carries its Command, Target and Value with the capitalized field names the macro format records. The command table: open becomes navigate with the absolute url as its value, click becomes click, type becomes type and verifyText becomes waittext; every other command refuses with the command named. The macro format carries no step identifiers, so the importer derives stable step ids from the command name and the position that stay stable across reimports of the same macro. The plan origin derives from the first open target — one plan addresses exactly one origin, so a later open away from that origin refuses — and the target grammar accepts the same css=, id= and name= locator prefixes the selenium importer resolves.
|
|
3192
|
+
* The importer converts only: it opens no page, clicks nothing and never executes anything outside the normal consent gates.
|
|
3193
|
+
*/
|
|
3194
|
+
export function importuivision(input: { source: unknown; now: number }): conversionrecord {
|
|
3195
|
+
if (typeof input.source !== "object" || input.source === null || Array.isArray(input.source))
|
|
3196
|
+
throw new Error("The ui vision macro must be a json object.");
|
|
3197
|
+
const root = input.source as Record<string, unknown>;
|
|
3198
|
+
const unknownroot = Object.keys(root).filter((key) => !["Name", "CreationDate", "Commands"].includes(key));
|
|
3199
|
+
if (unknownroot.length > 0)
|
|
3200
|
+
throw new Error(
|
|
3201
|
+
`The ui vision macro carries the unknown field${unknownroot.length === 1 ? "" : "s"} ${unknownroot.join(", ")}; the migration reports fields without a destination as refusals, never as silent drops.`,
|
|
3202
|
+
);
|
|
3203
|
+
const goal = requiredtext(root, "Name", "The ui vision macro");
|
|
3204
|
+
const commands = root.Commands;
|
|
3205
|
+
if (!Array.isArray(commands) || commands.length === 0)
|
|
3206
|
+
throw new Error("The ui vision macro carries no command; the plan file grammar needs at least one step.");
|
|
3207
|
+
const notes: string[] = [];
|
|
3208
|
+
const steps: planfilestep[] = [];
|
|
3209
|
+
let origin: string | undefined;
|
|
3210
|
+
commands.forEach((rawcommand, index) => {
|
|
3211
|
+
const path = `The ui vision command at index ${index}`;
|
|
3212
|
+
if (typeof rawcommand !== "object" || rawcommand === null || Array.isArray(rawcommand))
|
|
3213
|
+
throw new Error(`${path} must be an object.`);
|
|
3214
|
+
const command = rawcommand as Record<string, unknown>;
|
|
3215
|
+
const name = requiredtext(command, "Command", path);
|
|
3216
|
+
const target = optionaltext(command, "Target", path);
|
|
3217
|
+
const value = optionalvalue(command, "Value", path);
|
|
3218
|
+
const id = `${name.toLowerCase()}-${index + 1}`;
|
|
3219
|
+
if (name === "open") {
|
|
3220
|
+
if (target === undefined)
|
|
3221
|
+
throw new Error(
|
|
3222
|
+
`The ui vision command ${id} of open carries no Target; the open command names the absolute url it opens.`,
|
|
3223
|
+
);
|
|
3224
|
+
if (!target.startsWith("https://"))
|
|
3225
|
+
throw new Error(
|
|
3226
|
+
`The ui vision command ${id} of open carries the Target ${target} the reviewed grammar refuses; the plan origin must be an HTTPS origin.`,
|
|
3227
|
+
);
|
|
3228
|
+
const commandorigin = new URL(target).origin;
|
|
3229
|
+
if (origin === undefined) origin = commandorigin;
|
|
3230
|
+
else if (origin !== commandorigin)
|
|
3231
|
+
throw new Error(
|
|
3232
|
+
`The ui vision command ${id} of open navigates to ${commandorigin} while the plan addresses ${origin}; one plan addresses exactly one origin, so the importer refuses the navigation instead of guessing.`,
|
|
3233
|
+
);
|
|
3234
|
+
steps.push(
|
|
3235
|
+
convertedstep({
|
|
3236
|
+
entry: `ui vision command ${id} of ${name}`,
|
|
3237
|
+
id,
|
|
3238
|
+
kind: "navigate",
|
|
3239
|
+
label: derivedlabel("navigate", target),
|
|
3240
|
+
value: target,
|
|
3241
|
+
}),
|
|
3242
|
+
);
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
if (name === "click" || name === "type" || name === "verifyText") {
|
|
3246
|
+
if (target === undefined)
|
|
3247
|
+
throw new Error(
|
|
3248
|
+
`The ui vision command ${id} of ${name} carries no Target; a step that addresses the page names its selector.`,
|
|
3249
|
+
);
|
|
3250
|
+
const selector = resolvedlocator(`ui vision command ${id} of ${name}`, target);
|
|
3251
|
+
const kind = name === "verifyText" ? "waittext" : name.toLowerCase();
|
|
3252
|
+
steps.push(
|
|
3253
|
+
convertedstep({
|
|
3254
|
+
entry: `ui vision command ${id} of ${name}`,
|
|
3255
|
+
id,
|
|
3256
|
+
kind,
|
|
3257
|
+
label: derivedlabel(kind, value !== undefined ? `${selector} with ${value}` : selector),
|
|
3258
|
+
target: selector,
|
|
3259
|
+
...(value !== undefined ? { value } : {}),
|
|
3260
|
+
}),
|
|
3261
|
+
);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
throw new Error(
|
|
3265
|
+
`The ui vision command ${id} of ${name} stays outside the conversion table of the ui vision importer; the importer refuses unmapped commands with the command named instead of guessing a kind.`,
|
|
3266
|
+
);
|
|
3267
|
+
});
|
|
3268
|
+
if (origin === undefined)
|
|
3269
|
+
throw new Error(
|
|
3270
|
+
"The ui vision macro carries no open command; the plan origin derives from the first open target, so a macro without one refuses instead of guessing an origin.",
|
|
3271
|
+
);
|
|
3272
|
+
const gated = steps.filter((step) => step.gate === true).map((step) => step.id);
|
|
3273
|
+
notes.push(
|
|
3274
|
+
"The macro format carries no step identifiers, so the importer derived stable step ids from the command name and the position; the ids stay stable across reimports of the same macro.",
|
|
3275
|
+
`The Command, Target and Value fields moved onto the kind, target and value fields of the reviewed step grammar with the css=, id= and name= locator prefixes resolved onto the reviewed css selector grammar; the plan origin ${origin} derives from the first open target.`,
|
|
3276
|
+
`The converted sensitive step${gated.length === 1 ? "" : "s"} ${gated.join(", ")} carr${gated.length === 1 ? "ies" : "ry"} the explicit gate declaration the review demands, and the converted plan inherits no consent grant from the ui vision macro.`,
|
|
3277
|
+
);
|
|
3278
|
+
const file: planfile = { version: packageversion, goal, origin, steps };
|
|
3279
|
+
return {
|
|
3280
|
+
file,
|
|
3281
|
+
format: "uivision",
|
|
3282
|
+
sourceversion: optionaltext(root, "CreationDate", "The ui vision macro") ?? "unrecorded",
|
|
3283
|
+
mapped: steps.length,
|
|
3284
|
+
notes: [
|
|
3285
|
+
provenancenote({
|
|
3286
|
+
format: "uivision",
|
|
3287
|
+
sourceversion: optionaltext(root, "CreationDate", "The ui vision macro") ?? "unrecorded",
|
|
3288
|
+
mapped: steps.length,
|
|
3289
|
+
now: input.now,
|
|
3290
|
+
}),
|
|
3291
|
+
...notes,
|
|
3292
|
+
],
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/** Parses one csv table into its rows of cells: quoted cells carry their embedded commas, quotes and newlines, a doubled quote inside a quoted cell escapes one quote, the carriage returns of crlf line endings never enter a cell, and a table that ends inside a quoted cell refuses instead of guessing the cell end. */
|
|
3297
|
+
export function parsecsvrows(text: string): string[][] {
|
|
3298
|
+
const rows: string[][] = [];
|
|
3299
|
+
let row: string[] = [];
|
|
3300
|
+
let cell = "";
|
|
3301
|
+
let inquote = false;
|
|
3302
|
+
let cellstarted = false;
|
|
3303
|
+
const pushcell = (): void => {
|
|
3304
|
+
row.push(cell);
|
|
3305
|
+
cell = "";
|
|
3306
|
+
cellstarted = false;
|
|
3307
|
+
};
|
|
3308
|
+
const pushrow = (): void => {
|
|
3309
|
+
if (row.length > 0 || cellstarted || cell !== "") {
|
|
3310
|
+
pushcell();
|
|
3311
|
+
rows.push(row);
|
|
3312
|
+
row = [];
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3316
|
+
const char = text[index] ?? "";
|
|
3317
|
+
if (inquote) {
|
|
3318
|
+
if (char === '"') {
|
|
3319
|
+
if ((text[index + 1] ?? "") === '"') {
|
|
3320
|
+
cell += '"';
|
|
3321
|
+
index += 1;
|
|
3322
|
+
} else inquote = false;
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
cell += char;
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
if (char === '"') {
|
|
3329
|
+
inquote = true;
|
|
3330
|
+
cellstarted = true;
|
|
3331
|
+
continue;
|
|
3332
|
+
}
|
|
3333
|
+
if (char === ",") {
|
|
3334
|
+
pushcell();
|
|
3335
|
+
continue;
|
|
3336
|
+
}
|
|
3337
|
+
if (char === "\n") {
|
|
3338
|
+
pushrow();
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3341
|
+
if (char === "\r") continue; /* the carriage return of a crlf line ending never enters a cell */
|
|
3342
|
+
cell += char;
|
|
3343
|
+
cellstarted = true;
|
|
3344
|
+
}
|
|
3345
|
+
if (inquote)
|
|
3346
|
+
throw new Error(
|
|
3347
|
+
"The csv table ends inside a quoted cell; an unterminated quote refuses the parse instead of guessing the cell end.",
|
|
3348
|
+
);
|
|
3349
|
+
pushrow(); /* the last row of a table without its trailing newline */
|
|
3350
|
+
return rows;
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
/**
|
|
3354
|
+
* Converts one tabular csv plan into the reviewed plan file grammar of the migration bridge.
|
|
3355
|
+
* The tabular grammar this importer reads: the file opens with its preface comment lines — one `# goal:` line and one `# origin:` line — because a csv row set carries no metadata of its own; the header row then names the three columns step, target and value in any order, and every data row becomes one reviewed step: the step cell names the reviewed kind, the target cell names the css selector, the value cell carries the literal value, and the label derives from the kind and the target because a row set carries no labels. A row whose step cell names a kind outside the reviewed catalog refuses with the row named, the step ids derive from the kind and the row position so a reimport of the same table keeps its ids, and the csv parser honors quoted cells with embedded commas, quotes and newlines.
|
|
3356
|
+
* The importer converts only: it runs no step and never executes anything outside the normal consent gates.
|
|
3357
|
+
*/
|
|
3358
|
+
export function importtabular(input: { text: string; now: number }): conversionrecord {
|
|
3359
|
+
const rawlines = input.text.split("\n");
|
|
3360
|
+
let cursor = 0;
|
|
3361
|
+
let goal: string | undefined;
|
|
3362
|
+
let origin: string | undefined;
|
|
3363
|
+
while (cursor < rawlines.length && (rawlines[cursor] ?? "").trim().startsWith("#")) {
|
|
3364
|
+
/* the preface markers read as linear string scans over the uncontrolled preface text: a trimmed comment line sheds its marker, and a body that opens with the marker word and its colon carries the value — constant-shape scans never backtrack, so no polynomial regular expression ever runs over the tabular preface (this closes the two code scanning alerts the migration bridge carried) */
|
|
3365
|
+
const comment = (rawlines[cursor] ?? "").trim();
|
|
3366
|
+
const body = comment.slice(1).trim();
|
|
3367
|
+
const goalvalue = body.startsWith("goal:") ? body.slice("goal:".length).trim() : "";
|
|
3368
|
+
if (goalvalue !== "") goal = goalvalue;
|
|
3369
|
+
const originvalue = body.startsWith("origin:") ? body.slice("origin:".length).trim() : "";
|
|
3370
|
+
if (originvalue !== "") origin = originvalue;
|
|
3371
|
+
cursor += 1;
|
|
3372
|
+
}
|
|
3373
|
+
if (goal === undefined || goal === "")
|
|
3374
|
+
throw new Error(
|
|
3375
|
+
"The tabular source carries no `# goal:` preface line; a csv row set holds no metadata of its own, so the goal must ride the preface the conversion reads.",
|
|
3376
|
+
);
|
|
3377
|
+
if (origin === undefined || origin === "")
|
|
3378
|
+
throw new Error(
|
|
3379
|
+
"The tabular source carries no `# origin:` preface line; a csv row set holds no metadata of its own, so the HTTPS origin must ride the preface the conversion reads.",
|
|
3380
|
+
);
|
|
3381
|
+
if (!origin.startsWith("https://")) throw new Error("The tabular source origin preface must be an HTTPS origin.");
|
|
3382
|
+
const table = parsecsvrows(rawlines.slice(cursor).join("\n"));
|
|
3383
|
+
const header = table[0];
|
|
3384
|
+
if (header === undefined)
|
|
3385
|
+
throw new Error("The tabular source carries no header row; the header names the step, target and value columns.");
|
|
3386
|
+
const columns = header.map((cell) => cell.trim());
|
|
3387
|
+
for (const column of columns)
|
|
3388
|
+
if (column !== "step" && column !== "target" && column !== "value")
|
|
3389
|
+
throw new Error(
|
|
3390
|
+
`The tabular header carries the column ${column} outside the step, target and value columns; the migration reports columns without a destination as refusals, never as silent drops.`,
|
|
3391
|
+
);
|
|
3392
|
+
for (const column of ["step", "target", "value"])
|
|
3393
|
+
if (!columns.includes(column))
|
|
3394
|
+
throw new Error(
|
|
3395
|
+
`The tabular header misses its ${column} column; every tabular plan names its step, target and value columns.`,
|
|
3396
|
+
);
|
|
3397
|
+
const stepcolumn = columns.indexOf("step");
|
|
3398
|
+
const targetcolumn = columns.indexOf("target");
|
|
3399
|
+
const valuecolumn = columns.indexOf("value");
|
|
3400
|
+
const steps: planfilestep[] = [];
|
|
3401
|
+
table.slice(1).forEach((cells, rowindex) => {
|
|
3402
|
+
const rownumber = rowindex + 1;
|
|
3403
|
+
if (cells.every((cell) => cell.trim() === "")) return; /* an empty row never becomes a step */
|
|
3404
|
+
const kind = (cells[stepcolumn] ?? "").trim();
|
|
3405
|
+
if (kind === "")
|
|
3406
|
+
throw new Error(
|
|
3407
|
+
`The tabular row ${rownumber} carries no step cell; every row names the reviewed kind it converts onto.`,
|
|
3408
|
+
);
|
|
3409
|
+
const target = (cells[targetcolumn] ?? "").trim();
|
|
3410
|
+
const value = cells[valuecolumn] ?? "";
|
|
3411
|
+
const detail = target !== "" ? target : value.trim() !== "" ? value.trim() : undefined;
|
|
3412
|
+
steps.push(
|
|
3413
|
+
convertedstep({
|
|
3414
|
+
entry: `tabular row ${rownumber}`,
|
|
3415
|
+
id: `${kind}-${rownumber}`,
|
|
3416
|
+
kind,
|
|
3417
|
+
label: derivedlabel(kind, detail),
|
|
3418
|
+
...(target !== "" ? { target } : {}),
|
|
3419
|
+
...(value.trim() !== "" ? { value } : {}),
|
|
3420
|
+
}),
|
|
3421
|
+
);
|
|
3422
|
+
});
|
|
3423
|
+
if (steps.length === 0)
|
|
3424
|
+
throw new Error("The tabular source converted to no step; the plan file grammar needs at least one step.");
|
|
3425
|
+
const gated = steps.filter((step) => step.gate === true).map((step) => step.id);
|
|
3426
|
+
const notes: string[] = [
|
|
3427
|
+
`The tabular header row named the step, target and value columns, every data row became one reviewed step and the step ids derive from the kind and the row position so a reimport of the same table keeps its ids; the labels derive from the kind and the target because a row set carries no labels.`,
|
|
3428
|
+
`The converted sensitive step${gated.length === 1 ? "" : "s"} ${gated.join(", ")} carr${gated.length === 1 ? "ies" : "ry"} the explicit gate declaration the review demands, and the converted plan inherits no consent grant from the tabular source.`,
|
|
3429
|
+
];
|
|
3430
|
+
const file: planfile = { version: packageversion, goal, origin, steps };
|
|
3431
|
+
return {
|
|
3432
|
+
file,
|
|
3433
|
+
format: "tabular",
|
|
3434
|
+
sourceversion: "csv row set",
|
|
3435
|
+
mapped: steps.length,
|
|
3436
|
+
notes: [
|
|
3437
|
+
provenancenote({ format: "tabular", sourceversion: "csv row set", mapped: steps.length, now: input.now }),
|
|
3438
|
+
...notes,
|
|
3439
|
+
],
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
/** Dispatches one migration conversion onto its importer by the format the caller declared: the pure core of the migrateplan command, so the terminal wiring and the tests drive the same conversion. The tabular format reads its text; every other format parses its json first and refuses a source that parses no json with the format named. */
|
|
3444
|
+
export function migrateplanconversion(input: { format: string; text: string; now: number }): conversionrecord {
|
|
3445
|
+
if (input.format === "tabular") return importtabular({ text: input.text, now: input.now });
|
|
3446
|
+
if (input.format !== "v1" && input.format !== "automa" && input.format !== "selenium" && input.format !== "uivision")
|
|
3447
|
+
throw new Error(
|
|
3448
|
+
`The migrateplan format ${input.format} stays outside the v1, automa, selenium, uivision and tabular source formats; the converter never guesses the source format.`,
|
|
3449
|
+
);
|
|
3450
|
+
let source: unknown;
|
|
3451
|
+
try {
|
|
3452
|
+
source = JSON.parse(input.text);
|
|
3453
|
+
} catch (error) {
|
|
3454
|
+
throw new Error(
|
|
3455
|
+
`The ${input.format} source parses no json: ${error instanceof Error ? error.message : String(error)}`,
|
|
3456
|
+
);
|
|
3457
|
+
}
|
|
3458
|
+
if (input.format === "v1") return importv1plan({ source, now: input.now });
|
|
3459
|
+
if (input.format === "automa") return importautoma({ source, now: input.now });
|
|
3460
|
+
if (input.format === "selenium") return importselenium({ source, now: input.now });
|
|
3461
|
+
return importuivision({ source, now: input.now });
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/** Reads the bundle size accounting of every dist target of the library modes: every target name with its byte size grades against its budget, so a bundle that outgrows the budget fails the build before it ships. */
|
|
3465
|
+
export function bundlesizeaccounting(input: Array<{ target: string; bytes: number }>): {
|
|
3466
|
+
ok: boolean;
|
|
3467
|
+
over: Array<{ target: string; bytes: number; budget: number }>;
|
|
3468
|
+
reason: string;
|
|
3469
|
+
} {
|
|
3470
|
+
/** The byte budget of every dist target of the library modes: the bounds are build-time engineering bounds on the unminified variants (a minified variant stays strictly smaller than its unminified bundle), and an overgrown bundle fails the build before it ships. */
|
|
3471
|
+
const budgets: Record<string, number> = {
|
|
3472
|
+
index: 1_800_000,
|
|
3473
|
+
indexcjs: 1_900_000,
|
|
3474
|
+
neutral: 1_800_000,
|
|
3475
|
+
umd: 1_900_000,
|
|
3476
|
+
node: 1_900_000,
|
|
3477
|
+
bun: 1_800_000,
|
|
3478
|
+
deno: 1_800_000,
|
|
3479
|
+
cli: 600_000,
|
|
3480
|
+
headless: 700_000,
|
|
3481
|
+
policy: 600_000,
|
|
3482
|
+
protocol: 380_000,
|
|
3483
|
+
memory: 330_000,
|
|
3484
|
+
progress: 60_000,
|
|
3485
|
+
};
|
|
3486
|
+
const over: Array<{ target: string; bytes: number; budget: number }> = [];
|
|
3487
|
+
for (const entry of input) {
|
|
3488
|
+
const budget = budgets[entry.target];
|
|
3489
|
+
if (budget === undefined) continue;
|
|
3490
|
+
if (entry.bytes > budget) over.push({ target: entry.target, bytes: entry.bytes, budget });
|
|
3491
|
+
}
|
|
3492
|
+
return {
|
|
3493
|
+
ok: over.length === 0,
|
|
3494
|
+
over,
|
|
3495
|
+
reason:
|
|
3496
|
+
over.length === 0
|
|
3497
|
+
? `Every dist target stays inside its size budget: ${input.map((entry) => `${entry.target}=${entry.bytes}b`).join(", ")}.`
|
|
3498
|
+
: `The dist target${over.length === 1 ? "" : "s"} ${over.map((entry) => entry.target).join(", ")} exceed${over.length === 1 ? "s" : ""} the size budget: ${over.map((entry) => `${entry.target}=${entry.bytes}b>${entry.budget}b`).join(", ")}.`,
|
|
3499
|
+
};
|
|
3500
|
+
}
|