From 9d0d64c7986ad9a4e90ef8626c2ce4c2039600d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 27 Oct 2026 14:53:08 +0100 Subject: [PATCH 1/3] feat(updates): migrate GitHub installs to the plugin store --- .codex-plugin/plugin.json | 2 +- README.md | 12 +- docs/releases.md | 81 +++-- docs/telemetry.md | 13 + scripts/smoke-plugin-updates.mjs | 60 ++-- src/server/plugin-updates.ts | 319 ++++++++++++------ src/server/telemetry.ts | 2 +- src/shared/plugin-updates.ts | 13 +- src/ui/components/plugin-update-banner.tsx | 17 +- src/ui/plugin-updates.ts | 49 +-- tests/plugin-update-ui.test.ts | 86 +++-- tests/plugin-updates.test.ts | 359 ++++++++++++++------- tests/telemetry.test.ts | 8 +- 13 files changed, 697 insertions(+), 324 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4bda188e..eda5b01b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -69,7 +69,7 @@ "demo_recording_url": "https://raw.githubusercontent.com/callstackincubator/codex-mobile-dev-plugin/main/docs/review/media/mobile-dev-demo.mp4" }, "publication": { - "release_notes": "Adds product-specific privacy, terms and support links, three starter prompts, and reproducible review cases. Corrects TraceSift write/service annotations. Filters storage secrets and binary values before host delivery, scopes reads before decoding, and adds exact key/column selection." + "release_notes": "Checks updates against the published plugin-store version and opens the store to update. Adds a version-gated migration that verifies the enabled store installation before removing the GitHub plugin and marketplace, preserving local recordings and saved data. Reduces Sentry reporting volume and excludes expected failures." } } }, diff --git a/README.md b/README.md index f5faf120..f6f60b02 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,16 @@ Native, SwiftUI, and other native mobile projects. **[Add Mobile Dev to Codex](https://chatgpt.com/plugins/plugins_6ac4c117f76881918d3676e851fa4005)** from the plugin directory, then open a new chat. -To install the prebuilt plugin from this repository instead, see -[Install from the GitHub marketplace](CONTRIBUTING.md#install-from-the-github-marketplace). +Mobile Dev checks the version published in the plugin store. When a newer version +is available, click **Open plugin store** to update there, then fully quit and +reopen Codex. + +Existing GitHub installations offer **Install from plugin store** once version +0.1.165 or newer is published there. Install and enable the store version, return +to Mobile Dev, and click **Finish migration**. This verifies the store package, +removes the GitHub plugin and its marketplace, and preserves local recordings and +saved data. Fully quit and reopen Codex afterward. The migration is offered only +when the store version is at least as new as the running GitHub version. ## Features diff --git a/docs/releases.md b/docs/releases.md index a4281635..15a8df81 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -97,26 +97,63 @@ after updating so its MCP process and UI use the refreshed files. This is an exp not a promise of immediate automatic background updates. The first successful release with this workflow creates the branch. -Release builds check for updates when Mobile Dev becomes visible. The server -checks GitHub's latest full release and the public `release/latest` manifest; -their versions must agree before the banner offers an update. Successful checks +## Store updates and the GitHub migration + +Release builds read the published store version through the app-server +`plugin/read` RPC, matching Mobile Dev's exact remote store ID. The experimental +API is enabled explicitly; it preserves the catalog `version` separately from +`localVersion`. The CLI listing prefers the installed version and cannot serve as +the update source once a store plugin is installed. The CLI listing is used only +to identify the legacy Git marketplace for cleanup. GitHub tags and releases do +not advertise store updates. Successful checks are cached for one hour across chats in the active Codex profile; unavailable -checks retry after fifteen minutes. Development builds do not check or install -public updates. The embedded UI needs no GitHub network permissions. - -The Update button calls an app-only MCP tool. It locates the CLI process that -launched the plugin, verifies the `mobile-dev` Git marketplace belongs to this -repository, and runs `codex plugin marketplace upgrade mobile-dev --json`. -CLI discovery and update commands run from the Codex profile directory so they -remain usable when upgrading removes the running plugin's old directory. -Success requires an error-free result for the selected marketplace and -verification of the new manifest in Codex's installed plugin cache, including -when the marketplace reports no changed roots because the update is already -installed. The plugin preserves the restart notice across -chats until the new version runs. It does not restart Codex automatically. -`CODEX_HOME` is forwarded by the MCP manifest so updates use the same profile. - -`npm run test:updates` verifies the update flow with the real Codex CLI in a -temporary profile and a local Git mirror. It checks refreshed cache files and -the restart notice without changing the user's installation or fetching a -public plugin package. It requires `codex` on PATH or `CODEX_CLI_PATH`. +checks retry after fifteen minutes. Old GitHub update caches are discarded. +Development builds do not check or migrate public installations. + +The app-server plugin API is under development. This integration deliberately +accepts its compatibility risk. If discovery fails or its response no longer +matches, the check stays unavailable and retries; it never uses a GitHub version +as an alternative. The temporary app-server process closes after each lookup, +and disposal aborts pending requests. + +The update banner opens the store listing through the host's link API. Store +updates are installed there. The old Git marketplace upgrade tool is removed. + +For GitHub installations, migration requires a published store version of at +least **0.1.165**, and at least the running plugin version. The user installs and +enables the store version, then clicks **Finish migration**. An app-only MCP tool +checks the exact remote identity, enabled installation, installed `localVersion`, +and package manifest before removing `mobile-dev@mobile-dev` and the verified +official Git marketplace. It verifies +the store installation again afterward. Local development marketplaces and user +data are preserved. Cleanup can be retried after partial completion. A running +GitHub process retains its restart notice across chats until Codex restarts. + +CLI discovery and commands use the stable Codex profile directory because +removing the GitHub plugin deletes its installation directory. `CODEX_HOME` is +forwarded by the MCP manifest. The plugin never restarts Codex automatically. + +### Roll out the migration + +The existing release workflow can build and publish the GitHub transition release +before store review completes: + +1. Release **0.1.165** through the normal GitHub workflow. Existing GitHub versions + can update to it through their existing updater. +2. Upload that release's ZIP to the **existing Mobile Dev store listing**, then + complete review and publish it. +3. Once the Codex catalog exposes 0.1.165, the transition build offers migration. + Until then, it keeps the GitHub installation working and offers no migration + to the older store package. Catalog checks can delay the banner by up to one + hour after publication. + +One package serves both channels; a second GitHub version is not required after +store approval. The minimum migration version stays fixed for future releases. +The live 0.1.163 store package must not be used for migration: it still has the +GitHub updater. Later GitHub releases may run ahead of store review without +prematurely announcing a store update or allowing a migration downgrade. + +`npm run test:updates` exercises the real Codex CLI's Git installation and removal +in a temporary profile using a local Git mirror, with a fixture for the store +catalog and package. It does not install or remove plugins in the user's account. +It requires `codex` on PATH or `CODEX_CLI_PATH`. diff --git a/docs/telemetry.md b/docs/telemetry.md index 61dd1d83..3237c97b 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -102,6 +102,19 @@ coverage remains on this path with the same boundaries and units. Execution and invalid-response errors use fixed messages without implying a network failure; raw CLI errors and output are not sent to telemetry. +Since 0.1.165, update checks measure the Codex store catalog lookup instead of +GitHub HTTP requests. The existing operation count and duration names and units +remain unchanged. The `install` operation is replaced by `migrate`, covering +verification and GitHub cleanup; its successful outcome is `migrated`. Checks can +report `migration` while the GitHub installation remains. UI events +`ui.plugin_update.store_opened` and `ui.plugin_update.migrated` distinguish opening +the store from completing cleanup; the old `installed` event is removed. Fixed +migration errors retain handled-error coverage. A missing or disabled store +installation is an expected user action condition and does not report an error. +No CLI output, store metadata, +versions, paths, or user data are included. Disposal aborts CLI requests and clears +the UI timer and visibility listener. + Since 0.1.129, discovery command failures retain bounded `discovery_command`, `discovery_cause`, `discovery_termination`, and optional `discovery_signal` tags. Failed exception contexts contain only numeric `elapsed_ms`, `deadline_ms`, and an optional exit diff --git a/scripts/smoke-plugin-updates.mjs b/scripts/smoke-plugin-updates.mjs index acf7cd40..ceb58291 100644 --- a/scripts/smoke-plugin-updates.mjs +++ b/scripts/smoke-plugin-updates.mjs @@ -6,6 +6,7 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; +import { PLUGIN_STORE_ID } from "../src/shared/plugin-updates.ts"; import { createCodexRunner, PluginUpdates, resolveCodexCli } from "../src/server/plugin-updates.ts"; const execute = promisify(execFile); @@ -51,7 +52,7 @@ try { await writeJson(catalogPath, { name: "mobile-dev", plugins: [{ name: "mobile-dev", source: { source: "local", path: "./plugins/mobile-dev" } }] }); const mcpPath = join(plugin, ".mcp.json"); await writeJson(mcpPath, { mcpServers: { "mobile-dev": { command: "/bin/echo", args: ["update fixture"], cwd: "." } } }); - await release("0.1.1"); + await release("0.1.165"); http = createServer(async (request, response) => { try { const url = new URL(request.url, "http://localhost"); @@ -67,47 +68,50 @@ try { await git(["config", "--global", `url.${mirrorUrl}.insteadOf`, repositoryUrl]); await codex(["plugin", "marketplace", "add", repositoryUrl, "--ref", "release/latest", "--json"]); const initial = await codex(["plugin", "add", "mobile-dev@mobile-dev", "--json"]); - assert.equal(initial.version, "0.1.1"); + assert.equal(initial.version, "0.1.165"); const initialMcp = await codex(["mcp", "get", "mobile-dev", "--json"]); const installedCwd = resolve(initialMcp.transport.cwd); - await release("0.1.2"); - const fetchRelease = async input => { - const url = String(input); - const data = url.includes("api.github.com") - ? { tag_name: "v0.1.2", draft: false, prerelease: false } - : { name: "mobile-dev", version: "0.1.2" }; - const text = JSON.stringify(data); - return new Response(text); + const storeDirectory = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165/.codex-plugin"); + await mkdir(storeDirectory, { recursive: true }); + await writeJson(join(storeDirectory, "plugin.json"), { name: "mobile-dev", version: "0.1.165" }); + const store = { + pluginId: "mobile-dev@openai-curated-remote", name: "mobile-dev", marketplaceName: "openai-curated-remote", + version: "0.1.165", installed: true, enabled: true, source: { source: "remote", id: PLUGIN_STORE_ID }, }; - const options = { enabled: true, profile, currentVersion: "0.1.1", run: codex, fetch: fetchRelease }; + const run = async args => { + const result = await codex(args); + if (args[1] === "list") result.installed.push(store); + return result; + }; + const readStore = async () => ({ + remotePluginId: PLUGIN_STORE_ID, name: "mobile-dev", version: "0.1.165", localVersion: "0.1.165", installed: true, enabled: true, + }); + const options = { enabled: true, profile, pluginRoot: installedCwd, currentVersion: "0.1.165", run, readStore }; const updates = new PluginUpdates(options); process.chdir(installedCwd); const available = await updates.check(); - assert.equal(available.status, "available"); - const installed = await updates.install(); - assert.equal(installed.status, "updated"); - assert.equal(installed.latestVersion, "0.1.2"); + assert.equal(available.status, "migration"); + const migrated = await updates.migrate(); + assert.equal(migrated.status, "migrated"); const oldDirectory = access(installedCwd); await assert.rejects(oldDirectory, { code: "ENOENT" }); + const storePath = join(storeDirectory, "plugin.json"); + await access(storePath); const resolution = resolveCodexCli(process.pid, profile); await assert.rejects(resolution, /Could not locate the Codex executable/); - const mcp = await codex(["mcp", "get", "mobile-dev", "--json"]); - const refreshedCwd = resolve(mcp.transport.cwd); - const usesNewVersion = refreshedCwd.endsWith("/mobile-dev/mobile-dev/0.1.2"); - assert.ok(usesNewVersion); + const marketplaces = await codex(["plugin", "marketplace", "list", "--json"]); + const oldMarketplace = marketplaces.marketplaces.find(entry => entry.name === "mobile-dev"); + assert.equal(oldMarketplace, undefined); const anotherChat = new PluginUpdates(options); const notice = await anotherChat.check(); - assert.equal(notice.status, "updated"); - const cachePath = join(profile, "mobile-dev/updates.json"); - await rm(cachePath); - const retry = new PluginUpdates(options); - const retried = await retry.install(); - assert.equal(retried.status, "updated"); - assert.equal(retried.latestVersion, "0.1.2"); - const restarted = new PluginUpdates({ ...options, currentVersion: "0.1.2" }); + assert.equal(notice.status, "migrated"); + const retry = await updates.migrate(); + assert.equal(retry.status, "migrated"); + const storeRoot = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165"); + const restarted = new PluginUpdates({ ...options, pluginRoot: storeRoot }); const current = await restarted.check(); assert.equal(current.status, "current"); - console.log("Verified real Codex CLI upgrade from a removed plugin working directory, installed cache, already-installed retries, and restart notices using a local Git mirror."); + console.log("Verified real Codex CLI Git plugin and marketplace removal from the deleted working directory, store-package preservation, retries, and restart notices. Store catalog/install state uses a fixture."); } finally { process.chdir(originalCwd); if (http) http.close(); diff --git a/src/server/plugin-updates.ts b/src/server/plugin-updates.ts index 1d1463b0..40c4b75d 100644 --- a/src/server/plugin-updates.ts +++ b/src/server/plugin-updates.ts @@ -1,47 +1,69 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { createInterface } from "node:readline"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { compareReleaseVersions, pluginUpdateSchema, releaseVersionSchema } from "../shared/plugin-updates.ts"; +import { compareReleaseVersions, PLUGIN_STORE_ID, pluginUpdateSchema, releaseVersionSchema, STORE_MIGRATION_VERSION } from "../shared/plugin-updates.ts"; import type { PluginUpdate } from "../shared/plugin-updates.ts"; import { PLUGIN_VERSION } from "../shared/version.ts"; +import { ExpectedOperationError } from "../shared/error-reporting.ts"; import { captureServerError, recordPluginUpdate } from "./telemetry.ts"; import { resolveTelemetryEnvironment } from "./telemetry-environment.ts"; const execute = promisify(execFile); const repository = "callstackincubator/codex-mobile-dev-plugin"; -const releaseEndpoint = `https://api.github.com/repos/${repository}/releases/latest`; -const marketplaceEndpoint = `https://raw.githubusercontent.com/${repository}/release/latest/plugins/mobile-dev/.codex-plugin/plugin.json`; +const storeMarketplace = "openai-curated-remote"; +const githubPlugin = "mobile-dev@mobile-dev"; const checkIntervalMs = 60 * 60 * 1000; const failedCheckIntervalMs = 15 * 60 * 1000; const numberSchema = z.number(); const timestampSchema = numberSchema.nonnegative(); const optionalVersionSchema = releaseVersionSchema.optional(); -const cacheSchema = z.object({ checkedAt: timestampSchema, latestVersion: optionalVersionSchema, installedVersion: optionalVersionSchema }); +const booleanSchema = z.boolean(); +const storeIdSchema = z.literal(PLUGIN_STORE_ID); +const cacheSchema = z.object({ + source: storeIdSchema, checkedAt: timestampSchema, latestVersion: optionalVersionSchema, + githubConfigured: booleanSchema, storeInstalled: booleanSchema, migratedVersion: optionalVersionSchema, +}); const pluginNameSchema = z.literal("mobile-dev"); const manifestSchema = z.object({ name: pluginNameSchema, version: releaseVersionSchema }); const textSchema = z.string(); -const falseSchema = z.literal(false); -const releaseSchema = z.object({ tag_name: textSchema, draft: falseSchema, prerelease: falseSchema }); -const sourceSchema = z.object({ sourceType: textSchema, source: textSchema }); -const optionalSourceSchema = sourceSchema.optional(); -const marketplaceItemSchema = z.object({ name: textSchema, root: textSchema, marketplaceSource: optionalSourceSchema }); -const marketplacesSchema = z.array(marketplaceItemSchema); -const marketplaceSchema = z.object({ marketplaces: marketplacesSchema }); -const textsSchema = z.array(textSchema); -const unknownSchema = z.unknown(); -const errorsSchema = z.array(unknownSchema); -const upgradeSchema = z.object({ selectedMarketplaces: textsSchema, upgradedRoots: textsSchema, errors: errorsSchema }); -const booleanSchema = z.boolean(); -const installedPluginSchema = z.object({ pluginId: textSchema, version: textSchema, installed: booleanSchema }); -const installedPluginsSchema = z.array(installedPluginSchema); -const pluginsSchema = z.object({ installed: installedPluginsSchema }); +const optionalTextSchema = textSchema.nullish(); +const nullableVersionSchema = textSchema.nullable(); +const sourceSchema = z.object({ source: textSchema, id: optionalTextSchema }); +const marketplaceSourceSchema = z.object({ sourceType: textSchema, source: textSchema }); +const optionalSourceSchema = marketplaceSourceSchema.optional(); +const pluginSchema = z.object({ + pluginId: textSchema, name: textSchema, marketplaceName: textSchema, version: nullableVersionSchema, + installed: booleanSchema, enabled: booleanSchema, source: sourceSchema, marketplaceSource: optionalSourceSchema, +}); +const pluginsArraySchema = z.array(pluginSchema); +const pluginsSchema = z.object({ installed: pluginsArraySchema, available: pluginsArraySchema }); +const marketplaceItemSchema = z.object({ name: textSchema, marketplaceSource: optionalSourceSchema }); +const marketplacesArraySchema = z.array(marketplaceItemSchema); +const marketplaceSchema = z.object({ marketplaces: marketplacesArraySchema }); +const removedSchema = z.object({ pluginId: textSchema }); +const removedMarketplaceSchema = z.object({ marketplaceName: textSchema }); type Cache = z.infer; +const nullableReleaseSchema = releaseVersionSchema.nullable(); +const storeSummarySchema = z.object({ + remotePluginId: storeIdSchema, name: pluginNameSchema, version: releaseVersionSchema, + localVersion: nullableReleaseSchema, installed: booleanSchema, enabled: booleanSchema, +}); +const storeDetailsSchema = z.object({ summary: storeSummarySchema }); +const storeResponseSchema = z.object({ plugin: storeDetailsSchema }); +const unknownSchema = z.unknown(); +const optionalUnknownSchema = unknownSchema.optional(); +const rpcResponseSchema = z.object({ id: numberSchema, result: optionalUnknownSchema, error: optionalUnknownSchema }); +export type StorePlugin = z.infer; +type Catalog = z.infer & { store: StorePlugin }; +export type ReadStore = () => Promise; export type RunCodex = (args: string[]) => Promise; class PluginUpdateError extends Error {} @@ -55,14 +77,14 @@ export async function resolveCodexCli(parentPid = process.ppid, cwd = homedir()) return path; } -export function createCodexRunner(cwd: string, options: { path?: string; env?: NodeJS.ProcessEnv } = {}): RunCodex { +export function createCodexRunner(cwd: string, options: { path?: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {}): RunCodex { const environment = options.env ?? process.env; const env = { ...environment, GIT_TERMINAL_PROMPT: "0" }; return async args => { const path = options.path ?? await resolveCodexCli(process.ppid, cwd); let stdout: string; try { - const result = await execute(path, args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env }); + const result = await execute(path, args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env, signal: options.signal }); stdout = result.stdout; } catch { throw new PluginUpdateError("Codex could not complete the Mobile Dev update command. Try again."); @@ -72,42 +94,138 @@ export function createCodexRunner(cwd: string, options: { path?: string; env?: N }; } +export async function readPluginStore(cwd: string, options: { path?: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {}): Promise { + const path = options.path ?? await resolveCodexCli(process.ppid, cwd); + options.signal?.throwIfAborted(); + const host = spawn(path, ["--no-daemon", "app-server", "--stdio"], { + cwd, env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, + }); + const lines = createInterface({ input: host.stdout }); + const pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + let nextId = 1; + function fail() { + const error = new PluginUpdateError("Could not read the Codex plugin store catalog."); + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(error); + } + pending.clear(); + } + function send(message: unknown) { + const text = JSON.stringify(message); + host.stdin.write(`${text}\n`); + } + function request(method: string, params: unknown): Promise { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { fail(); host.kill(); }, 20000); + pending.set(id, { resolve, reject, timer }); + send({ id, method, params }); + }); + } + function abort() { fail(); host.kill(); } + host.stderr.resume(); + host.on("error", fail); + host.on("exit", fail); + host.stdin.on("error", fail); + options.signal?.addEventListener("abort", abort, { once: true }); + lines.on("line", line => { + let data: unknown; + try { data = JSON.parse(line); } catch { return; } + const parsed = rpcResponseSchema.safeParse(data); + if (parsed.success === false) return; + const response = parsed.data; + const request = pending.get(response.id); + if (request === undefined) return; + clearTimeout(request.timer); + pending.delete(response.id); + if (response.error) { + const error = new PluginUpdateError("Codex could not read the plugin store catalog."); + request.reject(error); + } else request.resolve(response.result); + }); + try { + await request("initialize", { + clientInfo: { name: "mobile-dev-updates", version: PLUGIN_VERSION }, + capabilities: { experimentalApi: true, requestAttestation: false }, + }); + send({ method: "initialized" }); + const result = await request("plugin/read", { remoteMarketplaceName: storeMarketplace, pluginName: "mobile-dev" }); + const parsed = storeResponseSchema.parse(result); + return parsed.plugin.summary; + } finally { + options.signal?.removeEventListener("abort", abort); + for (const request of pending.values()) clearTimeout(request.timer); + lines.close(); + host.kill(); + } +} + function isReleaseRepository(source: string): boolean { return source === `https://github.com/${repository}` || source === `https://github.com/${repository}.git` || source === `git@github.com:${repository}.git` || source === `ssh://git@github.com/${repository}.git`; } +function githubInstalled(catalog: Catalog): boolean { + return catalog.installed.some(plugin => { + const source = plugin.marketplaceSource; + return plugin.pluginId === githubPlugin && plugin.installed && source?.sourceType === "git" && isReleaseRepository(source.source); + }); +} + +function githubConfigured(catalog: Catalog): boolean { + const plugins = [...catalog.installed, ...catalog.available]; + return plugins.some(plugin => { + const source = plugin.marketplaceSource; + return plugin.pluginId === githubPlugin && source?.sourceType === "git" && isReleaseRepository(source.source); + }); +} + export class PluginUpdates { private readonly enabled: boolean; private readonly currentVersion: string; private readonly profile: string; + private readonly runningGithub: boolean; private readonly cachePath: string; - private readonly fetch: typeof fetch; private readonly run: RunCodex; + private readonly readStore: ReadStore; private readonly now: () => number; private checking?: Promise; - private installing?: Promise; + private migrating?: Promise; private readonly abort = new AbortController(); - constructor(options: { enabled?: boolean; currentVersion?: string; profile?: string; fetch?: typeof fetch; run?: RunCodex; now?: () => number } = {}) { + constructor(options: { enabled?: boolean; currentVersion?: string; profile?: string; pluginRoot?: string; run?: RunCodex; readStore?: ReadStore; now?: () => number } = {}) { this.enabled = options.enabled ?? resolveTelemetryEnvironment() === "release"; this.currentVersion = options.currentVersion ?? PLUGIN_VERSION; const home = homedir(); const defaultProfile = join(home, ".codex"); this.profile = options.profile ?? process.env.CODEX_HOME ?? defaultProfile; + const modulePath = fileURLToPath(import.meta.url); + const moduleDirectory = dirname(modulePath); + const pluginRoot = options.pluginRoot ?? dirname(moduleDirectory); + const pluginParent = dirname(pluginRoot); + const githubCache = join("plugins", "cache", "mobile-dev", "mobile-dev"); + this.runningGithub = pluginParent.endsWith(githubCache); this.cachePath = join(this.profile, "mobile-dev", "updates.json"); - this.fetch = options.fetch ?? fetch; - this.run = options.run ?? createCodexRunner(this.profile); + this.run = options.run ?? createCodexRunner(this.profile, { signal: this.abort.signal }); + this.readStore = options.readStore ?? (() => readPluginStore(this.profile, { signal: this.abort.signal })); this.now = options.now ?? Date.now; } + private emptyCache(): Cache { + return { source: PLUGIN_STORE_ID, checkedAt: 0, githubConfigured: false, storeInstalled: false }; + } + private status(cache: Cache): PluginUpdate { - const installed = cache.installedVersion; - if (installed !== undefined && compareReleaseVersions(installed, this.currentVersion) > 0) { - return { status: "updated", currentVersion: this.currentVersion, latestVersion: installed }; + if (cache.migratedVersion !== undefined && this.runningGithub) { + return { status: "migrated", currentVersion: this.currentVersion, latestVersion: cache.migratedVersion, storeInstalled: true }; } const latest = cache.latestVersion; if (latest === undefined) return { status: "unavailable", currentVersion: this.currentVersion }; + const migrationReady = compareReleaseVersions(latest, STORE_MIGRATION_VERSION) >= 0 && compareReleaseVersions(latest, this.currentVersion) >= 0; + if (cache.githubConfigured && migrationReady) { + return { status: "migration", currentVersion: this.currentVersion, latestVersion: latest, storeInstalled: cache.storeInstalled }; + } const newer = compareReleaseVersions(latest, this.currentVersion) > 0; return { status: newer ? "available" : "current", currentVersion: this.currentVersion, latestVersion: latest }; } @@ -117,7 +235,7 @@ export class PluginUpdates { const text = await readFile(this.cachePath, "utf8"); const data: unknown = JSON.parse(text); return cacheSchema.parse(data); - } catch { return { checkedAt: 0 }; } + } catch { return this.emptyCache(); } } private async saveCache(cache: Cache) { @@ -134,17 +252,32 @@ export class PluginUpdates { } finally { await rm(temporary, { force: true }); } } - private async readJson(url: string): Promise { - const timeout = AbortSignal.timeout(10000); - const signal = AbortSignal.any([timeout, this.abort.signal]); - const response = await this.fetch(url, { headers: { Accept: "application/vnd.github+json" }, signal, redirect: "error" }); - if (response.ok === false) throw new Error("Could not check for plugin updates."); - return response.json(); + private async catalog(): Promise { + const plugins = this.run(["plugin", "list", "--available", "--json"]); + const store = this.readStore(); + const results = await Promise.all([plugins, store]); + const catalog = pluginsSchema.parse(results[0]); + const summary = storeSummarySchema.parse(results[1]); + return { ...catalog, store: summary }; + } + + private async storeInstalled(catalog: Catalog): Promise { + const entry = catalog.store; + const version = entry.localVersion; + if (entry.installed === false || entry.enabled === false || version === null) return false; + if (compareReleaseVersions(version, STORE_MIGRATION_VERSION) < 0 || compareReleaseVersions(version, this.currentVersion) < 0) return false; + const path = join(this.profile, "plugins/cache", storeMarketplace, "mobile-dev", version, ".codex-plugin/plugin.json"); + try { + const text = await readFile(path, "utf8"); + const data: unknown = JSON.parse(text); + const manifest = manifestSchema.parse(data); + return manifest.version === version; + } catch { return false; } } check(): Promise { if (this.enabled === false) return Promise.resolve({ status: "disabled", currentVersion: this.currentVersion }); - if (this.installing !== undefined) return this.installing; + if (this.migrating !== undefined) return this.migrating; if (this.checking !== undefined) return this.checking; const checking = this.checkLatest(); this.checking = checking.finally(() => { this.checking = undefined; }); @@ -156,18 +289,16 @@ export class PluginUpdates { const status = this.status(cache); const age = this.now() - cache.checkedAt; const interval = cache.latestVersion === undefined ? failedCheckIntervalMs : checkIntervalMs; - if (status.status === "updated" || cache.checkedAt > 0 && age >= 0 && age < interval) return status; + if (status.status === "migrated" || cache.checkedAt > 0 && age >= 0 && age < interval) return status; const startedAt = performance.now(); - const next: Cache = { checkedAt: this.now(), installedVersion: cache.installedVersion }; + const next = this.emptyCache(); + next.checkedAt = this.now(); try { - const releaseData = this.readJson(releaseEndpoint); - const marketplaceData = this.readJson(marketplaceEndpoint); - const data = await Promise.all([releaseData, marketplaceData]); - const release = releaseSchema.parse(data[0]); - const manifest = manifestSchema.parse(data[1]); - if (release.tag_name !== `v${manifest.version}`) throw new Error("The latest release is not available in the marketplace yet."); - next.latestVersion = manifest.version; - } catch { /* A failed check leaves the workspace usable and retries after the cache expires. */ } + const catalog = await this.catalog(); + next.latestVersion = catalog.store.version; + next.githubConfigured = githubConfigured(catalog); + next.storeInstalled = await this.storeInstalled(catalog); + } catch { /* Unavailable catalog checks retry after the cache expires. */ } try { await this.saveCache(next); } catch { const error = new Error("Could not save the plugin update check."); @@ -180,59 +311,63 @@ export class PluginUpdates { return update; } - install(): Promise { + migrate(): Promise { if (this.enabled === false) { - const error = new Error("Development builds do not install public plugin updates."); + const error = new Error("Development builds do not migrate public plugin installations."); return Promise.reject(error); } - if (this.installing !== undefined) return this.installing; - const installing = this.installLatest(); - this.installing = installing.finally(() => { this.installing = undefined; }); - return this.installing; + if (this.migrating !== undefined) return this.migrating; + const migrating = this.migrateToStore(); + this.migrating = migrating.finally(() => { this.migrating = undefined; }); + return this.migrating; } - private async installLatest(): Promise { + private async migrateToStore(): Promise { const startedAt = performance.now(); try { - const update = await this.check(); - if (update.status === "updated") return update; - if (update.status !== "available" || update.latestVersion === undefined) throw new PluginUpdateError("No installable Mobile Dev update is available. Try again later."); - const listed = await this.run(["plugin", "marketplace", "list", "--json"]); - const marketplaces = marketplaceSchema.parse(listed); - const marketplace = marketplaces.marketplaces.find(item => item.name === "mobile-dev"); - const source = marketplace?.marketplaceSource; - if (marketplace === undefined || source?.sourceType !== "git" || isReleaseRepository(source.source) === false) { - throw new PluginUpdateError("Mobile Dev must be installed from its public release marketplace to update here."); + if (this.checking !== undefined) await this.checking; + const catalog = await this.catalog(); + const version = catalog.store.version; + const ready = compareReleaseVersions(version, STORE_MIGRATION_VERSION) >= 0 && compareReleaseVersions(version, this.currentVersion) >= 0; + const installed = ready && await this.storeInstalled(catalog); + if (installed === false) { + throw new ExpectedOperationError("app_unavailable", "Install and enable the latest Mobile Dev version from the plugin store, then finish migration here."); } - const result = await this.run(["plugin", "marketplace", "upgrade", "mobile-dev", "--json"]); - const upgrade = upgradeSchema.parse(result); - const selected = upgrade.selectedMarketplaces.includes("mobile-dev"); - if (upgrade.errors.length > 0 || selected === false) { - throw new PluginUpdateError("Codex could not refresh the Mobile Dev marketplace. Try again."); + if (githubInstalled(catalog)) { + const result = await this.run(["plugin", "remove", githubPlugin, "--json"]); + const removed = removedSchema.parse(result); + if (removed.pluginId !== githubPlugin) throw new PluginUpdateError("Codex did not remove the GitHub installation. Try again."); } - const listedPlugins = await this.run(["plugin", "list", "--json"]); - const plugins = pluginsSchema.parse(listedPlugins); - const plugin = plugins.installed.find(item => item.pluginId === "mobile-dev@mobile-dev" && item.installed); - const installedVersion = releaseVersionSchema.parse(plugin?.version); - const path = join(this.profile, "plugins/cache/mobile-dev/mobile-dev", installedVersion, ".codex-plugin/plugin.json"); - const text = await readFile(path, "utf8"); - const data: unknown = JSON.parse(text); - const installed = manifestSchema.parse(data); - if (installed.version !== installedVersion || compareReleaseVersions(installedVersion, update.latestVersion) < 0) { - throw new PluginUpdateError("The new Mobile Dev version has not been installed. Try again later."); + const marketplaces = await this.run(["plugin", "marketplace", "list", "--json"]); + const marketplaceResult = marketplaceSchema.parse(marketplaces); + const marketplace = marketplaceResult.marketplaces.find(item => item.name === "mobile-dev"); + if (marketplace !== undefined) { + const source = marketplace.marketplaceSource; + if (source?.sourceType !== "git" || isReleaseRepository(source.source) === false) { + throw new PluginUpdateError("The mobile-dev marketplace belongs to another source and cannot be removed here."); + } + const result = await this.run(["plugin", "marketplace", "remove", "mobile-dev", "--json"]); + const removed = removedMarketplaceSchema.parse(result); + if (removed.marketplaceName !== "mobile-dev") throw new PluginUpdateError("Codex did not remove the GitHub marketplace. Try again."); + } + const verified = await this.catalog(); + if (githubConfigured(verified) || await this.storeInstalled(verified) === false) { + throw new PluginUpdateError("Could not verify the store migration. Try again."); } - const cache: Cache = { checkedAt: this.now(), latestVersion: installedVersion, installedVersion }; + const installedVersion = releaseVersionSchema.parse(verified.store.localVersion); + const cache: Cache = { source: PLUGIN_STORE_ID, checkedAt: this.now(), latestVersion: version, githubConfigured: false, storeInstalled: true, migratedVersion: installedVersion }; await this.saveCache(cache); const elapsed = performance.now() - startedAt; - recordPluginUpdate("install", "updated", elapsed); - return this.status(cache); + recordPluginUpdate("migrate", "migrated", elapsed); + return { status: "migrated", currentVersion: this.currentVersion, latestVersion: installedVersion, storeInstalled: true }; } catch (error) { const elapsed = performance.now() - startedAt; - recordPluginUpdate("install", "failed", elapsed); - const telemetryError = new Error("The plugin update failed."); - captureServerError(telemetryError, "plugin.update.install"); + recordPluginUpdate("migrate", "failed", elapsed); + if (error instanceof ExpectedOperationError) throw error; + const telemetryError = new Error("The plugin store migration failed."); + captureServerError(telemetryError, "plugin.update.migrate"); if (error instanceof PluginUpdateError) throw error; - throw new PluginUpdateError("Could not verify the Mobile Dev update. Try again."); + throw new PluginUpdateError("Could not verify the Mobile Dev migration. Try again."); } } @@ -244,23 +379,23 @@ export function registerPluginUpdateTools(server: McpServer, updates: PluginUpda const metadata = { ui: { visibility } }; const outputSchema = { update: pluginUpdateSchema }; registerAppTool(server, "mobile_check_plugin_update", { - title: "Check Mobile Dev updates", description: "Check for an installable public Mobile Dev release.", inputSchema: {}, + title: "Check Mobile Dev updates", description: "Read the published Mobile Dev version from the Codex plugin store.", inputSchema: {}, outputSchema, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, _meta: metadata, }, async () => { const update = await updates.check(); return { content: [], structuredContent: { update } }; }); - registerAppTool(server, "mobile_install_plugin_update", { - title: "Update Mobile Dev", description: "Install the available Mobile Dev release after the user clicks Update. Codex must be quit and reopened afterward.", inputSchema: {}, + registerAppTool(server, "mobile_migrate_plugin_to_store", { + title: "Finish Mobile Dev store migration", description: "After the user installs Mobile Dev from the plugin store and clicks Finish migration, verify that installation and remove the GitHub plugin and marketplace. Restart Codex afterward.", inputSchema: {}, outputSchema, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: metadata, + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }, _meta: metadata, }, async () => { try { - const update = await updates.install(); + const update = await updates.migrate(); return { content: [], structuredContent: { update } }; } catch (error) { - const message = error instanceof Error ? error.message : "Could not update Mobile Dev. Try again."; + const message = error instanceof Error ? error.message : "Could not migrate Mobile Dev. Try again."; return { isError: true, content: [{ type: "text", text: message }] }; } }); diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts index 6c3cf328..962e50c6 100644 --- a/src/server/telemetry.ts +++ b/src/server/telemetry.ts @@ -20,7 +20,7 @@ export function recordIosMirrorSharing(captures: number, subscribers: number, dr if (dropped > 0) Sentry.metrics.count("ios.mirror.shared.queue_dropped", dropped, { attributes }); } -export function recordPluginUpdate(operation: "check" | "install", outcome: "disabled" | "unavailable" | "current" | "available" | "updated" | "failed", duration: number) { +export function recordPluginUpdate(operation: "check" | "migrate", outcome: "disabled" | "unavailable" | "current" | "available" | "migration" | "migrated" | "failed", duration: number) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; const attributes = { component: "server", operation, outcome }; Sentry.metrics.count("plugin.update.operations", 1, { attributes }); diff --git a/src/shared/plugin-updates.ts b/src/shared/plugin-updates.ts index 9183ea1f..f065845c 100644 --- a/src/shared/plugin-updates.ts +++ b/src/shared/plugin-updates.ts @@ -1,13 +1,20 @@ import { z } from "zod"; +export const PLUGIN_STORE_ID = "plugins_6ac4c117f76881918d3676e851fa4005"; +export const PLUGIN_STORE_URL = `https://chatgpt.com/plugins/${PLUGIN_STORE_ID}`; +export const STORE_MIGRATION_VERSION = "0.1.165"; + const versionTextSchema = z.string(); export const releaseVersionSchema = versionTextSchema.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); -const statusSchema = z.enum(["disabled", "unavailable", "current", "available", "updated"]); +const statusSchema = z.enum(["disabled", "unavailable", "current", "available", "migration", "migrated"]); const optionalVersionSchema = releaseVersionSchema.optional(); +const booleanSchema = z.boolean(); +const optionalBooleanSchema = booleanSchema.optional(); export const pluginUpdateSchema = z.object({ status: statusSchema, currentVersion: releaseVersionSchema, latestVersion: optionalVersionSchema, + storeInstalled: optionalBooleanSchema, }); export type PluginUpdate = z.infer; @@ -21,7 +28,3 @@ export function compareReleaseVersions(left: string, right: string): number { } return 0; } - -export function pluginReleaseUrl(version: string): string { - return `https://github.com/callstackincubator/codex-mobile-dev-plugin/releases/tag/v${version}`; -} diff --git a/src/ui/components/plugin-update-banner.tsx b/src/ui/components/plugin-update-banner.tsx index 923b5463..d61a228a 100644 --- a/src/ui/components/plugin-update-banner.tsx +++ b/src/ui/components/plugin-update-banner.tsx @@ -6,20 +6,23 @@ import { Button } from "./ui/button"; export function PluginUpdateBanner({ controller }: { controller: PluginUpdateController }) { const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const update = state.update; - if (update === undefined || update.status !== "available" && update.status !== "updated" || update.latestVersion === state.dismissedVersion) return null; - const installed = update.status === "updated"; + if (update === undefined || update.status !== "available" && update.status !== "migration" && update.status !== "migrated" || update.latestVersion === state.dismissedVersion) return null; + const migrated = update.status === "migrated"; + const migration = update.status === "migration"; + const finishMigration = migration && (update.storeInstalled === true || state.storeOpened === true); return ; diff --git a/src/ui/plugin-updates.ts b/src/ui/plugin-updates.ts index 9c11f4bc..c32a86f4 100644 --- a/src/ui/plugin-updates.ts +++ b/src/ui/plugin-updates.ts @@ -1,9 +1,9 @@ import type { App } from "@modelcontextprotocol/ext-apps"; -import { pluginReleaseUrl, pluginUpdateSchema } from "../shared/plugin-updates.ts"; +import { PLUGIN_STORE_URL, pluginUpdateSchema } from "../shared/plugin-updates.ts"; import type { PluginUpdate } from "../shared/plugin-updates.ts"; import { captureUiError, countUiEvent } from "./telemetry.ts"; -export type UpdateSnapshot = { update?: PluginUpdate; busy: boolean; error: string; dismissedVersion?: string }; +export type UpdateSnapshot = { update?: PluginUpdate; busy: boolean; error: string; dismissedVersion?: string; storeOpened?: boolean }; export class PluginUpdateController { private readonly app: Pick; @@ -43,47 +43,47 @@ export class PluginUpdateController { clearInterval(this.timer); this.timer = undefined; if (this.disposed || this.available === false || this.visibility.visibilityState === "hidden") return; - if (this.snapshot.update?.status === "disabled" || this.snapshot.update?.status === "updated") return; + if (this.snapshot.update?.status === "disabled" || this.snapshot.update?.status === "migrated") return; void this.check(); this.timer = setInterval(() => { void this.check(); }, 15 * 60 * 1000); }; async check() { - if (this.disposed || this.checking || this.snapshot.busy || this.snapshot.update?.status === "updated") return; + if (this.disposed || this.checking || this.snapshot.busy || this.snapshot.update?.status === "migrated") return; this.checking = true; try { const result = await this.app.callServerTool({ name: "mobile_check_plugin_update", arguments: {} }, { signal: this.abort.signal }); if (result.isError) return; const update = pluginUpdateSchema.parse(result.structuredContent?.update); this.publish({ update }); - if (update.status === "disabled" || update.status === "updated") clearInterval(this.timer); + if (update.status === "disabled" || update.status === "migrated") clearInterval(this.timer); } catch { // Update checks must not block or replace the device workspace. } finally { this.checking = false; } } - async install() { - if (this.disposed || this.snapshot.busy || this.snapshot.update?.status !== "available") return; + async migrate() { + if (this.disposed || this.snapshot.busy || this.snapshot.update?.status !== "migration") return; this.publish({ busy: true, error: "" }); try { - const result = await this.app.callServerTool({ name: "mobile_install_plugin_update", arguments: {} }, { signal: this.abort.signal, timeout: 180000 }); + const result = await this.app.callServerTool({ name: "mobile_migrate_plugin_to_store", arguments: {} }, { signal: this.abort.signal, timeout: 180000 }); if (result.isError) { const texts = result.content.filter(item => item.type === "text"); const messages = texts.map(item => item.text); const message = messages.join("\n"); - this.publish({ error: message || "Could not update Mobile Dev. Try again." }); + this.publish({ error: message || "Could not finish migration. Try again." }); return; } const update = pluginUpdateSchema.parse(result.structuredContent?.update); - if (update.status !== "updated") throw new Error("The plugin update did not finish."); + if (update.status !== "migrated") throw new Error("The store migration did not finish."); clearInterval(this.timer); this.publish({ update }); - countUiEvent("ui.plugin_update.installed"); + countUiEvent("ui.plugin_update.migrated"); } catch { if (this.abort.signal.aborted) return; - this.publish({ error: "Could not confirm the update. Try again." }); - const error = new Error("The plugin update request failed."); - captureUiError(error, "plugin.update.install"); + this.publish({ error: "Could not confirm the migration. Try again." }); + const error = new Error("The store migration request failed."); + captureUiError(error, "plugin.update.migrate"); } finally { this.publish({ busy: false }); } @@ -95,19 +95,20 @@ export class PluginUpdateController { countUiEvent("ui.plugin_update.dismissed"); } - async openReleaseNotes() { - const version = this.snapshot.update?.latestVersion; - if (version === undefined) return; + async openStore() { + if (this.disposed || this.snapshot.busy) return; + this.publish({ busy: true, error: "" }); try { - const url = pluginReleaseUrl(version); - const result = await this.app.openLink({ url }, { signal: this.abort.signal }); - if (result.isError) throw new Error("Could not open the release notes."); + const result = await this.app.openLink({ url: PLUGIN_STORE_URL }, { signal: this.abort.signal }); + if (result.isError) throw new Error("Could not open the plugin store."); + this.publish({ storeOpened: true }); + countUiEvent("ui.plugin_update.store_opened"); } catch { if (this.abort.signal.aborted) return; - this.publish({ error: "Could not open release notes. Try again." }); - const error = new Error("Could not open plugin release notes."); - captureUiError(error, "plugin.update.release_notes"); - } + this.publish({ error: "Could not open the plugin store. Try again." }); + const error = new Error("Could not open the plugin store."); + captureUiError(error, "plugin.update.store"); + } finally { this.publish({ busy: false }); } } dispose() { diff --git a/tests/plugin-update-ui.test.ts b/tests/plugin-update-ui.test.ts index 9a071868..356baf50 100644 --- a/tests/plugin-update-ui.test.ts +++ b/tests/plugin-update-ui.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { build } from "esbuild"; import { JSDOM } from "jsdom"; -const update = { status: "available", currentVersion: "0.1.131", latestVersion: "0.1.132" }; +const update = { status: "available", currentVersion: "0.1.164", latestVersion: "0.1.165" }; async function banner(t: test.TestContext, app: unknown) { const dom = new JSDOM('
', { pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test" }); @@ -38,14 +38,37 @@ async function banner(t: test.TestContext, app: unknown) { return { dom, ...mounted, settle, button }; } -test("the banner handles progress, failures, retry, release notes, and restart instructions", async t => { - let installs = 0; +test("store updates open the exact listing without installing through GitHub", async t => { + const links: string[] = []; + const tools: string[] = []; + const app = { + async callServerTool({ name }: { name: string }) { + tools.push(name); + return { content: [], structuredContent: { update } }; + }, + async openLink({ url }: { url: string }) { links.push(url); return {}; }, + }; + const view = await banner(t, app); + view.controller.setAvailable(true); + await view.settle(); + assert.match(view.dom.window.document.body.textContent!, /0.1.165 is available in the plugin store/); + const open = view.button("Open plugin store"); + open.click(); + await view.settle(); + assert.deepEqual(links, ["https://chatgpt.com/plugins/plugins_6ac4c117f76881918d3676e851fa4005"]); + assert.deepEqual(tools, ["mobile_check_plugin_update"]); +}); + +test("migration installs through the store before cleanup and handles progress, retry and restart", async t => { + let migrations = 0; let finish: (value: unknown) => void = () => {}; const links: string[] = []; + const migration = { ...update, status: "migration", storeInstalled: false }; const app = { async callServerTool({ name }: { name: string }) { - if (name === "mobile_check_plugin_update") return { content: [], structuredContent: { update } }; - installs++; + if (name === "mobile_check_plugin_update") return { content: [], structuredContent: { update: migration } }; + assert.equal(name, "mobile_migrate_plugin_to_store"); + migrations++; return new Promise(resolve => { finish = resolve; }); }, async openLink({ url }: { url: string }) { links.push(url); return {}; }, @@ -53,28 +76,28 @@ test("the banner handles progress, failures, retry, release notes, and restart i const view = await banner(t, app); view.controller.setAvailable(true); await view.settle(); - assert.match(view.dom.window.document.body.textContent!, /Mobile Dev 0.1.132 is available/); - const releaseNotes = view.button("Release notes"); - releaseNotes.click(); + const open = view.button("Install from plugin store"); + open.click(); await view.settle(); - assert.deepEqual(links, ["https://github.com/callstackincubator/codex-mobile-dev-plugin/releases/tag/v0.1.132"]); - const install = view.button("Update"); - install.click(); - install.click(); + assert.equal(links.length, 1); + assert.equal(migrations, 0); + const migrate = view.button("Finish migration"); + migrate.click(); + migrate.click(); await view.settle(); - assert.equal(installs, 1); - const updating = view.button("Updating…"); - assert.equal(updating.disabled, true); - finish({ isError: true, content: [{ type: "text", text: "Network unavailable" }] }); + assert.equal(migrations, 1); + const finishing = view.button("Finishing…"); + assert.equal(finishing.disabled, true); + finish({ isError: true, content: [{ type: "text", text: "Install and enable the store version first" }] }); await view.settle(); - assert.match(view.dom.window.document.body.textContent!, /Network unavailable/); - const retry = view.button("Retry update"); + assert.match(view.dom.window.document.body.textContent!, /Install and enable the store version first/); + const retry = view.button("Finish migration"); retry.click(); await view.settle(); - finish({ content: [], structuredContent: { update: { ...update, status: "updated" } } }); + finish({ content: [], structuredContent: { update: { ...migration, status: "migrated", storeInstalled: true } } }); await view.settle(); - assert.equal(installs, 2); - assert.match(view.dom.window.document.body.textContent!, /Quit and reopen Codex to use the new version/); + assert.equal(migrations, 2); + assert.match(view.dom.window.document.body.textContent!, /Quit and reopen Codex to use the store version/); const busy = view.dom.window.document.querySelector('[aria-busy="true"]'); assert.equal(busy, null); await view.controller.check(); @@ -85,6 +108,27 @@ test("the banner handles progress, failures, retry, release notes, and restart i assert.equal(dismissed, null); }); +test("failed store links stay retryable and an installed store copy can go straight to cleanup", async t => { + let available = false; + const app = { + async callServerTool() { return { content: [], structuredContent: { update: { ...update, status: "migration", storeInstalled: available } } }; }, + async openLink() { return { isError: true }; }, + }; + const view = await banner(t, app); + view.controller.setAvailable(true); + await view.settle(); + const open = view.button("Install from plugin store"); + open.click(); + await view.settle(); + assert.match(view.dom.window.document.body.textContent!, /Could not open the plugin store/); + assert.equal(view.controller.getSnapshot().storeOpened, undefined); + available = true; + await view.controller.check(); + await view.settle(); + const finish = view.button("Finish migration"); + assert.equal(finish.disabled, false); +}); + test("hidden workspaces do not check, dismissed versions stay hidden, and teardown cancels requests", async t => { let checks = 0; let signal: AbortSignal | undefined; diff --git a/tests/plugin-updates.test.ts b/tests/plugin-updates.test.ts index 70c42c3f..370a85e7 100644 --- a/tests/plugin-updates.test.ts +++ b/tests/plugin-updates.test.ts @@ -6,9 +6,9 @@ import { join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { PluginUpdates, registerPluginUpdateTools, resolveCodexCli } from "../src/server/plugin-updates.ts"; -import type { RunCodex } from "../src/server/plugin-updates.ts"; -import { compareReleaseVersions, pluginUpdateSchema } from "../src/shared/plugin-updates.ts"; +import { PluginUpdates, registerPluginUpdateTools, resolveCodexCli, readPluginStore } from "../src/server/plugin-updates.ts"; +import type { ReadStore, RunCodex } from "../src/server/plugin-updates.ts"; +import { compareReleaseVersions, PLUGIN_STORE_ID, pluginUpdateSchema } from "../src/shared/plugin-updates.ts"; async function profile(t: test.TestContext) { const parent = tmpdir(); @@ -18,172 +18,251 @@ async function profile(t: test.TestContext) { return directory; } -function releaseFetch(version = "0.1.132", marketplaceVersion = version): typeof fetch { - return async input => { - const url = String(input); - const data = url.includes("api.github.com") - ? { tag_name: `v${version}`, draft: false, prerelease: false } - : { name: "mobile-dev", version: marketplaceVersion }; - const text = JSON.stringify(data); - return new Response(text); - }; -} - -async function installedManifest(directory: string, version: string) { - const cache = join(directory, "plugins/cache/mobile-dev/mobile-dev", version, ".codex-plugin"); +async function storeManifest(directory: string, version: string) { + const cache = join(directory, "plugins/cache/openai-curated-remote/mobile-dev", version, ".codex-plugin"); await mkdir(cache, { recursive: true }); const text = JSON.stringify({ name: "mobile-dev", version }); const path = join(cache, "plugin.json"); await writeFile(path, text); } -function cliFixture(directory: string, calls: string[][], options: { source?: string; errors?: unknown[]; version?: string; refreshed?: boolean; selected?: boolean } = {}): RunCodex { - const root = join(directory, ".tmp/marketplaces/mobile-dev"); - return async args => { +type FixtureOptions = { + github?: boolean; storeVersion?: string; installedVersion?: string; storeInstalled?: boolean; + storeEnabled?: boolean; storeId?: string; source?: string; keepGithub?: boolean; + removeFails?: boolean; marketplaceRemoveFails?: boolean; +}; + +function cliFixture(directory: string, calls: string[][], options: FixtureOptions = {}) { + let github = options.github ?? true; + let marketplace = github; + const source = options.source ?? "https://github.com/callstackincubator/codex-mobile-dev-plugin.git"; + const run: RunCodex = async args => { calls.push(args); - if (args[2] === "list") return { marketplaces: [{ name: "mobile-dev", root, marketplaceSource: { - sourceType: "git", source: options.source ?? "https://github.com/callstackincubator/codex-mobile-dev-plugin.git", - } }] }; - if (args[2] === "upgrade") { - const version = options.version ?? "0.1.132"; - await installedManifest(directory, version); - const selected = options.selected ?? true; - const refreshed = options.refreshed ?? true; - const selectedMarketplaces = selected ? ["mobile-dev"] : []; - const upgradedRoots = refreshed ? [root] : []; - return { selectedMarketplaces, upgradedRoots, errors: options.errors ?? [] }; + if (args[1] === "marketplace") { + if (args[2] === "list") return { marketplaces: marketplace ? [{ name: "mobile-dev", marketplaceSource: { sourceType: "git", source } }] : [] }; + if (options.marketplaceRemoveFails) throw new Error("Marketplace removal failed"); + marketplace = false; + return { marketplaceName: "mobile-dev" }; } - return { installed: [{ pluginId: "mobile-dev@mobile-dev", installed: true, version: options.version ?? "0.1.132" }] }; + if (args[1] === "remove") { + if (options.removeFails) throw new Error("Removal failed"); + if (options.keepGithub !== true) github = false; + return { pluginId: "mobile-dev@mobile-dev" }; + } + const version = options.storeVersion ?? "0.1.165"; + if (options.storeInstalled) await storeManifest(directory, options.installedVersion ?? version); + const store = { + pluginId: "mobile-dev@openai-curated-remote", name: "mobile-dev", marketplaceName: "openai-curated-remote", + version, installed: options.storeInstalled ?? false, enabled: options.storeEnabled ?? true, + source: { source: "remote", id: options.storeId ?? PLUGIN_STORE_ID }, + }; + const installed = []; + const available = []; + const legacy = { + pluginId: "mobile-dev@mobile-dev", name: "mobile-dev", marketplaceName: "mobile-dev", version: "0.1.165", installed: true, enabled: true, + source: { source: "local", path: "fixture" }, marketplaceSource: { sourceType: "git", source }, + }; + legacy.installed = github; + if (github) installed.push(legacy); + else if (marketplace) available.push(legacy); + if (store.installed) installed.push({ ...store, version: options.installedVersion ?? store.version }); + else available.push(store); + return { installed, available }; }; + const readStore: ReadStore = async () => ({ + remotePluginId: options.storeId ?? PLUGIN_STORE_ID, name: "mobile-dev", version: options.storeVersion ?? "0.1.165", + localVersion: options.storeInstalled ? options.installedVersion ?? options.storeVersion ?? "0.1.165" : null, + installed: options.storeInstalled ?? false, enabled: options.storeEnabled ?? true, + }); + return { run, readStore }; +} + +function githubRoot(directory: string) { + return join(directory, "plugins/cache/mobile-dev/mobile-dev/0.1.165"); } test("update comparisons use numerical release versions", () => { const numeric = compareReleaseVersions("0.1.100", "0.1.99"); - const equal = compareReleaseVersions("0.1.132", "0.1.132"); - const older = compareReleaseVersions("0.1.132", "0.2.0"); + const equal = compareReleaseVersions("0.1.165", "0.1.165"); + const older = compareReleaseVersions("0.1.165", "0.2.0"); assert.equal(numeric, 1); assert.equal(equal, 0); assert.equal(older, -1); }); -test("checks cache across plugin processes and require a ready marketplace release", async t => { +test("the store catalog is the sole update source and checks are shared across chats", async t => { const directory = await profile(t); - let requests = 0; + const calls: string[][] = []; + const fixture = { github: false, storeVersion: "0.1.165", storeInstalled: true, installedVersion: "0.1.164" }; + const { run, readStore } = cliFixture(directory, calls, fixture); let now = 1000000; - let marketplaceVersion = "0.1.131"; - const fetchRelease: typeof fetch = async input => { - requests++; - const fetcher = releaseFetch("0.1.132", marketplaceVersion); - return fetcher(input); - }; - const options = { enabled: true, currentVersion: "0.1.99", profile: directory, fetch: fetchRelease, now: () => now }; + const options = { enabled: true, currentVersion: "0.1.164", profile: directory, run, readStore, now: () => now }; const updates = new PluginUpdates(options); const pending = [updates.check(), updates.check()]; const initial = await Promise.all(pending); - assert.equal(initial[0].status, "unavailable"); - assert.equal(initial[1].status, "unavailable"); - assert.equal(requests, 2); + assert.equal(initial[0].status, "available"); + assert.equal(initial[0].latestVersion, "0.1.165"); + assert.equal(initial[1].status, "available"); const another = new PluginUpdates(options); - const cached = await another.check(); - assert.equal(cached.status, "unavailable"); - assert.equal(requests, 2); - marketplaceVersion = "0.1.132"; - now += 15 * 60 * 1000; - const ready = await another.check(); - assert.equal(ready.status, "available"); - assert.equal(requests, 4); - const fresh = new PluginUpdates(options); - await fresh.check(); - assert.equal(requests, 4); + await another.check(); + assert.deepEqual(calls, [["plugin", "list", "--available", "--json"]]); + fixture.storeVersion = "0.1.166"; now += 60 * 60 * 1000; - await fresh.check(); - assert.equal(requests, 6); + const next = await another.check(); + assert.equal(next.latestVersion, "0.1.166"); + assert.equal(calls.length, 2); +}); + +test("pending review and a newer GitHub build never offer an older store migration", async t => { + for (const version of ["0.1.163", "0.1.165"]) { + const directory = await profile(t); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, { storeVersion: version, storeInstalled: true }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); + const status = await updates.check(); + assert.equal(status.status, "current"); + const migration = updates.migrate(); + await assert.rejects(migration, /Install and enable/); + assert.equal(calls.length, 2); + } + const directory = await profile(t); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, { storeVersion: "0.1.163" }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.164", run, readStore }); + const status = await updates.check(); + assert.equal(status.status, "current"); +}); + +test("legacy GitHub update caches cannot advertise unpublished store versions", async t => { + const directory = await profile(t); + const cacheDirectory = join(directory, "mobile-dev"); + await mkdir(cacheDirectory); + const path = join(cacheDirectory, "updates.json"); + const text = JSON.stringify({ checkedAt: Date.now(), latestVersion: "9.9.9", installedVersion: "9.9.9" }); + await writeFile(path, text); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, { github: false, storeVersion: "0.1.165" }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const status = await updates.check(); + assert.equal(status.status, "current"); + assert.equal(status.latestVersion, "0.1.165"); + assert.equal(calls.length, 1); }); -test("offline checks remain quiet, invalid tags cannot advertise updates, and development does no work", async t => { +test("unavailable catalogs, wrong identities and invalid versions stay quiet; development does no CLI work", async t => { + for (const fixture of [{ storeId: "another-plugin" }, { storeVersion: "../bad" }]) { + const directory = await profile(t); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, fixture); + const updates = new PluginUpdates({ enabled: true, profile: directory, run, readStore }); + const status = await updates.check(); + assert.equal(status.status, "unavailable"); + } const directory = await profile(t); - const offline: typeof fetch = async () => { throw new Error("Offline"); }; - const updates = new PluginUpdates({ enabled: true, profile: directory, fetch: offline }); + let now = 1000000; + let checks = 0; + const run = async () => { checks++; throw new Error("Offline"); }; + const readStore = async () => { throw new Error("Offline"); }; + const updates = new PluginUpdates({ enabled: true, profile: directory, run, readStore, now: () => now }); const unavailable = await updates.check(); assert.equal(unavailable.status, "unavailable"); - const dev = new PluginUpdates({ enabled: false, profile: directory, fetch: async () => { assert.fail("Development must not fetch updates"); } }); + await updates.check(); + assert.equal(checks, 1); + now += 15 * 60 * 1000; + await updates.check(); + assert.equal(checks, 2); + const dev = new PluginUpdates({ enabled: false, run: async () => { assert.fail("Development must not run update commands"); } }); const disabled = await dev.check(); assert.equal(disabled.status, "disabled"); - const installation = dev.install(); - await assert.rejects(installation, /Development builds/); - const otherDirectory = await profile(t); - const invalid = new PluginUpdates({ enabled: true, profile: otherDirectory, fetch: releaseFetch("../bad", "0.1.132") }); - const malformed = await invalid.check(); - assert.equal(malformed.status, "unavailable"); - const newerDirectory = await profile(t); - const newer = new PluginUpdates({ enabled: true, profile: newerDirectory, currentVersion: "0.2.0", fetch: releaseFetch() }); - const current = await newer.check(); - assert.equal(current.status, "current"); + const migration = dev.migrate(); + await assert.rejects(migration, /Development builds/); +}); + +test("migration is offered for the same version and verifies the store before any deletion", async t => { + for (const fixture of [{}, { storeInstalled: true, installedVersion: "0.1.163" }, { storeInstalled: true, storeEnabled: false }]) { + const directory = await profile(t); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, fixture); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const notice = await updates.check(); + assert.equal(notice.status, "migration"); + assert.equal(notice.storeInstalled, false); + const migration = updates.migrate(); + await assert.rejects(migration, /Install and enable/); + for (const call of calls) assert.deepEqual(call, ["plugin", "list", "--available", "--json"]); + } }); -test("installation is deduplicated, verifies cache files, and retains the restart notice across chats", async t => { +test("migration removes only GitHub, preserves data, deduplicates clicks and persists the restart notice", async t => { const directory = await profile(t); const calls: string[][] = []; - const run = cliFixture(directory, calls); - const options = { enabled: true, profile: directory, currentVersion: "0.1.131", fetch: releaseFetch(), run }; + const { run, readStore } = cliFixture(directory, calls, { storeInstalled: true }); + const pluginRoot = githubRoot(directory); + const options = { enabled: true, profile: directory, pluginRoot, currentVersion: "0.1.165", run, readStore }; + const dataPath = join(directory, "recording.json"); + await writeFile(dataPath, "saved recording"); const updates = new PluginUpdates(options); - const existingChat = new PluginUpdates(options); - await existingChat.check(); - const installations = [updates.install(), updates.install()]; - const results = await Promise.all(installations); - assert.equal(results[0].status, "updated"); - assert.equal(results[1].status, "updated"); - const existingNotice = await existingChat.check(); - assert.equal(existingNotice.status, "updated"); + const initial = await updates.check(); + assert.equal(initial.status, "migration"); + assert.equal(initial.storeInstalled, true); + const migrations = [updates.migrate(), updates.migrate()]; + const results = await Promise.all(migrations); + assert.equal(results[0].status, "migrated"); + assert.equal(results[1].status, "migrated"); assert.deepEqual(calls, [ + ["plugin", "list", "--available", "--json"], + ["plugin", "list", "--available", "--json"], + ["plugin", "remove", "mobile-dev@mobile-dev", "--json"], ["plugin", "marketplace", "list", "--json"], - ["plugin", "marketplace", "upgrade", "mobile-dev", "--json"], - ["plugin", "list", "--json"], + ["plugin", "marketplace", "remove", "mobile-dev", "--json"], + ["plugin", "list", "--available", "--json"], ]); const another = new PluginUpdates(options); const restart = await another.check(); - assert.equal(restart.status, "updated"); - const restarted = new PluginUpdates({ ...options, currentVersion: "0.1.132" }); + assert.equal(restart.status, "migrated"); + const storeRoot = join(directory, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165"); + const restarted = new PluginUpdates({ ...options, pluginRoot: storeRoot }); const current = await restarted.check(); assert.equal(current.status, "current"); + const data = await readFile(dataPath, "utf8"); + assert.equal(data, "saved recording"); const cachePath = join(directory, "mobile-dev/updates.json"); const cacheText = await readFile(cachePath, "utf8"); const includesProfile = cacheText.includes(directory); assert.equal(includesProfile, false); }); -test("an unchanged marketplace still verifies the installed version and reports a restart notice", async t => { - const directory = await profile(t); - const calls: string[][] = []; - const run = cliFixture(directory, calls, { refreshed: false }); - const options = { enabled: true, profile: directory, currentVersion: "0.1.131", fetch: releaseFetch(), run }; - const updates = new PluginUpdates(options); - const installed = await updates.install(); - assert.equal(installed.status, "updated"); - assert.equal(installed.latestVersion, "0.1.132"); - const lastCall = calls.at(-1); - assert.deepEqual(lastCall, ["plugin", "list", "--json"]); - const anotherChat = new PluginUpdates(options); - const notice = await anotherChat.check(); - assert.equal(notice.status, "updated"); -}); - -test("wrong marketplaces, JSON upgrade errors, unselected marketplaces, and stale installs never report success", async t => { - for (const options of [{ source: "https://github.com/other/repo" }, { errors: ["failed"] }, { selected: false }, - { version: "0.1.131" }, { refreshed: false, version: "0.1.131" }]) { +test("wrong marketplaces, failed deletion and unremoved GitHub installations never report success", async t => { + for (const fixture of [{ source: "https://github.com/other/repo" }, { removeFails: true }, { keepGithub: true }]) { const directory = await profile(t); const calls: string[][] = []; - const run = cliFixture(directory, calls, options); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.131", fetch: releaseFetch(), run }); - const installation = updates.install(); - await assert.rejects(installation); - const status = await updates.check(); - assert.equal(status.status, "available"); - if (options.source !== undefined) assert.equal(calls.length, 1); + const { run, readStore } = cliFixture(directory, calls, { ...fixture, storeInstalled: true }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const migration = updates.migrate(); + await assert.rejects(migration); + if (fixture.source !== undefined) assert.equal(calls.some(call => call[1] === "remove"), false); } }); -test("update tools are app-only, have no command arguments, and enforce development mode", async t => { +test("cleanup can resume when plugin removal succeeded but marketplace removal failed", async t => { + const directory = await profile(t); + const calls: string[][] = []; + const fixture = { storeInstalled: true, marketplaceRemoveFails: true }; + const { run, readStore } = cliFixture(directory, calls, fixture); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const migration = updates.migrate(); + await assert.rejects(migration); + const anotherChat = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const notice = await anotherChat.check(); + assert.equal(notice.status, "migration"); + fixture.marketplaceRemoveFails = false; + const retried = await anotherChat.migrate(); + assert.equal(retried.status, "migrated"); + const removals = calls.filter(call => call[1] === "remove"); + assert.equal(removals.length, 1); +}); + +test("update tools remain app-only and the GitHub upgrade tool is removed", async t => { const server = new McpServer({ name: "updates-test", version: "1" }); const updates = new PluginUpdates({ enabled: false }); registerPluginUpdateTools(server, updates); @@ -196,15 +275,61 @@ test("update tools are app-only, have no command arguments, and enforce developm for (const tool of tools.tools) { assert.deepEqual(tool._meta?.ui, { visibility: ["app"] }); assert.deepEqual(tool.inputSchema.properties, {}); + assert.notEqual(tool.name, "mobile_install_plugin_update"); } const check = await client.callTool({ name: "mobile_check_plugin_update", arguments: {} }); const checkedUpdate = pluginUpdateSchema.parse(check.structuredContent?.update); assert.equal(checkedUpdate.status, "disabled"); - const install = await client.callTool({ name: "mobile_install_plugin_update", arguments: {} }); - assert.equal(install.isError, true); + const migrate = await client.callTool({ name: "mobile_migrate_plugin_to_store", arguments: {} }); + assert.equal(migrate.isError, true); }); -test("CLI resolution rejects processes that are not the launching Codex executable", async () => { +test("CLI resolution rejects processes other than the launching Codex executable", async () => { const resolution = resolveCodexCli(process.pid); await assert.rejects(resolution, /Could not locate the Codex executable/); }); + +async function catalogServer(t: test.TestContext, reject = false) { + const directory = await profile(t); + const executable = join(directory, "codex-fixture"); + const script = `#!${process.execPath} +import { createInterface } from "node:readline"; +const lines = createInterface({ input: process.stdin }); +for await (const line of lines) { + const message = JSON.parse(line); + if (message.id === undefined) continue; + let result = {}; + let error; + if (message.method === "initialize") { + if (message.params.capabilities.experimentalApi !== true) error = { message: "experimental API required" }; + } else if (${reject}) { + error = { message: "PRIVATE_CATALOG_ERROR" }; + } else if (message.method === "plugin/read" && message.params.remoteMarketplaceName === "openai-curated-remote" && message.params.pluginName === "mobile-dev") { + result = { plugin: { summary: { remotePluginId: "${PLUGIN_STORE_ID}", name: "mobile-dev", version: "0.1.166", localVersion: "0.1.165", installed: true, enabled: true } } }; + } else error = { message: "unexpected request" }; + const response = JSON.stringify({ id: message.id, result, error }); + console.log("null"); + console.log(response); +} +`; + await writeFile(executable, script, { mode: 0o755 }); + return { directory, executable }; +} + +test("the catalog RPC preserves published and installed versions separately", async t => { + const fixture = await catalogServer(t); + const plugin = await readPluginStore(fixture.directory, { path: fixture.executable }); + assert.equal(plugin.version, "0.1.166"); + assert.equal(plugin.localVersion, "0.1.165"); + assert.equal(plugin.remotePluginId, PLUGIN_STORE_ID); +}); + +test("catalog RPC errors are fixed messages and requests stop when aborted", async t => { + const fixture = await catalogServer(t, true); + const reading = readPluginStore(fixture.directory, { path: fixture.executable }); + await assert.rejects(reading, /Codex could not read the plugin store catalog/); + const abort = new AbortController(); + const pending = readPluginStore(fixture.directory, { path: fixture.executable, signal: abort.signal }); + abort.abort(); + await assert.rejects(pending); +}); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index ec5651b6..c00e3c2b 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -265,14 +265,14 @@ test("plugin update metrics measure plugin work without device context and honor await Sentry.close(); }); recordPluginUpdate("check", "available", 80); - recordPluginUpdate("install", "updated", 2000); - recordPluginUpdate("install", "failed", 3000); + recordPluginUpdate("migrate", "migrated", 2000); + recordPluginUpdate("migrate", "failed", 3000); await Sentry.flush(); const sent = JSON.stringify(envelopes); - for (const fragment of ["plugin.update.operations", "plugin.update.duration", "millisecond", '"operation":{"value":"check"', '"outcome":{"value":"updated"', '"outcome":{"value":"failed"']) contains(sent, fragment); + for (const fragment of ["plugin.update.operations", "plugin.update.duration", "millisecond", '"operation":{"value":"check"', '"outcome":{"value":"migrated"', '"outcome":{"value":"failed"']) contains(sent, fragment); for (const fragment of ["device_platform", "device_kind", "latestVersion", "currentVersion", "user.id", "telemetry_session"]) contains(sent, fragment, false); process.env.MOBILE_DEV_TELEMETRY = "off"; - recordPluginUpdate("install", "updated", 999); + recordPluginUpdate("migrate", "migrated", 999); await Sentry.flush(); const afterOptOut = JSON.stringify(envelopes); assert.equal(afterOptOut, sent); From 1159989447603ca26dd0fdfbefc47e4d571f431a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 27 Oct 2026 14:53:56 +0100 Subject: [PATCH 2/3] chore(release): prepare store migration for 0.1.166 --- .codex-plugin/plugin.json | 2 +- README.md | 2 +- docs/releases.md | 6 ++-- docs/telemetry.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- scripts/smoke-plugin-updates.mjs | 16 +++++----- src/shared/plugin-updates.ts | 2 +- src/shared/version.ts | 2 +- tests/plugin-update-ui.test.ts | 4 +-- tests/plugin-updates.test.ts | 54 ++++++++++++++++---------------- 11 files changed, 48 insertions(+), 48 deletions(-) diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index eda5b01b..55aa5771 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.165", + "version": "0.1.166", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack", diff --git a/README.md b/README.md index f6f60b02..41c88112 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ is available, click **Open plugin store** to update there, then fully quit and reopen Codex. Existing GitHub installations offer **Install from plugin store** once version -0.1.165 or newer is published there. Install and enable the store version, return +0.1.166 or newer is published there. Install and enable the store version, return to Mobile Dev, and click **Finish migration**. This verifies the store package, removes the GitHub plugin and its marketplace, and preserves local recordings and saved data. Fully quit and reopen Codex afterward. The migration is offered only diff --git a/docs/releases.md b/docs/releases.md index 15a8df81..8ea76978 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -120,7 +120,7 @@ The update banner opens the store listing through the host's link API. Store updates are installed there. The old Git marketplace upgrade tool is removed. For GitHub installations, migration requires a published store version of at -least **0.1.165**, and at least the running plugin version. The user installs and +least **0.1.166**, and at least the running plugin version. The user installs and enables the store version, then clicks **Finish migration**. An app-only MCP tool checks the exact remote identity, enabled installation, installed `localVersion`, and package manifest before removing `mobile-dev@mobile-dev` and the verified @@ -138,11 +138,11 @@ forwarded by the MCP manifest. The plugin never restarts Codex automatically. The existing release workflow can build and publish the GitHub transition release before store review completes: -1. Release **0.1.165** through the normal GitHub workflow. Existing GitHub versions +1. Release **0.1.166** through the normal GitHub workflow. Existing GitHub versions can update to it through their existing updater. 2. Upload that release's ZIP to the **existing Mobile Dev store listing**, then complete review and publish it. -3. Once the Codex catalog exposes 0.1.165, the transition build offers migration. +3. Once the Codex catalog exposes 0.1.166, the transition build offers migration. Until then, it keeps the GitHub installation working and offers no migration to the older store package. Catalog checks can delay the banner by up to one hour after publication. diff --git a/docs/telemetry.md b/docs/telemetry.md index 3237c97b..36c4d509 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -102,7 +102,7 @@ coverage remains on this path with the same boundaries and units. Execution and invalid-response errors use fixed messages without implying a network failure; raw CLI errors and output are not sent to telemetry. -Since 0.1.165, update checks measure the Codex store catalog lookup instead of +Since 0.1.166, update checks measure the Codex store catalog lookup instead of GitHub HTTP requests. The existing operation count and duration names and units remain unchanged. The `install` operation is replaced by `migrate`, covering verification and GitHub cleanup; its successful outcome is `migrated`. Checks can diff --git a/package-lock.json b/package-lock.json index cf0ce41a..9fb30284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "version": "0.1.166", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "version": "0.1.166", "dependencies": { "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", diff --git a/package.json b/package.json index fd49fd4a..bbba57d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "version": "0.1.166", "private": true, "type": "module", "engines": { diff --git a/scripts/smoke-plugin-updates.mjs b/scripts/smoke-plugin-updates.mjs index ceb58291..e815f90d 100644 --- a/scripts/smoke-plugin-updates.mjs +++ b/scripts/smoke-plugin-updates.mjs @@ -52,7 +52,7 @@ try { await writeJson(catalogPath, { name: "mobile-dev", plugins: [{ name: "mobile-dev", source: { source: "local", path: "./plugins/mobile-dev" } }] }); const mcpPath = join(plugin, ".mcp.json"); await writeJson(mcpPath, { mcpServers: { "mobile-dev": { command: "/bin/echo", args: ["update fixture"], cwd: "." } } }); - await release("0.1.165"); + await release("0.1.166"); http = createServer(async (request, response) => { try { const url = new URL(request.url, "http://localhost"); @@ -68,15 +68,15 @@ try { await git(["config", "--global", `url.${mirrorUrl}.insteadOf`, repositoryUrl]); await codex(["plugin", "marketplace", "add", repositoryUrl, "--ref", "release/latest", "--json"]); const initial = await codex(["plugin", "add", "mobile-dev@mobile-dev", "--json"]); - assert.equal(initial.version, "0.1.165"); + assert.equal(initial.version, "0.1.166"); const initialMcp = await codex(["mcp", "get", "mobile-dev", "--json"]); const installedCwd = resolve(initialMcp.transport.cwd); - const storeDirectory = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165/.codex-plugin"); + const storeDirectory = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.166/.codex-plugin"); await mkdir(storeDirectory, { recursive: true }); - await writeJson(join(storeDirectory, "plugin.json"), { name: "mobile-dev", version: "0.1.165" }); + await writeJson(join(storeDirectory, "plugin.json"), { name: "mobile-dev", version: "0.1.166" }); const store = { pluginId: "mobile-dev@openai-curated-remote", name: "mobile-dev", marketplaceName: "openai-curated-remote", - version: "0.1.165", installed: true, enabled: true, source: { source: "remote", id: PLUGIN_STORE_ID }, + version: "0.1.166", installed: true, enabled: true, source: { source: "remote", id: PLUGIN_STORE_ID }, }; const run = async args => { const result = await codex(args); @@ -84,9 +84,9 @@ try { return result; }; const readStore = async () => ({ - remotePluginId: PLUGIN_STORE_ID, name: "mobile-dev", version: "0.1.165", localVersion: "0.1.165", installed: true, enabled: true, + remotePluginId: PLUGIN_STORE_ID, name: "mobile-dev", version: "0.1.166", localVersion: "0.1.166", installed: true, enabled: true, }); - const options = { enabled: true, profile, pluginRoot: installedCwd, currentVersion: "0.1.165", run, readStore }; + const options = { enabled: true, profile, pluginRoot: installedCwd, currentVersion: "0.1.166", run, readStore }; const updates = new PluginUpdates(options); process.chdir(installedCwd); const available = await updates.check(); @@ -107,7 +107,7 @@ try { assert.equal(notice.status, "migrated"); const retry = await updates.migrate(); assert.equal(retry.status, "migrated"); - const storeRoot = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165"); + const storeRoot = join(profile, "plugins/cache/openai-curated-remote/mobile-dev/0.1.166"); const restarted = new PluginUpdates({ ...options, pluginRoot: storeRoot }); const current = await restarted.check(); assert.equal(current.status, "current"); diff --git a/src/shared/plugin-updates.ts b/src/shared/plugin-updates.ts index f065845c..137a2df1 100644 --- a/src/shared/plugin-updates.ts +++ b/src/shared/plugin-updates.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const PLUGIN_STORE_ID = "plugins_6ac4c117f76881918d3676e851fa4005"; export const PLUGIN_STORE_URL = `https://chatgpt.com/plugins/${PLUGIN_STORE_ID}`; -export const STORE_MIGRATION_VERSION = "0.1.165"; +export const STORE_MIGRATION_VERSION = "0.1.166"; const versionTextSchema = z.string(); export const releaseVersionSchema = versionTextSchema.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); diff --git a/src/shared/version.ts b/src/shared/version.ts index 7e560944..45807977 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.165"; +export const PLUGIN_VERSION = "0.1.166"; diff --git a/tests/plugin-update-ui.test.ts b/tests/plugin-update-ui.test.ts index 356baf50..dd2abce4 100644 --- a/tests/plugin-update-ui.test.ts +++ b/tests/plugin-update-ui.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { build } from "esbuild"; import { JSDOM } from "jsdom"; -const update = { status: "available", currentVersion: "0.1.164", latestVersion: "0.1.165" }; +const update = { status: "available", currentVersion: "0.1.165", latestVersion: "0.1.166" }; async function banner(t: test.TestContext, app: unknown) { const dom = new JSDOM('
', { pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test" }); @@ -51,7 +51,7 @@ test("store updates open the exact listing without installing through GitHub", a const view = await banner(t, app); view.controller.setAvailable(true); await view.settle(); - assert.match(view.dom.window.document.body.textContent!, /0.1.165 is available in the plugin store/); + assert.match(view.dom.window.document.body.textContent!, /0.1.166 is available in the plugin store/); const open = view.button("Open plugin store"); open.click(); await view.settle(); diff --git a/tests/plugin-updates.test.ts b/tests/plugin-updates.test.ts index 370a85e7..6ec48ef7 100644 --- a/tests/plugin-updates.test.ts +++ b/tests/plugin-updates.test.ts @@ -49,7 +49,7 @@ function cliFixture(directory: string, calls: string[][], options: FixtureOption if (options.keepGithub !== true) github = false; return { pluginId: "mobile-dev@mobile-dev" }; } - const version = options.storeVersion ?? "0.1.165"; + const version = options.storeVersion ?? "0.1.166"; if (options.storeInstalled) await storeManifest(directory, options.installedVersion ?? version); const store = { pluginId: "mobile-dev@openai-curated-remote", name: "mobile-dev", marketplaceName: "openai-curated-remote", @@ -59,7 +59,7 @@ function cliFixture(directory: string, calls: string[][], options: FixtureOption const installed = []; const available = []; const legacy = { - pluginId: "mobile-dev@mobile-dev", name: "mobile-dev", marketplaceName: "mobile-dev", version: "0.1.165", installed: true, enabled: true, + pluginId: "mobile-dev@mobile-dev", name: "mobile-dev", marketplaceName: "mobile-dev", version: "0.1.166", installed: true, enabled: true, source: { source: "local", path: "fixture" }, marketplaceSource: { sourceType: "git", source }, }; legacy.installed = github; @@ -70,21 +70,21 @@ function cliFixture(directory: string, calls: string[][], options: FixtureOption return { installed, available }; }; const readStore: ReadStore = async () => ({ - remotePluginId: options.storeId ?? PLUGIN_STORE_ID, name: "mobile-dev", version: options.storeVersion ?? "0.1.165", - localVersion: options.storeInstalled ? options.installedVersion ?? options.storeVersion ?? "0.1.165" : null, + remotePluginId: options.storeId ?? PLUGIN_STORE_ID, name: "mobile-dev", version: options.storeVersion ?? "0.1.166", + localVersion: options.storeInstalled ? options.installedVersion ?? options.storeVersion ?? "0.1.166" : null, installed: options.storeInstalled ?? false, enabled: options.storeEnabled ?? true, }); return { run, readStore }; } function githubRoot(directory: string) { - return join(directory, "plugins/cache/mobile-dev/mobile-dev/0.1.165"); + return join(directory, "plugins/cache/mobile-dev/mobile-dev/0.1.166"); } test("update comparisons use numerical release versions", () => { const numeric = compareReleaseVersions("0.1.100", "0.1.99"); - const equal = compareReleaseVersions("0.1.165", "0.1.165"); - const older = compareReleaseVersions("0.1.165", "0.2.0"); + const equal = compareReleaseVersions("0.1.166", "0.1.166"); + const older = compareReleaseVersions("0.1.166", "0.2.0"); assert.equal(numeric, 1); assert.equal(equal, 0); assert.equal(older, -1); @@ -93,32 +93,32 @@ test("update comparisons use numerical release versions", () => { test("the store catalog is the sole update source and checks are shared across chats", async t => { const directory = await profile(t); const calls: string[][] = []; - const fixture = { github: false, storeVersion: "0.1.165", storeInstalled: true, installedVersion: "0.1.164" }; + const fixture = { github: false, storeVersion: "0.1.166", storeInstalled: true, installedVersion: "0.1.165" }; const { run, readStore } = cliFixture(directory, calls, fixture); let now = 1000000; - const options = { enabled: true, currentVersion: "0.1.164", profile: directory, run, readStore, now: () => now }; + const options = { enabled: true, currentVersion: "0.1.165", profile: directory, run, readStore, now: () => now }; const updates = new PluginUpdates(options); const pending = [updates.check(), updates.check()]; const initial = await Promise.all(pending); assert.equal(initial[0].status, "available"); - assert.equal(initial[0].latestVersion, "0.1.165"); + assert.equal(initial[0].latestVersion, "0.1.166"); assert.equal(initial[1].status, "available"); const another = new PluginUpdates(options); await another.check(); assert.deepEqual(calls, [["plugin", "list", "--available", "--json"]]); - fixture.storeVersion = "0.1.166"; + fixture.storeVersion = "0.1.167"; now += 60 * 60 * 1000; const next = await another.check(); - assert.equal(next.latestVersion, "0.1.166"); + assert.equal(next.latestVersion, "0.1.167"); assert.equal(calls.length, 2); }); test("pending review and a newer GitHub build never offer an older store migration", async t => { - for (const version of ["0.1.163", "0.1.165"]) { + for (const version of ["0.1.163", "0.1.166"]) { const directory = await profile(t); const calls: string[][] = []; const { run, readStore } = cliFixture(directory, calls, { storeVersion: version, storeInstalled: true }); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.167", run, readStore }); const status = await updates.check(); assert.equal(status.status, "current"); const migration = updates.migrate(); @@ -128,7 +128,7 @@ test("pending review and a newer GitHub build never offer an older store migrati const directory = await profile(t); const calls: string[][] = []; const { run, readStore } = cliFixture(directory, calls, { storeVersion: "0.1.163" }); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.164", run, readStore }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); const status = await updates.check(); assert.equal(status.status, "current"); }); @@ -141,11 +141,11 @@ test("legacy GitHub update caches cannot advertise unpublished store versions", const text = JSON.stringify({ checkedAt: Date.now(), latestVersion: "9.9.9", installedVersion: "9.9.9" }); await writeFile(path, text); const calls: string[][] = []; - const { run, readStore } = cliFixture(directory, calls, { github: false, storeVersion: "0.1.165" }); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const { run, readStore } = cliFixture(directory, calls, { github: false, storeVersion: "0.1.166" }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); const status = await updates.check(); assert.equal(status.status, "current"); - assert.equal(status.latestVersion, "0.1.165"); + assert.equal(status.latestVersion, "0.1.166"); assert.equal(calls.length, 1); }); @@ -183,7 +183,7 @@ test("migration is offered for the same version and verifies the store before an const directory = await profile(t); const calls: string[][] = []; const { run, readStore } = cliFixture(directory, calls, fixture); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); const notice = await updates.check(); assert.equal(notice.status, "migration"); assert.equal(notice.storeInstalled, false); @@ -198,7 +198,7 @@ test("migration removes only GitHub, preserves data, deduplicates clicks and per const calls: string[][] = []; const { run, readStore } = cliFixture(directory, calls, { storeInstalled: true }); const pluginRoot = githubRoot(directory); - const options = { enabled: true, profile: directory, pluginRoot, currentVersion: "0.1.165", run, readStore }; + const options = { enabled: true, profile: directory, pluginRoot, currentVersion: "0.1.166", run, readStore }; const dataPath = join(directory, "recording.json"); await writeFile(dataPath, "saved recording"); const updates = new PluginUpdates(options); @@ -220,7 +220,7 @@ test("migration removes only GitHub, preserves data, deduplicates clicks and per const another = new PluginUpdates(options); const restart = await another.check(); assert.equal(restart.status, "migrated"); - const storeRoot = join(directory, "plugins/cache/openai-curated-remote/mobile-dev/0.1.165"); + const storeRoot = join(directory, "plugins/cache/openai-curated-remote/mobile-dev/0.1.166"); const restarted = new PluginUpdates({ ...options, pluginRoot: storeRoot }); const current = await restarted.check(); assert.equal(current.status, "current"); @@ -237,7 +237,7 @@ test("wrong marketplaces, failed deletion and unremoved GitHub installations nev const directory = await profile(t); const calls: string[][] = []; const { run, readStore } = cliFixture(directory, calls, { ...fixture, storeInstalled: true }); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); const migration = updates.migrate(); await assert.rejects(migration); if (fixture.source !== undefined) assert.equal(calls.some(call => call[1] === "remove"), false); @@ -249,10 +249,10 @@ test("cleanup can resume when plugin removal succeeded but marketplace removal f const calls: string[][] = []; const fixture = { storeInstalled: true, marketplaceRemoveFails: true }; const { run, readStore } = cliFixture(directory, calls, fixture); - const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); const migration = updates.migrate(); await assert.rejects(migration); - const anotherChat = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.165", run, readStore }); + const anotherChat = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); const notice = await anotherChat.check(); assert.equal(notice.status, "migration"); fixture.marketplaceRemoveFails = false; @@ -305,7 +305,7 @@ for await (const line of lines) { } else if (${reject}) { error = { message: "PRIVATE_CATALOG_ERROR" }; } else if (message.method === "plugin/read" && message.params.remoteMarketplaceName === "openai-curated-remote" && message.params.pluginName === "mobile-dev") { - result = { plugin: { summary: { remotePluginId: "${PLUGIN_STORE_ID}", name: "mobile-dev", version: "0.1.166", localVersion: "0.1.165", installed: true, enabled: true } } }; + result = { plugin: { summary: { remotePluginId: "${PLUGIN_STORE_ID}", name: "mobile-dev", version: "0.1.167", localVersion: "0.1.166", installed: true, enabled: true } } }; } else error = { message: "unexpected request" }; const response = JSON.stringify({ id: message.id, result, error }); console.log("null"); @@ -319,8 +319,8 @@ for await (const line of lines) { test("the catalog RPC preserves published and installed versions separately", async t => { const fixture = await catalogServer(t); const plugin = await readPluginStore(fixture.directory, { path: fixture.executable }); - assert.equal(plugin.version, "0.1.166"); - assert.equal(plugin.localVersion, "0.1.165"); + assert.equal(plugin.version, "0.1.167"); + assert.equal(plugin.localVersion, "0.1.166"); assert.equal(plugin.remotePluginId, PLUGIN_STORE_ID); }); From 865fee7265207314d019e14375fe3bd778f28fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 27 Oct 2026 14:57:50 +0100 Subject: [PATCH 3/3] fix(updates): recognize the renamed official marketplace source --- docs/releases.md | 7 +++++++ src/server/plugin-updates.ts | 6 +++--- tests/plugin-updates.test.ts | 13 +++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 8ea76978..6eb6f484 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -157,3 +157,10 @@ prematurely announcing a store update or allowing a migration downgrade. in a temporary profile using a local Git mirror, with a fixture for the store catalog and package. It does not install or remove plugins in the user's account. It requires `codex` on PATH or `CODEX_CLI_PATH`. + +The repository was renamed from `codex-mobile-dev-plugin` to `mobile-dev` before +this transition release. Version 0.1.163's GitHub update check rejects the redirect +from the old release API URL. Existing GitHub users can receive the transition +build by running `codex plugin marketplace upgrade mobile-dev`, then fully quitting +and reopening Codex. Git follows the repository redirect. Migration recognizes +both official repository URLs when verifying the legacy marketplace. diff --git a/src/server/plugin-updates.ts b/src/server/plugin-updates.ts index 40c4b75d..1018333d 100644 --- a/src/server/plugin-updates.ts +++ b/src/server/plugin-updates.ts @@ -17,7 +17,7 @@ import { captureServerError, recordPluginUpdate } from "./telemetry.ts"; import { resolveTelemetryEnvironment } from "./telemetry-environment.ts"; const execute = promisify(execFile); -const repository = "callstackincubator/codex-mobile-dev-plugin"; +const repositories = ["callstackincubator/codex-mobile-dev-plugin", "callstackincubator/mobile-dev"]; const storeMarketplace = "openai-curated-remote"; const githubPlugin = "mobile-dev@mobile-dev"; const checkIntervalMs = 60 * 60 * 1000; @@ -162,8 +162,8 @@ export async function readPluginStore(cwd: string, options: { path?: string; env } function isReleaseRepository(source: string): boolean { - return source === `https://github.com/${repository}` || source === `https://github.com/${repository}.git` - || source === `git@github.com:${repository}.git` || source === `ssh://git@github.com/${repository}.git`; + return repositories.some(repository => source === `https://github.com/${repository}` || source === `https://github.com/${repository}.git` + || source === `git@github.com:${repository}.git` || source === `ssh://git@github.com/${repository}.git`); } function githubInstalled(catalog: Catalog): boolean { diff --git a/tests/plugin-updates.test.ts b/tests/plugin-updates.test.ts index 6ec48ef7..a9c70b78 100644 --- a/tests/plugin-updates.test.ts +++ b/tests/plugin-updates.test.ts @@ -333,3 +333,16 @@ test("catalog RPC errors are fixed messages and requests stop when aborted", asy abort.abort(); await assert.rejects(pending); }); + +test("migration recognizes the renamed official GitHub repository", async t => { + const directory = await profile(t); + const calls: string[][] = []; + const { run, readStore } = cliFixture(directory, calls, { storeInstalled: true, source: "https://github.com/callstackincubator/mobile-dev.git" }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.166", run, readStore }); + const notice = await updates.check(); + assert.equal(notice.status, "migration"); + const migrated = await updates.migrate(); + assert.equal(migrated.status, "migrated"); + const removed = calls.some(call => call[1] === "remove" && call[2] === "mobile-dev@mobile-dev"); + assert.equal(removed, true); +});