toolfactory 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser extension: one WXT project under `hosts/browser/`, built for Chromium, Firefox and
|
|
3
|
+
* Safari by the upstream toolchain. The surface is both halves of the boundary at once. For
|
|
4
|
+
* portable operations it is a *client of the kernel*, exactly like `web`: the background worker
|
|
5
|
+
* POSTs stateless MCP to the `mcp --http` endpoint on loopback (hence `requires: ["mcp"]`) and
|
|
6
|
+
* the popup renders the `web` surface's own component tree, imported. For an operation that
|
|
7
|
+
* needs a `browser`, it is §4.4's bridge 3, the host-native escape hatch: the content script and
|
|
8
|
+
* the background tail are the author's, and they turn page state into JSON arguments before the
|
|
9
|
+
* call.
|
|
10
|
+
*
|
|
11
|
+
* Every per-browser manifest transform — MV2 vs MV3, `background.service_worker` vs
|
|
12
|
+
* `background.scripts`, `action` vs `browser_action`, `host_permissions` folded into
|
|
13
|
+
* `permissions` — is WXT's, never this file's: the manifest is not written here but projected
|
|
14
|
+
* into `wxt.config.ts`, and `wxt build -b <target>` emits three of them.
|
|
15
|
+
*
|
|
16
|
+
* §8 C2, "mirror scaffolds by execution, not transcription": `plan()` is pure and therefore has
|
|
17
|
+
* to carry the generator's output, so every pinned value lives in BROWSER_SCAFFOLD below and
|
|
18
|
+
* nowhere else, and `validate()` runs `src/hosts/browser.ts`, which re-runs `wxt init` in a temp
|
|
19
|
+
* directory and fails naming whatever drifted. The placeholder icon set is deliberately not
|
|
20
|
+
* planned, for the same reason the shadcn components are not: they are WXT's binary files, and
|
|
21
|
+
* the drift host copies the ones the project is missing out of that same fresh init.
|
|
22
|
+
*/
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { execArgv } from "node:process";
|
|
25
|
+
import { DRIFT_ENTRY } from "../hosts/browser.js";
|
|
26
|
+
import { githubOwner } from "../identity/name.js";
|
|
27
|
+
import { includedOperations } from "../report/coverage.js";
|
|
28
|
+
import { has, mcpVerdict } from "./shared.js";
|
|
29
|
+
import { WEB_SCAFFOLD } from "./web.js";
|
|
30
|
+
export const HOST_DIR = "hosts/browser";
|
|
31
|
+
/** The path from `hosts/browser/` to the `web` surface's source tree. */
|
|
32
|
+
const WEB_SRC_FROM_HOST = "../../web/src";
|
|
33
|
+
/**
|
|
34
|
+
* The WXT release the scaffold, the build and the drift probe all run. Exact, not a range: the
|
|
35
|
+
* constants below are what *this* version's `init` writes, and the drift host runs
|
|
36
|
+
* `npx wxt@<pin> init` to prove it. Renovate bumps it (`renovate.json` `customManagers`).
|
|
37
|
+
*/
|
|
38
|
+
export const WXT_PIN = "0.21.4";
|
|
39
|
+
/** The engines one codebase is built for; `wxt build -b <target>` names each. */
|
|
40
|
+
export const TARGETS = ["chrome", "edge", "firefox", "safari"];
|
|
41
|
+
/** The three engines a verdict can differ on; Chrome and Edge are one Chromium package. */
|
|
42
|
+
export const ENGINES = ["chromium", "firefox", "safari"];
|
|
43
|
+
/** Where `<tool> mcp --http` listens by default, and therefore what the extension pairs with. */
|
|
44
|
+
export const DEFAULT_ENDPOINT = "http://127.0.0.1:3000/mcp";
|
|
45
|
+
/** MCP revision whose per-request `_meta` keys the kernel reads; the `web` surface sends the same. */
|
|
46
|
+
const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
47
|
+
/** The generated regions: everything outside them is the author's and survives every build. */
|
|
48
|
+
export const CONFIG_BEGIN = "// tf:config";
|
|
49
|
+
export const CONFIG_END = "// /tf:config";
|
|
50
|
+
export const BG_BEGIN = "// tf:bg";
|
|
51
|
+
export const BG_END = "// /tf:bg";
|
|
52
|
+
export const CONTENT_BEGIN = "// tf:content";
|
|
53
|
+
export const CONTENT_END = "// /tf:content";
|
|
54
|
+
/**
|
|
55
|
+
* Everything `npx wxt@<WXT_PIN> init <dir> --template react --pm npm` wrote, transcribed once;
|
|
56
|
+
* `src/hosts/browser.ts` proves it current by running that command again.
|
|
57
|
+
*/
|
|
58
|
+
export const BROWSER_SCAFFOLD = {
|
|
59
|
+
template: "react",
|
|
60
|
+
packageManager: "npm",
|
|
61
|
+
/** The placeholder icon set the template ships; WXT auto-wires `icon/<size>.png`. */
|
|
62
|
+
icons: ["16", "32", "48", "96", "128"],
|
|
63
|
+
packageJson: {
|
|
64
|
+
scripts: {
|
|
65
|
+
dev: "wxt",
|
|
66
|
+
"dev:firefox": "wxt -b firefox",
|
|
67
|
+
build: "wxt build",
|
|
68
|
+
"build:firefox": "wxt build -b firefox",
|
|
69
|
+
zip: "wxt zip",
|
|
70
|
+
"zip:firefox": "wxt zip -b firefox",
|
|
71
|
+
compile: "tsc --noEmit",
|
|
72
|
+
postinstall: "wxt prepare",
|
|
73
|
+
},
|
|
74
|
+
dependencies: {
|
|
75
|
+
react: "^19.2.4",
|
|
76
|
+
"react-dom": "^19.2.4",
|
|
77
|
+
},
|
|
78
|
+
devDependencies: {
|
|
79
|
+
"@types/react": "^19.2.14",
|
|
80
|
+
"@types/react-dom": "^19.2.3",
|
|
81
|
+
"@wxt-dev/module-react": "^1.1.5",
|
|
82
|
+
typescript: "^5.9.3",
|
|
83
|
+
"web-ext": "^10.5.0",
|
|
84
|
+
wxt: "^0.21.3",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
tsconfig: `{
|
|
88
|
+
"extends": "./.wxt/tsconfig.json",
|
|
89
|
+
"compilerOptions": {
|
|
90
|
+
"allowImportingTsExtensions": true,
|
|
91
|
+
"jsx": "react-jsx"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`,
|
|
95
|
+
gitignore: `# Logs
|
|
96
|
+
logs
|
|
97
|
+
*.log
|
|
98
|
+
npm-debug.log*
|
|
99
|
+
yarn-debug.log*
|
|
100
|
+
yarn-error.log*
|
|
101
|
+
pnpm-debug.log*
|
|
102
|
+
lerna-debug.log*
|
|
103
|
+
|
|
104
|
+
node_modules
|
|
105
|
+
.output
|
|
106
|
+
stats.html
|
|
107
|
+
stats-*.json
|
|
108
|
+
.wxt
|
|
109
|
+
web-ext.config.ts
|
|
110
|
+
|
|
111
|
+
# Editor directories and files
|
|
112
|
+
.vscode/*
|
|
113
|
+
!.vscode/extensions.json
|
|
114
|
+
.idea
|
|
115
|
+
.DS_Store
|
|
116
|
+
*.suo
|
|
117
|
+
*.ntvs*
|
|
118
|
+
*.njsproj
|
|
119
|
+
*.sln
|
|
120
|
+
*.sw?
|
|
121
|
+
`,
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* toolfactory's own pins on top of the scaffold's. The React half deliberately follows the `web`
|
|
125
|
+
* surface's, because the popup bundles that tree and the two must resolve to one copy.
|
|
126
|
+
*/
|
|
127
|
+
const PINS = {
|
|
128
|
+
wxt: WXT_PIN,
|
|
129
|
+
"@wxt-dev/module-react": "^1.2.2",
|
|
130
|
+
/** WXT's Firefox engine, and `web-ext lint`, the only keyless manifest validator that exists. */
|
|
131
|
+
"web-ext": "^10.6.0",
|
|
132
|
+
/** The fake extension API `WxtVitest()` installs for the unit tier. */
|
|
133
|
+
"@webext-core/fake-browser": "^2.0.1",
|
|
134
|
+
/** Matches the repository's own runner, and the vite major WXT builds with. */
|
|
135
|
+
vitest: "^4.1.11",
|
|
136
|
+
};
|
|
137
|
+
/** The private package `hosts/browser` is; its name is what `wxt zip` names the archives. */
|
|
138
|
+
export function browserPackage(project) {
|
|
139
|
+
return `${project.identity.name}-browser`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The zip `wxt zip -b <browser>` writes, by name, inside the release assets. WXT names an archive
|
|
143
|
+
* after `zip.name`, which the generated config sets to the tool's own name, so the assets sit
|
|
144
|
+
* beside the tarball and the bundle under one identity instead of the package's local one.
|
|
145
|
+
*/
|
|
146
|
+
export function zipName(project, browser) {
|
|
147
|
+
return `${project.identity.name}-${project.identity.version ?? "0.0.0"}-${browser}.zip`;
|
|
148
|
+
}
|
|
149
|
+
/** The sources archive `wxt zip -b firefox` writes beside it; AMO asks for it when code is minified. */
|
|
150
|
+
export function sourcesZipName(project) {
|
|
151
|
+
return `${project.identity.name}-${project.identity.version ?? "0.0.0"}-sources.zip`;
|
|
152
|
+
}
|
|
153
|
+
function options(project) {
|
|
154
|
+
return project.tool.browserExtension ?? {};
|
|
155
|
+
}
|
|
156
|
+
export function endpoint(project) {
|
|
157
|
+
return options(project).endpoint ?? DEFAULT_ENDPOINT;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Firefox's add-on id. Required for a Firefox MV3 build and recommended for MV2, so it is
|
|
161
|
+
* emitted whenever it can be derived — an explicit `browserExtension.geckoId`, else the
|
|
162
|
+
* owner-scoped default from the identity file's repository. A tool with neither ships without
|
|
163
|
+
* the block, exactly as the README surface omits the lines that need a GitHub repository.
|
|
164
|
+
*/
|
|
165
|
+
export function geckoId(project) {
|
|
166
|
+
const declared = options(project).geckoId;
|
|
167
|
+
if (declared)
|
|
168
|
+
return declared;
|
|
169
|
+
const owner = githubOwner(project.identity.repository);
|
|
170
|
+
return owner ? `${project.identity.name}@${owner}.github.io` : undefined;
|
|
171
|
+
}
|
|
172
|
+
/** Match patterns the extension may act in: loopback always, plus the declared session domains. */
|
|
173
|
+
export function hostPermissions(project) {
|
|
174
|
+
// A port in a match pattern is Chromium-only (Firefox rejects it outright), so the portable
|
|
175
|
+
// all-ports form is the only one emitted.
|
|
176
|
+
return ["http://127.0.0.1/*", "http://localhost/*", ...(options(project).authDomains ?? [])];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The permission list is a capability projection, never authored: `storage` for the pairing
|
|
180
|
+
* store, `activeTab` when an operation declares `browser` (the store-friendly minimum for acting
|
|
181
|
+
* in the tab the user invoked the action on), and the two opt-ins. Anything wider — `debugger`
|
|
182
|
+
* and `tabGroups` for a CDP relay — is Chromium-only and belongs in the author's tail, where the
|
|
183
|
+
* generated per-target filter still drops it for the other engines.
|
|
184
|
+
*/
|
|
185
|
+
export function permissions(project, operations) {
|
|
186
|
+
const declared = options(project);
|
|
187
|
+
return [
|
|
188
|
+
"storage",
|
|
189
|
+
...(operations.some((operation) => operation.requires.includes("browser"))
|
|
190
|
+
? ["activeTab"]
|
|
191
|
+
: []),
|
|
192
|
+
...(declared.cookieExport ? ["cookies"] : []),
|
|
193
|
+
...(declared.sidePanel ? ["sidePanel"] : []),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The coalesced verdict, for `COVERAGE.md`'s single column: the browser is present, so a
|
|
198
|
+
* `browser` operation is native here (bridge 3, authored in this directory) and a `user-input`
|
|
199
|
+
* one is native too (the popup is the human). Nothing bridges a model or a channel.
|
|
200
|
+
*/
|
|
201
|
+
export function verdict(operation) {
|
|
202
|
+
if (operation.requires.includes("model"))
|
|
203
|
+
return { kind: "excluded", reason: "excluded:no-model-bridge" };
|
|
204
|
+
if (operation.requires.includes("channel"))
|
|
205
|
+
return { kind: "excluded", reason: "excluded:no-channel-bridge" };
|
|
206
|
+
return { kind: "native" };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The same verdict per engine, which is the finer grain `TARGETS.md` carries. The vocabulary has
|
|
210
|
+
* one `browser` term, so the surface cannot tell a DOM operation from one that reaches for
|
|
211
|
+
* `chrome.debugger`; that stays prose in `TARGETS.md`. What it can derive is the session export:
|
|
212
|
+
* Safari Web Extensions cannot read httpOnly cookies, so an opted-in export degrades there.
|
|
213
|
+
*/
|
|
214
|
+
export function browserVerdict(operation, project, engine) {
|
|
215
|
+
const coalesced = verdict(operation);
|
|
216
|
+
if (coalesced.kind !== "native")
|
|
217
|
+
return coalesced;
|
|
218
|
+
if (engine === "safari" &&
|
|
219
|
+
options(project).cookieExport &&
|
|
220
|
+
operation.requires.includes("browser"))
|
|
221
|
+
return { kind: "degraded", reason: "degraded:no-httponly-cookies" };
|
|
222
|
+
return coalesced;
|
|
223
|
+
}
|
|
224
|
+
/** The operations the reused page carries: the `web` surface's own rule, so the two agree. */
|
|
225
|
+
function pageOperations(project) {
|
|
226
|
+
return project.operations.filter((operation) => mcpVerdict(operation).kind !== "excluded");
|
|
227
|
+
}
|
|
228
|
+
/** What the Playwright smoke submits: the first page operation with an example, else the first. */
|
|
229
|
+
function smokeCase(project) {
|
|
230
|
+
const shown = pageOperations(project);
|
|
231
|
+
const examples = project.tool.tests.examples;
|
|
232
|
+
const operation = shown.find((candidate) => examples[candidate.name]) ?? shown[0];
|
|
233
|
+
return operation ? { name: operation.name, arguments: examples[operation.name] ?? {} } : null;
|
|
234
|
+
}
|
|
235
|
+
/** npm writes a dependency map in name order; a merge patch that does not would churn it. */
|
|
236
|
+
function sorted(record) {
|
|
237
|
+
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
|
238
|
+
}
|
|
239
|
+
function packageJsonPatch(project, reuseWeb) {
|
|
240
|
+
const web = WEB_SCAFFOLD.packageJson;
|
|
241
|
+
return {
|
|
242
|
+
name: browserPackage(project),
|
|
243
|
+
description: project.identity.description,
|
|
244
|
+
private: true,
|
|
245
|
+
version: project.identity.version ?? "0.0.0",
|
|
246
|
+
type: "module",
|
|
247
|
+
scripts: {
|
|
248
|
+
...BROWSER_SCAFFOLD.packageJson.scripts,
|
|
249
|
+
test: "vitest run",
|
|
250
|
+
smoke: "node tests/smoke.mjs",
|
|
251
|
+
},
|
|
252
|
+
// React follows `web/`'s pins: the popup bundles that tree, and `dedupe` resolves both to one copy.
|
|
253
|
+
dependencies: {
|
|
254
|
+
react: web.dependencies.react,
|
|
255
|
+
"react-dom": web.dependencies["react-dom"],
|
|
256
|
+
},
|
|
257
|
+
devDependencies: sorted({
|
|
258
|
+
...BROWSER_SCAFFOLD.packageJson.devDependencies,
|
|
259
|
+
"@types/react": web.devDependencies["@types/react"],
|
|
260
|
+
"@types/react-dom": web.devDependencies["@types/react-dom"],
|
|
261
|
+
...PINS,
|
|
262
|
+
playwright: WEB_SCAFFOLD.playwright,
|
|
263
|
+
...(reuseWeb ? { "@tailwindcss/vite": web.dependencies["@tailwindcss/vite"] } : {}),
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** A string array as a formatted TypeScript literal, so the generated config reads like source. */
|
|
268
|
+
function list(values) {
|
|
269
|
+
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
|
270
|
+
}
|
|
271
|
+
function configRegion(project, operations, reuseWeb) {
|
|
272
|
+
const gecko = geckoId(project);
|
|
273
|
+
return `
|
|
274
|
+
// Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
|
|
275
|
+
// Everything below the end marker is yours — merge into \`generated\` there and it survives a rebuild.
|
|
276
|
+
${reuseWeb ? 'import { resolve } from "node:path";\nimport tailwindcss from "@tailwindcss/vite";\n' : ""}import { defineConfig } from "wxt";
|
|
277
|
+
|
|
278
|
+
/** Permissions only Chromium implements; dropped from every other target's manifest. */
|
|
279
|
+
export const CHROMIUM_ONLY = ["sidePanel", "debugger", "tabGroups"];
|
|
280
|
+
const CHROMIUM = ["chrome", "edge"];
|
|
281
|
+
|
|
282
|
+
const PERMISSIONS = ${list(permissions(project, operations))};
|
|
283
|
+
const HOST_PERMISSIONS = ${list(hostPermissions(project))};
|
|
284
|
+
${reuseWeb ? `const web = resolve(import.meta.dirname, ${JSON.stringify(WEB_SRC_FROM_HOST)});\n` : ""}
|
|
285
|
+
export const generated = {
|
|
286
|
+
modules: ["@wxt-dev/module-react"],
|
|
287
|
+
// The release assets carry the tool's name, not this package's local one.
|
|
288
|
+
zip: { name: ${JSON.stringify(project.identity.name)} },${reuseWeb
|
|
289
|
+
? `
|
|
290
|
+
vite: () => ({
|
|
291
|
+
plugins: [
|
|
292
|
+
tailwindcss(),
|
|
293
|
+
// The popup and the options page are the \`web\` surface's own component tree, imported
|
|
294
|
+
// rather than duplicated. \`enforce: "post"\` is what makes this alias outrank WXT's own
|
|
295
|
+
// \`@\` (this directory, still reachable as \`~\`), so that tree's internal \`@/\` imports
|
|
296
|
+
// resolve inside it.
|
|
297
|
+
{
|
|
298
|
+
name: "toolfactory:web-alias",
|
|
299
|
+
enforce: "post" as const,
|
|
300
|
+
config: () => ({ resolve: { alias: { "@web": web, "@": web } } }),
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
resolve: { dedupe: ["react", "react-dom"] },
|
|
304
|
+
}),`
|
|
305
|
+
: ""}
|
|
306
|
+
// WXT emits one manifest per target from this: MV3 with a service worker for Chromium, MV2
|
|
307
|
+
// with background scripts for Firefox and Safari, \`host_permissions\` folded into
|
|
308
|
+
// \`permissions\` where the format has no such key.
|
|
309
|
+
manifest: ({ browser }: { browser: string }) => ({
|
|
310
|
+
name: ${JSON.stringify(project.identity.name)},
|
|
311
|
+
permissions: PERMISSIONS.filter(
|
|
312
|
+
(permission) => CHROMIUM.includes(browser) || !CHROMIUM_ONLY.includes(permission),
|
|
313
|
+
),
|
|
314
|
+
host_permissions: HOST_PERMISSIONS,${gecko
|
|
315
|
+
? `
|
|
316
|
+
browser_specific_settings: {
|
|
317
|
+
gecko: {
|
|
318
|
+
id: ${JSON.stringify(gecko)},
|
|
319
|
+
// The floor for \`storage.session\` and for the data-collection key below.
|
|
320
|
+
strict_min_version: "140.0",
|
|
321
|
+
// Everything this extension sends goes to the kernel on this machine, so there is no
|
|
322
|
+
// collection to declare. Change it if your operations ship user data to you.
|
|
323
|
+
data_collection_permissions: { required: ["none"] },
|
|
324
|
+
},
|
|
325
|
+
},`
|
|
326
|
+
: ""}
|
|
327
|
+
action: { default_title: ${JSON.stringify(project.identity.name)} },
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
const CONFIG_TAIL = `
|
|
333
|
+
// Yours: everything below the end marker survives \`toolfactory build\`. Merge into the generated
|
|
334
|
+
// config — extra \`modules\`, an \`srcDir\`, manifest keys your capability code needs:
|
|
335
|
+
// export default defineConfig({ ...generated, manifest: (env) => ({ ...generated.manifest(env), … }) })
|
|
336
|
+
export default defineConfig({ ...generated });
|
|
337
|
+
`;
|
|
338
|
+
function mcpTs(project) {
|
|
339
|
+
return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
340
|
+
import { browser } from "wxt/browser";
|
|
341
|
+
|
|
342
|
+
/** Where \`${project.identity.name} mcp --http\` listens by default. */
|
|
343
|
+
export const DEFAULT_ENDPOINT = ${JSON.stringify(endpoint(project))};
|
|
344
|
+
/** MCP revision whose per-request \`_meta\` keys the kernel reads. */
|
|
345
|
+
export const MCP_PROTOCOL_VERSION = ${JSON.stringify(MCP_PROTOCOL_VERSION)};
|
|
346
|
+
|
|
347
|
+
export interface McpRequest {
|
|
348
|
+
method: string;
|
|
349
|
+
params: { name: string };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The paired kernel: its URL (persisted) and this session's token (in memory only). */
|
|
353
|
+
export async function paired(): Promise<{ endpoint: string; token?: string }> {
|
|
354
|
+
const { endpoint } = await browser.storage.local.get("endpoint");
|
|
355
|
+
const { token } = await browser.storage.session.get("token");
|
|
356
|
+
return {
|
|
357
|
+
endpoint: typeof endpoint === "string" && endpoint ? endpoint : DEFAULT_ENDPOINT,
|
|
358
|
+
token: typeof token === "string" && token ? token : undefined,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Pair with \`<url>#<token>\`, the string \`${project.identity.name} mcp --http --pair\` prints. The
|
|
364
|
+
* token is kept in \`storage.session\` — in memory, cleared when the browser closes, unreachable
|
|
365
|
+
* from a content script — and never in \`storage.local\`.
|
|
366
|
+
*/
|
|
367
|
+
export async function pair(pairing: string): Promise<{ endpoint: string; token: boolean }> {
|
|
368
|
+
const hash = pairing.indexOf("#");
|
|
369
|
+
const endpoint = (hash < 0 ? pairing : pairing.slice(0, hash)).trim() || DEFAULT_ENDPOINT;
|
|
370
|
+
const token = hash < 0 ? "" : pairing.slice(hash + 1).trim();
|
|
371
|
+
await browser.storage.local.set({ endpoint });
|
|
372
|
+
await browser.storage.session.set({ token });
|
|
373
|
+
return { endpoint, token: Boolean(token) };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* One stateless MCP POST, from the background worker and nowhere else: an extension page's fetch
|
|
378
|
+
* would carry no token, and a content script's is not CORS-exempt. A worker fetch to a
|
|
379
|
+
* \`host_permissions\` host is, and sends no preflight, so the kernel needs no CORS middleware.
|
|
380
|
+
*/
|
|
381
|
+
export async function post(request: McpRequest, endpoint?: string): Promise<string> {
|
|
382
|
+
const target = await paired();
|
|
383
|
+
const url = endpoint && /^https?:/i.test(endpoint) ? endpoint : target.endpoint;
|
|
384
|
+
let response: Response;
|
|
385
|
+
try {
|
|
386
|
+
response = await fetch(url, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers: {
|
|
389
|
+
"Content-Type": "application/json",
|
|
390
|
+
Accept: "application/json, text/event-stream",
|
|
391
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
392
|
+
"Mcp-Method": request.method,
|
|
393
|
+
"Mcp-Name": request.params.name,
|
|
394
|
+
...(target.token ? { Authorization: \`Bearer \${target.token}\` } : {}),
|
|
395
|
+
},
|
|
396
|
+
body: JSON.stringify(request),
|
|
397
|
+
});
|
|
398
|
+
} catch (cause) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
\`\${url} refused the connection: start \\\`${project.identity.name} mcp --http\\\`, and on Chromium make sure the browser is 144 or newer (Local Network Access exempts an extension that holds the host permission).\`,
|
|
401
|
+
{ cause },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return await response.text();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The same POST, parsed: plain JSON or the one \`data:\` SSE frame, exactly as the page reads it. */
|
|
408
|
+
export async function call(request: McpRequest, endpoint?: string): Promise<unknown> {
|
|
409
|
+
const text = await post(request, endpoint);
|
|
410
|
+
const framed = text.split("\\n").find((line) => line.startsWith("data:"));
|
|
411
|
+
let body: { error?: unknown; result?: { structuredContent?: unknown } };
|
|
412
|
+
try {
|
|
413
|
+
body = JSON.parse(framed ? framed.slice(5).trim() : text);
|
|
414
|
+
} catch {
|
|
415
|
+
throw new Error(\`not an MCP response: \${text.slice(0, 500)}\`);
|
|
416
|
+
}
|
|
417
|
+
if (body.error) throw new Error(JSON.stringify(body.error));
|
|
418
|
+
return body.result?.structuredContent ?? body.result;
|
|
419
|
+
}
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
const BG_REGION = `
|
|
423
|
+
// Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
|
|
424
|
+
// Everything below the end marker is yours: the operations that need a browser live there.
|
|
425
|
+
import { browser } from "wxt/browser";
|
|
426
|
+
import { defineBackground } from "wxt/utils/define-background";
|
|
427
|
+
import { call, pair, paired, post, type McpRequest } from "../utils/mcp";
|
|
428
|
+
|
|
429
|
+
export type Message =
|
|
430
|
+
| { type: "mcp"; request: McpRequest; endpoint?: string }
|
|
431
|
+
| { type: "fetch"; request: McpRequest; endpoint?: string }
|
|
432
|
+
| { type: "pair"; pairing: string }
|
|
433
|
+
| { type: "paired" };
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The router: the one place a message reaches the kernel, and the only context holding the
|
|
437
|
+
* token. \`mcp\` answers with the operation's result — what your capability code wants — and
|
|
438
|
+
* \`fetch\` with the raw MCP response, which the generated page parses itself.
|
|
439
|
+
*/
|
|
440
|
+
export async function route(message: Message): Promise<unknown> {
|
|
441
|
+
switch (message.type) {
|
|
442
|
+
case "mcp":
|
|
443
|
+
return await call(message.request, message.endpoint);
|
|
444
|
+
case "fetch":
|
|
445
|
+
return await post(message.request, message.endpoint);
|
|
446
|
+
case "pair":
|
|
447
|
+
return await pair(message.pairing);
|
|
448
|
+
case "paired": {
|
|
449
|
+
const { endpoint, token } = await paired();
|
|
450
|
+
return { endpoint, token: Boolean(token) };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export const background = defineBackground(() => {
|
|
456
|
+
browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
457
|
+
route(message as Message).then(
|
|
458
|
+
(value) => sendResponse({ value }),
|
|
459
|
+
(error: unknown) =>
|
|
460
|
+
sendResponse({ error: error instanceof Error ? error.message : String(error) }),
|
|
461
|
+
);
|
|
462
|
+
return true;
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
`;
|
|
466
|
+
const BG_TAIL = `
|
|
467
|
+
// Yours: the operations that need a browser are implemented here, and survive every build.
|
|
468
|
+
// Decompose, don't inject (§4.5): the page state becomes JSON arguments, and the kernel runs the
|
|
469
|
+
// operation.
|
|
470
|
+
//
|
|
471
|
+
// browser.runtime.onMessage.addListener(...) // your own message types
|
|
472
|
+
// await route({ type: "mcp", request: { method: "tools/call", params: { name: "…", arguments: {} } } })
|
|
473
|
+
|
|
474
|
+
export default background;
|
|
475
|
+
`;
|
|
476
|
+
const CONTENT_REGION = `
|
|
477
|
+
// Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
|
|
478
|
+
import { browser } from "wxt/browser";
|
|
479
|
+
import { defineContentScript } from "wxt/utils/define-content-script";
|
|
480
|
+
`;
|
|
481
|
+
function contentTemplate(project) {
|
|
482
|
+
const matches = options(project).authDomains ?? ["https://example.com/*"];
|
|
483
|
+
return `${CONTENT_BEGIN}${CONTENT_REGION}${CONTENT_END}
|
|
484
|
+
|
|
485
|
+
// Yours: the shim that turns page state into JSON arguments. It runs inside the logged-in page,
|
|
486
|
+
// so the session is already there — read the DOM, hand the result to a portable operation, put
|
|
487
|
+
// the answer back. Never fetch the kernel from here: a content script's fetch uses the page's
|
|
488
|
+
// origin and is not CORS-exempt. Message the background worker instead, as below.
|
|
489
|
+
//
|
|
490
|
+
// This file is yours to keep working against the page it scrapes; a selector that moves is not
|
|
491
|
+
// something the generator can version-proof.
|
|
492
|
+
export default defineContentScript({
|
|
493
|
+
matches: ${list(matches)},
|
|
494
|
+
async main() {
|
|
495
|
+
const answer = await browser.runtime.sendMessage({
|
|
496
|
+
type: "mcp",
|
|
497
|
+
request: { method: "tools/call", params: { name: "…", arguments: {} } },
|
|
498
|
+
});
|
|
499
|
+
console.log(${JSON.stringify(project.identity.name)}, answer);
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
const PAGE_TSX = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
505
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
506
|
+
import { createRoot } from "react-dom/client";
|
|
507
|
+
import { browser } from "wxt/browser";
|
|
508
|
+
|
|
509
|
+
async function send(message: unknown): Promise<unknown> {
|
|
510
|
+
const response = (await browser.runtime.sendMessage(message)) as
|
|
511
|
+
| { value: unknown }
|
|
512
|
+
| { error: string };
|
|
513
|
+
if (response && "error" in response) throw new Error(response.error);
|
|
514
|
+
return (response as { value: unknown })?.value;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The page never fetches: every MCP POST is forwarded to the background worker, which holds the
|
|
519
|
+
* paired endpoint and the token. That is what lets an extension page be the \`web\` surface's own
|
|
520
|
+
* component tree, unchanged — and what keeps the credential out of a document.
|
|
521
|
+
*/
|
|
522
|
+
function bridgeFetch(): void {
|
|
523
|
+
const direct = globalThis.fetch;
|
|
524
|
+
globalThis.fetch = async (input, init) => {
|
|
525
|
+
if (init?.method !== "POST") return direct(input, init);
|
|
526
|
+
const endpoint =
|
|
527
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
528
|
+
const request = JSON.parse(String(init.body));
|
|
529
|
+
const text = (await send({ type: "fetch", request, endpoint })) as string;
|
|
530
|
+
return new Response(text, { headers: { "content-type": "application/json" } });
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Pairing: \`<url>#<token>\`, the string the kernel prints when it starts serving HTTP. */
|
|
535
|
+
export function Pairing() {
|
|
536
|
+
const [pairing, setPairing] = useState("");
|
|
537
|
+
const [status, setStatus] = useState("");
|
|
538
|
+
const show = (state: { endpoint: string; token: boolean }) =>
|
|
539
|
+
setStatus(\`\${state.endpoint}\${state.token ? " (token held for this browser session)" : ""}\`);
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
send({ type: "paired" }).then((state) => show(state as { endpoint: string; token: boolean }));
|
|
542
|
+
}, []);
|
|
543
|
+
return (
|
|
544
|
+
<form
|
|
545
|
+
data-slot="pairing"
|
|
546
|
+
style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.5rem" }}
|
|
547
|
+
onSubmit={(event) => {
|
|
548
|
+
event.preventDefault();
|
|
549
|
+
send({ type: "pair", pairing })
|
|
550
|
+
.then((state) => show(state as { endpoint: string; token: boolean }))
|
|
551
|
+
.catch((error: Error) => setStatus(error.message));
|
|
552
|
+
}}
|
|
553
|
+
>
|
|
554
|
+
<input
|
|
555
|
+
id="pairing"
|
|
556
|
+
aria-label="Pairing string"
|
|
557
|
+
placeholder="http://127.0.0.1:3000/mcp#token"
|
|
558
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
559
|
+
value={pairing}
|
|
560
|
+
onChange={(event) => setPairing(event.target.value)}
|
|
561
|
+
/>
|
|
562
|
+
<button type="submit">Pair</button>
|
|
563
|
+
<span data-slot="paired">{status}</span>
|
|
564
|
+
</form>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The popup is the same page in 540 pixels; this is how it becomes a real one. \`openOptionsPage\`
|
|
570
|
+
* is the WebExtension API for exactly that, and the options page already mounts this tree.
|
|
571
|
+
*/
|
|
572
|
+
export function OpenFullPage() {
|
|
573
|
+
return (
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
data-slot="open-full-page"
|
|
577
|
+
style={{ marginLeft: "auto" }}
|
|
578
|
+
onClick={() => browser.runtime.openOptionsPage()}
|
|
579
|
+
>
|
|
580
|
+
Open full page
|
|
581
|
+
</button>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function mount(children: ReactNode): void {
|
|
586
|
+
bridgeFetch();
|
|
587
|
+
createRoot(document.getElementById("root") as HTMLElement).render(children);
|
|
588
|
+
}
|
|
589
|
+
`;
|
|
590
|
+
const PAGE_CSS = `/* Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. */
|
|
591
|
+
@import "../${WEB_SRC_FROM_HOST}/index.css";
|
|
592
|
+
/* Tailwind scans from this file's directory; the classes are in the web surface's tree. */
|
|
593
|
+
@source "../${WEB_SRC_FROM_HOST}";
|
|
594
|
+
`;
|
|
595
|
+
const ENV_D_TS = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
596
|
+
/**
|
|
597
|
+
* \`@web/*\` is the \`web\` surface's component tree, resolved by the alias in \`wxt.config.ts\`. It
|
|
598
|
+
* is declared here, not typechecked: \`web\`'s own build typechecks it, against its own React
|
|
599
|
+
* types, and pulling it into this program would compile it against a second copy of them.
|
|
600
|
+
*/
|
|
601
|
+
declare module "@web/App" {
|
|
602
|
+
const App: () => import("react").ReactNode;
|
|
603
|
+
export default App;
|
|
604
|
+
}
|
|
605
|
+
`;
|
|
606
|
+
/** A page entrypoint's HTML. WXT reads the manifest slot and its options from the meta tags. */
|
|
607
|
+
function pageHtml(project, kind) {
|
|
608
|
+
const meta = kind === "popup"
|
|
609
|
+
? '\n <meta name="manifest.type" content="browser_action" />'
|
|
610
|
+
: kind === "options"
|
|
611
|
+
? '\n <meta name="manifest.open_in_tab" content="true" />'
|
|
612
|
+
: "";
|
|
613
|
+
// A popup sizes itself to its content; the reused operations page needs room to be usable.
|
|
614
|
+
const style = kind === "popup"
|
|
615
|
+
? "body { width: 540px; min-height: 320px; margin: 0; }"
|
|
616
|
+
: "body { margin: 0; }";
|
|
617
|
+
return `<!doctype html>
|
|
618
|
+
<!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
|
|
619
|
+
<html lang="en">
|
|
620
|
+
<head>
|
|
621
|
+
<meta charset="UTF-8" />
|
|
622
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
623
|
+
<title>${project.identity.name}</title>${meta}
|
|
624
|
+
<style>
|
|
625
|
+
${style}
|
|
626
|
+
</style>
|
|
627
|
+
</head>
|
|
628
|
+
<body>
|
|
629
|
+
<div id="root"></div>
|
|
630
|
+
<script type="module" src="./main.tsx"></script>
|
|
631
|
+
</body>
|
|
632
|
+
</html>
|
|
633
|
+
`;
|
|
634
|
+
}
|
|
635
|
+
/** The page entrypoint: the `web` surface's app under the pairing row, or pairing alone. */
|
|
636
|
+
function pageMain(reuseWeb, kind) {
|
|
637
|
+
// Only the popup needs it: it is the one page that is not already a full one.
|
|
638
|
+
const full = kind === "popup";
|
|
639
|
+
return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
640
|
+
${reuseWeb ? 'import App from "@web/App";\nimport "../../utils/page.css";\n' : ""}import { mount, ${full ? "OpenFullPage, " : ""}Pairing } from "../../utils/page";
|
|
641
|
+
|
|
642
|
+
mount(
|
|
643
|
+
<>
|
|
644
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
645
|
+
<Pairing />${full ? "\n <OpenFullPage />" : ""}
|
|
646
|
+
</div>${reuseWeb ? "\n <App />" : ""}
|
|
647
|
+
</>,
|
|
648
|
+
);
|
|
649
|
+
`;
|
|
650
|
+
}
|
|
651
|
+
const VITEST_CONFIG = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
652
|
+
import { defineConfig } from "vitest/config";
|
|
653
|
+
import { WxtVitest } from "wxt/testing/vitest-plugin";
|
|
654
|
+
|
|
655
|
+
// WxtVitest polyfills the extension APIs with @webext-core/fake-browser and applies the same
|
|
656
|
+
// aliases and auto-imports the build does, so a test imports an entrypoint like any module.
|
|
657
|
+
export default defineConfig({ plugins: [WxtVitest()] });
|
|
658
|
+
`;
|
|
659
|
+
const WEB_EXT_CONFIG = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
660
|
+
// What \`web-ext run\` and \`web-ext lint\` act on when run from this directory: the Firefox
|
|
661
|
+
// build \`wxt build -b firefox\` writes.
|
|
662
|
+
export default { sourceDir: ".output/firefox-mv2" };
|
|
663
|
+
`;
|
|
664
|
+
function backgroundTest(project) {
|
|
665
|
+
const first = pageOperations(project)[0]?.name ?? "tools/list";
|
|
666
|
+
return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
667
|
+
/**
|
|
668
|
+
* The generated router, in memory: \`@webext-core/fake-browser\` stands in for the extension APIs
|
|
669
|
+
* and \`fetch\` is the only thing stubbed, so what is under test is the pairing store and the one
|
|
670
|
+
* privileged call to the kernel.
|
|
671
|
+
*/
|
|
672
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
673
|
+
import { fakeBrowser } from "wxt/testing/fake-browser";
|
|
674
|
+
import { route } from "../entrypoints/background";
|
|
675
|
+
|
|
676
|
+
const OPERATION = ${JSON.stringify(first)};
|
|
677
|
+
const ENVELOPE = JSON.stringify({
|
|
678
|
+
jsonrpc: "2.0",
|
|
679
|
+
id: 1,
|
|
680
|
+
result: { structuredContent: { ok: true } },
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("the background router", () => {
|
|
684
|
+
beforeEach(() => {
|
|
685
|
+
fakeBrowser.reset();
|
|
686
|
+
vi.restoreAllMocks();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("pairs from <url>#<token> and reports what it holds", async () => {
|
|
690
|
+
expect(await route({ type: "pair", pairing: "http://127.0.0.1:4242/mcp#s3cret" })).toEqual({
|
|
691
|
+
endpoint: "http://127.0.0.1:4242/mcp",
|
|
692
|
+
token: true,
|
|
693
|
+
});
|
|
694
|
+
expect(await route({ type: "paired" })).toEqual({
|
|
695
|
+
endpoint: "http://127.0.0.1:4242/mcp",
|
|
696
|
+
token: true,
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("calls the paired kernel with the MCP headers and the bearer token", async () => {
|
|
701
|
+
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(ENVELOPE));
|
|
702
|
+
await route({ type: "pair", pairing: "http://127.0.0.1:4242/mcp#s3cret" });
|
|
703
|
+
const result = await route({
|
|
704
|
+
type: "mcp",
|
|
705
|
+
request: { method: "tools/call", params: { name: OPERATION } },
|
|
706
|
+
});
|
|
707
|
+
expect(result).toEqual({ ok: true });
|
|
708
|
+
const [url, init] = fetch.mock.calls[0] as [string, RequestInit];
|
|
709
|
+
expect(url).toBe("http://127.0.0.1:4242/mcp");
|
|
710
|
+
expect(init.headers).toMatchObject({
|
|
711
|
+
"Mcp-Method": "tools/call",
|
|
712
|
+
"Mcp-Name": OPERATION,
|
|
713
|
+
Authorization: "Bearer s3cret",
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("says which endpoint refused when the kernel is not running", async () => {
|
|
718
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
|
|
719
|
+
await expect(
|
|
720
|
+
route({ type: "mcp", request: { method: "tools/call", params: { name: OPERATION } } }),
|
|
721
|
+
).rejects.toThrow(\`\${${JSON.stringify(endpoint(project))}} refused the connection\`);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
`;
|
|
725
|
+
}
|
|
726
|
+
function smokeMjs(project, reuseWeb) {
|
|
727
|
+
const kase = reuseWeb ? smokeCase(project) : null;
|
|
728
|
+
const form = !kase
|
|
729
|
+
? `
|
|
730
|
+
const popup = await context.newPage();
|
|
731
|
+
await popup.goto(\`chrome-extension://\${id}/popup.html\`);
|
|
732
|
+
await popup.locator('[data-slot="pairing"]').waitFor();
|
|
733
|
+
console.log("PASS the popup renders the pairing form");
|
|
734
|
+
`
|
|
735
|
+
: `
|
|
736
|
+
const popup = await context.newPage();
|
|
737
|
+
await popup.goto(\`chrome-extension://\${id}/popup.html\`);
|
|
738
|
+
await popup.getByRole("button", { name: CASE.name, exact: true }).click();
|
|
739
|
+
for (const [name, value] of Object.entries(CASE.arguments)) {
|
|
740
|
+
if (typeof value === "string") await popup.locator(\`[id="\${CASE.name}-\${name}"]\`).fill(value);
|
|
741
|
+
}
|
|
742
|
+
console.log(\`PASS the popup renders the \${CASE.name} form\`);
|
|
743
|
+
|
|
744
|
+
await popup.getByRole("button", { name: "Run" }).click();
|
|
745
|
+
await popup.getByRole("tab", { name: "Result" }).click();
|
|
746
|
+
const result = popup.locator('pre[data-slot="result"]');
|
|
747
|
+
await result.waitFor({ state: "visible" });
|
|
748
|
+
const text = (await result.textContent()).trim();
|
|
749
|
+
if (!text.includes(MARKER)) throw new Error(\`the popup rendered no result: \${text}\`);
|
|
750
|
+
console.log(\`PASS \${CASE.name} answered through the worker: \${text.replace(/\\s+/g, " ")}\`);
|
|
751
|
+
|
|
752
|
+
const call = seen.at(-1);
|
|
753
|
+
if (!call) throw new Error("the mock kernel was never called");
|
|
754
|
+
if (call.headers.authorization !== \`Bearer \${TOKEN}\`) {
|
|
755
|
+
throw new Error(\`the worker sent no bearer token: \${call.headers.authorization}\`);
|
|
756
|
+
}
|
|
757
|
+
if (call.body.params?.name !== CASE.name) {
|
|
758
|
+
throw new Error(\`the worker called \${call.body.params?.name}, not \${CASE.name}\`);
|
|
759
|
+
}
|
|
760
|
+
console.log(\`PASS the worker sent Bearer <token> and Mcp-Name: \${call.headers["mcp-name"]}\`);
|
|
761
|
+
`;
|
|
762
|
+
return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
763
|
+
/**
|
|
764
|
+
* The keyless end-to-end: the built \`chrome-mv3\` output is side-loaded into a real Chromium and
|
|
765
|
+
* driven the way a human drives it — the options page takes the pairing string, the popup renders
|
|
766
|
+
* the operation, and Run reaches a mock kernel on loopback through the background worker, which
|
|
767
|
+
* is the only context holding the token. No LLM, no store credentials, no real session.
|
|
768
|
+
*
|
|
769
|
+
* Run \`npm --prefix ${HOST_DIR} run build\` first; this loads \`.output/chrome-mv3\`.
|
|
770
|
+
*/
|
|
771
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
772
|
+
import { createServer } from "node:http";
|
|
773
|
+
import { tmpdir } from "node:os";
|
|
774
|
+
import { join } from "node:path";
|
|
775
|
+
import { fileURLToPath } from "node:url";
|
|
776
|
+
import { chromium } from "playwright";
|
|
777
|
+
|
|
778
|
+
const extension = fileURLToPath(new URL("../.output/chrome-mv3", import.meta.url));
|
|
779
|
+
const CASE = ${JSON.stringify(kase ?? { name: "", arguments: {} })};
|
|
780
|
+
const TOKEN = "smoke-token";
|
|
781
|
+
const MARKER = "${project.identity.name}-browser-smoke";
|
|
782
|
+
|
|
783
|
+
const seen = [];
|
|
784
|
+
const kernel = createServer((request, response) => {
|
|
785
|
+
let body = "";
|
|
786
|
+
request.on("data", (chunk) => {
|
|
787
|
+
body += chunk;
|
|
788
|
+
});
|
|
789
|
+
request.on("end", () => {
|
|
790
|
+
seen.push({ headers: request.headers, body: JSON.parse(body || "{}") });
|
|
791
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
792
|
+
response.end(
|
|
793
|
+
JSON.stringify({ jsonrpc: "2.0", id: 1, result: { structuredContent: { smoke: MARKER } } }),
|
|
794
|
+
);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
await new Promise((resolve) => kernel.listen(0, "127.0.0.1", resolve));
|
|
798
|
+
const endpoint = \`http://127.0.0.1:\${kernel.address().port}/mcp\`;
|
|
799
|
+
|
|
800
|
+
const profile = mkdtempSync(join(tmpdir(), "${project.identity.name}-browser-"));
|
|
801
|
+
// \`channel: "chromium"\` is the full build; the headless shell cannot side-load an extension.
|
|
802
|
+
const context = await chromium.launchPersistentContext(profile, {
|
|
803
|
+
channel: "chromium",
|
|
804
|
+
args: [\`--disable-extensions-except=\${extension}\`, \`--load-extension=\${extension}\`],
|
|
805
|
+
});
|
|
806
|
+
const failures = [];
|
|
807
|
+
context.on("weberror", (error) => failures.push(error.error().message));
|
|
808
|
+
try {
|
|
809
|
+
const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent("serviceworker"));
|
|
810
|
+
const id = new URL(worker.url()).host;
|
|
811
|
+
console.log(\`PASS the extension loaded as \${id}\`);
|
|
812
|
+
|
|
813
|
+
const options = await context.newPage();
|
|
814
|
+
await options.goto(\`chrome-extension://\${id}/options.html\`);
|
|
815
|
+
await options.locator("#pairing").fill(\`\${endpoint}#\${TOKEN}\`);
|
|
816
|
+
await options.getByRole("button", { name: "Pair" }).click();
|
|
817
|
+
await options.locator('[data-slot="paired"]').filter({ hasText: endpoint }).waitFor();
|
|
818
|
+
console.log(\`PASS paired with \${endpoint}\`);
|
|
819
|
+
${form}
|
|
820
|
+
if (failures.length) throw new Error(\`the extension raised: \${failures.join("; ")}\`);
|
|
821
|
+
console.log("PASS no uncaught page errors");
|
|
822
|
+
} finally {
|
|
823
|
+
await context.close();
|
|
824
|
+
kernel.close();
|
|
825
|
+
rmSync(profile, { recursive: true, force: true });
|
|
826
|
+
}
|
|
827
|
+
`;
|
|
828
|
+
}
|
|
829
|
+
const ENGINE_LABEL = {
|
|
830
|
+
chromium: "Chromium (chrome, edge)",
|
|
831
|
+
firefox: "Firefox",
|
|
832
|
+
safari: "Safari",
|
|
833
|
+
};
|
|
834
|
+
function verdictCell(verdict) {
|
|
835
|
+
return verdict.kind === "native" ? "native" : `${verdict.kind} (${verdict.reason})`;
|
|
836
|
+
}
|
|
837
|
+
function targetsMarkdown(project) {
|
|
838
|
+
const rows = project.operations.map((operation) => `| \`${operation.name}\` | ${ENGINES.map((engine) => verdictCell(browserVerdict(operation, project, engine))).join(" | ")} |`);
|
|
839
|
+
const table = project.operations.length
|
|
840
|
+
? [
|
|
841
|
+
`| Operation | ${ENGINES.map((engine) => ENGINE_LABEL[engine]).join(" | ")} |`,
|
|
842
|
+
`|---|${ENGINES.map(() => "---").join("|")}|`,
|
|
843
|
+
...rows,
|
|
844
|
+
].join("\n")
|
|
845
|
+
: "No operation reaches this surface; what the extension does is the authored code in `entrypoints/`.";
|
|
846
|
+
return `# Targets
|
|
847
|
+
|
|
848
|
+
<!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
|
|
849
|
+
|
|
850
|
+
One codebase, three engines. \`wxt build -b <chrome|edge|firefox|safari>\` writes each of them, and
|
|
851
|
+
every per-browser manifest difference — MV2 against MV3, \`background.scripts\` against
|
|
852
|
+
\`background.service_worker\`, \`browser_action\` against \`action\`, \`host_permissions\` folded into
|
|
853
|
+
\`permissions\` — is WXT's transform, not this project's.
|
|
854
|
+
|
|
855
|
+
${table}
|
|
856
|
+
|
|
857
|
+
## What the table cannot tell you
|
|
858
|
+
|
|
859
|
+
- **The vocabulary has one \`browser\` term.** An operation that reads the DOM from a content
|
|
860
|
+
script is native on all three engines; one that reaches for \`chrome.debugger\` (a CDP relay, or
|
|
861
|
+
driving another tab) is **Chromium only** — Firefox never implemented the debugger protocol and
|
|
862
|
+
Safari has no such API. You know which one you wrote; the generator does not. Keep the
|
|
863
|
+
Chromium-only permissions in \`CHROMIUM_ONLY\` in \`wxt.config.ts\` so the other targets' manifests
|
|
864
|
+
drop them, and say so here.
|
|
865
|
+
- **Content scripts are yours to maintain.** A selector that follows the page it scrapes is
|
|
866
|
+
authored escape-hatch code: the surface can bootstrap it, never version-proof it.
|
|
867
|
+
- **Session export degrades on Safari.** Safari Web Extensions cannot read httpOnly cookies, so
|
|
868
|
+
"export the session" is Chromium and Firefox only; "act in the session" — the default, where the
|
|
869
|
+
request is made inside the logged-in page and only the result leaves — works on all three.
|
|
870
|
+
|
|
871
|
+
## Installing what you built
|
|
872
|
+
|
|
873
|
+
| Engine | Install |
|
|
874
|
+
|---|---|
|
|
875
|
+
| Chromium (Chrome, Edge) | \`chrome://extensions\` → Load unpacked → \`.output/chrome-mv3\`. Unpacked installs expire after 30 days and Chrome 149 disables ever-unpacked extensions on update, so the store is the real channel. |
|
|
876
|
+
| Firefox | \`npm --prefix ${HOST_DIR} exec -- web-ext run\` for a temporary install, or the Mozilla-signed \`.xpi\` from the release — the one download-and-install channel that is not a store. |
|
|
877
|
+
| Safari | \`xcrun safari-web-extension-converter .output/safari-mv2\` on macOS, then Xcode. The payload builds anywhere; signing needs macOS and an Apple developer account. |
|
|
878
|
+
`;
|
|
879
|
+
}
|
|
880
|
+
function readme(project, reuseWeb) {
|
|
881
|
+
const name = project.identity.name;
|
|
882
|
+
return `# ${name} — browser extension
|
|
883
|
+
|
|
884
|
+
<!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
|
|
885
|
+
|
|
886
|
+
A [WXT](https://wxt.dev) extension for Chromium, Firefox and Safari. It is a client of this
|
|
887
|
+
tool's own kernel: the background worker POSTs stateless MCP to \`${name} mcp --http\` on
|
|
888
|
+
loopback, and ${reuseWeb
|
|
889
|
+
? "the popup and options page are the `web` surface's component tree, imported rather than duplicated"
|
|
890
|
+
: "the popup is the pairing form"}.
|
|
891
|
+
|
|
892
|
+
\`\`\`
|
|
893
|
+
npm --prefix ${HOST_DIR} install # wxt prepare runs as postinstall
|
|
894
|
+
npm --prefix ${HOST_DIR} run dev # Chromium, hot reload
|
|
895
|
+
npm --prefix ${HOST_DIR} run dev:firefox
|
|
896
|
+
npm --prefix ${HOST_DIR} run build # .output/chrome-mv3
|
|
897
|
+
npm --prefix ${HOST_DIR} run test # the router, against a fake browser
|
|
898
|
+
npm --prefix ${HOST_DIR} run smoke # a real Chromium, a mock kernel (build first)
|
|
899
|
+
\`\`\`
|
|
900
|
+
|
|
901
|
+
## Pairing
|
|
902
|
+
|
|
903
|
+
Any local process can POST to loopback, so the kernel's token is the bar for anything
|
|
904
|
+
destructive. Run \`${name} mcp --http --pair\` and paste the \`<url>#<token>\` it prints into the
|
|
905
|
+
extension's options page. The token lives in \`storage.session\` — in memory, gone when the
|
|
906
|
+
browser closes, invisible to content scripts — and only the background worker ever sends it.${reuseWeb
|
|
907
|
+
? "\nThe reused page's own *MCP endpoint* field is advisory: an absolute `http(s)` URL there is\nused as typed, and anything else falls back to the paired endpoint."
|
|
908
|
+
: ""}
|
|
909
|
+
|
|
910
|
+
## What is generated and what is yours
|
|
911
|
+
|
|
912
|
+
| Path | Owner |
|
|
913
|
+
|---|---|
|
|
914
|
+
| \`wxt.config.ts\` | the region between the markers is generated (modules, aliases, the projected manifest); below it is yours |
|
|
915
|
+
| \`entrypoints/background.ts\` | the region is the router; below it is where operations that need a browser are implemented |
|
|
916
|
+
| \`entrypoints/example.content.ts\` | a template, matching \`example.com\` until you point it at your own domains (or delete it) — the whole script below the marker is yours |
|
|
917
|
+
| \`utils/\`, \`entrypoints/popup\`, \`entrypoints/options\`, \`tests/\` | generated |
|
|
918
|
+
| \`package.json\` | toolfactory owns the keys it writes; your own dependencies survive |
|
|
919
|
+
| \`public/icon/*.png\` | WXT's placeholder set, copied in by \`toolfactory validate\`; replace them with your own |
|
|
920
|
+
|
|
921
|
+
${reuseWeb
|
|
922
|
+
? "`@` and `@web` resolve to the web surface's `src/` so that tree's own imports work; use WXT's\n`~/` for files in this directory.\n\n"
|
|
923
|
+
: ""}## The boundary
|
|
924
|
+
|
|
925
|
+
Core logic stays in the kernel, where it is testable; the extension is transport and policy. An
|
|
926
|
+
operation that needs the page is *decomposed*, not injected: the content script reads the DOM,
|
|
927
|
+
hands the result to a portable operation as JSON arguments, and puts the answer back. That is why
|
|
928
|
+
\`get_professor_rating(name)\` belongs in the kernel and only the scraping belongs here.
|
|
929
|
+
|
|
930
|
+
Per-engine coverage, the Chromium-only caveats and how to install each build: \`TARGETS.md\`.
|
|
931
|
+
`;
|
|
932
|
+
}
|
|
933
|
+
export const surface = {
|
|
934
|
+
id: "browser-extension",
|
|
935
|
+
plan(project) {
|
|
936
|
+
const operations = includedOperations(project, surface);
|
|
937
|
+
const reuseWeb = has(project, "web");
|
|
938
|
+
const file = (path, content) => ({
|
|
939
|
+
kind: "file",
|
|
940
|
+
path: `${HOST_DIR}/${path}`,
|
|
941
|
+
content,
|
|
942
|
+
});
|
|
943
|
+
const page = (kind) => [
|
|
944
|
+
file(`entrypoints/${kind}/index.html`, pageHtml(project, kind)),
|
|
945
|
+
file(`entrypoints/${kind}/main.tsx`, pageMain(reuseWeb, kind)),
|
|
946
|
+
];
|
|
947
|
+
return [
|
|
948
|
+
file(".gitignore", BROWSER_SCAFFOLD.gitignore),
|
|
949
|
+
file("README.md", readme(project, reuseWeb)),
|
|
950
|
+
file("TARGETS.md", targetsMarkdown(project)),
|
|
951
|
+
{
|
|
952
|
+
kind: "region",
|
|
953
|
+
path: `${HOST_DIR}/entrypoints/background.ts`,
|
|
954
|
+
regions: [{ begin: BG_BEGIN, end: BG_END, content: BG_REGION }],
|
|
955
|
+
template: `${BG_BEGIN}${BG_END}\n${BG_TAIL}`,
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
kind: "region",
|
|
959
|
+
path: `${HOST_DIR}/entrypoints/example.content.ts`,
|
|
960
|
+
regions: [{ begin: CONTENT_BEGIN, end: CONTENT_END, content: CONTENT_REGION }],
|
|
961
|
+
template: contentTemplate(project),
|
|
962
|
+
},
|
|
963
|
+
...page("popup"),
|
|
964
|
+
...page("options"),
|
|
965
|
+
...(options(project).sidePanel ? page("sidepanel") : []),
|
|
966
|
+
{
|
|
967
|
+
kind: "merge",
|
|
968
|
+
path: `${HOST_DIR}/package.json`,
|
|
969
|
+
format: "json",
|
|
970
|
+
patch: packageJsonPatch(project, reuseWeb),
|
|
971
|
+
},
|
|
972
|
+
file("tests/background.test.ts", backgroundTest(project)),
|
|
973
|
+
file("tests/smoke.mjs", smokeMjs(project, reuseWeb)),
|
|
974
|
+
file("tsconfig.json", BROWSER_SCAFFOLD.tsconfig),
|
|
975
|
+
file("utils/mcp.ts", mcpTs(project)),
|
|
976
|
+
file("utils/page.tsx", PAGE_TSX),
|
|
977
|
+
...(reuseWeb ? [file("utils/page.css", PAGE_CSS), file("env.d.ts", ENV_D_TS)] : []),
|
|
978
|
+
file("vitest.config.ts", VITEST_CONFIG),
|
|
979
|
+
file("web-ext-config.mjs", WEB_EXT_CONFIG),
|
|
980
|
+
{
|
|
981
|
+
kind: "region",
|
|
982
|
+
path: `${HOST_DIR}/wxt.config.ts`,
|
|
983
|
+
regions: [
|
|
984
|
+
{
|
|
985
|
+
begin: CONFIG_BEGIN,
|
|
986
|
+
end: CONFIG_END,
|
|
987
|
+
content: configRegion(project, operations, reuseWeb),
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
template: `${CONFIG_BEGIN}${CONFIG_END}\n${CONFIG_TAIL}`,
|
|
991
|
+
},
|
|
992
|
+
];
|
|
993
|
+
},
|
|
994
|
+
validate(project) {
|
|
995
|
+
// Every step runs inside the extension, against the binaries it pins as devDependencies:
|
|
996
|
+
// `wxt` reads its entrypoints from the working directory, so `npm --prefix` alone would
|
|
997
|
+
// point it at the repository root.
|
|
998
|
+
const at = { cwd: join(project.root, HOST_DIR) };
|
|
999
|
+
const own = (label, ...args) => ({
|
|
1000
|
+
label,
|
|
1001
|
+
command: "npm",
|
|
1002
|
+
args: ["exec", "--no", "--", ...args],
|
|
1003
|
+
...at,
|
|
1004
|
+
});
|
|
1005
|
+
return [
|
|
1006
|
+
{ label: "npm install (hosts/browser)", command: "npm", args: ["install"], ...at },
|
|
1007
|
+
own("wxt prepare", "wxt", "prepare"),
|
|
1008
|
+
// C2: the scaffold is mirrored by running the real generator, never by trusting constants.
|
|
1009
|
+
// The same run seeds WXT's placeholder icons, which plan() cannot write: they are binary.
|
|
1010
|
+
{
|
|
1011
|
+
label: "browser scaffold drift",
|
|
1012
|
+
command: "node",
|
|
1013
|
+
args: [...execArgv, DRIFT_ENTRY, project.root],
|
|
1014
|
+
cwd: project.root,
|
|
1015
|
+
},
|
|
1016
|
+
own("wxt build (chrome-mv3)", "wxt", "build"),
|
|
1017
|
+
own("wxt build (firefox-mv2)", "wxt", "build", "-b", "firefox"),
|
|
1018
|
+
// addons-linter, wrapped: the only keyless manifest validator any engine publishes, and it
|
|
1019
|
+
// reads a Chromium manifest sensibly too.
|
|
1020
|
+
own("web-ext lint", "web-ext", "lint", "-s", ".output/firefox-mv2"),
|
|
1021
|
+
// The smoke drives the Chromium this project's own playwright expects; `install` is a
|
|
1022
|
+
// no-op once that revision is present.
|
|
1023
|
+
own("playwright install chromium (hosts/browser)", "playwright", "install", "chromium"),
|
|
1024
|
+
{ label: "vitest (hosts/browser)", command: "npm", args: ["run", "test"], ...at },
|
|
1025
|
+
{
|
|
1026
|
+
label: "playwright smoke (hosts/browser)",
|
|
1027
|
+
command: "node",
|
|
1028
|
+
args: ["tests/smoke.mjs"],
|
|
1029
|
+
...at,
|
|
1030
|
+
},
|
|
1031
|
+
];
|
|
1032
|
+
},
|
|
1033
|
+
verdict,
|
|
1034
|
+
};
|