From d8bd18fc5abc0c99ca67b1ece35b38ca082b913c Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Sat, 24 Oct 2026 13:14:38 +0400 Subject: [PATCH] refactor(scripts): share plugin packaging across hosts Replace the per-host config, package and smoke scripts for Claude and Cursor with one script per job that takes the host as an argument. Host differences (manifest folder, plugin-root variable, config snippet, Claude Desktop MCPB bundle) live in scripts/plugin-hosts.mjs. npm command names, output paths and the checks each smoke test ran are unchanged; the Claude plugin smoke test now also covers development and the chat fallback, matching Cursor. Co-Authored-By: Claude Opus 5.5 --- package.json | 12 ++-- scripts/claude-config.mjs | 18 ----- scripts/cursor-config.mjs | 18 ----- scripts/package-claude.mjs | 39 ----------- scripts/package-cursor.mjs | 23 ------- scripts/package-plugin.mjs | 44 +++++++++++++ scripts/plugin-config.mjs | 16 +++++ scripts/plugin-hosts.mjs | 24 +++++++ scripts/smoke-claude-package.mjs | 110 ------------------------------- scripts/smoke-cursor-package.mjs | 66 ------------------- scripts/smoke-plugin.mjs | 109 ++++++++++++++++++++++++++++++ 11 files changed, 199 insertions(+), 280 deletions(-) delete mode 100644 scripts/claude-config.mjs delete mode 100644 scripts/cursor-config.mjs delete mode 100644 scripts/package-claude.mjs delete mode 100644 scripts/package-cursor.mjs create mode 100644 scripts/package-plugin.mjs create mode 100644 scripts/plugin-config.mjs create mode 100644 scripts/plugin-hosts.mjs delete mode 100644 scripts/smoke-claude-package.mjs delete mode 100644 scripts/smoke-cursor-package.mjs create mode 100644 scripts/smoke-plugin.mjs diff --git a/package.json b/package.json index e78ad1a..b2fb53b 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,12 @@ "test:ios-cpu": "node scripts/smoke-ios-cpu.mjs", "package": "node scripts/package.mjs", "package:release": "node scripts/package.mjs --release", - "claude:config": "node scripts/claude-config.mjs", - "package:claude": "node scripts/package-claude.mjs", - "test:claude": "node scripts/smoke-claude-package.mjs", - "cursor:config": "node scripts/cursor-config.mjs", - "package:cursor": "node scripts/package-cursor.mjs", - "test:cursor": "node scripts/smoke-cursor-package.mjs", + "claude:config": "node scripts/plugin-config.mjs claude", + "package:claude": "node scripts/package-plugin.mjs claude", + "test:claude": "node scripts/smoke-plugin.mjs claude", + "cursor:config": "node scripts/plugin-config.mjs cursor", + "package:cursor": "node scripts/package-plugin.mjs cursor", + "test:cursor": "node scripts/smoke-plugin.mjs cursor", "vendor:serve-emu": "node scripts/vendor-serve-emu.mjs", "dev": "node scripts/dev-ui.mjs", "dev:off": "node scripts/dev-ui.mjs --off", diff --git a/scripts/claude-config.mjs b/scripts/claude-config.mjs deleted file mode 100644 index af960de..0000000 --- a/scripts/claude-config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -// Generate a snippet only; never replace the user's existing Claude configuration. -await access("dist/server.mjs"); -const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8")); -const config = { - mcpServers: { - "mobile-dev": { - command: process.execPath, - args: [resolve(manifest.server.entry_point)], - }, - }, -}; -await mkdir(".local-dev", { recursive: true }); -const path = resolve(".local-dev/claude-desktop-config.json"); -await writeFile(path, JSON.stringify(config, null, 2) + "\n"); -console.log(`Merge the mcpServers entry from ${path} into Claude Desktop Settings > Developer > Edit Config, then fully restart Claude.`); diff --git a/scripts/cursor-config.mjs b/scripts/cursor-config.mjs deleted file mode 100644 index 2785675..0000000 --- a/scripts/cursor-config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { access, mkdir, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -// Generate a snippet only; never replace the user's existing Cursor configuration. -await access("dist/server.mjs"); -const config = { - mcpServers: { - "mobile-dev": { - type: "stdio", - command: process.execPath, - args: [resolve("dist/server.mjs")], - }, - }, -}; -await mkdir(".local-dev", { recursive: true }); -const path = resolve(".local-dev/cursor-mcp.json"); -await writeFile(path, JSON.stringify(config, null, 2) + "\n"); -console.log(`Merge the mcpServers entry from ${path} into ~/.cursor/mcp.json, then reload Cursor.`); diff --git a/scripts/package-claude.mjs b/scripts/package-claude.mjs deleted file mode 100644 index b02c813..0000000 --- a/scripts/package-claude.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { telemetryBuildEnvironment } from "./telemetry-build.mjs"; - -// Reuse the existing artifact inventory, native assets and environment checks. -telemetryBuildEnvironment(); -execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" }); -const metadata = JSON.parse(await readFile("package.json", "utf8")); -const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8")); -manifest.version = metadata.version; -const source = resolve("release/marketplace/plugins/mobile-dev"); -const documents = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]; -const directory = resolve("release/claude-desktop/mobile-dev"); -await rm(directory, { recursive: true, force: true }); -await mkdir(directory, { recursive: true }); -for (const path of ["dist", ...documents]) { - await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true }); -} -await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n"); -const archive = resolve(`release/mobile-dev-${manifest.version}-claude-desktop-darwin-arm64.mcpb`); -await rm(archive, { force: true }); -// MCPB is a ZIP with manifest.json at its root. Preserve executable modes. -execFileSync("zip", ["-qr", archive, "manifest.json", "dist", ...documents], { cwd: directory }); -execFileSync("unzip", ["-tq", archive], { stdio: "inherit" }); -console.log(`Claude Desktop bundle: ${archive}`); - -// Claude plugin: the same payload without the Codex manifest, .mcp.json or launcher. -// The server starts from PATH Node, and the updater stays off outside Codex packages. -const plugin = JSON.parse(await readFile(".claude-plugin/plugin.json", "utf8")); -plugin.version = metadata.version; -const pluginDirectory = resolve(`release/claude-plugin/${plugin.name}`); -await rm(pluginDirectory, { recursive: true, force: true }); -await mkdir(`${pluginDirectory}/.claude-plugin`, { recursive: true }); -for (const path of ["dist", "assets", "skills", ...documents]) { - await cp(`${source}/${path}`, `${pluginDirectory}/${path}`, { recursive: true, verbatimSymlinks: true }); -} -await writeFile(`${pluginDirectory}/.claude-plugin/plugin.json`, JSON.stringify(plugin, null, 2) + "\n"); -console.log(`Claude plugin: ${pluginDirectory}`); diff --git a/scripts/package-cursor.mjs b/scripts/package-cursor.mjs deleted file mode 100644 index 1c95621..0000000 --- a/scripts/package-cursor.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { telemetryBuildEnvironment } from "./telemetry-build.mjs"; - -// Reuse the existing artifact inventory, native assets and environment checks. -telemetryBuildEnvironment(); -execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" }); -const metadata = JSON.parse(await readFile("package.json", "utf8")); -// Cursor plugin: the same payload without the Codex manifest, .mcp.json or launcher. -// The server starts from PATH Node, and the updater stays off outside Codex packages. -const plugin = JSON.parse(await readFile(".cursor-plugin/plugin.json", "utf8")); -plugin.version = metadata.version; -const source = resolve("release/marketplace/plugins/mobile-dev"); -const documents = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]; -const directory = resolve(`release/cursor-plugin/${plugin.name}`); -await rm(directory, { recursive: true, force: true }); -await mkdir(`${directory}/.cursor-plugin`, { recursive: true }); -for (const path of ["dist", "assets", "skills", ...documents]) { - await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true }); -} -await writeFile(`${directory}/.cursor-plugin/plugin.json`, JSON.stringify(plugin, null, 2) + "\n"); -console.log(`Cursor plugin: ${directory}`); diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs new file mode 100644 index 0000000..ccf7d96 --- /dev/null +++ b/scripts/package-plugin.mjs @@ -0,0 +1,44 @@ +import { execFileSync } from "node:child_process"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "./telemetry-build.mjs"; +import { documents, pluginHost } from "./plugin-hosts.mjs"; + +const name = process.argv[2]; +const host = pluginHost(name); +const args = process.argv.slice(3); +// Reuse the existing artifact inventory, native assets and environment checks. +telemetryBuildEnvironment(args); +execFileSync(process.execPath, ["scripts/package.mjs", ...args], { stdio: "inherit" }); +const metadata = JSON.parse(await readFile("package.json", "utf8")); +const source = resolve("release/marketplace/plugins/mobile-dev"); + +if (host.desktopBundle) { + const manifest = JSON.parse(await readFile(host.desktopBundle, "utf8")); + manifest.version = metadata.version; + const directory = resolve(`release/${name}-desktop/mobile-dev`); + await rm(directory, { recursive: true, force: true }); + await mkdir(directory, { recursive: true }); + for (const path of ["dist", ...documents]) { + await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true }); + } + await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n"); + const archive = resolve(`release/mobile-dev-${manifest.version}-${name}-desktop-darwin-arm64.mcpb`); + await rm(archive, { force: true }); + // MCPB is a ZIP with manifest.json at its root. Preserve executable modes. + execFileSync("zip", ["-qr", archive, "manifest.json", "dist", ...documents], { cwd: directory }); + execFileSync("unzip", ["-tq", archive], { stdio: "inherit" }); + console.log(`${host.label} Desktop bundle: ${archive}`); +} + +// The same payload as the Codex package, without its manifest, .mcp.json or launcher. +const plugin = JSON.parse(await readFile(`${host.manifest}/plugin.json`, "utf8")); +plugin.version = metadata.version; +const directory = resolve(`release/${name}-plugin/${plugin.name}`); +await rm(directory, { recursive: true, force: true }); +await mkdir(`${directory}/${host.manifest}`, { recursive: true }); +for (const path of ["dist", "assets", "skills", ...documents]) { + await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true }); +} +await writeFile(`${directory}/${host.manifest}/plugin.json`, JSON.stringify(plugin, null, 2) + "\n"); +console.log(`${host.label} plugin: ${directory}`); diff --git a/scripts/plugin-config.mjs b/scripts/plugin-config.mjs new file mode 100644 index 0000000..cc156f4 --- /dev/null +++ b/scripts/plugin-config.mjs @@ -0,0 +1,16 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pluginHost } from "./plugin-hosts.mjs"; + +// Generate a snippet only; never replace the user's existing host configuration. +const { config: host } = pluginHost(process.argv[2]); +await access("dist/server.mjs"); +const config = { + mcpServers: { + "mobile-dev": { ...host.server, command: process.execPath, args: [resolve("dist/server.mjs")] }, + }, +}; +await mkdir(".local-dev", { recursive: true }); +const path = resolve(`.local-dev/${host.file}`); +await writeFile(path, JSON.stringify(config, null, 2) + "\n"); +console.log(`Merge the mcpServers entry from ${path} into ${host.target}.`); diff --git a/scripts/plugin-hosts.mjs b/scripts/plugin-hosts.mjs new file mode 100644 index 0000000..a310f6b --- /dev/null +++ b/scripts/plugin-hosts.mjs @@ -0,0 +1,24 @@ +// Hosts that load the plugin folder through their own manifest. They start the server from +// PATH Node at the manifest's plugin-root variable; the updater stays off outside Codex. +export const pluginHosts = { + claude: { + label: "Claude", manifest: ".claude-plugin", root: "CLAUDE_PLUGIN_ROOT", + // Claude Desktop also installs a self-contained MCPB bundle. + desktopBundle: "claude-desktop/manifest.json", + config: { file: "claude-desktop-config.json", server: {}, target: "Claude Desktop Settings > Developer > Edit Config, then fully restart Claude" }, + }, + cursor: { + label: "Cursor", manifest: ".cursor-plugin", root: "CURSOR_PLUGIN_ROOT", + config: { file: "cursor-mcp.json", server: { type: "stdio" }, target: "~/.cursor/mcp.json, then reload Cursor" }, + }, +}; + +export const documents = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]; +// Codex-only files would load the Codex launcher or enable the Codex updater. +export const codexOnlyPaths = [".mcp.json", "mcp.json", ".codex-plugin", "scripts/launch-mcp.sh"]; + +export function pluginHost(name) { + const host = pluginHosts[name]; + if (host === undefined) throw new Error(`Pass a host: ${Object.keys(pluginHosts).join(" or ")}.`); + return host; +} diff --git a/scripts/smoke-claude-package.mjs b/scripts/smoke-claude-package.mjs deleted file mode 100644 index 7926b06..0000000 --- a/scripts/smoke-claude-package.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { access, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; - -const metadata = JSON.parse(await readFile("package.json", "utf8")); -const archive = resolve(process.argv[2] ?? `release/mobile-dev-${metadata.version}-claude-desktop-darwin-arm64.mcpb`); -const temporary = await mkdtemp(join(tmpdir(), "mobile-dev-claude-")); -try { - const directory = join(temporary, "bundle with spaces"); - await mkdir(directory); - execFileSync("unzip", ["-q", archive, "-d", directory]); - const manifest = JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")); - assert.equal(manifest.manifest_version, "0.3"); - assert.equal(manifest.version, metadata.version); - assert.equal(manifest.server.type, "node"); - assert.equal(manifest.server.mcp_config.command, "node"); - assert.equal(manifest.server.mcp_config.env, undefined); - assert.equal(manifest.compatibility.runtimes.node, metadata.engines.node); - for (const path of [manifest.server.entry_point, "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node", "dist/agent-device-server.mjs", "dist/serve-emu/src/cli.mjs"]) { - await access(join(directory, path)); - } - const args = manifest.server.mcp_config.args.map(arg => arg.replaceAll("${__dirname}", directory)); - // No Codex installation, no PATH Node lookup, and no dependency on package cwd. - const home = join(temporary, "home"); - await mkdir(home); - // Behavior follows client capabilities, not the host's name. Release must still - // disable the updater because the bundle is not a Codex plugin package. - for (const [environment, forms] of [["development", false], ["release", false], ["development", true]]) { - const capabilities = forms ? { extensions: { "openai/elicitation": { form: {} } } } : {}; - const client = new Client({ name: "claude-package-smoke", version: "1" }, { capabilities }); - const transport = new StdioClientTransport({ - command: process.execPath, args, cwd: temporary, stderr: "pipe", - env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, - MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment }, - }); - let diagnostics = ""; - transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); }); - try { - await client.connect(transport); - assert.match(client.getInstructions(), /in chat when it is unsupported/); - const { tools } = await client.listTools(); - for (const name of ["mobile_show_devices", "mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) { - assert.ok(tools.some(tool => tool.name === name), name); - } - const workspace = tools.find(tool => tool.name === "mobile_open_workspace"); - const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri }); - const html = resource.contents[0].text; - assert.doesNotMatch(html, /data-host=/); - assert.deepEqual(resource.contents[0]._meta.ui.permissions, { clipboardWrite: {} }); - assert.match(html, /data-view="workspace"/); - assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`)); - assert.match(html, /name="mobile-dev-telemetry" content="off"/); - assert.match(html, /ui.workspace.display_mode_changed/); - const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); - assert.notEqual(check.isError, true); - assert.equal(check.structuredContent.update.status, "disabled"); - const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} }); - assert.equal(install.isError, true); - assert.match(install.content[0].text, /installed as a Codex plugin/); - console.log(`${environment}${forms ? "/forms" : ""}: relocated MCP startup, tools, UI resource and disabled updater passed.`); - } catch (error) { - if (diagnostics) console.error(diagnostics); - throw error; - } finally { await client.close(); await transport.close(); } - } - - // Claude plugin folder: copy it to a path with spaces, as an install cache would. - const plugin = join(temporary, "plugin with spaces"); - await cp(resolve("release/claude-plugin/mobile-dev"), plugin, { recursive: true, verbatimSymlinks: true }); - const pluginManifest = JSON.parse(await readFile(join(plugin, ".claude-plugin/plugin.json"), "utf8")); - assert.equal(pluginManifest.name, "mobile-dev"); - assert.equal(pluginManifest.version, metadata.version); - assert.equal(pluginManifest.license, "MIT"); - const server = pluginManifest.mcpServers["mobile-dev"]; - assert.equal(server.command, "node"); - assert.deepEqual(server.args, ["${CLAUDE_PLUGIN_ROOT}/dist/server.mjs"]); - for (const path of ["LICENSE", "README.md", "PRIVACY.md", "skills/mobile-dev/SKILL.md", "skills/mobile-dev-setup/SKILL.md", "dist/app.html"]) { - await access(join(plugin, path)); - } - // Codex-only files would load the Codex launcher or enable the Codex updater. - for (const path of [".mcp.json", ".codex-plugin", "scripts/launch-mcp.sh"]) { - await assert.rejects(access(join(plugin, path)), path); - } - const client = new Client({ name: "claude-plugin-smoke", version: "1" }, { capabilities: {} }); - const transport = new StdioClientTransport({ - command: process.execPath, args: server.args.map(arg => arg.replaceAll("${CLAUDE_PLUGIN_ROOT}", plugin)), - cwd: temporary, stderr: "pipe", - env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, CLAUDE_PLUGIN_ROOT: plugin, - MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: "release" }, - }); - let diagnostics = ""; - transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); }); - try { - await client.connect(transport); - const { tools } = await client.listTools(); - for (const name of ["mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) { - assert.ok(tools.some(tool => tool.name === name), name); - } - const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); - assert.equal(check.structuredContent.update.status, "disabled"); - console.log("Claude plugin: manifest, payload, MCP startup from the plugin root and disabled updater passed."); - } catch (error) { - if (diagnostics) console.error(diagnostics); - throw error; - } finally { await client.close(); await transport.close(); } -} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/scripts/smoke-cursor-package.mjs b/scripts/smoke-cursor-package.mjs deleted file mode 100644 index ec2943f..0000000 --- a/scripts/smoke-cursor-package.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { access, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; - -const metadata = JSON.parse(await readFile("package.json", "utf8")); -const temporary = await mkdtemp(join(tmpdir(), "mobile-dev-cursor-")); -try { - // Copy the plugin to a path with spaces, as ~/.cursor/plugins/local or an install cache would. - const plugin = join(temporary, "plugin with spaces"); - await cp(resolve(process.argv[2] ?? "release/cursor-plugin/mobile-dev"), plugin, { recursive: true, verbatimSymlinks: true }); - const manifest = JSON.parse(await readFile(join(plugin, ".cursor-plugin/plugin.json"), "utf8")); - assert.equal(manifest.name, "mobile-dev"); - assert.equal(manifest.version, metadata.version); - assert.equal(manifest.license, "MIT"); - await access(join(plugin, manifest.logo)); - const server = manifest.mcpServers["mobile-dev"]; - assert.equal(server.command, "node"); - assert.deepEqual(server.args, ["${CURSOR_PLUGIN_ROOT}/dist/server.mjs"]); - for (const path of ["LICENSE", "README.md", "PRIVACY.md", "skills/mobile-dev/SKILL.md", "skills/mobile-dev-setup/SKILL.md", "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node"]) { - await access(join(plugin, path)); - } - // Codex-only files would load the Codex launcher or enable the Codex updater. - for (const path of [".mcp.json", "mcp.json", ".codex-plugin", ".claude-plugin", "scripts/launch-mcp.sh"]) { - await assert.rejects(access(join(plugin, path)), path); - } - const home = join(temporary, "home"); - await mkdir(home); - // Behavior follows client capabilities, not the host's name: without forms or - // MCP Apps, device choices fall back to chat. - for (const environment of ["development", "release"]) { - const client = new Client({ name: "cursor-plugin-smoke", version: "1" }, { capabilities: {} }); - const transport = new StdioClientTransport({ - command: process.execPath, args: server.args.map(arg => arg.replaceAll("${CURSOR_PLUGIN_ROOT}", plugin)), - cwd: temporary, stderr: "pipe", - env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, - MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment }, - }); - let diagnostics = ""; - transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); }); - try { - await client.connect(transport); - assert.match(client.getInstructions(), /in chat when it is unsupported/); - const { tools } = await client.listTools(); - for (const name of ["mobile_show_devices", "mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) { - assert.ok(tools.some(tool => tool.name === name), name); - } - const workspace = tools.find(tool => tool.name === "mobile_open_workspace"); - const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri }); - const html = resource.contents[0].text; - assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`)); - assert.match(html, /name="mobile-dev-telemetry" content="off"/); - const choice = await client.callTool({ name: "mobile_show_devices", arguments: { message: "Which device?", selectionMode: "single" } }); - assert.equal(choice.isError, true); - assert.match(choice.content[0].text, /ask the user in chat/); - const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); - assert.equal(check.structuredContent.update.status, "disabled"); - console.log(`${environment}: Cursor plugin manifest, payload, MCP startup from the plugin root, chat fallback and disabled updater passed.`); - } catch (error) { - if (diagnostics) console.error(diagnostics); - throw error; - } finally { await client.close(); await transport.close(); } - } -} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/scripts/smoke-plugin.mjs b/scripts/smoke-plugin.mjs new file mode 100644 index 0000000..47939b8 --- /dev/null +++ b/scripts/smoke-plugin.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { access, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { codexOnlyPaths, pluginHost, pluginHosts } from "./plugin-hosts.mjs"; + +const name = process.argv[2]; +const host = pluginHost(name); +const metadata = JSON.parse(await readFile("package.json", "utf8")); +const temporary = await mkdtemp(join(tmpdir(), `mobile-dev-${name}-`)); +const home = join(temporary, "home"); +await mkdir(home); + +async function withServer(label, args, env, capabilities, check) { + const client = new Client({ name: `${name}-${label}-smoke`, version: "1" }, { capabilities }); + const transport = new StdioClientTransport({ + command: process.execPath, args, cwd: temporary, stderr: "pipe", + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, MOBILE_DEV_TELEMETRY: "off", ...env }, + }); + let diagnostics = ""; + transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); }); + try { + await client.connect(transport); + // Behavior follows client capabilities, not the host's name. + assert.match(client.getInstructions(), /in chat when it is unsupported/); + const { tools } = await client.listTools(); + for (const tool of ["mobile_show_devices", "mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) { + assert.ok(tools.some(candidate => candidate.name === tool), tool); + } + const workspace = tools.find(tool => tool.name === "mobile_open_workspace"); + const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri }); + const html = resource.contents[0].text; + assert.ok(html.includes(`name="mobile-dev-environment" content="${env.MOBILE_DEV_ENVIRONMENT}"`)); + assert.match(html, /name="mobile-dev-telemetry" content="off"/); + // Release must still disable the updater because these packages are not Codex plugins. + const update = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); + assert.notEqual(update.isError, true); + assert.equal(update.structuredContent.update.status, "disabled"); + await check(client, resource); + } catch (error) { + if (diagnostics) console.error(diagnostics); + throw error; + } finally { await client.close(); await transport.close(); } +} + +async function smokeDesktopBundle() { + const archive = resolve(`release/mobile-dev-${metadata.version}-${name}-desktop-darwin-arm64.mcpb`); + const directory = join(temporary, "bundle with spaces"); + await mkdir(directory); + execFileSync("unzip", ["-q", archive, "-d", directory]); + const manifest = JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")); + assert.equal(manifest.manifest_version, "0.3"); + assert.equal(manifest.version, metadata.version); + assert.equal(manifest.server.type, "node"); + assert.equal(manifest.server.mcp_config.command, "node"); + assert.equal(manifest.server.mcp_config.env, undefined); + assert.equal(manifest.compatibility.runtimes.node, metadata.engines.node); + for (const path of [manifest.server.entry_point, "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node", "dist/agent-device-server.mjs", "dist/serve-emu/src/cli.mjs"]) { + await access(join(directory, path)); + } + // No Codex installation, no PATH Node lookup, and no dependency on package cwd. + const args = manifest.server.mcp_config.args.map(arg => arg.replaceAll("${__dirname}", directory)); + for (const [environment, forms] of [["development", false], ["release", false], ["development", true]]) { + const capabilities = forms ? { extensions: { "openai/elicitation": { form: {} } } } : {}; + await withServer("desktop", args, { MOBILE_DEV_ENVIRONMENT: environment }, capabilities, async (client, resource) => { + assert.doesNotMatch(resource.contents[0].text, /data-host=/); + assert.deepEqual(resource.contents[0]._meta.ui.permissions, { clipboardWrite: {} }); + assert.match(resource.contents[0].text, /data-view="workspace"/); + assert.match(resource.contents[0].text, /ui.workspace.display_mode_changed/); + const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} }); + assert.equal(install.isError, true); + assert.match(install.content[0].text, /installed as a Codex plugin/); + }); + console.log(`${environment}${forms ? "/forms" : ""}: ${host.label} Desktop bundle startup, tools, UI resource and disabled updater passed.`); + } +} + +try { + if (host.desktopBundle) await smokeDesktopBundle(); + // Copy the plugin to a path with spaces, as a host's install cache would. + const plugin = join(temporary, "plugin with spaces"); + await cp(resolve(process.argv[3] ?? `release/${name}-plugin/mobile-dev`), plugin, { recursive: true, verbatimSymlinks: true }); + const manifest = JSON.parse(await readFile(join(plugin, host.manifest, "plugin.json"), "utf8")); + assert.equal(manifest.name, "mobile-dev"); + assert.equal(manifest.version, metadata.version); + assert.equal(manifest.license, "MIT"); + await access(join(plugin, manifest.logo ?? manifest.icon)); + const server = manifest.mcpServers["mobile-dev"]; + assert.equal(server.command, "node"); + assert.deepEqual(server.args, [`\${${host.root}}/dist/server.mjs`]); + for (const path of ["LICENSE", "README.md", "PRIVACY.md", "skills/mobile-dev/SKILL.md", "skills/mobile-dev-setup/SKILL.md", "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node"]) { + await access(join(plugin, path)); + } + const otherManifests = Object.values(pluginHosts).filter(other => other !== host).map(other => other.manifest); + for (const path of [...codexOnlyPaths, ...otherManifests]) await assert.rejects(access(join(plugin, path)), path); + const args = server.args.map(arg => arg.replaceAll(`\${${host.root}}`, plugin)); + for (const environment of ["development", "release"]) { + // Without forms or MCP Apps, device choices fall back to chat. + await withServer("plugin", args, { MOBILE_DEV_ENVIRONMENT: environment, [host.root]: plugin }, {}, async client => { + const choice = await client.callTool({ name: "mobile_show_devices", arguments: { message: "Which device?", selectionMode: "single" } }); + assert.equal(choice.isError, true); + assert.match(choice.content[0].text, /ask the user in chat/); + }); + console.log(`${environment}: ${host.label} plugin manifest, payload, startup from the plugin root, chat fallback and disabled updater passed.`); + } +} finally { await rm(temporary, { recursive: true, force: true }); }