From 0c1ec916ce393d628bfe31078d2601f4d63b4be2 Mon Sep 17 00:00:00 2001 From: Will Wang Date: Tue, 23 Jun 2026 11:55:39 -0700 Subject: [PATCH] add app-server update API --- codex-rs/Cargo.lock | 19 ++ codex-rs/Cargo.toml | 2 + .../schema/json/ClientRequest.json | 46 ++++ .../codex_app_server_protocol.schemas.json | 87 +++++++ .../codex_app_server_protocol.v2.schemas.json | 87 +++++++ .../schema/json/v2/UpdateApplyResponse.json | 5 + .../schema/json/v2/UpdateCheckResponse.json | 38 +++ .../schema/typescript/ClientRequest.ts | 2 +- .../typescript/v2/InstallationMethod.ts | 5 + .../typescript/v2/UpdateApplyResponse.ts | 5 + .../typescript/v2/UpdateCheckResponse.ts | 6 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 10 + .../src/protocol/v2/mod.rs | 2 + .../src/protocol/v2/tests.rs | 19 ++ .../src/protocol/v2/update.rs | 31 +++ codex-rs/app-server/Cargo.toml | 2 + codex-rs/app-server/README.md | 37 +++ codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 2 + .../request_processors/update_processor.rs | 101 ++++++++ .../tests/common/test_app_server.rs | 10 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/app-server/tests/suite/v2/update.rs | 64 +++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 4 - codex-rs/tui/src/npm_registry.rs | 130 ---------- codex-rs/tui/src/update_action.rs | 165 +------------ codex-rs/tui/src/update_versions.rs | 70 ------ codex-rs/tui/src/updates.rs | 71 +----- codex-rs/update/BUILD.bazel | 6 + codex-rs/update/Cargo.toml | 27 +++ codex-rs/update/src/lib.rs | 223 ++++++++++++++++++ codex-rs/update/src/tests.rs | 161 +++++++++++++ 34 files changed, 1013 insertions(+), 434 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/UpdateApplyResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/UpdateCheckResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/InstallationMethod.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/UpdateApplyResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/UpdateCheckResponse.ts create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/update.rs create mode 100644 codex-rs/app-server/src/request_processors/update_processor.rs create mode 100644 codex-rs/app-server/tests/suite/v2/update.rs delete mode 100644 codex-rs/tui/src/npm_registry.rs delete mode 100644 codex-rs/tui/src/update_versions.rs create mode 100644 codex-rs/update/BUILD.bazel create mode 100644 codex-rs/update/Cargo.toml create mode 100644 codex-rs/update/src/lib.rs create mode 100644 codex-rs/update/src/tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 39f16431a8..62d6f19eb8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1991,6 +1991,7 @@ dependencies = [ "codex-home", "codex-hooks", "codex-image-generation-extension", + "codex-install-context", "codex-login", "codex-mcp", "codex-mcp-extension", @@ -2010,6 +2011,7 @@ dependencies = [ "codex-state", "codex-thread-store", "codex-tools", + "codex-update", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -4060,6 +4062,7 @@ dependencies = [ "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-update", "codex-utils-absolute-path", "codex-utils-approval-presets", "codex-utils-cargo-bin", @@ -4141,6 +4144,22 @@ dependencies = [ "uds_windows", ] +[[package]] +name = "codex-update" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-install-context", + "codex-login", + "codex-utils-absolute-path", + "pretty_assertions", + "serde", + "serde_json", + "shlex", + "tokio", + "wiremock", +] + [[package]] name = "codex-utils-absolute-path" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 17c2d1561b..63eb0b26a2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -84,6 +84,7 @@ members = [ "stdio-to-uds", "otel", "tui", + "update", "tools", "v8-poc", "utils/absolute-path", @@ -227,6 +228,7 @@ codex-test-binary-support = { path = "test-binary-support" } codex-thread-store = { path = "thread-store" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } +codex-update = { path = "update" } codex-uds = { path = "uds" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-approval-presets = { path = "utils/approval-presets" } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 490161486a..12cd47a368 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -6067,6 +6067,52 @@ "title": "ModelProvider/capabilities/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "update/check" + ], + "title": "Update/checkRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/checkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "update/apply" + ], + "title": "Update/applyRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/applyRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8e1000aaa1..946ef07fad 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1525,6 +1525,52 @@ "title": "ModelProvider/capabilities/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "update/check" + ], + "title": "Update/checkRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/checkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "update/apply" + ], + "title": "Update/applyRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/applyRequest", + "type": "object" + }, { "properties": { "id": { @@ -11147,6 +11193,17 @@ } ] }, + "InstallationMethod": { + "enum": [ + "npm", + "bun", + "brew", + "standaloneUnix", + "standaloneWindows", + "other" + ], + "type": "string" + }, "InternalChatMessageMetadataPassthrough": { "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", "properties": { @@ -20167,6 +20224,36 @@ ], "type": "object" }, + "UpdateApplyResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UpdateApplyResponse", + "type": "object" + }, + "UpdateCheckResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "canAutoUpdate": { + "type": "boolean" + }, + "currentVersion": { + "type": "string" + }, + "installationMethod": { + "$ref": "#/definitions/v2/InstallationMethod" + }, + "latestVersion": { + "type": "string" + } + }, + "required": [ + "canAutoUpdate", + "currentVersion", + "installationMethod", + "latestVersion" + ], + "title": "UpdateCheckResponse", + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c98f7d44da..478a93e10d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2536,6 +2536,52 @@ "title": "ModelProvider/capabilities/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "update/check" + ], + "title": "Update/checkRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/checkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "update/apply" + ], + "title": "Update/applyRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Update/applyRequest", + "type": "object" + }, { "properties": { "id": { @@ -7551,6 +7597,17 @@ } ] }, + "InstallationMethod": { + "enum": [ + "npm", + "bun", + "brew", + "standaloneUnix", + "standaloneWindows", + "other" + ], + "type": "string" + }, "InternalChatMessageMetadataPassthrough": { "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", "properties": { @@ -17946,6 +18003,36 @@ ], "type": "object" }, + "UpdateApplyResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UpdateApplyResponse", + "type": "object" + }, + "UpdateCheckResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "canAutoUpdate": { + "type": "boolean" + }, + "currentVersion": { + "type": "string" + }, + "installationMethod": { + "$ref": "#/definitions/InstallationMethod" + }, + "latestVersion": { + "type": "string" + } + }, + "required": [ + "canAutoUpdate", + "currentVersion", + "installationMethod", + "latestVersion" + ], + "title": "UpdateCheckResponse", + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/UpdateApplyResponse.json b/codex-rs/app-server-protocol/schema/json/v2/UpdateApplyResponse.json new file mode 100644 index 0000000000..fec281ead8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/UpdateApplyResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UpdateApplyResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/UpdateCheckResponse.json b/codex-rs/app-server-protocol/schema/json/v2/UpdateCheckResponse.json new file mode 100644 index 0000000000..ffc867c89d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/UpdateCheckResponse.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "InstallationMethod": { + "enum": [ + "npm", + "bun", + "brew", + "standaloneUnix", + "standaloneWindows", + "other" + ], + "type": "string" + } + }, + "properties": { + "canAutoUpdate": { + "type": "boolean" + }, + "currentVersion": { + "type": "string" + }, + "installationMethod": { + "$ref": "#/definitions/InstallationMethod" + }, + "latestVersion": { + "type": "string" + } + }, + "required": [ + "canAutoUpdate", + "currentVersion", + "installationMethod", + "latestVersion" + ], + "title": "UpdateCheckResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 6af04ef401..e2390bc2c9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "update/check", id: RequestId, params: undefined, } | { "method": "update/apply", id: RequestId, params: undefined, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/InstallationMethod.ts b/codex-rs/app-server-protocol/schema/typescript/v2/InstallationMethod.ts new file mode 100644 index 0000000000..59291e69ca --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/InstallationMethod.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InstallationMethod = "npm" | "bun" | "brew" | "standaloneUnix" | "standaloneWindows" | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/UpdateApplyResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/UpdateApplyResponse.ts new file mode 100644 index 0000000000..56c1a58fa1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/UpdateApplyResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateApplyResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/UpdateCheckResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/UpdateCheckResponse.ts new file mode 100644 index 0000000000..da3881b220 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/UpdateCheckResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InstallationMethod } from "./InstallationMethod"; + +export type UpdateCheckResponse = { currentVersion: string, latestVersion: string, installationMethod: InstallationMethod, canAutoUpdate: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 57c08391c7..87ebe24118 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -186,6 +186,7 @@ export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; +export type { InstallationMethod } from "./InstallationMethod"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; @@ -486,6 +487,8 @@ export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; export type { TurnsPage } from "./TurnsPage"; +export type { UpdateApplyResponse } from "./UpdateApplyResponse"; +export type { UpdateCheckResponse } from "./UpdateCheckResponse"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7fe20a84d5..2680bd962e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -865,6 +865,16 @@ client_request_definitions! { serialization: None, response: v2::ModelProviderCapabilitiesReadResponse, }, + UpdateCheck => "update/check" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("update"), + response: v2::UpdateCheckResponse, + }, + UpdateApply => "update/apply" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("update"), + response: v2::UpdateApplyResponse, + }, ExperimentalFeatureList => "experimentalFeature/list" { params: v2::ExperimentalFeatureListParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 1097b03811..55577d3440 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -25,6 +25,7 @@ mod review; mod thread; mod thread_data; mod turn; +mod update; mod windows_sandbox; pub use account::*; @@ -53,6 +54,7 @@ pub use shared::*; pub use thread::*; pub use thread_data::*; pub use turn::*; +pub use update::*; pub use windows_sandbox::*; #[cfg(test)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 96ba995770..6a3062113b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2911,6 +2911,25 @@ fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { ); } +#[test] +fn update_check_response_serializes_installation_capabilities() { + assert_eq!( + serde_json::to_value(UpdateCheckResponse { + current_version: "1.2.3".to_string(), + latest_version: "1.3.0".to_string(), + installation_method: InstallationMethod::StandaloneUnix, + can_auto_update: true, + }) + .unwrap(), + json!({ + "currentVersion": "1.2.3", + "latestVersion": "1.3.0", + "installationMethod": "standaloneUnix", + "canAutoUpdate": true, + }), + ); +} + #[test] fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { assert_eq!( diff --git a/codex-rs/app-server-protocol/src/protocol/v2/update.rs b/codex-rs/app-server-protocol/src/protocol/v2/update.rs new file mode 100644 index 0000000000..0c20053fad --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/update.rs @@ -0,0 +1,31 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum InstallationMethod { + Npm, + Bun, + Brew, + StandaloneUnix, + StandaloneWindows, + Other, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UpdateCheckResponse { + pub current_version: String, + pub latest_version: String, + pub installation_method: InstallationMethod, + pub can_auto_update: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UpdateApplyResponse {} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 3d08059a63..2261cd6c49 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -57,6 +57,7 @@ codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } codex-image-generation-extension = { workspace = true } +codex-install-context = { workspace = true } codex-memories-extension = { workspace = true } codex-web-search-extension = { workspace = true } codex-memories-write = { workspace = true } @@ -74,6 +75,7 @@ codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-thread-store = { workspace = true } codex-tools = { workspace = true } +codex-update = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } codex-utils-path-uri = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 50f0052094..fbbbc4950d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -201,6 +201,8 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. +- `update/check` — force a fresh update check and return the current version, latest version available through the detected distribution channel, installation method, and whether app-server can apply the update automatically. +- `update/apply` — run the updater associated with the detected installation method; returns `{}` after the updater exits successfully. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. @@ -1899,6 +1901,41 @@ $demo-app Pull the latest updates from the team. } ``` +## Update endpoints + +Use `update/check` to bypass the TUI's cached startup check and +query the current installation's distribution channel immediately. The +request has no params: + +```json +{ "method": "update/check", "id": 50 } +{ "id": 50, "result": { + "currentVersion": "1.2.3", + "latestVersion": "1.3.0", + "installationMethod": "npm", + "canAutoUpdate": true +} } +``` + +`installationMethod` is one of `npm`, `bun`, `brew`, `standaloneUnix`, +`standaloneWindows`, or `other`. npm and Bun checks require the latest GitHub +release to be ready under npm's `latest` tag. Homebrew checks the cask version; +standalone and other installations check the latest GitHub release. + +When `canAutoUpdate` is `true`, call `update/apply` to run the +corresponding npm, Bun, Homebrew, or standalone installer. The updater's output +is not written to the JSON-RPC transport. A successful response means the +installer exited with a zero status; restart app-server to run the newly +installed version. + +```json +{ "method": "update/apply", "id": 51 } +{ "id": 51, "result": {} } +``` + +For `installationMethod: "other"`, `update/apply` returns an invalid-request +error because app-server cannot determine a safe updater command. + ## Auth endpoints The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 33cf142fe8..269493b32a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -39,6 +39,7 @@ use crate::request_processors::SearchRequestProcessor; use crate::request_processors::ThreadGoalRequestProcessor; use crate::request_processors::ThreadRequestProcessor; use crate::request_processors::TurnRequestProcessor; +use crate::request_processors::UpdateRequestProcessor; use crate::request_processors::WindowsSandboxRequestProcessor; use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; @@ -207,6 +208,7 @@ pub(crate) struct MessageProcessor { thread_goal_processor: ThreadGoalRequestProcessor, thread_processor: ThreadRequestProcessor, turn_processor: TurnRequestProcessor, + update_processor: UpdateRequestProcessor, windows_sandbox_processor: WindowsSandboxRequestProcessor, request_serialization_queues: RequestSerializationQueues, } @@ -576,6 +578,7 @@ impl MessageProcessor { thread_goal_processor, thread_processor, turn_processor, + update_processor: UpdateRequestProcessor, windows_sandbox_processor, request_serialization_queues: RequestSerializationQueues::default(), } @@ -1087,6 +1090,8 @@ impl MessageProcessor { .model_provider_capabilities_read() .await .map(|response| Some(response.into())), + ClientRequest::UpdateCheck { params: _, .. } => self.update_processor.check().await, + ClientRequest::UpdateApply { params: _, .. } => self.update_processor.apply().await, ClientRequest::ThreadStart { params, .. } => { self.thread_processor .thread_start( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 35df59d5cc..aba961d149 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -500,6 +500,7 @@ mod search; mod thread_processor; mod token_usage_replay; mod turn_processor; +mod update_processor; mod windows_sandbox_processor; pub(crate) use account_processor::AccountRequestProcessor; @@ -523,6 +524,7 @@ pub(crate) use search::SearchRequestProcessor; pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; pub(crate) use thread_processor::ThreadRequestProcessor; pub(crate) use turn_processor::TurnRequestProcessor; +pub(crate) use update_processor::UpdateRequestProcessor; pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor; use crate::error_code::internal_error; diff --git a/codex-rs/app-server/src/request_processors/update_processor.rs b/codex-rs/app-server/src/request_processors/update_processor.rs new file mode 100644 index 0000000000..1db40a0259 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/update_processor.rs @@ -0,0 +1,101 @@ +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::InstallationMethod; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::UpdateApplyResponse; +use codex_app_server_protocol::UpdateCheckResponse; +use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; +use codex_install_context::StandalonePlatform; +use codex_update::UpdateAction; +use std::process::Stdio; +use tokio::process::Command; + +#[derive(Clone, Copy, Default)] +pub(crate) struct UpdateRequestProcessor; + +impl UpdateRequestProcessor { + pub(crate) async fn check(&self) -> Result, JSONRPCErrorError> { + let context = InstallContext::current(); + let installation_method = installation_method(&context.method); + let can_auto_update = UpdateAction::from_install_context(context).is_some(); + let latest_version = codex_update::latest_version() + .await + .map_err(|err| internal_error(format!("failed to check for Codex updates: {err}")))?; + + Ok(Some( + UpdateCheckResponse { + current_version: env!("CARGO_PKG_VERSION").to_string(), + latest_version, + installation_method, + can_auto_update, + } + .into(), + )) + } + + pub(crate) async fn apply(&self) -> Result, JSONRPCErrorError> { + let Some(action) = codex_update::get_update_action() else { + return Err(invalid_request( + "automatic updates are unavailable for this Codex installation", + )); + }; + + #[cfg(windows)] + let mut command = if action == UpdateAction::StandaloneWindows { + let (program, args) = action.command_args(); + let mut command = Command::new(program); + command.args(args); + command + } else { + let mut command = Command::new("cmd"); + command.args(["/C", &action.command_str()]); + command + }; + #[cfg(not(windows))] + let mut command = { + let (program, args) = action.command_args(); + let mut command = Command::new(program); + command.args(args); + command + }; + + let status = command + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map_err(|err| { + internal_error(format!( + "failed to run Codex update via `{}`: {err}", + action.command_str() + )) + })?; + if !status.success() { + return Err(internal_error(format!( + "Codex update via `{}` failed with status {status}", + action.command_str() + ))); + } + + Ok(Some(UpdateApplyResponse {}.into())) + } +} + +fn installation_method(method: &InstallMethod) -> InstallationMethod { + match method { + InstallMethod::Npm => InstallationMethod::Npm, + InstallMethod::Bun => InstallationMethod::Bun, + InstallMethod::Brew => InstallationMethod::Brew, + InstallMethod::Standalone { + platform: StandalonePlatform::Unix, + .. + } => InstallationMethod::StandaloneUnix, + InstallMethod::Standalone { + platform: StandalonePlatform::Windows, + .. + } => InstallationMethod::StandaloneWindows, + InstallMethod::Other => InstallationMethod::Other, + } +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index e940d9a42b..227b4135e2 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -630,6 +630,16 @@ impl TestAppServer { .await } + /// Send an `update/check` JSON-RPC request. + pub async fn send_update_check_request(&mut self) -> anyhow::Result { + self.send_request("update/check", /*params*/ None).await + } + + /// Send an `update/apply` JSON-RPC request. + pub async fn send_update_apply_request(&mut self) -> anyhow::Result { + self.send_request("update/apply", /*params*/ None).await + } + /// Send an `experimentalFeature/list` JSON-RPC request. pub async fn send_experimental_feature_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index c777142e82..1b87c6b400 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -79,5 +79,6 @@ mod turn_interrupt; mod turn_start; mod turn_start_zsh_fork; mod turn_steer; +mod update; mod web_search; mod windows_sandbox_setup; diff --git a/codex-rs/app-server/tests/suite/v2/update.rs b/codex-rs/app-server/tests/suite/v2/update.rs new file mode 100644 index 0000000000..4052daefc7 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/update.rs @@ -0,0 +1,64 @@ +#![cfg(unix)] + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::UpdateApplyResponse; +use pretty_assertions::assert_eq; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn update_apply_runs_the_detected_npm_updater() -> Result<()> { + let codex_home = TempDir::new()?; + let bin_dir = codex_home.path().join("bin"); + fs::create_dir_all(&bin_dir)?; + let npm_path = bin_dir.join("npm"); + let invocation_path = codex_home.path().join("npm-invocation.txt"); + fs::write( + &npm_path, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CODEX_TEST_UPDATE_INVOCATION\"\n", + )?; + let mut permissions = fs::metadata(&npm_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&npm_path, permissions)?; + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let invocation_path = invocation_path.to_string_lossy().into_owned(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("CODEX_MANAGED_BY_NPM", Some("1")), + ("CODEX_TEST_UPDATE_INVOCATION", Some(&invocation_path)), + ("PATH", Some(&path)), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_update_apply_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response = to_response::(response)?; + + assert_eq!(response, UpdateApplyResponse {}); + assert_eq!( + fs::read_to_string(invocation_path)?, + "install\n-g\n@openai/codex\n" + ); + Ok(()) +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6c0b83a72e..8066a1bf5b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -54,6 +54,7 @@ codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-update = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 290430604d..978645655c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -146,8 +146,6 @@ mod model_migration; mod motion; mod multi_agents; mod notifications; -#[cfg(any(not(debug_assertions), test))] -mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; @@ -189,8 +187,6 @@ pub use update_action::UpdateAction; #[cfg(not(debug_assertions))] pub use update_action::get_update_action; mod update_prompt; -#[cfg(any(not(debug_assertions), test))] -mod update_versions; mod updates; #[cfg(any(not(debug_assertions), test))] mod updates_cache; diff --git a/codex-rs/tui/src/npm_registry.rs b/codex-rs/tui/src/npm_registry.rs deleted file mode 100644 index 61ff2425e6..0000000000 --- a/codex-rs/tui/src/npm_registry.rs +++ /dev/null @@ -1,130 +0,0 @@ -use serde::Deserialize; -use std::collections::HashMap; - -#[cfg(not(debug_assertions))] -pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex"; - -#[derive(Deserialize, Debug, Clone)] -pub(crate) struct NpmPackageInfo { - #[serde(rename = "dist-tags")] - dist_tags: HashMap, - versions: HashMap, -} - -#[derive(Deserialize, Debug, Clone)] -struct NpmPackageVersionInfo { - dist: Option, -} - -#[derive(Deserialize, Debug, Clone)] -struct NpmPackageDist { - tarball: Option, - integrity: Option, -} - -pub(crate) fn ensure_version_ready( - package_info: &NpmPackageInfo, - version: &str, -) -> anyhow::Result<()> { - let version = version.trim(); - - match package_info.dist_tags.get("latest").map(String::as_str) { - Some(latest) if latest == version => {} - Some(latest) => anyhow::bail!( - "npm latest dist-tag points to {latest}, expected GitHub release {version}" - ), - None => anyhow::bail!("npm package is missing latest dist-tag"), - } - - version_info_with_dist(package_info, version)?; - Ok(()) -} - -fn version_info_with_dist<'a>( - package_info: &'a NpmPackageInfo, - version: &str, -) -> anyhow::Result<&'a NpmPackageVersionInfo> { - let info = package_info - .versions - .get(version) - .ok_or_else(|| anyhow::anyhow!("npm package version {version} is missing"))?; - let Some(dist) = info.dist.as_ref() else { - anyhow::bail!("npm package version {version} is missing dist metadata"); - }; - let has_tarball = dist - .tarball - .as_deref() - .is_some_and(|tarball| !tarball.is_empty()); - if !has_tarball { - anyhow::bail!("npm package version {version} is missing dist.tarball"); - } - let has_integrity = dist - .integrity - .as_ref() - .is_some_and(|integrity| !integrity.is_empty()); - if !has_integrity { - anyhow::bail!("npm package version {version} is missing dist.integrity"); - } - Ok(info) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn version_json(version: &str) -> serde_json::Value { - serde_json::json!({ - "dist": { - "integrity": format!("sha512-{version}"), - "tarball": format!("https://registry.npmjs.org/@openai/codex/-/codex-{version}.tgz"), - } - }) - } - - fn package_info(github_latest: &str, npm_latest: &str) -> NpmPackageInfo { - let mut versions = serde_json::Map::new(); - versions.insert(github_latest.to_string(), version_json(github_latest)); - - serde_json::from_value(serde_json::json!({ - "dist-tags": { "latest": npm_latest }, - "versions": serde_json::Value::Object(versions), - })) - .expect("valid npm package metadata") - } - - #[test] - fn ready_version_requires_latest_dist_tag_and_root_dist() { - let latest = "1.2.3"; - let package_info = package_info(latest, latest); - - ensure_version_ready(&package_info, latest).expect("npm package is ready"); - } - - #[test] - fn ready_version_rejects_stale_latest_dist_tag() { - let package_info = package_info("1.2.3", "1.2.2"); - - let err = ensure_version_ready(&package_info, "1.2.3") - .expect_err("npm latest dist-tag must match GitHub latest"); - assert!( - err.to_string().contains("latest dist-tag"), - "error should name stale latest dist-tag: {err}" - ); - } - - #[test] - fn ready_version_rejects_missing_root_dist() { - let package_info: NpmPackageInfo = serde_json::from_value(serde_json::json!({ - "dist-tags": { "latest": "1.2.3" }, - "versions": { "1.2.3": {} }, - })) - .expect("valid npm package metadata"); - - let err = ensure_version_ready(&package_info, "1.2.3") - .expect_err("root package must have dist metadata"); - assert!( - err.to_string().contains("missing dist metadata"), - "error should name missing dist metadata: {err}" - ); - } -} diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 420562f13b..1dfad2f84c 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,165 +1,4 @@ -#[cfg(any(not(debug_assertions), test))] -use codex_install_context::InstallContext; -#[cfg(any(not(debug_assertions), test))] -use codex_install_context::InstallMethod; -#[cfg(any(not(debug_assertions), test))] -use codex_install_context::StandalonePlatform; - -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via `brew upgrade codex`. - BrewUpgrade, - /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. - StandaloneUnix, - /// Update via `$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex`. - StandaloneWindows, -} - -impl UpdateAction { - #[cfg(any(not(debug_assertions), test))] - pub(crate) fn from_install_context(context: &InstallContext) -> Option { - match &context.method { - InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), - InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), - InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), - InstallMethod::Standalone { platform, .. } => Some(match platform { - StandalonePlatform::Unix => UpdateAction::StandaloneUnix, - StandalonePlatform::Windows => UpdateAction::StandaloneWindows, - }), - InstallMethod::Other => None, - } - } - - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (&'static str, &'static [&'static str]) { - match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), - UpdateAction::StandaloneUnix => ( - "sh", - &[ - "-c", - "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh", - ], - ), - UpdateAction::StandaloneWindows => ( - "powershell", - &[ - "-ExecutionPolicy", - "Bypass", - "-c", - "$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex", - ], - ), - } - } - - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(self) -> String { - let (command, args) = self.command_args(); - shlex::try_join(std::iter::once(command).chain(args.iter().copied())) - .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) - } -} +pub use codex_update::UpdateAction; #[cfg(not(debug_assertions))] -pub fn get_update_action() -> Option { - UpdateAction::from_install_context(InstallContext::current()) -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - - #[test] - fn maps_install_context_to_update_action() { - let native_release_dir = - AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join("native-release")) - .expect("temp dir path should be absolute"); - - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Other, - package_layout: None, - }), - None - ); - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Npm, - package_layout: None, - }), - Some(UpdateAction::NpmGlobalLatest) - ); - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Bun, - package_layout: None, - }), - Some(UpdateAction::BunGlobalLatest) - ); - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Brew, - package_layout: None, - }), - Some(UpdateAction::BrewUpgrade) - ); - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Standalone { - platform: StandalonePlatform::Unix, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), - }, - package_layout: None, - }), - Some(UpdateAction::StandaloneUnix) - ); - assert_eq!( - UpdateAction::from_install_context(&InstallContext { - method: InstallMethod::Standalone { - platform: StandalonePlatform::Windows, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), - }, - package_layout: None, - }), - Some(UpdateAction::StandaloneWindows) - ); - } - - #[test] - fn standalone_update_commands_rerun_latest_installer() { - assert_eq!( - UpdateAction::StandaloneUnix.command_args(), - ( - "sh", - &[ - "-c", - "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh" - ][..], - ) - ); - assert_eq!( - UpdateAction::StandaloneWindows.command_args(), - ( - "powershell", - &[ - "-ExecutionPolicy", - "Bypass", - "-c", - "$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex" - ][..], - ) - ); - } -} +pub use codex_update::get_update_action; diff --git a/codex-rs/tui/src/update_versions.rs b/codex-rs/tui/src/update_versions.rs deleted file mode 100644 index 29ff04369b..0000000000 --- a/codex-rs/tui/src/update_versions.rs +++ /dev/null @@ -1,70 +0,0 @@ -pub(crate) fn is_newer(latest: &str, current: &str) -> Option { - match (parse_version(latest), parse_version(current)) { - (Some(l), Some(c)) => Some(l > c), - _ => None, - } -} - -pub(crate) fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { - latest_tag_name - .strip_prefix("rust-v") - .map(str::to_owned) - .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")) -} - -pub(crate) fn is_source_build_version(version: &str) -> bool { - parse_version(version) == Some((0, 0, 0)) -} - -fn parse_version(v: &str) -> Option<(u64, u64, u64)> { - let mut iter = v.trim().split('.'); - let maj = iter.next()?.parse::().ok()?; - let min = iter.next()?.parse::().ok()?; - let pat = iter.next()?.parse::().ok()?; - Some((maj, min, pat)) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn extracts_version_from_latest_tag() { - assert_eq!( - extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"), - "1.5.0" - ); - } - - #[test] - fn latest_tag_without_prefix_is_invalid() { - assert!(extract_version_from_latest_tag("v1.5.0").is_err()); - } - - #[test] - fn prerelease_version_is_not_considered_newer() { - assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); - assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); - } - - #[test] - fn plain_semver_comparisons_work() { - assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); - assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); - assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); - assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false)); - } - - #[test] - fn source_build_version_is_not_checked() { - assert!(is_source_build_version("0.0.0")); - assert!(!is_source_build_version("0.1.0")); - } - - #[test] - fn whitespace_is_ignored() { - assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); - assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); - } -} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 65a2317560..a29ff04af0 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,20 +1,13 @@ #![cfg(not(debug_assertions))] use crate::legacy_core::config::Config; -use crate::npm_registry; -use crate::npm_registry::NpmPackageInfo; -use crate::update_action; -use crate::update_action::UpdateAction; -use crate::update_versions::extract_version_from_latest_tag; -use crate::update_versions::is_newer; -use crate::update_versions::is_source_build_version; use crate::updates_cache::VersionInfo; use crate::updates_cache::read_version_info; use crate::updates_cache::version_filepath; use chrono::Duration; use chrono::Utc; -use codex_login::default_client::create_client; -use serde::Deserialize; +use codex_update::is_newer; +use codex_update::is_source_build_version; use std::path::Path; use crate::version::CODEX_CLI_VERSION; @@ -26,7 +19,6 @@ pub fn get_upgrade_version(config: &Config) -> Option { return None; } - let action = update_action::get_update_action(); let version_file = version_filepath(config); let info = read_version_info(&version_file).ok(); @@ -38,7 +30,7 @@ pub fn get_upgrade_version(config: &Config) -> Option { // isn’t blocked by a network call. The UI reads the previously cached // value (if any) for this run; the next run shows the banner if needed. tokio::spawn(async move { - check_for_update(&version_file, action) + check_for_update(&version_file) .await .inspect_err(|e| tracing::error!("Failed to update version: {e}")) }); @@ -53,48 +45,8 @@ pub fn get_upgrade_version(config: &Config) -> Option { }) } -// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind. -const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; -const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; - -#[derive(Deserialize, Debug, Clone)] -struct ReleaseInfo { - tag_name: String, -} - -#[derive(Deserialize, Debug, Clone)] -struct HomebrewCaskInfo { - version: String, -} - -async fn check_for_update(version_file: &Path, action: Option) -> anyhow::Result<()> { - let latest_version = match action { - Some(UpdateAction::BrewUpgrade) => { - let HomebrewCaskInfo { version } = create_client() - .get(HOMEBREW_CASK_API_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; - version - } - Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { - let latest_version = fetch_latest_github_release_version().await?; - let package_info = create_client() - .get(npm_registry::PACKAGE_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; - npm_registry::ensure_version_ready(&package_info, &latest_version)?; - latest_version - } - Some(UpdateAction::StandaloneUnix) | Some(UpdateAction::StandaloneWindows) | None => { - fetch_latest_github_release_version().await? - } - }; +async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { + let latest_version = codex_update::latest_version().await?; // Preserve any previously dismissed version if present. let prev_info = read_version_info(version_file).ok(); @@ -112,19 +64,6 @@ async fn check_for_update(version_file: &Path, action: Option) -> Ok(()) } -async fn fetch_latest_github_release_version() -> anyhow::Result { - let ReleaseInfo { - tag_name: latest_tag_name, - } = create_client() - .get(LATEST_RELEASE_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; - extract_version_from_latest_tag(&latest_tag_name) -} - /// Returns the latest version to show in a popup, if it should be shown. /// This respects the user's dismissal choice for the current latest version. pub fn get_upgrade_version_for_popup(config: &Config) -> Option { diff --git a/codex-rs/update/BUILD.bazel b/codex-rs/update/BUILD.bazel new file mode 100644 index 0000000000..734963e0a9 --- /dev/null +++ b/codex-rs/update/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "update", + crate_name = "codex_update", +) diff --git a/codex-rs/update/Cargo.toml b/codex-rs/update/Cargo.toml new file mode 100644 index 0000000000..5c023e613e --- /dev/null +++ b/codex-rs/update/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-update" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_update" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-install-context = { workspace = true } +codex-login = { workspace = true } +serde = { workspace = true, features = ["derive"] } +shlex = { workspace = true } + +[dev-dependencies] +codex-utils-absolute-path = { workspace = true } +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +wiremock = { workspace = true } diff --git a/codex-rs/update/src/lib.rs b/codex-rs/update/src/lib.rs new file mode 100644 index 0000000000..ccaecdfd52 --- /dev/null +++ b/codex-rs/update/src/lib.rs @@ -0,0 +1,223 @@ +use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; +use codex_install_context::StandalonePlatform; +use codex_login::default_client::create_client; +use serde::Deserialize; +use std::collections::HashMap; + +const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const NPM_PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex"; + +/// An updater command matched to the package manager or standalone installer +/// that launched Codex. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + NpmGlobalLatest, + BunGlobalLatest, + BrewUpgrade, + StandaloneUnix, + StandaloneWindows, +} + +impl UpdateAction { + pub fn from_install_context(context: &InstallContext) -> Option { + match &context.method { + InstallMethod::Npm => Some(Self::NpmGlobalLatest), + InstallMethod::Bun => Some(Self::BunGlobalLatest), + InstallMethod::Brew => Some(Self::BrewUpgrade), + InstallMethod::Standalone { platform, .. } => Some(match platform { + StandalonePlatform::Unix => Self::StandaloneUnix, + StandalonePlatform::Windows => Self::StandaloneWindows, + }), + InstallMethod::Other => None, + } + } + + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + Self::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), + Self::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + Self::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), + Self::StandaloneUnix => ( + "sh", + &[ + "-c", + "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh", + ], + ), + Self::StandaloneWindows => ( + "powershell", + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex", + ], + ), + } + } + + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + shlex::try_join(std::iter::once(command).chain(args.iter().copied())) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + } +} + +pub fn get_update_action() -> Option { + UpdateAction::from_install_context(InstallContext::current()) +} + +/// Fetch the newest version available through the current installation's +/// distribution channel. +pub async fn latest_version() -> anyhow::Result { + latest_version_from_sources(get_update_action(), &UpdateSources::default()).await +} + +pub fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => Some(latest > current), + _ => None, + } +} + +pub fn is_source_build_version(version: &str) -> bool { + parse_version(version) == Some((0, 0, 0)) +} + +struct UpdateSources { + homebrew_cask_api_url: String, + latest_release_url: String, + npm_package_url: String, +} + +impl Default for UpdateSources { + fn default() -> Self { + Self { + homebrew_cask_api_url: HOMEBREW_CASK_API_URL.to_string(), + latest_release_url: LATEST_RELEASE_URL.to_string(), + npm_package_url: NPM_PACKAGE_URL.to_string(), + } + } +} + +#[derive(Deserialize)] +struct ReleaseInfo { + tag_name: String, +} + +#[derive(Deserialize)] +struct HomebrewCaskInfo { + version: String, +} + +#[derive(Deserialize)] +struct NpmPackageInfo { + #[serde(rename = "dist-tags")] + dist_tags: HashMap, + versions: HashMap, +} + +#[derive(Deserialize)] +struct NpmPackageVersionInfo { + dist: Option, +} + +#[derive(Deserialize)] +struct NpmPackageDist { + tarball: Option, + integrity: Option, +} + +async fn latest_version_from_sources( + action: Option, + sources: &UpdateSources, +) -> anyhow::Result { + match action { + Some(UpdateAction::BrewUpgrade) => { + let HomebrewCaskInfo { version } = create_client() + .get(&sources.homebrew_cask_api_url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(version) + } + Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { + let latest_version = + fetch_latest_github_release_version(&sources.latest_release_url).await?; + let package_info = create_client() + .get(&sources.npm_package_url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + ensure_npm_version_ready(&package_info, &latest_version)?; + Ok(latest_version) + } + Some(UpdateAction::StandaloneUnix) | Some(UpdateAction::StandaloneWindows) | None => { + fetch_latest_github_release_version(&sources.latest_release_url).await + } + } +} + +async fn fetch_latest_github_release_version(url: &str) -> anyhow::Result { + let ReleaseInfo { + tag_name: latest_tag_name, + } = create_client() + .get(url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + extract_version_from_latest_tag(&latest_tag_name) +} + +fn ensure_npm_version_ready(package_info: &NpmPackageInfo, version: &str) -> anyhow::Result<()> { + let version = version.trim(); + match package_info.dist_tags.get("latest").map(String::as_str) { + Some(latest) if latest == version => {} + Some(latest) => anyhow::bail!( + "npm latest dist-tag points to {latest}, expected GitHub release {version}" + ), + None => anyhow::bail!("npm package is missing latest dist-tag"), + } + + let info = package_info + .versions + .get(version) + .ok_or_else(|| anyhow::anyhow!("npm package version {version} is missing"))?; + let Some(dist) = info.dist.as_ref() else { + anyhow::bail!("npm package version {version} is missing dist metadata"); + }; + if dist.tarball.as_deref().is_none_or(str::is_empty) { + anyhow::bail!("npm package version {version} is missing dist.tarball"); + } + if dist.integrity.as_deref().is_none_or(str::is_empty) { + anyhow::bail!("npm package version {version} is missing dist.integrity"); + } + Ok(()) +} + +fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { + latest_tag_name + .strip_prefix("rust-v") + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("failed to parse latest tag name '{latest_tag_name}'")) +} + +fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/update/src/tests.rs b/codex-rs/update/src/tests.rs new file mode 100644 index 0000000000..77b137c17d --- /dev/null +++ b/codex-rs/update/src/tests.rs @@ -0,0 +1,161 @@ +use super::*; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn sources(server: &MockServer) -> UpdateSources { + let base_url = server.uri(); + UpdateSources { + homebrew_cask_api_url: format!("{base_url}/brew"), + latest_release_url: format!("{base_url}/release"), + npm_package_url: format!("{base_url}/npm"), + } +} + +#[test] +fn maps_install_context_to_update_action() { + let native_release_dir = + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join("native-release")) + .expect("temp dir path should be absolute"); + let cases = [ + (InstallMethod::Other, None), + (InstallMethod::Npm, Some(UpdateAction::NpmGlobalLatest)), + (InstallMethod::Bun, Some(UpdateAction::BunGlobalLatest)), + (InstallMethod::Brew, Some(UpdateAction::BrewUpgrade)), + ( + InstallMethod::Standalone { + platform: StandalonePlatform::Unix, + release_dir: native_release_dir.clone(), + resources_dir: None, + }, + Some(UpdateAction::StandaloneUnix), + ), + ( + InstallMethod::Standalone { + platform: StandalonePlatform::Windows, + release_dir: native_release_dir, + resources_dir: None, + }, + Some(UpdateAction::StandaloneWindows), + ), + ]; + + for (method, expected) in cases { + assert_eq!( + UpdateAction::from_install_context(&InstallContext { + method, + package_layout: None, + }), + expected + ); + } +} + +#[test] +fn compares_plain_versions_and_rejects_prereleases() { + assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); + assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); + assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); + assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); + assert!(is_source_build_version("0.0.0")); +} + +#[tokio::test] +async fn brew_uses_the_cask_version() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/brew")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "version": "1.2.3" + }))) + .mount(&server) + .await; + + let latest = latest_version_from_sources(Some(UpdateAction::BrewUpgrade), &sources(&server)) + .await + .expect("brew version should load"); + + assert_eq!(latest, "1.2.3"); +} + +#[tokio::test] +async fn npm_requires_the_release_to_be_installable() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/release")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "rust-v1.2.3" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/npm")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dist-tags": { "latest": "1.2.3" }, + "versions": { + "1.2.3": { + "dist": { + "tarball": "https://registry.npmjs.org/codex-1.2.3.tgz", + "integrity": "sha512-test" + } + } + } + }))) + .mount(&server) + .await; + + let latest = + latest_version_from_sources(Some(UpdateAction::NpmGlobalLatest), &sources(&server)) + .await + .expect("npm version should be ready"); + + assert_eq!(latest, "1.2.3"); +} + +#[tokio::test] +async fn npm_rejects_a_stale_latest_tag() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/release")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "rust-v1.2.3" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/npm")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dist-tags": { "latest": "1.2.2" }, + "versions": {} + }))) + .mount(&server) + .await; + + let error = latest_version_from_sources(Some(UpdateAction::NpmGlobalLatest), &sources(&server)) + .await + .expect_err("stale npm latest tag should fail"); + + assert!(error.to_string().contains("latest dist-tag")); +} + +#[tokio::test] +async fn other_installations_use_the_latest_github_release() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/release")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "rust-v1.2.3" + }))) + .mount(&server) + .await; + + let latest = latest_version_from_sources(/*action*/ None, &sources(&server)) + .await + .expect("GitHub version should load"); + + assert_eq!(latest, "1.2.3"); +}