@darwinium/portal-mcp 0.0.1 → 0.0.2
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +237 -7
- package/TESTING.md +478 -0
- package/dist/bin/bundlePaths-CSftU0OK.js +59 -0
- package/dist/bin/bundlePaths-CSftU0OK.js.map +1 -0
- package/dist/bin/doctor-D7BbIXN2.js +411 -0
- package/dist/bin/doctor-D7BbIXN2.js.map +1 -0
- package/dist/bin/install-BhzwhhI-.js +264 -0
- package/dist/bin/install-BhzwhhI-.js.map +1 -0
- package/dist/bin/killProcessOnPort-Bt5MA5Dv.js +136 -0
- package/dist/bin/killProcessOnPort-Bt5MA5Dv.js.map +1 -0
- package/dist/bin/oobPair-ybkkqrSh.js +250 -0
- package/dist/bin/oobPair-ybkkqrSh.js.map +1 -0
- package/dist/bin/portal-mcp.js +38 -0
- package/dist/bin/portal-mcp.js.map +1 -0
- package/dist/bin/rotate-Dzf8pwua.js +64 -0
- package/dist/bin/rotate-Dzf8pwua.js.map +1 -0
- package/dist/bin/server-Beyo9MT8.js +224 -0
- package/dist/bin/server-Beyo9MT8.js.map +1 -0
- package/dist/bin/server-CuNr9ZtJ.js +702 -0
- package/dist/bin/server-CuNr9ZtJ.js.map +1 -0
- package/dist/bin/store-B3e4WCLw.js +78 -0
- package/dist/bin/store-B3e4WCLw.js.map +1 -0
- package/dist/bin/term-Bg7icwx7.js +164 -0
- package/dist/bin/term-Bg7icwx7.js.map +1 -0
- package/docs/adr/ADR-001-static-mcp-tool-surface.md +100 -0
- package/extension-bundle/.gitkeep +0 -0
- package/extension-bundle/extension.zip +0 -0
- package/package.json +54 -12
- package/bin/portal-mcp.js +0 -19
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { a as TOKEN_DIR, i as EXTENSION_DIR, o as TOKEN_PATH } from "./killProcessOnPort-Bt5MA5Dv.js";
|
|
2
|
+
import { t as bundledExtensionZipPath } from "./bundlePaths-CSftU0OK.js";
|
|
3
|
+
import { a as info, c as lockAcl, i as fail, o as ok, s as warn } from "./term-Bg7icwx7.js";
|
|
4
|
+
import { t as generateAndWrite } from "./store-B3e4WCLw.js";
|
|
5
|
+
import { n as maybeMigrate, t as run } from "./oobPair-ybkkqrSh.js";
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import AdmZip from "adm-zip";
|
|
10
|
+
|
|
11
|
+
//#region src/install/extract.ts
|
|
12
|
+
/**
|
|
13
|
+
* Bundled-extension extractor.
|
|
14
|
+
*
|
|
15
|
+
* The npm-published binary ships with a copy of the WXT-built portal-extension
|
|
16
|
+
* zip at `extension-bundle/extension.zip` (placed there by
|
|
17
|
+
* `scripts/bundle-extension.ts` during prebuild). At install time we extract it
|
|
18
|
+
* to `<env-paths data dir>/extension/` so customers can load it via Chrome's
|
|
19
|
+
* "Load Unpacked" path on locked corporate machines that can't use the Web
|
|
20
|
+
* Store.
|
|
21
|
+
*
|
|
22
|
+
* Idempotency: if the target directory already contains a manifest.json
|
|
23
|
+
* with the same version as the bundled zip, we skip extraction so re-running
|
|
24
|
+
* `install` doesn't churn the extracted dir. Drift (different version) is
|
|
25
|
+
* handled by overwriting (with `extractAllTo(dir, true, false)`).
|
|
26
|
+
*
|
|
27
|
+
* Security: adm-zip's `extractAllTo(target, true, false)` does NOT preserve
|
|
28
|
+
* original permissions and resolves entry paths relative to the extraction
|
|
29
|
+
* target — no traversal beyond `targetDir`. A symlink pre-planted at the
|
|
30
|
+
* target is risk-accepted.
|
|
31
|
+
*/
|
|
32
|
+
function readManifestVersion(json, source) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(json);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(`${source}: manifest.json is not valid JSON`);
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`${source}: manifest.json is not a JSON object`);
|
|
40
|
+
const v = parsed.version;
|
|
41
|
+
if (typeof v !== "string" || v.length === 0) throw new Error(`${source}: manifest.json missing version string`);
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extract `zipPath` into `targetDir`, skipping when the existing manifest
|
|
46
|
+
* version matches the incoming version.
|
|
47
|
+
*
|
|
48
|
+
* Throws if the bundled zip is missing `manifest.json` or its version field
|
|
49
|
+
* is missing/non-string (catastrophic build-time mistake — caller maps to
|
|
50
|
+
* the `extract failed: ...` line and exits 1).
|
|
51
|
+
*/
|
|
52
|
+
function extractBundledExtension(zipPath, targetDir) {
|
|
53
|
+
const zip = new AdmZip(zipPath);
|
|
54
|
+
const manifestEntry = zip.getEntry("manifest.json");
|
|
55
|
+
if (!manifestEntry) throw new Error(`bundled zip ${zipPath} missing manifest.json`);
|
|
56
|
+
const incomingVersion = readManifestVersion(manifestEntry.getData().toString("utf8"), `bundled zip ${zipPath}`);
|
|
57
|
+
const existingManifest = path.join(targetDir, "manifest.json");
|
|
58
|
+
if (fs.existsSync(existingManifest)) try {
|
|
59
|
+
if (readManifestVersion(fs.readFileSync(existingManifest, "utf8"), `existing ${existingManifest}`) === incomingVersion) return {
|
|
60
|
+
extracted: false,
|
|
61
|
+
version: incomingVersion
|
|
62
|
+
};
|
|
63
|
+
} catch {}
|
|
64
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
65
|
+
zip.extractAllTo(targetDir, true, false);
|
|
66
|
+
return {
|
|
67
|
+
extracted: true,
|
|
68
|
+
version: incomingVersion
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/install/configMerge.ts
|
|
74
|
+
/**
|
|
75
|
+
* Returns a merged JSON object with `mcpServers[serverName]` set to `entry`,
|
|
76
|
+
* preserving all other top-level keys and other `mcpServers.*` siblings.
|
|
77
|
+
*
|
|
78
|
+
* `current` semantics:
|
|
79
|
+
* - `null` → treated as empty starting point (file did not exist on disk).
|
|
80
|
+
* - non-null non-object (e.g. array, string, number) → throws.
|
|
81
|
+
*
|
|
82
|
+
* Idempotency: if the existing entry is byte-equal (via JSON.stringify of
|
|
83
|
+
* `args` and `env`) to the proposed entry, returns `{changed: false}` so the
|
|
84
|
+
* caller can skip the `.bak` write and the temp-rename.
|
|
85
|
+
*/
|
|
86
|
+
function mergeMcpServersEntry(current, serverName, entry) {
|
|
87
|
+
if (current !== null && (typeof current !== "object" || Array.isArray(current))) throw new Error("claude_desktop_config.json is not a JSON object");
|
|
88
|
+
const base = current ?? {};
|
|
89
|
+
const rawServers = base.mcpServers;
|
|
90
|
+
if (rawServers !== void 0 && (typeof rawServers !== "object" || rawServers === null || Array.isArray(rawServers))) throw new Error("claude_desktop_config.json has a malformed \"mcpServers\" value");
|
|
91
|
+
const mcpServers = rawServers ?? {};
|
|
92
|
+
const existing = mcpServers[serverName];
|
|
93
|
+
const existed = existing !== void 0;
|
|
94
|
+
const existingEntry = typeof existing === "object" && existing !== null ? existing : void 0;
|
|
95
|
+
if (existingEntry !== void 0 && existingEntry.command === entry.command && JSON.stringify(existingEntry.args) === JSON.stringify(entry.args) && JSON.stringify(existingEntry.env ?? {}) === JSON.stringify(entry.env ?? {})) return {
|
|
96
|
+
merged: base,
|
|
97
|
+
changed: false,
|
|
98
|
+
existed: true
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
merged: {
|
|
102
|
+
...base,
|
|
103
|
+
mcpServers: {
|
|
104
|
+
...mcpServers,
|
|
105
|
+
[serverName]: entry
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
changed: true,
|
|
109
|
+
existed
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/install/install.ts
|
|
115
|
+
/**
|
|
116
|
+
* `npx @darwinium/portal-mcp install` — orchestrator.
|
|
117
|
+
*
|
|
118
|
+
* Sequence:
|
|
119
|
+
* 1. migrate.maybeMigrate() — legacy token-path move
|
|
120
|
+
* 2. token: skip if exists, else generateAndWrite({silent:true})
|
|
121
|
+
* 3. winAcl.lockAcl(TOKEN_PATH) AND winAcl.lockAcl(TOKEN_DIR)
|
|
122
|
+
* 4. extract.extractBundledExtension(zip, EXTENSION_DIR)
|
|
123
|
+
* 5. configMerge → write `.bak` then atomic temp+rename
|
|
124
|
+
* 6. Print Claude Code marketplace + Chrome extension instructions
|
|
125
|
+
* 7. oobPair.run({mode:'install', expectedToken: token})
|
|
126
|
+
* 8. Success summary + process.exit(0)
|
|
127
|
+
*
|
|
128
|
+
* Security:
|
|
129
|
+
* - Token never echoed — generateAndWrite({silent:true}).
|
|
130
|
+
* - configMerge entry contains only `npx @darwinium/portal-mcp serve` —
|
|
131
|
+
* no token, no env.
|
|
132
|
+
* - `.bak` written via copyFileSync BEFORE temp-write; atomic fs.renameSync
|
|
133
|
+
* replaces target. Malformed pre-existing JSON → refusal.
|
|
134
|
+
*/
|
|
135
|
+
/** Config entry — never includes the token; only the cli invocation. */
|
|
136
|
+
const MCP_ENTRY = {
|
|
137
|
+
command: "npx",
|
|
138
|
+
args: [
|
|
139
|
+
"-y",
|
|
140
|
+
"@darwinium/portal-mcp",
|
|
141
|
+
"serve"
|
|
142
|
+
]
|
|
143
|
+
};
|
|
144
|
+
const SERVER_NAME = "darwinium-portal-mcp";
|
|
145
|
+
/** Resolve the platform-correct claude_desktop_config.json path. */
|
|
146
|
+
function resolveClaudeDesktopConfigPath() {
|
|
147
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
148
|
+
if (process.platform === "win32") {
|
|
149
|
+
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
150
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
151
|
+
}
|
|
152
|
+
return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
153
|
+
}
|
|
154
|
+
/** Read + parse claude_desktop_config.json. Returns null on ENOENT. */
|
|
155
|
+
function readConfigJson(configPath) {
|
|
156
|
+
if (!fs.existsSync(configPath)) return null;
|
|
157
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(raw);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new Error(`claude_desktop_config.json is not valid JSON: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Atomic write: `.bak` (if pre-existing) → temp → rename. */
|
|
165
|
+
function writeConfigJsonAtomic(configPath, merged, existed) {
|
|
166
|
+
if (existed) fs.copyFileSync(configPath, `${configPath}.bak`);
|
|
167
|
+
const tmpPath = `${configPath}.tmp.${process.pid}`;
|
|
168
|
+
fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 420 });
|
|
169
|
+
fs.renameSync(tmpPath, configPath);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Apply the configMerge to claude_desktop_config.json. Emits exactly one
|
|
173
|
+
* summary-line variant for the config row.
|
|
174
|
+
*/
|
|
175
|
+
function applyClaudeDesktopMerge() {
|
|
176
|
+
const configPath = resolveClaudeDesktopConfigPath();
|
|
177
|
+
const configDir = path.dirname(configPath);
|
|
178
|
+
if (!fs.existsSync(configDir)) {
|
|
179
|
+
warn(`config (Claude Desktop config dir not found at ${configDir} — install Claude Desktop or use the Claude Code marketplace path).`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let current;
|
|
183
|
+
try {
|
|
184
|
+
current = readConfigJson(configPath);
|
|
185
|
+
} catch {
|
|
186
|
+
fail(`config (refusing to write — ${configPath} is not valid JSON; please fix or delete and re-run)`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = mergeMcpServersEntry(current, SERVER_NAME, MCP_ENTRY);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
fail(`config (refusing to write — ${configPath} is not a JSON object: ${err.message})`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
const fileExisted = current !== null;
|
|
197
|
+
if (!result.changed) {
|
|
198
|
+
ok("config (existing match)");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
writeConfigJsonAtomic(configPath, result.merged, fileExisted);
|
|
202
|
+
if (result.existed) ok(`config (updated darwinium-portal-mcp entry; .bak written)`);
|
|
203
|
+
else if (fileExisted) ok(`config (added darwinium-portal-mcp entry to ${configPath}; .bak written)`);
|
|
204
|
+
else ok(`config (added darwinium-portal-mcp entry to ${configPath}; new file)`);
|
|
205
|
+
}
|
|
206
|
+
/** Print the Claude Code + Chrome extension instructions block. */
|
|
207
|
+
function printPostInstallInstructions() {
|
|
208
|
+
console.error("");
|
|
209
|
+
info("Claude Code: run `/plugin marketplace add darwinium-com/portal-mcp-marketplace`");
|
|
210
|
+
console.error(" then `/plugin install portal-mcp` from inside Claude Code.");
|
|
211
|
+
console.error("");
|
|
212
|
+
info("Chrome extension — pick one:");
|
|
213
|
+
console.error(" (a) Web Store: https://chromewebstore.google.com/detail/<placeholder-listing-url>");
|
|
214
|
+
console.error(" (b) Unpacked: chrome://extensions → enable Developer Mode →");
|
|
215
|
+
console.error(` Load Unpacked → point at ${EXTENSION_DIR}`);
|
|
216
|
+
console.error("");
|
|
217
|
+
}
|
|
218
|
+
/** Public entry point — invoked from the lazy-loaded `install` action in bin. */
|
|
219
|
+
async function runInstall(_opts = {}) {
|
|
220
|
+
info("install starting...");
|
|
221
|
+
maybeMigrate(TOKEN_PATH);
|
|
222
|
+
let token;
|
|
223
|
+
if (fs.existsSync(TOKEN_PATH)) {
|
|
224
|
+
token = fs.readFileSync(TOKEN_PATH, "utf8").trim();
|
|
225
|
+
const aclSuffix = process.platform === "win32" ? ", ACL locked" : "";
|
|
226
|
+
ok(`token (existing at ${TOKEN_PATH}, mode 0600${aclSuffix})`);
|
|
227
|
+
} else {
|
|
228
|
+
token = generateAndWrite({ silent: true });
|
|
229
|
+
const aclSuffix = process.platform === "win32" ? ", ACL locked" : "";
|
|
230
|
+
ok(`token (added at ${TOKEN_PATH}, mode 0600${aclSuffix})`);
|
|
231
|
+
}
|
|
232
|
+
for (const p of [TOKEN_PATH, TOKEN_DIR]) {
|
|
233
|
+
const r = lockAcl(p);
|
|
234
|
+
if (!r.ok) warn(`Could not restrict ACLs on ${p}: ${r.error}. Run \`${r.cmd}\` manually.`);
|
|
235
|
+
}
|
|
236
|
+
const extZip = bundledExtensionZipPath();
|
|
237
|
+
try {
|
|
238
|
+
const extResult = extractBundledExtension(extZip, EXTENSION_DIR);
|
|
239
|
+
if (extResult.extracted) ok(`extension (extracted v${extResult.version} to ${EXTENSION_DIR})`);
|
|
240
|
+
else ok(`extension (existing v${extResult.version})`);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
fail(`extension (extract failed: ${err.message}; bundled extension at ${extZip} is intact — re-run install)`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
applyClaudeDesktopMerge();
|
|
246
|
+
printPostInstallInstructions();
|
|
247
|
+
await run({
|
|
248
|
+
mode: "install",
|
|
249
|
+
expectedToken: token
|
|
250
|
+
});
|
|
251
|
+
let extVersionForSuccess = "";
|
|
252
|
+
const manifestPath = path.join(EXTENSION_DIR, "manifest.json");
|
|
253
|
+
if (fs.existsSync(manifestPath)) try {
|
|
254
|
+
extVersionForSuccess = JSON.parse(fs.readFileSync(manifestPath, "utf8")).version;
|
|
255
|
+
} catch {}
|
|
256
|
+
if (extVersionForSuccess) ok(`paired with extension (extension version ${extVersionForSuccess})`);
|
|
257
|
+
else ok("paired with extension");
|
|
258
|
+
info("install complete. You can close this terminal.");
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
//#endregion
|
|
263
|
+
export { runInstall };
|
|
264
|
+
//# sourceMappingURL=install-BhzwhhI-.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install-BhzwhhI-.js","names":["winAcl.lockAcl","extract.extractBundledExtension","oobPair.run"],"sources":["../../src/install/extract.ts","../../src/install/configMerge.ts","../../src/install/install.ts"],"sourcesContent":["/**\n * Bundled-extension extractor.\n *\n * The npm-published binary ships with a copy of the WXT-built portal-extension\n * zip at `extension-bundle/extension.zip` (placed there by\n * `scripts/bundle-extension.ts` during prebuild). At install time we extract it\n * to `<env-paths data dir>/extension/` so customers can load it via Chrome's\n * \"Load Unpacked\" path on locked corporate machines that can't use the Web\n * Store.\n *\n * Idempotency: if the target directory already contains a manifest.json\n * with the same version as the bundled zip, we skip extraction so re-running\n * `install` doesn't churn the extracted dir. Drift (different version) is\n * handled by overwriting (with `extractAllTo(dir, true, false)`).\n *\n * Security: adm-zip's `extractAllTo(target, true, false)` does NOT preserve\n * original permissions and resolves entry paths relative to the extraction\n * target — no traversal beyond `targetDir`. A symlink pre-planted at the\n * target is risk-accepted.\n */\nimport AdmZip from 'adm-zip';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/** Outcome of `extractBundledExtension(zip, target)`. */\nexport interface ExtractResult {\n /** True if the target dir was actually written (false = idempotent skip). */\n extracted: boolean;\n /** Manifest version of the bundled extension (always populated). */\n version: string;\n}\n\n// Validate that `json` parses to an object whose `version` field is a non-empty\n// string. Without this, an `as string` cast lets `version: undefined` flow into\n// the idempotency check, where `undefined === undefined` is true and every\n// re-install silently skips extraction.\nfunction readManifestVersion(json: string, source: string): string {\n let parsed: unknown;\n try {\n parsed = JSON.parse(json);\n } catch {\n throw new Error(`${source}: manifest.json is not valid JSON`);\n }\n if (!parsed || typeof parsed !== 'object') {\n throw new Error(`${source}: manifest.json is not a JSON object`);\n }\n const v = (parsed as { version?: unknown }).version;\n if (typeof v !== 'string' || v.length === 0) {\n throw new Error(`${source}: manifest.json missing version string`);\n }\n return v;\n}\n\n/**\n * Extract `zipPath` into `targetDir`, skipping when the existing manifest\n * version matches the incoming version.\n *\n * Throws if the bundled zip is missing `manifest.json` or its version field\n * is missing/non-string (catastrophic build-time mistake — caller maps to\n * the `extract failed: ...` line and exits 1).\n */\nexport function extractBundledExtension(zipPath: string, targetDir: string): ExtractResult {\n // Read the incoming zip's version BEFORE deciding to extract, so we can\n // implement the idempotency check without writing any bytes.\n const zip = new AdmZip(zipPath);\n const manifestEntry = zip.getEntry('manifest.json');\n if (!manifestEntry) {\n throw new Error(`bundled zip ${zipPath} missing manifest.json`);\n }\n const incomingVersion = readManifestVersion(manifestEntry.getData().toString('utf8'), `bundled zip ${zipPath}`);\n\n const existingManifest = path.join(targetDir, 'manifest.json');\n if (fs.existsSync(existingManifest)) {\n try {\n const existingVersion = readManifestVersion(\n fs.readFileSync(existingManifest, 'utf8'),\n `existing ${existingManifest}`,\n );\n if (existingVersion === incomingVersion) {\n return { extracted: false, version: incomingVersion };\n }\n } catch {\n // Existing manifest is malformed or missing version — fall through and overwrite.\n }\n }\n\n fs.mkdirSync(targetDir, { recursive: true });\n zip.extractAllTo(targetDir, /* overwrite */ true, /* keepOriginalPermission */ false);\n return { extracted: true, version: incomingVersion };\n}\n","/**\n * Pure additive merge for `claude_desktop_config.json`.\n *\n * Customers may already have other MCP servers configured in their\n * `claude_desktop_config.json`. We must add or update ONLY our own\n * `mcpServers[\"darwinium-portal-mcp\"]` entry, preserving everything else.\n *\n * To guard against config corruption, the caller (`install.ts`) writes a\n * `.bak` before any modification and uses temp+rename for atomicity. This\n * module is pure — no fs reads/writes, no side effects — so it's trivially\n * testable and cannot leak partial state.\n *\n * Pre-existing malformed JSON triggers a hard refusal: if we can't parse it,\n * we don't touch it. The caller maps the thrown error to the user-facing\n * fail line.\n */\n\n/** Shape of the per-server entry written under `mcpServers[<name>]`. */\nexport interface McpServerEntry {\n command: string;\n args: string[];\n env?: Record<string, string>;\n}\n\n/** Result of a merge: the new full JSON object plus diff metadata. */\nexport interface MergeResult {\n /** Full top-level JSON object after merge. Caller serializes + writes. */\n merged: object;\n /** True if the merged JSON differs from `current` (warrants a write). */\n changed: boolean;\n /** True if `current` already had a `mcpServers[serverName]` entry. */\n existed: boolean;\n}\n\n/**\n * Returns a merged JSON object with `mcpServers[serverName]` set to `entry`,\n * preserving all other top-level keys and other `mcpServers.*` siblings.\n *\n * `current` semantics:\n * - `null` → treated as empty starting point (file did not exist on disk).\n * - non-null non-object (e.g. array, string, number) → throws.\n *\n * Idempotency: if the existing entry is byte-equal (via JSON.stringify of\n * `args` and `env`) to the proposed entry, returns `{changed: false}` so the\n * caller can skip the `.bak` write and the temp-rename.\n */\nexport function mergeMcpServersEntry(current: unknown, serverName: string, entry: McpServerEntry): MergeResult {\n // Treat null (file absent) as empty starting point. Any other non-object is\n // a malformed file and we refuse to write.\n if (current !== null && (typeof current !== 'object' || Array.isArray(current))) {\n throw new Error('claude_desktop_config.json is not a JSON object');\n }\n const base: Record<string, unknown> = (current ?? {}) as Record<string, unknown>;\n\n // `mcpServers` must be a plain object or absent. Anything else (a string, an\n // array) is malformed, and spreading it would scatter its indices across the\n // rewritten config — so it takes the same hard refusal as malformed JSON\n // rather than being silently coerced.\n const rawServers = base.mcpServers;\n if (rawServers !== undefined && (typeof rawServers !== 'object' || rawServers === null || Array.isArray(rawServers))) {\n throw new Error('claude_desktop_config.json has a malformed \"mcpServers\" value');\n }\n const mcpServers: Record<string, unknown> = (rawServers ?? {}) as Record<string, unknown>;\n\n const existing = mcpServers[serverName];\n const existed = existing !== undefined;\n\n // Narrow before reading fields: a non-object entry simply fails the match and\n // gets overwritten, which is what the old `any` did implicitly.\n const existingEntry =\n typeof existing === 'object' && existing !== null ? (existing as Partial<McpServerEntry>) : undefined;\n\n // Drift detection: compare command, args, env via stable JSON encoding. We\n // intentionally use JSON.stringify(args) instead of array equality so order\n // and nested structures are compared deeply.\n const isMatch =\n existingEntry !== undefined &&\n existingEntry.command === entry.command &&\n JSON.stringify(existingEntry.args) === JSON.stringify(entry.args) &&\n JSON.stringify(existingEntry.env ?? {}) === JSON.stringify(entry.env ?? {});\n\n if (isMatch) {\n return { merged: base, changed: false, existed: true };\n }\n\n return {\n merged: { ...base, mcpServers: { ...mcpServers, [serverName]: entry } },\n changed: true,\n existed,\n };\n}\n","/**\n * `npx @darwinium/portal-mcp install` — orchestrator.\n *\n * Sequence:\n * 1. migrate.maybeMigrate() — legacy token-path move\n * 2. token: skip if exists, else generateAndWrite({silent:true})\n * 3. winAcl.lockAcl(TOKEN_PATH) AND winAcl.lockAcl(TOKEN_DIR)\n * 4. extract.extractBundledExtension(zip, EXTENSION_DIR)\n * 5. configMerge → write `.bak` then atomic temp+rename\n * 6. Print Claude Code marketplace + Chrome extension instructions\n * 7. oobPair.run({mode:'install', expectedToken: token})\n * 8. Success summary + process.exit(0)\n *\n * Security:\n * - Token never echoed — generateAndWrite({silent:true}).\n * - configMerge entry contains only `npx @darwinium/portal-mcp serve` —\n * no token, no env.\n * - `.bak` written via copyFileSync BEFORE temp-write; atomic fs.renameSync\n * replaces target. Malformed pre-existing JSON → refusal.\n */\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\nimport { TOKEN_DIR, TOKEN_PATH, EXTENSION_DIR } from '../token/paths.js';\nimport { generateAndWrite } from '../token/store.js';\nimport * as term from './term.js';\nimport * as migrate from './migrate.js';\nimport * as winAcl from './winAcl.js';\nimport * as extract from './extract.js';\nimport { bundledExtensionZipPath } from './bundlePaths.js';\nimport { mergeMcpServersEntry, type McpServerEntry } from './configMerge.js';\nimport * as oobPair from './oobPair.js';\n\n/** Config entry — never includes the token; only the cli invocation. */\nconst MCP_ENTRY: McpServerEntry = {\n command: 'npx',\n args: ['-y', '@darwinium/portal-mcp', 'serve'],\n};\n\nconst SERVER_NAME = 'darwinium-portal-mcp';\n\n/** Resolve the platform-correct claude_desktop_config.json path. */\nfunction resolveClaudeDesktopConfigPath(): string {\n if (process.platform === 'darwin') {\n return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');\n }\n if (process.platform === 'win32') {\n const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');\n return path.join(appData, 'Claude', 'claude_desktop_config.json');\n }\n // Linux fallback — Anthropic's documented location matches XDG_CONFIG_HOME default.\n return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');\n}\n\n/** Read + parse claude_desktop_config.json. Returns null on ENOENT. */\nfunction readConfigJson(configPath: string): unknown {\n if (!fs.existsSync(configPath)) return null;\n const raw = fs.readFileSync(configPath, 'utf8');\n try {\n return JSON.parse(raw);\n } catch (err) {\n // Re-throw as a tagged error the caller can map to the user-facing fail line.\n throw new Error(`claude_desktop_config.json is not valid JSON: ${(err as Error).message}`);\n }\n}\n\n/** Atomic write: `.bak` (if pre-existing) → temp → rename. */\nfunction writeConfigJsonAtomic(configPath: string, merged: object, existed: boolean): void {\n if (existed) {\n fs.copyFileSync(configPath, `${configPath}.bak`);\n }\n const tmpPath = `${configPath}.tmp.${process.pid}`;\n fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o644 });\n fs.renameSync(tmpPath, configPath);\n}\n\n/**\n * Apply the configMerge to claude_desktop_config.json. Emits exactly one\n * summary-line variant for the config row.\n */\nfunction applyClaudeDesktopMerge(): void {\n const configPath = resolveClaudeDesktopConfigPath();\n const configDir = path.dirname(configPath);\n\n if (!fs.existsSync(configDir)) {\n // Claude Desktop not installed — warn and continue.\n term.warn(\n `config (Claude Desktop config dir not found at ${configDir} — install Claude Desktop or use the Claude Code marketplace path).`,\n );\n return;\n }\n\n let current: unknown;\n try {\n current = readConfigJson(configPath);\n } catch {\n // Refuse to overwrite invalid JSON.\n term.fail(`config (refusing to write — ${configPath} is not valid JSON; please fix or delete and re-run)`);\n process.exit(1);\n }\n\n let result: ReturnType<typeof mergeMcpServersEntry>;\n try {\n result = mergeMcpServersEntry(current, SERVER_NAME, MCP_ENTRY);\n } catch (err) {\n // mergeMcpServersEntry throws on non-object top-level — same fail line.\n term.fail(`config (refusing to write — ${configPath} is not a JSON object: ${(err as Error).message})`);\n process.exit(1);\n }\n\n const fileExisted = current !== null;\n if (!result.changed) {\n term.ok('config (existing match)');\n return;\n }\n\n writeConfigJsonAtomic(configPath, result.merged, fileExisted);\n if (result.existed) {\n term.ok(`config (updated darwinium-portal-mcp entry; .bak written)`);\n } else if (fileExisted) {\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; .bak written)`);\n } else {\n // First-ever config file — there's no pre-existing content to back up.\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; new file)`);\n }\n}\n\n/** Print the Claude Code + Chrome extension instructions block. */\nfunction printPostInstallInstructions(): void {\n console.error('');\n term.info('Claude Code: run `/plugin marketplace add darwinium-com/portal-mcp-marketplace`');\n console.error(' then `/plugin install portal-mcp` from inside Claude Code.');\n console.error('');\n term.info('Chrome extension — pick one:');\n console.error(' (a) Web Store: https://chromewebstore.google.com/detail/<placeholder-listing-url>');\n console.error(' (b) Unpacked: chrome://extensions → enable Developer Mode →');\n console.error(` Load Unpacked → point at ${EXTENSION_DIR}`);\n console.error('');\n}\n\n/** Public entry point — invoked from the lazy-loaded `install` action in bin. */\nexport async function runInstall(_opts: { json?: boolean; yes?: boolean } = {}): Promise<void> {\n term.info('install starting...');\n\n // 1. Legacy token-path migration (silent on no-op).\n migrate.maybeMigrate(TOKEN_PATH);\n\n // 2. Token — skip generation if existing, else write silently.\n let token: string;\n if (fs.existsSync(TOKEN_PATH)) {\n token = fs.readFileSync(TOKEN_PATH, 'utf8').trim();\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (existing at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n } else {\n token = generateAndWrite({ silent: true });\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (added at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n }\n\n // 3. Windows ACL on token + parent dir. Non-Windows: no-op.\n for (const p of [TOKEN_PATH, TOKEN_DIR]) {\n const r = winAcl.lockAcl(p);\n if (!r.ok) {\n term.warn(`Could not restrict ACLs on ${p}: ${r.error}. Run \\`${r.cmd}\\` manually.`);\n }\n }\n\n // 4. Extract bundled extension (idempotent).\n const extZip = bundledExtensionZipPath();\n try {\n const extResult = extract.extractBundledExtension(extZip, EXTENSION_DIR);\n if (extResult.extracted) {\n term.ok(`extension (extracted v${extResult.version} to ${EXTENSION_DIR})`);\n } else {\n term.ok(`extension (existing v${extResult.version})`);\n }\n } catch (err) {\n term.fail(\n `extension (extract failed: ${(err as Error).message}; bundled extension at ${extZip} is intact — re-run install)`,\n );\n process.exit(1);\n }\n\n // 5. claude_desktop_config.json additive merge.\n applyClaudeDesktopMerge();\n\n // 6. Post-install instructions.\n printPostInstallInstructions();\n\n // 7. Foreground OOB pair — blocks until paired or SIGINT.\n await oobPair.run({ mode: 'install', expectedToken: token });\n\n // 8. Success summary. Read the manifest of the just-extracted extension to\n // get its version for the success line. If it's missing for any reason,\n // emit the version-less variant.\n let extVersionForSuccess = '';\n const manifestPath = path.join(EXTENSION_DIR, 'manifest.json');\n if (fs.existsSync(manifestPath)) {\n try {\n extVersionForSuccess = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version as string;\n } catch {\n /* fall through to versionless variant */\n }\n }\n if (extVersionForSuccess) {\n term.ok(`paired with extension (extension version ${extVersionForSuccess})`);\n } else {\n term.ok('paired with extension');\n }\n term.info('install complete. You can close this terminal.');\n process.exit(0);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import envPaths from "env-paths";
|
|
5
|
+
import * as net from "node:net";
|
|
6
|
+
|
|
7
|
+
//#region src/bridge/wireProtocol.ts
|
|
8
|
+
/**
|
|
9
|
+
* Wire-protocol schemas for the binary↔extension WebSocket bridge.
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT — KEEP IN SYNC: this file MUST stay aligned with
|
|
12
|
+
* `dwn_aphex/packages/portal-extension/src/shared/wireProtocol.ts`.
|
|
13
|
+
* The binary bundle (Node, tsdown) and extension bundle (browser, WXT/Vite)
|
|
14
|
+
* cannot share a workspace dep, so the schemas are duplicated. Do NOT change
|
|
15
|
+
* one without updating the other; the smoke test checks the constants for drift.
|
|
16
|
+
*/
|
|
17
|
+
/** Application-level wire protocol version. Bumped when frame shapes change. */
|
|
18
|
+
const PROTOCOL_VERSION = "0.1.0";
|
|
19
|
+
/** WS subprotocol token returned in the response Sec-WebSocket-Protocol header. */
|
|
20
|
+
const VERSION_SUBPROTOCOL = "darwinium.v1";
|
|
21
|
+
/** Prefix for the per-connection token entry in the offered Sec-WebSocket-Protocol list. */
|
|
22
|
+
const TOKEN_SUBPROTOCOL_PREFIX = "tok.";
|
|
23
|
+
/**
|
|
24
|
+
* Token subprotocol regex: exactly 64 lowercase hex characters after the
|
|
25
|
+
* `tok.` prefix.
|
|
26
|
+
*
|
|
27
|
+
* Used by `src/ws/server.ts` `handleProtocols` to reject ill-shaped offers at
|
|
28
|
+
* the upgrade boundary (HTTP 401 — distinct from post-upgrade WS 4401).
|
|
29
|
+
*/
|
|
30
|
+
const TOKEN_SUBPROTOCOL_REGEX = /^tok\.[a-f0-9]{64}$/;
|
|
31
|
+
/** Pairing-code subprotocol regex: `pair.<6-digit>`. */
|
|
32
|
+
const PAIRING_CODE_REGEX = /^pair\.[0-9]{6}$/;
|
|
33
|
+
/** Maximum window for the post-upgrade `{type:'hello'}` first frame to arrive. */
|
|
34
|
+
const HELLO_TIMEOUT_MS = 1e3;
|
|
35
|
+
/** WS close code for token-handshake failure. */
|
|
36
|
+
const WS_CLOSE_TOKEN_MISMATCH = 4401;
|
|
37
|
+
const HelloFrameSchema = z.object({
|
|
38
|
+
type: z.literal("hello"),
|
|
39
|
+
token: z.string().regex(/^[0-9a-f]{64}$/),
|
|
40
|
+
version: z.string()
|
|
41
|
+
});
|
|
42
|
+
const ReqFrameSchema = z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
type: z.literal("req"),
|
|
45
|
+
op: z.string(),
|
|
46
|
+
args: z.unknown()
|
|
47
|
+
});
|
|
48
|
+
const RespFrameSchema = z.object({
|
|
49
|
+
id: z.string(),
|
|
50
|
+
type: z.literal("resp"),
|
|
51
|
+
result: z.unknown().optional(),
|
|
52
|
+
error: z.string().optional()
|
|
53
|
+
});
|
|
54
|
+
const PageIdChangedFrameSchema = z.object({
|
|
55
|
+
type: z.literal("pageIdChanged"),
|
|
56
|
+
pageId: z.string().nullable()
|
|
57
|
+
});
|
|
58
|
+
const PingFrameSchema = z.object({ type: z.literal("ping") });
|
|
59
|
+
const PongFrameSchema = z.object({ type: z.literal("pong") });
|
|
60
|
+
/**
|
|
61
|
+
* Frame the OOB pairing-WS server sends to the popup once a `pair.<6-digit>`
|
|
62
|
+
* upgrade matches the armed code. Delivers the 64-hex token across the
|
|
63
|
+
* authenticated pairing channel.
|
|
64
|
+
*/
|
|
65
|
+
const PairedFrameSchema = z.object({
|
|
66
|
+
type: z.literal("paired"),
|
|
67
|
+
token: z.string().regex(/^[0-9a-f]{64}$/),
|
|
68
|
+
version: z.string()
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/token/paths.ts
|
|
73
|
+
/**
|
|
74
|
+
* Cross-platform paths for the portal-mcp installer.
|
|
75
|
+
*
|
|
76
|
+
* Uses `env-paths`-driven platform-conventional locations:
|
|
77
|
+
* - macOS: ~/Library/Application Support/darwinium-portal-mcp/
|
|
78
|
+
* - Windows: %APPDATA%/darwinium-portal-mcp/Data/ (env-paths default)
|
|
79
|
+
* - Linux: ~/.local/share/darwinium-portal-mcp/ (XDG_DATA_HOME)
|
|
80
|
+
*
|
|
81
|
+
* The token file and the extracted bundled extension share this single per-user
|
|
82
|
+
* data dir so customers see one platform-conventional folder rather than two.
|
|
83
|
+
*
|
|
84
|
+
* Migration of the legacy `~/.config/darwinium-portal-mcp/token` location to
|
|
85
|
+
* `<env-paths data dir>/token` lives in `src/install/migrate.ts`.
|
|
86
|
+
*/
|
|
87
|
+
const paths = envPaths("darwinium-portal-mcp", { suffix: "" });
|
|
88
|
+
/** Per-user data directory; parent of the token file and extracted extension. */
|
|
89
|
+
const TOKEN_DIR = paths.data;
|
|
90
|
+
/** Token file path: 64-hex secret used for the binary↔extension handshake. */
|
|
91
|
+
const TOKEN_PATH = path.join(TOKEN_DIR, "token");
|
|
92
|
+
/** Extracted bundled-extension directory (loaded via Chrome's "Load Unpacked"). */
|
|
93
|
+
const EXTENSION_DIR = path.join(TOKEN_DIR, "extension");
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/ws/killProcessOnPort.ts
|
|
97
|
+
/**
|
|
98
|
+
* Kill any process holding the given port. Cross-platform; safe to call when port is free.
|
|
99
|
+
*
|
|
100
|
+
* SECURITY: kills ONLY the PID returned by `lsof -ti:PORT` (or netstat equivalent).
|
|
101
|
+
* Does NOT take a port-range; cannot kill unrelated processes. Caller-controlled
|
|
102
|
+
* port number is the only risk surface.
|
|
103
|
+
*
|
|
104
|
+
* Pattern verbatim from BrowserMCP: github.com/BrowserMCP/mcp/blob/main/src/utils/port.ts
|
|
105
|
+
*/
|
|
106
|
+
function killProcessOnPort(port) {
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
execSync(`FOR /F "tokens=5" %a in ('netstat -ano ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "pipe" });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const pids = execSync(`lsof -ti:${port}`, { stdio: "pipe" }).toString().split("\n").map((line) => Number(line.trim())).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
113
|
+
for (const pid of pids) try {
|
|
114
|
+
process.kill(pid, "SIGKILL");
|
|
115
|
+
} catch {}
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check whether a port is currently bound. Returns true if held; false if free.
|
|
120
|
+
* Polls by attempting to bind a tester server on the same host.
|
|
121
|
+
*/
|
|
122
|
+
function isPortInUse(port) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const tester = net.createServer().once("error", () => resolve(true)).once("listening", () => tester.close(() => resolve(false))).listen(port, "127.0.0.1");
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Sleep for `ms` milliseconds. Used by the bind-loop poll cap.
|
|
129
|
+
*/
|
|
130
|
+
function wait(ms) {
|
|
131
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
export { TOKEN_DIR as a, PAIRING_CODE_REGEX as c, TOKEN_SUBPROTOCOL_REGEX as d, VERSION_SUBPROTOCOL as f, EXTENSION_DIR as i, PROTOCOL_VERSION as l, killProcessOnPort as n, TOKEN_PATH as o, WS_CLOSE_TOKEN_MISMATCH as p, wait as r, HELLO_TIMEOUT_MS as s, isPortInUse as t, TOKEN_SUBPROTOCOL_PREFIX as u };
|
|
136
|
+
//# sourceMappingURL=killProcessOnPort-Bt5MA5Dv.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"killProcessOnPort-Bt5MA5Dv.js","names":[],"sources":["../../src/bridge/wireProtocol.ts","../../src/token/paths.ts","../../src/ws/killProcessOnPort.ts"],"sourcesContent":["/**\n * Wire-protocol schemas for the binary↔extension WebSocket bridge.\n *\n * IMPORTANT — KEEP IN SYNC: this file MUST stay aligned with\n * `dwn_aphex/packages/portal-extension/src/shared/wireProtocol.ts`.\n * The binary bundle (Node, tsdown) and extension bundle (browser, WXT/Vite)\n * cannot share a workspace dep, so the schemas are duplicated. Do NOT change\n * one without updating the other; the smoke test checks the constants for drift.\n */\nimport { z } from 'zod';\n\n// -- Wire literals ----------------------------------------------------------\n\n/** Application-level wire protocol version. Bumped when frame shapes change. */\nexport const PROTOCOL_VERSION = '0.1.0';\n\n/** WS subprotocol token returned in the response Sec-WebSocket-Protocol header. */\nexport const VERSION_SUBPROTOCOL = 'darwinium.v1';\n\n/** Prefix for the per-connection token entry in the offered Sec-WebSocket-Protocol list. */\nexport const TOKEN_SUBPROTOCOL_PREFIX = 'tok.';\n\n/**\n * Token subprotocol regex: exactly 64 lowercase hex characters after the\n * `tok.` prefix.\n *\n * Used by `src/ws/server.ts` `handleProtocols` to reject ill-shaped offers at\n * the upgrade boundary (HTTP 401 — distinct from post-upgrade WS 4401).\n */\nexport const TOKEN_SUBPROTOCOL_REGEX = /^tok\\.[a-f0-9]{64}$/;\n\n/**\n * Out-of-band pairing wire format. The installer's foreground pairing-WS\n * server in `src/install/oobPair.ts` accepts a parallel subprotocol shape\n * `pair.<6-digit>` (separate lifecycle from production `serve`). The pairing\n * server returns ONLY `VERSION_SUBPROTOCOL` — never echoes the code.\n *\n * KEEP IN SYNC: this constant ALSO lives in\n * `dwn_aphex/packages/portal-extension/src/shared/wireProtocol.ts`; the smoke\n * test enforces byte-equality.\n */\nexport const PAIR_SUBPROTOCOL_PREFIX = 'pair.';\n\n/** Pairing-code subprotocol regex: `pair.<6-digit>`. */\nexport const PAIRING_CODE_REGEX = /^pair\\.[0-9]{6}$/;\n\n/** Maximum window for the post-upgrade `{type:'hello'}` first frame to arrive. */\nexport const HELLO_TIMEOUT_MS = 1000;\n\n/** WS close code for token-handshake failure. */\nexport const WS_CLOSE_TOKEN_MISMATCH = 4401;\n\n// -- Frame schemas ----------------------------------------------------------\n\nexport const HelloFrameSchema = z.object({\n type: z.literal('hello'),\n token: z.string().regex(/^[0-9a-f]{64}$/),\n version: z.string(),\n});\nexport type HelloFrame = z.infer<typeof HelloFrameSchema>;\n\nexport const ReqFrameSchema = z.object({\n id: z.string(),\n type: z.literal('req'),\n op: z.string(),\n args: z.unknown(),\n});\nexport type ReqFrame = z.infer<typeof ReqFrameSchema>;\n\nexport const RespFrameSchema = z.object({\n id: z.string(),\n type: z.literal('resp'),\n result: z.unknown().optional(),\n error: z.string().optional(),\n});\nexport type RespFrame = z.infer<typeof RespFrameSchema>;\n\nexport const PageIdChangedFrameSchema = z.object({\n type: z.literal('pageIdChanged'),\n pageId: z.string().nullable(),\n});\nexport type PageIdChangedFrame = z.infer<typeof PageIdChangedFrameSchema>;\n\nexport const PingFrameSchema = z.object({ type: z.literal('ping') });\nexport type PingFrame = z.infer<typeof PingFrameSchema>;\n\nexport const PongFrameSchema = z.object({ type: z.literal('pong') });\nexport type PongFrame = z.infer<typeof PongFrameSchema>;\n\n/**\n * Frame the OOB pairing-WS server sends to the popup once a `pair.<6-digit>`\n * upgrade matches the armed code. Delivers the 64-hex token across the\n * authenticated pairing channel.\n */\nexport const PairedFrameSchema = z.object({\n type: z.literal('paired'),\n token: z.string().regex(/^[0-9a-f]{64}$/),\n version: z.string(),\n});\nexport type PairedFrame = z.infer<typeof PairedFrameSchema>;\n","/**\n * Cross-platform paths for the portal-mcp installer.\n *\n * Uses `env-paths`-driven platform-conventional locations:\n * - macOS: ~/Library/Application Support/darwinium-portal-mcp/\n * - Windows: %APPDATA%/darwinium-portal-mcp/Data/ (env-paths default)\n * - Linux: ~/.local/share/darwinium-portal-mcp/ (XDG_DATA_HOME)\n *\n * The token file and the extracted bundled extension share this single per-user\n * data dir so customers see one platform-conventional folder rather than two.\n *\n * Migration of the legacy `~/.config/darwinium-portal-mcp/token` location to\n * `<env-paths data dir>/token` lives in `src/install/migrate.ts`.\n */\nimport envPaths from 'env-paths';\nimport * as path from 'node:path';\n\nconst paths = envPaths('darwinium-portal-mcp', { suffix: '' });\n\n/** Per-user data directory; parent of the token file and extracted extension. */\nexport const TOKEN_DIR = paths.data;\n\n/** Token file path: 64-hex secret used for the binary↔extension handshake. */\nexport const TOKEN_PATH = path.join(TOKEN_DIR, 'token');\n\n/** Extracted bundled-extension directory (loaded via Chrome's \"Load Unpacked\"). */\nexport const EXTENSION_DIR = path.join(TOKEN_DIR, 'extension');\n","import { execSync } from 'node:child_process';\nimport * as net from 'node:net';\n\n/**\n * Kill any process holding the given port. Cross-platform; safe to call when port is free.\n *\n * SECURITY: kills ONLY the PID returned by `lsof -ti:PORT` (or netstat equivalent).\n * Does NOT take a port-range; cannot kill unrelated processes. Caller-controlled\n * port number is the only risk surface.\n *\n * Pattern verbatim from BrowserMCP: github.com/BrowserMCP/mcp/blob/main/src/utils/port.ts\n */\nexport function killProcessOnPort(port: number): void {\n try {\n if (process.platform === 'win32') {\n execSync(`FOR /F \"tokens=5\" %a in ('netstat -ano ^| findstr :${port}') do taskkill /F /PID %a`, {\n stdio: 'pipe',\n });\n return;\n }\n // `lsof -ti` lists every process with ANY socket on the port, including\n // OUR OWN client connections to it (e.g. the peer probe in ws/server.ts).\n // Piping straight into `xargs kill -9` therefore makes the caller kill\n // itself along with the holder. Filter our own pid out and signal the rest\n // directly rather than shelling out a second time.\n const out = execSync(`lsof -ti:${port}`, { stdio: 'pipe' }).toString();\n const pids = out\n .split('\\n')\n .map((line) => Number(line.trim()))\n .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);\n for (const pid of pids) {\n try {\n process.kill(pid, 'SIGKILL');\n } catch {\n /* already gone, or not ours to kill — the isPortInUse poll decides. */\n }\n }\n } catch {\n /* port was free, lsof returned nonzero — fine. The subsequent isPortInUse poll is the source of truth. */\n }\n}\n\n/**\n * Check whether a port is currently bound. Returns true if held; false if free.\n * Polls by attempting to bind a tester server on the same host.\n */\nexport function isPortInUse(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const tester = net\n .createServer()\n .once('error', () => resolve(true))\n .once('listening', () => tester.close(() => resolve(false)))\n .listen(port, '127.0.0.1');\n });\n}\n\n/**\n * Sleep for `ms` milliseconds. Used by the bind-loop poll cap.\n */\nexport function wait(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAcA,MAAa,mBAAmB;;AAGhC,MAAa,sBAAsB;;AAGnC,MAAa,2BAA2B;;;;;;;;AASxC,MAAa,0BAA0B;;AAevC,MAAa,qBAAqB;;AAGlC,MAAa,mBAAmB;;AAGhC,MAAa,0BAA0B;AAIvC,MAAa,mBAAmB,EAAE,OAAO;CACvC,MAAM,EAAE,QAAQ,QAAQ;CACxB,OAAO,EAAE,QAAQ,CAAC,MAAM,iBAAiB;CACzC,SAAS,EAAE,QAAQ;CACpB,CAAC;AAGF,MAAa,iBAAiB,EAAE,OAAO;CACrC,IAAI,EAAE,QAAQ;CACd,MAAM,EAAE,QAAQ,MAAM;CACtB,IAAI,EAAE,QAAQ;CACd,MAAM,EAAE,SAAS;CAClB,CAAC;AAGF,MAAa,kBAAkB,EAAE,OAAO;CACtC,IAAI,EAAE,QAAQ;CACd,MAAM,EAAE,QAAQ,OAAO;CACvB,QAAQ,EAAE,SAAS,CAAC,UAAU;CAC9B,OAAO,EAAE,QAAQ,CAAC,UAAU;CAC7B,CAAC;AAGF,MAAa,2BAA2B,EAAE,OAAO;CAC/C,MAAM,EAAE,QAAQ,gBAAgB;CAChC,QAAQ,EAAE,QAAQ,CAAC,UAAU;CAC9B,CAAC;AAGF,MAAa,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,CAAC;AAGpE,MAAa,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,CAAC;;;;;;AAQpE,MAAa,oBAAoB,EAAE,OAAO;CACxC,MAAM,EAAE,QAAQ,SAAS;CACzB,OAAO,EAAE,QAAQ,CAAC,MAAM,iBAAiB;CACzC,SAAS,EAAE,QAAQ;CACpB,CAAC;;;;;;;;;;;;;;;;;;ACjFF,MAAM,QAAQ,SAAS,wBAAwB,EAAE,QAAQ,IAAI,CAAC;;AAG9D,MAAa,YAAY,MAAM;;AAG/B,MAAa,aAAa,KAAK,KAAK,WAAW,QAAQ;;AAGvD,MAAa,gBAAgB,KAAK,KAAK,WAAW,YAAY;;;;;;;;;;;;;ACd9D,SAAgB,kBAAkB,MAAoB;AACpD,KAAI;AACF,MAAI,QAAQ,aAAa,SAAS;AAChC,YAAS,sDAAsD,KAAK,4BAA4B,EAC9F,OAAO,QACR,CAAC;AACF;;EAQF,MAAM,OADM,SAAS,YAAY,QAAQ,EAAE,OAAO,QAAQ,CAAC,CAAC,UAC5C,CACb,MAAM,KAAK,CACX,KAAK,SAAS,OAAO,KAAK,MAAM,CAAC,CAAC,CAClC,QAAQ,QAAQ,OAAO,UAAU,IAAI,IAAI,MAAM,KAAK,QAAQ,QAAQ,IAAI;AAC3E,OAAK,MAAM,OAAO,KAChB,KAAI;AACF,WAAQ,KAAK,KAAK,UAAU;UACtB;SAIJ;;;;;;AASV,SAAgB,YAAY,MAAgC;AAC1D,QAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,SAAS,IACZ,cAAc,CACd,KAAK,eAAe,QAAQ,KAAK,CAAC,CAClC,KAAK,mBAAmB,OAAO,YAAY,QAAQ,MAAM,CAAC,CAAC,CAC3D,OAAO,MAAM,YAAY;GAC5B;;;;;AAMJ,SAAgB,KAAK,IAA2B;AAC9C,QAAO,IAAI,SAAS,YAAY,WAAW,SAAS,GAAG,CAAC"}
|