diff --git a/.env.example b/.env.example index 6293848c8..f680aab97 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,13 @@ MAIN_VITE_POSTHOG_HOST=https://us.i.posthog.com VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VITE_POSTHOG_HOST=https://us.i.posthog.com -# Feedback URL (optional - defaults to open source Discord if not set) -# Set this in hosted builds to use a private feedback channel +# OpenCodex link overrides (optional - keep product/community/changelog links OpenCodex-native) +# Community falls back to VITE_FEEDBACK_URL, then the public Discord invite # VITE_FEEDBACK_URL=https://discord.gg/your-private-invite +# VITE_OPENCODEX_COMMUNITY_URL=https://community.example.com/opencodex +# VITE_OPENCODEX_CHANGELOG_URL=https://product.example.com/opencodex/changelog +# VITE_OPENCODEX_AGENTS_CHANGELOG_URL=https://product.example.com/opencodex/agents/changelog +# VITE_OPENCODEX_CHANGELOG_FEED_URL=https://product.example.com/api/opencodex/changelog?per_page=3 # API URL (optional - defaults to https://21st.dev) # Only change this if you're running the web app locally diff --git a/CLAUDE.md b/CLAUDE.md index 724c9cd9a..d5c3bf677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What is this? -**21st Agents** - A local-first Electron desktop app for AI-powered code assistance. Users create chat sessions linked to local project folders, interact with Claude in Plan or Agent mode, and see real-time tool execution (bash, file edits, web search, etc.). +**OpenCodex** - A local-native Electron desktop coding workstation. Users create chat sessions linked to local project folders, interact through the desktop UI, and see real-time tool execution (bash, file edits, web search, etc.). ## Commands @@ -165,8 +165,8 @@ rm -rf ~/Library/Application\ Support/Agents\ Dev/ /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -kill -r -domain local -domain system -domain user # 3. Clear app preferences -defaults delete dev.21st.agents.dev # Dev mode -defaults delete dev.21st.agents # Production +defaults delete dev.opencodex.desktop.dev # Dev mode +defaults delete dev.opencodex.desktop # Production # 4. Run in dev mode with clean state cd apps/desktop @@ -178,7 +178,7 @@ bun run dev - **Folder dialog not appearing**: Window focus timing issues on first launch. Fixed by ensuring window focus before showing `dialog.showOpenDialog()`. **Dev vs Production App:** -- Dev mode uses `twentyfirst-agents-dev://` protocol +- Dev mode uses `opencodex-dev://` protocol - Dev mode uses separate userData path (`~/Library/Application Support/Agents Dev/`) - This prevents conflicts between dev and production installs @@ -186,8 +186,8 @@ bun run dev ### Prerequisites for Notarization -- Keychain profile: `21st-notarize` -- Create with: `xcrun notarytool store-credentials "21st-notarize" --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID` +- Keychain profile: `opencodex-notarize` +- Create with: `xcrun notarytool store-credentials "opencodex-notarize" --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID` ### Release Commands @@ -199,7 +199,8 @@ bun run release bun run build # Compile TypeScript bun run package:mac # Build & sign macOS app bun run dist:manifest # Generate latest-mac.yml manifests -./scripts/upload-release-wrangler.sh # Submit notarization & upload to R2 CDN +bun run dist:doctor # Verify build deps plus OPENCODEX_WEB_URL / OPENCODEX_UPDATE_BASE_URL +bun run dist:upload # Print the OpenCodex upload plan for the configured update target ``` ### Bump Version Before Release @@ -210,30 +211,31 @@ npm version patch --no-git-tag-version # 0.0.27 → 0.0.28 ### After Release Script Completes -1. Wait for notarization (2-5 min): `xcrun notarytool history --keychain-profile "21st-notarize"` +1. Wait for notarization (2-5 min): `xcrun notarytool history --keychain-profile "opencodex-notarize"` 2. Staple DMGs: `cd release && xcrun stapler staple *.dmg` 3. Re-upload stapled DMGs to R2 and GitHub (see RELEASE.md for commands) 4. Update changelog: `gh release edit v0.0.X --notes "..."` 5. **Upload manifests (triggers auto-updates!)** — see RELEASE.md 6. Sync to public: `./scripts/sync-to-public.sh` -### Files Uploaded to CDN +### Files Uploaded to Configured Update Target | File | Purpose | |------|---------| | `latest-mac.yml` | Manifest for arm64 auto-updates | | `latest-mac-x64.yml` | Manifest for Intel auto-updates | -| `1Code-{version}-arm64-mac.zip` | Auto-update payload (arm64) | -| `1Code-{version}-mac.zip` | Auto-update payload (Intel) | -| `1Code-{version}-arm64.dmg` | Manual download (arm64) | -| `1Code-{version}.dmg` | Manual download (Intel) | +| `OpenCodex-{version}-arm64-mac.zip` | Auto-update payload (arm64) | +| `OpenCodex-{version}-mac.zip` | Auto-update payload (Intel) | +| `OpenCodex-{version}-arm64.dmg` | Manual download (arm64) | +| `OpenCodex-{version}.dmg` | Manual download (Intel) | ### Auto-Update Flow -1. App checks `https://cdn.21st.dev/releases/desktop/latest-mac.yml` on startup and when window regains focus (with 1 min cooldown) -2. If version in manifest > current version, shows "Update Available" banner -3. User clicks Download → downloads ZIP in background -4. User clicks "Restart Now" → installs update and restarts +1. Release validation requires both `OPENCODEX_WEB_URL` and `OPENCODEX_UPDATE_BASE_URL`; packaged builds keep `.invalid` placeholders only as a non-ready safety fallback. +2. App checks the configured `OPENCODEX_UPDATE_BASE_URL` target for `latest-mac.yml` on startup and when window regains focus (with 1 min cooldown) +3. If version in manifest > current version, shows "Update Available" banner +4. User clicks Download → downloads ZIP in background +5. User clicks "Restart Now" → installs update and restarts ## Current Status (WIP) diff --git a/bun.lock b/bun.lock index f338c9b34..0865057e8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.32", + "@anthropic-ai/claude-agent-sdk": "0.2.45", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", @@ -42,7 +42,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", - "@zed-industries/codex-acp": "^0.9.3", + "@zed-industries/codex-acp": "0.9.3", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -90,6 +90,7 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@electron/rebuild": "^4.0.3", + "@tailwindcss/container-queries": "^0.1.1", "@types/better-sqlite3": "^7.6.13", "@types/diff": "^8.0.0", "@types/node": "^20.17.50", @@ -126,7 +127,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.32", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.45", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.45.tgz", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AKH2hKoJNyjLf9ThAttKqbmCjUFg7qs/8+LR/UTVX20fCLn359YH9WrQc6dAiAfi8RYNA+mWwrNYCAq+Sdo5Ag=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -648,6 +649,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "https://registry.npmmirror.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], diff --git a/package.json b/package.json index da2a5e747..771a600bb 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "21st-desktop", + "name": "opencodex-desktop", "version": "0.0.72", "private": true, - "description": "1Code - UI for parallel work with AI agents", - "homepage": "https://21st.dev", + "description": "OpenCodex - local-native desktop coding workstation", + "homepage": "", "author": { - "name": "21st.dev", - "email": "support@21st.dev" + "name": "OpenCodex", + "email": "support@opencodex.local" }, "main": "out/main/index.js", "scripts": { @@ -18,14 +18,15 @@ "package:win": "electron-builder --win", "package:linux": "electron-builder --linux", "dist": "electron-builder", + "dist:doctor": "node scripts/check-packaging-readiness.mjs", "dist:manifest": "node scripts/generate-update-manifest.mjs", "dist:upload": "node scripts/upload-release.mjs", "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.45", "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.45 --all", "codex:download": "node scripts/download-codex-binary.mjs --version=0.98.0", "codex:download:all": "node scripts/download-codex-binary.mjs --version=0.98.0 --all", - "release": "rm -rf release && bun i && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh", - "release:dev": "rm -rf release && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && rm -rf node_modules && bun i", + "release": "node scripts/clean-paths.mjs release && bun i && bun run dist:doctor && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && bun run dist:upload", + "release:dev": "node scripts/clean-paths.mjs release node_modules && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun i", "sync:public": "./scripts/sync-to-public.sh", "icon:generate": "node scripts/generate-icon.mjs", "db:generate": "drizzle-kit generate", @@ -140,14 +141,14 @@ "vite": "^6.3.4" }, "build": { - "appId": "dev.21st.agents", - "productName": "1Code", + "appId": "dev.opencodex.desktop", + "productName": "OpenCodex", "npmRebuild": true, "protocols": [ { - "name": "1Code", + "name": "OpenCodex", "schemes": [ - "twentyfirst-agents" + "opencodex" ] } ], @@ -173,6 +174,14 @@ { "from": "resources/bin/VERSION", "to": "bin/VERSION" + }, + { + "from": "resources/cli/opencodex", + "to": "cli/opencodex" + }, + { + "from": "resources/cli/opencodex.cmd", + "to": "cli/opencodex.cmd" } ], "asar": true, @@ -207,7 +216,7 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extendInfo": { - "NSMicrophoneUsageDescription": "1Code needs microphone access for voice dictation" + "NSMicrophoneUsageDescription": "OpenCodex needs microphone access for voice dictation" } }, "dmg": { @@ -253,7 +262,7 @@ }, "publish": { "provider": "generic", - "url": "https://cdn.21st.dev/releases/desktop" + "url": "${env.OPENCODEX_UPDATE_BASE_URL}" } }, "pnpm": { diff --git a/resources/cli/1code b/resources/cli/opencodex old mode 100755 new mode 100644 similarity index 64% rename from resources/cli/1code rename to resources/cli/opencodex index ef4898e24..b03581f43 --- a/resources/cli/1code +++ b/resources/cli/opencodex @@ -1,6 +1,6 @@ #!/bin/bash -# 1code CLI launcher -# Opens 1Code app with the specified directory +# OpenCodex CLI launcher +# Opens OpenCodex with the specified directory # Resolve the directory argument (default to current directory) DIR="${1:-.}" @@ -15,5 +15,5 @@ if [ -z "$DIR" ] || [ ! -d "$DIR" ]; then exit 1 fi -# Open 1Code app with the directory argument -open -a "1Code" --args "$DIR" +# Open OpenCodex with the directory argument +open -a "OpenCodex" --args "$DIR" diff --git a/resources/cli/opencodex.cmd b/resources/cli/opencodex.cmd new file mode 100644 index 000000000..54cc8be70 --- /dev/null +++ b/resources/cli/opencodex.cmd @@ -0,0 +1,18 @@ +@echo off +setlocal +set "DIR=%~1" +if "%DIR%"=="" set "DIR=." + +for %%I in ("%DIR%") do set "TARGET_DIR=%%~fI" +if not exist "%TARGET_DIR%\" ( + echo Error: Invalid directory + exit /b 1 +) + +set "APP_EXE=%LOCALAPPDATA%\Programs\OpenCodex\OpenCodex.exe" +if not exist "%APP_EXE%" ( + echo Error: OpenCodex.exe not found at "%APP_EXE%" + exit /b 1 +) + +start "" "%APP_EXE%" "%TARGET_DIR%" diff --git a/scripts/check-packaging-readiness.mjs b/scripts/check-packaging-readiness.mjs new file mode 100644 index 000000000..8d8221c5c --- /dev/null +++ b/scripts/check-packaging-readiness.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import path from "node:path" +import { fileURLToPath } from "node:url" +import { assessPackagingReadiness } from "./release-config.mjs" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const readiness = assessPackagingReadiness({ + env: process.env, + rootDir, +}) + +if (readiness.ok) { + console.log("OpenCodex packaging readiness: OK") + process.exit(0) +} + +console.log("OpenCodex packaging readiness: BLOCKED") +for (const issue of readiness.issues) { + console.log(`- ${issue}`) +} +process.exit(1) diff --git a/scripts/clean-paths.mjs b/scripts/clean-paths.mjs new file mode 100644 index 000000000..969bb9137 --- /dev/null +++ b/scripts/clean-paths.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const targets = process.argv.slice(2) + +if (targets.length === 0) { + console.error("Provide one or more relative paths to clean.") + process.exit(1) +} + +for (const target of targets) { + const normalizedTarget = path.normalize(target) + if ( + path.isAbsolute(normalizedTarget) || + normalizedTarget === ".." || + normalizedTarget.startsWith(`..${path.sep}`) + ) { + console.error(`Refusing to clean unsafe path: ${target}`) + process.exit(1) + } + + fs.rmSync(path.join(rootDir, normalizedTarget), { recursive: true, force: true }) +} diff --git a/scripts/download-claude-binary.mjs b/scripts/download-claude-binary.mjs index b41c57cd1..cde0304e8 100644 --- a/scripts/download-claude-binary.mjs +++ b/scripts/download-claude-binary.mjs @@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const ROOT_DIR = path.join(__dirname, "..") const BIN_DIR = path.join(ROOT_DIR, "resources", "bin") +const USER_AGENT = "opencodex-desktop-claude-downloader" // Claude Code distribution base URL const DIST_BASE = @@ -36,10 +37,16 @@ const PLATFORMS = { /** * Fetch JSON from URL */ +function getRequestHeaders() { + return { + "User-Agent": USER_AGENT, + } +} + function fetchJson(url) { return new Promise((resolve, reject) => { https - .get(url, (res) => { + .get(url, { headers: getRequestHeaders() }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { return fetchJson(res.headers.location).then(resolve).catch(reject) } @@ -64,7 +71,7 @@ function downloadFile(url, destPath) { const request = (url) => { https - .get(url, (res) => { + .get(url, { headers: getRequestHeaders() }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { file.close() fs.unlinkSync(destPath) @@ -136,7 +143,9 @@ async function getLatestVersion() { try { // Fetch from the same endpoint that install.sh uses - const response = await fetch("https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest") + const response = await fetch("https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest", { + headers: getRequestHeaders(), + }) if (response.ok) { const version = await response.text() return version.trim() diff --git a/scripts/download-codex-binary.mjs b/scripts/download-codex-binary.mjs index 30fd9477d..f9aa2bd0c 100644 --- a/scripts/download-codex-binary.mjs +++ b/scripts/download-codex-binary.mjs @@ -21,7 +21,7 @@ const BIN_DIR = path.join(ROOT_DIR, "resources", "bin") const RELEASE_REPO = "openai/codex" const RELEASE_TAG_PREFIX = "rust-v" -const USER_AGENT = "21st-desktop-codex-downloader" +const USER_AGENT = "opencodex-desktop-codex-downloader" const PLATFORMS = { "darwin-arm64": { diff --git a/scripts/generate-update-manifest.mjs b/scripts/generate-update-manifest.mjs index d8f73ee6a..1acb473b7 100644 --- a/scripts/generate-update-manifest.mjs +++ b/scripts/generate-update-manifest.mjs @@ -9,9 +9,7 @@ * Usage: * node scripts/generate-update-manifest.mjs * - * The script expects ZIP files to exist in the release/ directory: - * - Agents-{version}-arm64-mac.zip - * - Agents-{version}-mac.zip + * The script expects ZIP files to exist in the release/ directory using the packaged product name. * * Run this after `npm run dist` to generate the manifest files. */ @@ -20,6 +18,7 @@ import { createHash } from "crypto" import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs" import { join, dirname } from "path" import { fileURLToPath } from "url" +import { getMacArtifactNames, getManifestNextStepLines } from "./release-config.mjs" const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -40,6 +39,8 @@ const packageJson = JSON.parse( readFileSync(join(__dirname, "../package.json"), "utf-8") ) const version = process.env.VERSION || packageJson.version +const productName = packageJson.build?.productName || packageJson.name +const macArtifactNames = getMacArtifactNames({ productName, version }) const releaseDir = join(__dirname, "../release") @@ -76,9 +77,7 @@ function findReleaseFile(pattern, ext = ".zip") { * Generate manifest for a specific architecture */ function generateManifest(arch) { - // electron-builder names files differently: - // arm64: Agents-{version}-arm64-mac.zip - // x64: Agents-{version}-mac.zip + // electron-builder names files differently by architecture, but always prefixes them with the packaged product name. const pattern = arch === "arm64" ? `${version}-arm64-mac` : `${version}-mac` const zipPath = findReleaseFile(pattern, ".zip") @@ -243,18 +242,14 @@ if (!arm64Manifest && !x64Manifest && !linuxManifest) { console.log("=".repeat(50)) console.log("Manifest generation complete!") console.log() -const prefix = channel === "beta" ? "beta" : "latest" console.log("Next steps:") -console.log("1. Upload the following files to cdn.21st.dev/releases/desktop/:") -if (arm64Manifest) { - console.log(` - ${prefix}-mac.yml`) - console.log(` - Agents-${version}-arm64-mac.zip`) - console.log(` - Agents-${version}-arm64.dmg (for manual download)`) +for (const line of getManifestNextStepLines({ + env: process.env, + channel, + macArtifactNames, + hasArm64Manifest: !!arm64Manifest, + hasX64Manifest: !!x64Manifest, +})) { + console.log(line) } -if (x64Manifest) { - console.log(` - ${prefix}-mac-x64.yml`) - console.log(` - Agents-${version}-mac.zip`) - console.log(` - Agents-${version}.dmg (for manual download)`) -} -console.log("2. Create a release entry in the admin dashboard") console.log("=".repeat(50)) diff --git a/scripts/patch-electron-dev.mjs b/scripts/patch-electron-dev.mjs index cb5fe8d03..a138a5314 100644 --- a/scripts/patch-electron-dev.mjs +++ b/scripts/patch-electron-dev.mjs @@ -1,4 +1,4 @@ -// Patches the Electron.app bundle in node_modules to show "1Code" name and icon in macOS dock during dev mode. +// Patches the Electron.app bundle in node_modules to show "OpenCodex" name and icon in macOS dock during dev mode. import { execSync } from "child_process" import { copyFileSync, existsSync } from "fs" import { join, dirname } from "path" @@ -18,9 +18,9 @@ if (process.platform !== "darwin") { if (existsSync(plistPath)) { try { - execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleName 1Code" "${plistPath}"`) - execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName 1Code" "${plistPath}"`) - console.log("[patch-electron-dev] Updated Info.plist: name -> 1Code") + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleName OpenCodex" "${plistPath}"`) + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName OpenCodex" "${plistPath}"`) + console.log("[patch-electron-dev] Updated Info.plist: name -> OpenCodex") } catch (e) { console.warn("[patch-electron-dev] Failed to update Info.plist:", e.message) } diff --git a/scripts/release-config.mjs b/scripts/release-config.mjs new file mode 100644 index 000000000..27344a71f --- /dev/null +++ b/scripts/release-config.mjs @@ -0,0 +1,127 @@ +import fs from "node:fs" +import path from "node:path" + +export function getMacArtifactNames({ productName, version }) { + return { + arm64Zip: `${productName}-${version}-arm64-mac.zip`, + x64Zip: `${productName}-${version}-mac.zip`, + arm64Dmg: `${productName}-${version}-arm64.dmg`, + x64Dmg: `${productName}-${version}.dmg`, + } +} + +export function getConfiguredReleaseUploadTarget(env = process.env) { + const configuredBaseUrl = env.OPENCODEX_UPDATE_BASE_URL?.trim() + if (!configuredBaseUrl) { + return null + } + + return configuredBaseUrl.replace(/\/+$/, "") +} + +export function getReleaseUploadTargetLabel(env = process.env) { + return getConfiguredReleaseUploadTarget(env) || "the OPENCODEX_UPDATE_BASE_URL target" +} + +export function getConfiguredWebBaseUrl(env = process.env) { + const configuredBaseUrl = env.OPENCODEX_WEB_URL?.trim() + if (!configuredBaseUrl) { + return null + } + + return configuredBaseUrl.replace(/\/+$/, "") +} + +export function getReleaseWebTargetLabel(env = process.env) { + return getConfiguredWebBaseUrl(env) || "the OPENCODEX_WEB_URL host" +} + +export function assessPackagingReadiness({ env = process.env, rootDir }) { + const issues = [] + + for (const pkg of ["electron-vite", "electron-builder"]) { + const pkgPath = path.join(rootDir, "node_modules", pkg, "package.json") + if (!fs.existsSync(pkgPath)) { + issues.push(`Missing installed dependency: ${pkg}`) + } + } + + if (!getConfiguredReleaseUploadTarget(env)) { + issues.push("Missing required release env: OPENCODEX_UPDATE_BASE_URL") + } + + if (!getConfiguredWebBaseUrl(env)) { + issues.push("Missing required packaged web env: OPENCODEX_WEB_URL") + } + + return { + ok: issues.length === 0, + issues, + } +} + +export function getManifestNextStepLines({ + env = process.env, + channel = "latest", + macArtifactNames, + hasArm64Manifest, + hasX64Manifest, +}) { + const prefix = channel === "beta" ? "beta" : "latest" + const lines = [ + `1. Confirm packaged hosts: web=${getReleaseWebTargetLabel(env)}, updates=${getReleaseUploadTargetLabel(env)}`, + `2. Upload the following files to ${getReleaseUploadTargetLabel(env)}:`, + ] + + if (hasArm64Manifest) { + lines.push(` - ${prefix}-mac.yml`) + lines.push(` - ${macArtifactNames.arm64Zip}`) + lines.push(` - ${macArtifactNames.arm64Dmg} (for manual download)`) + } + + if (hasX64Manifest) { + lines.push(` - ${prefix}-mac-x64.yml`) + lines.push(` - ${macArtifactNames.x64Zip}`) + lines.push(` - ${macArtifactNames.x64Dmg} (for manual download)`) + } + + lines.push("3. Create a release entry in the admin dashboard") + return lines +} + +export function collectReleaseUploadPlan({ env = process.env, productName, version, releaseDir }) { + const targetBaseUrl = getConfiguredReleaseUploadTarget(env) + if (!targetBaseUrl) { + throw new Error("OpenCodex release upload requires OPENCODEX_UPDATE_BASE_URL to be set.") + } + + const macArtifacts = getMacArtifactNames({ productName, version }) + const expectedFiles = [ + "latest-linux.yml", + "latest-mac-x64.yml", + "latest-mac.yml", + `${productName}-${version}.AppImage`, + macArtifacts.arm64Zip, + macArtifacts.arm64Dmg, + macArtifacts.x64Zip, + macArtifacts.x64Dmg, + ] + + const entries = expectedFiles + .filter((fileName) => fs.existsSync(path.join(releaseDir, fileName))) + .sort((a, b) => a.localeCompare(b)) + .map((fileName) => ({ + fileName, + sourcePath: path.join(releaseDir, fileName), + targetUrl: `${targetBaseUrl}/${fileName}`, + })) + + if (entries.length === 0) { + throw new Error(`No release artifacts found in ${releaseDir}. Run the packaging and manifest steps first.`) + } + + return { + targetBaseUrl, + entries, + } +} \ No newline at end of file diff --git a/scripts/release-config.test.ts b/scripts/release-config.test.ts new file mode 100644 index 000000000..6559f9895 --- /dev/null +++ b/scripts/release-config.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { + assessPackagingReadiness, + collectReleaseUploadPlan, + getConfiguredWebBaseUrl, + getMacArtifactNames, + getManifestNextStepLines, + getReleaseUploadTargetLabel, + getReleaseWebTargetLabel, +} from "./release-config.mjs" + +const tempRoots: string[] = [] +const codexDownloaderSource = readFileSync( + join(import.meta.dir, "download-codex-binary.mjs"), + "utf-8", +) +const claudeDownloaderSource = readFileSync( + join(import.meta.dir, "download-claude-binary.mjs"), + "utf-8", +) +const syncToPublicSource = readFileSync( + join(import.meta.dir, "sync-to-public.sh"), + "utf-8", +) +const legacyCliLauncherPath = join(import.meta.dir, "..", "resources", "cli", "1code") + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function createTempReleaseDir(): string { + const root = mkdtempSync(join(tmpdir(), "opencodex-release-")) + tempRoots.push(root) + return root +} + +const packageJson = JSON.parse( + readFileSync(join(import.meta.dir, "..", "package.json"), "utf-8"), +) as { + name: string + description: string + author?: { name?: string } + build?: { + appId?: string + productName?: string + protocols?: Array<{ name?: string; schemes?: string[] }> + publish?: { url?: string } + extraResources?: Array<{ from?: string; to?: string }> + } +} + +describe("release packaging identity", () => { + test("package metadata uses OpenCodex-owned identifiers", () => { + expect(packageJson.name).toBe("opencodex-desktop") + expect(packageJson.description).toContain("OpenCodex") + expect(packageJson.author?.name).toBe("OpenCodex") + expect(packageJson.build?.appId).toBe("dev.opencodex.desktop") + expect(packageJson.build?.productName).toBe("OpenCodex") + expect(packageJson.build?.protocols).toEqual([ + { + name: "OpenCodex", + schemes: ["opencodex"], + }, + ]) + expect(packageJson.build?.publish?.url).toBe("${env.OPENCODEX_UPDATE_BASE_URL}") + }) + + test("release packaging includes only the OpenCodex CLI launchers in extra resources", () => { + expect(packageJson.build?.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "resources/cli/opencodex", + to: "cli/opencodex", + }), + expect.objectContaining({ + from: "resources/cli/opencodex.cmd", + to: "cli/opencodex.cmd", + }), + ]), + ) + expect(packageJson.build?.extraResources).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "resources/cli", + to: "cli", + }), + ]), + ) + }) + + test("release script uses the OpenCodex doctor and upload flow", () => { + expect(packageJson.scripts?.release).toContain("bun run dist:doctor") + expect(packageJson.scripts?.release).toContain("bun run dist:upload") + expect(packageJson.scripts?.release).not.toContain("upload-release-wrangler.sh") + }) + + test("release scripts avoid Unix-only rm -rf cleanup", () => { + expect(packageJson.scripts?.release).not.toContain("rm -rf") + expect(packageJson.scripts?.["release:dev"]).not.toContain("rm -rf") + }) + + test("codex downloader uses an OpenCodex-owned user-agent", () => { + expect(codexDownloaderSource).toContain('const USER_AGENT = "opencodex-desktop-codex-downloader"') + expect(codexDownloaderSource).not.toContain('21st-desktop-codex-downloader') + }) + + test("claude downloader uses an OpenCodex-owned user-agent", () => { + expect(claudeDownloaderSource).toContain('const USER_AGENT = "opencodex-desktop-claude-downloader"') + expect(claudeDownloaderSource).toContain('"User-Agent": USER_AGENT') + }) + + test("sync-to-public script requires explicit OpenCodex repo targets", () => { + expect(syncToPublicSource).toContain('OPENCODEX_PUBLIC_REPO_GIT') + expect(syncToPublicSource).toContain('OPENCODEX_PUBLIC_REPO_HTTPS') + expect(syncToPublicSource).toContain('OPENCODEX_PRIVATE_REPO') + expect(syncToPublicSource).not.toContain('git@github.com:21st-dev/1code.git') + expect(syncToPublicSource).not.toContain('https://github.com/21st-dev/1code') + expect(syncToPublicSource).not.toContain('21st-dev/21st') + }) + + test("legacy 1code cli launcher resource is removed", () => { + expect(require('fs').existsSync(legacyCliLauncherPath)).toBe(false) + }) + + test("release artifact names follow the packaged product name", () => { + expect(getMacArtifactNames({ productName: "OpenCodex", version: "1.2.3" })).toEqual({ + arm64Zip: "OpenCodex-1.2.3-arm64-mac.zip", + x64Zip: "OpenCodex-1.2.3-mac.zip", + arm64Dmg: "OpenCodex-1.2.3-arm64.dmg", + x64Dmg: "OpenCodex-1.2.3.dmg", + }) + }) + + test("release upload instructions use the explicit OpenCodex update target when present", () => { + expect( + getReleaseUploadTargetLabel({ + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop/", + }), + ).toBe("https://updates.opencodex.test/releases/desktop") + }) + + test("release upload instructions call out the required OpenCodex update target when unset", () => { + expect(getReleaseUploadTargetLabel({})).toBe("the OPENCODEX_UPDATE_BASE_URL target") + }) + + test("release web-host instructions use the explicit OpenCodex web host when present", () => { + expect(getConfiguredWebBaseUrl({ OPENCODEX_WEB_URL: " https://desktop.opencodex.test/root/ " })).toBe( + "https://desktop.opencodex.test/root", + ) + expect(getReleaseWebTargetLabel({ OPENCODEX_WEB_URL: "https://desktop.opencodex.test/root/" })).toBe( + "https://desktop.opencodex.test/root", + ) + }) + + test("release web-host instructions call out the required OpenCodex web host when unset", () => { + expect(getConfiguredWebBaseUrl({})).toBeNull() + expect(getReleaseWebTargetLabel({})).toBe("the OPENCODEX_WEB_URL host") + }) + + test("manifest next-step guidance includes both configured hosts and packaged artifacts", () => { + expect( + getManifestNextStepLines({ + env: { + OPENCODEX_WEB_URL: "https://desktop.opencodex.test", + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop", + }, + channel: "latest", + macArtifactNames: getMacArtifactNames({ productName: "OpenCodex", version: "1.2.3" }), + hasArm64Manifest: true, + hasX64Manifest: true, + }), + ).toEqual([ + "1. Confirm packaged hosts: web=https://desktop.opencodex.test, updates=https://updates.opencodex.test/releases/desktop", + "2. Upload the following files to https://updates.opencodex.test/releases/desktop:", + " - latest-mac.yml", + " - OpenCodex-1.2.3-arm64-mac.zip", + " - OpenCodex-1.2.3-arm64.dmg (for manual download)", + " - latest-mac-x64.yml", + " - OpenCodex-1.2.3-mac.zip", + " - OpenCodex-1.2.3.dmg (for manual download)", + "3. Create a release entry in the admin dashboard", + ]) + }) + + test("collects a release upload plan for generated manifests and artifacts", () => { + const releaseDir = createTempReleaseDir() + for (const fileName of [ + "latest-mac.yml", + "latest-mac-x64.yml", + "latest-linux.yml", + "OpenCodex-1.2.3-arm64-mac.zip", + "OpenCodex-1.2.3-mac.zip", + "OpenCodex-1.2.3-arm64.dmg", + "OpenCodex-1.2.3.dmg", + "OpenCodex-1.2.3.AppImage", + ]) { + writeFileSync(join(releaseDir, fileName), fileName, "utf-8") + } + + const plan = collectReleaseUploadPlan({ + env: { + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop/", + }, + productName: "OpenCodex", + version: "1.2.3", + releaseDir, + }) + + expect(plan.targetBaseUrl).toBe("https://updates.opencodex.test/releases/desktop") + expect(plan.entries.map((entry) => entry.fileName)).toEqual([ + "latest-linux.yml", + "latest-mac-x64.yml", + "latest-mac.yml", + "OpenCodex-1.2.3-arm64-mac.zip", + "OpenCodex-1.2.3-arm64.dmg", + "OpenCodex-1.2.3-mac.zip", + "OpenCodex-1.2.3.AppImage", + "OpenCodex-1.2.3.dmg", + ]) + expect(plan.entries[0]?.targetUrl).toBe( + "https://updates.opencodex.test/releases/desktop/latest-linux.yml", + ) + }) + + test("requires an explicit OpenCodex update target for upload planning", () => { + const releaseDir = createTempReleaseDir() + writeFileSync(join(releaseDir, "latest-mac.yml"), "manifest", "utf-8") + + expect(() => + collectReleaseUploadPlan({ + env: {}, + productName: "OpenCodex", + version: "1.2.3", + releaseDir, + }), + ).toThrow(/OPENCODEX_UPDATE_BASE_URL/i) + }) + + test("reports missing build-tool dependencies and update target as packaging-readiness blockers", () => { + const rootDir = createTempReleaseDir() + + const readiness = assessPackagingReadiness({ + env: {}, + rootDir, + }) + + expect(readiness.ok).toBe(false) + expect(readiness.issues).toEqual([ + "Missing installed dependency: electron-vite", + "Missing installed dependency: electron-builder", + "Missing required release env: OPENCODEX_UPDATE_BASE_URL", + "Missing required packaged web env: OPENCODEX_WEB_URL", + ]) + }) + + test("reports packaging readiness once build tools and update target are available", () => { + const rootDir = createTempReleaseDir() + for (const pkg of ["electron-vite", "electron-builder"]) { + const pkgDir = join(rootDir, "node_modules", pkg) + require("fs").mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: pkg }, null, 2), "utf-8") + } + + const readiness = assessPackagingReadiness({ + env: { + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop", + OPENCODEX_WEB_URL: "https://desktop.opencodex.test", + }, + rootDir, + }) + + expect(readiness.ok).toBe(true) + expect(readiness.issues).toEqual([]) + }) +}) \ No newline at end of file diff --git a/scripts/sync-to-public.sh b/scripts/sync-to-public.sh index 9acfa6394..df9c637a7 100755 --- a/scripts/sync-to-public.sh +++ b/scripts/sync-to-public.sh @@ -1,27 +1,46 @@ -#!/bin/bash +#!/bin/bash set -e -# Sync desktop app to public 1code repository +# Sync desktop app to the public OpenCodex repository mirror # Usage: ./scripts/sync-to-public.sh # +# Required env: +# OPENCODEX_PUBLIC_REPO_GIT git remote URL for the public mirror +# OPENCODEX_PUBLIC_REPO_HTTPS GitHub HTTPS repo URL for gh release operations +# OPENCODEX_PRIVATE_REPO source repo slug for release-note lookup +# # This script: # 1. Syncs code from private repo to public repo # 2. Creates a GitHub release in public repo with same notes as private repo +require_env() { + local name="$1" + local value="${!name}" + if [ -z "$value" ]; then + echo "Missing required env: $name" >&2 + exit 1 + fi +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DESKTOP_DIR="$(dirname "$SCRIPT_DIR")" ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)" VERSION=$(node -p "require('$DESKTOP_DIR/package.json').version") TAG="v$VERSION" -PUBLIC_REPO="git@github.com:21st-dev/1code.git" -PUBLIC_REPO_HTTPS="https://github.com/21st-dev/1code" -PRIVATE_REPO="21st-dev/21st" -TEMP_DIR="/tmp/1code-sync-$$" +require_env OPENCODEX_PUBLIC_REPO_GIT +require_env OPENCODEX_PUBLIC_REPO_HTTPS +require_env OPENCODEX_PRIVATE_REPO + +PUBLIC_REPO="$OPENCODEX_PUBLIC_REPO_GIT" +PUBLIC_REPO_HTTPS="$OPENCODEX_PUBLIC_REPO_HTTPS" +PRIVATE_REPO="$OPENCODEX_PRIVATE_REPO" +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/opencodex-sync-XXXXXX") echo "🔄 Syncing desktop app to public repository..." echo " Version: $VERSION" echo " Tag: $TAG" +echo " Public repo: $PUBLIC_REPO_HTTPS" echo "" # Get release notes from private repo @@ -31,7 +50,7 @@ RELEASE_NOTES=$(gh release view "$TAG" --repo "$PRIVATE_REPO" --json body -q '.b if [ -z "$RELEASE_NOTES" ]; then echo "⚠️ No release found for $TAG in private repo" echo " Please create a release in the private repo first:" - echo " gh release create $TAG --title \"1Code $TAG\" --notes \"...\"" + echo " gh release create $TAG --repo \"$PRIVATE_REPO\" --title \"OpenCodex $TAG\" --notes \"...\"" echo "" read -p "Continue without release notes? (y/N) " -n 1 -r echo @@ -77,7 +96,7 @@ test-electron.js # Exclude internal release docs (contains credentials, CDN URLs) RELEASE.md -scripts/upload-release-wrangler.sh +scripts/upload-release.mjs # Exclude wrangler local state (large R2 blobs) .wrangler @@ -109,7 +128,7 @@ if gh release view "$TAG" --repo "$PUBLIC_REPO_HTTPS" &>/dev/null; then echo " Release $TAG already exists, updating..." gh release edit "$TAG" \ --repo "$PUBLIC_REPO_HTTPS" \ - --title "1Code $TAG" \ + --title "OpenCodex $TAG" \ --notes "$RELEASE_NOTES" else echo " Creating new release $TAG..." @@ -119,7 +138,7 @@ else gh release create "$TAG" \ --repo "$PUBLIC_REPO_HTTPS" \ - --title "1Code $TAG" \ + --title "OpenCodex $TAG" \ --notes "$RELEASE_NOTES" fi diff --git a/scripts/upload-release.mjs b/scripts/upload-release.mjs new file mode 100644 index 000000000..3df95c35d --- /dev/null +++ b/scripts/upload-release.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { collectReleaseUploadPlan } from "./release-config.mjs" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const packageJson = JSON.parse(readFileSync(path.join(rootDir, "package.json"), "utf-8")) +const releaseDir = path.join(rootDir, "release") +const productName = packageJson.build?.productName || packageJson.name +const version = process.env.VERSION || packageJson.version + +try { + const plan = collectReleaseUploadPlan({ + env: process.env, + productName, + version, + releaseDir, + }) + + console.log("=".repeat(50)) + console.log("OpenCodex Release Upload Plan") + console.log("=".repeat(50)) + console.log(`Version: ${version}`) + console.log(`Target: ${plan.targetBaseUrl}`) + console.log(`Artifacts: ${plan.entries.length}`) + console.log() + + for (const entry of plan.entries) { + console.log(`${entry.fileName}`) + console.log(` from: ${entry.sourcePath}`) + console.log(` to: ${entry.targetUrl}`) + } + + console.log() + console.log("Upload transport is deployment-specific.") + console.log("Use this plan with the publisher for your configured update target.") + console.log("=".repeat(50)) +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/src/env.d.ts b/src/env.d.ts index b80a04255..5cc9b84a9 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -11,6 +11,11 @@ declare global { // Renderer process (VITE_ prefix) readonly VITE_POSTHOG_KEY?: string readonly VITE_POSTHOG_HOST?: string + readonly VITE_FEEDBACK_URL?: string + readonly VITE_OPENCODEX_COMMUNITY_URL?: string + readonly VITE_OPENCODEX_CHANGELOG_URL?: string + readonly VITE_OPENCODEX_AGENTS_CHANGELOG_URL?: string + readonly VITE_OPENCODEX_CHANGELOG_FEED_URL?: string } } diff --git a/src/main/auth-manager.test.ts b/src/main/auth-manager.test.ts new file mode 100644 index 000000000..4172a551a --- /dev/null +++ b/src/main/auth-manager.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { resolveOpenCodexAuthApiBaseUrl } from "./lib/opencodex/auth-url" +import { resolveOpenCodexCloudAuthEnabled } from "./lib/opencodex/cloud-auth" + +describe("resolveOpenCodexAuthApiBaseUrl", () => { + test("uses the OpenCodex web base-url seam for packaged and dev auth calls", () => { + expect(resolveOpenCodexAuthApiBaseUrl({ isPackaged: true, env: {} })).toBe("https://opencodex.invalid") + expect( + resolveOpenCodexAuthApiBaseUrl({ + isPackaged: true, + env: { OPENCODEX_WEB_URL: " https://desktop.opencodex.example/root/ " }, + }), + ).toBe("https://desktop.opencodex.example/root") + expect( + resolveOpenCodexAuthApiBaseUrl({ + isPackaged: false, + env: { MAIN_VITE_API_URL: " https://dev-api.opencodex.example/base/ " }, + }), + ).toBe("https://dev-api.opencodex.example/base") + }) +}) + +describe("resolveOpenCodexCloudAuthEnabled", () => { + test("defaults OpenCodex to local-native mode", () => { + expect(resolveOpenCodexCloudAuthEnabled({})).toBe(false) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "" })).toBe(false) + }) + + test("only enables cloud auth when explicitly requested", () => { + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "1" })).toBe(true) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "true" })).toBe(true) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "0" })).toBe(false) + }) +}) diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts deleted file mode 100644 index e31b7bc1b..000000000 --- a/src/main/auth-manager.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { AuthStore, AuthData, AuthUser } from "./auth-store" -import { app, BrowserWindow } from "electron" -import { AUTH_SERVER_PORT } from "./constants" - -// Get API URL - in packaged app always use production, in dev allow override -function getApiBaseUrl(): string { - if (app.isPackaged) { - return "https://21st.dev" - } - return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" -} - -export class AuthManager { - private store: AuthStore - private refreshTimer?: NodeJS.Timeout - private isDev: boolean - private onTokenRefresh?: (authData: AuthData) => void - - constructor(isDev: boolean = false) { - this.store = new AuthStore(app.getPath("userData")) - this.isDev = isDev - - // Schedule refresh if already authenticated - if (this.store.isAuthenticated()) { - this.scheduleRefresh() - } - } - - /** - * Set callback to be called when token is refreshed - * This allows the main process to update cookies when tokens change - */ - setOnTokenRefresh(callback: (authData: AuthData) => void): void { - this.onTokenRefresh = callback - } - - private getApiUrl(): string { - return getApiBaseUrl() - } - - /** - * Exchange auth code for session tokens - * Called after receiving code via deep link - */ - async exchangeCode(code: string): Promise { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - deviceInfo: this.getDeviceInfo(), - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Exchange failed: ${response.status}`) - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - return authData - } - - /** - * Get device info for session tracking - */ - private getDeviceInfo(): string { - const platform = process.platform - const arch = process.arch - const version = app.getVersion() - return `21st Desktop ${version} (${platform} ${arch})` - } - - /** - * Get a valid token, refreshing if necessary - */ - async getValidToken(): Promise { - if (!this.store.isAuthenticated()) { - return null - } - - if (this.store.needsRefresh()) { - await this.refresh() - } - - return this.store.getToken() - } - - /** - * Refresh the current session - */ - async refresh(): Promise { - const refreshToken = this.store.getRefreshToken() - if (!refreshToken) { - console.warn("No refresh token available") - return false - } - - try { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }) - - if (!response.ok) { - console.error("Refresh failed:", response.status) - // If refresh fails, clear auth and require re-login - if (response.status === 401) { - this.logout() - } - return false - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - // Notify callback about token refresh (so cookie can be updated) - if (this.onTokenRefresh) { - this.onTokenRefresh(authData) - } - - return true - } catch (error) { - console.error("Refresh error:", error) - return false - } - } - - /** - * Schedule token refresh before expiration - */ - private scheduleRefresh(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - } - - const authData = this.store.load() - if (!authData) return - - const expiresAt = new Date(authData.expiresAt).getTime() - const now = Date.now() - - // Refresh 5 minutes before expiration - const refreshIn = Math.max(0, expiresAt - now - 5 * 60 * 1000) - - this.refreshTimer = setTimeout(() => { - this.refresh() - }, refreshIn) - - console.log(`Scheduled token refresh in ${Math.round(refreshIn / 1000 / 60)} minutes`) - } - - /** - * Check if user is authenticated - */ - isAuthenticated(): boolean { - return this.store.isAuthenticated() - } - - /** - * Get current user - */ - getUser(): AuthUser | null { - return this.store.getUser() - } - - /** - * Get current auth data - */ - getAuth(): AuthData | null { - return this.store.load() - } - - /** - * Logout and clear stored credentials - */ - logout(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - this.refreshTimer = undefined - } - this.store.clear() - } - - /** - * Start auth flow by opening browser - */ - startAuthFlow(mainWindow: BrowserWindow | null): void { - const { shell } = require("electron") - - let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true` - - // In dev mode, use localhost callback (we run HTTP server on AUTH_SERVER_PORT) - // Also pass the protocol so web knows which deep link to use as fallback - if (this.isDev) { - authUrl += `&callback=${encodeURIComponent(`http://localhost:${AUTH_SERVER_PORT}/auth/callback`)}` - // Pass dev protocol so production web can use correct deep link if callback fails - authUrl += `&protocol=twentyfirst-agents-dev` - } - - shell.openExternal(authUrl) - } - - /** - * Update user profile on server and locally - */ - async updateUser(updates: { name?: string }): Promise { - const token = await this.getValidToken() - if (!token) { - throw new Error("Not authenticated") - } - - // Update on server using X-Desktop-Token header - const response = await fetch(`${this.getApiUrl()}/api/user/profile`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-Desktop-Token": token, - }, - body: JSON.stringify({ - display_name: updates.name, - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Update failed: ${response.status}`) - } - - // Update locally - return this.store.updateUser({ name: updates.name ?? null }) - } - - /** - * Fetch user's subscription plan from web backend - * Used for PostHog analytics enrichment - */ - async fetchUserPlan(): Promise<{ email: string; plan: string; status: string | null } | null> { - const token = await this.getValidToken() - if (!token) return null - - try { - const response = await fetch(`${this.getApiUrl()}/api/desktop/user/plan`, { - headers: { "X-Desktop-Token": token }, - }) - - if (!response.ok) { - console.error("[AuthManager] Failed to fetch user plan:", response.status) - return null - } - - return response.json() - } catch (error) { - console.error("[AuthManager] Failed to fetch user plan:", error) - return null - } - } -} - -// Global singleton instance -let authManagerInstance: AuthManager | null = null - -/** - * Initialize the global auth manager instance - * Must be called once from main process initialization - */ -export function initAuthManager(isDev: boolean = false): AuthManager { - if (!authManagerInstance) { - authManagerInstance = new AuthManager(isDev) - } - return authManagerInstance -} - -/** - * Get the global auth manager instance - * Returns null if not initialized - */ -export function getAuthManager(): AuthManager | null { - return authManagerInstance -} diff --git a/src/main/index.ts b/src/main/index.ts index 57af873f0..6d862f7d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,12 @@ import * as Sentry from "@sentry/electron/main" -import { app, BrowserWindow, dialog, Menu, nativeImage, session } from "electron" +import { app, BrowserWindow, dialog, Menu, nativeImage } from "electron" import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs" import { createServer } from "http" import { join } from "path" -import { AuthManager, initAuthManager, getAuthManager as getAuthManagerFromModule } from "./auth-manager" import { - identify, initAnalytics, - setSubscriptionPlan, shutdown as shutdownAnalytics, trackAppOpened, - trackAuthCompleted, } from "./lib/analytics" import { checkForUpdates, @@ -19,6 +15,13 @@ import { setupFocusUpdateCheck, } from "./lib/auto-updater" import { closeDatabase, initDatabase } from "./lib/db" +import { runOpenCodexPackagingPreflight } from "./lib/opencodex/preflight" +import { + maybeStartOpenCodexBackendHost, + stopOpenCodexBackendHost, +} from "./lib/opencodex/backend-host" +import { setOpenCodexStartupState } from "./lib/opencodex/startup-state" +import { getOpenCodexAppUserModelId, getOpenCodexAuthPageTitle, getOpenCodexCopyright, getOpenCodexProtocol, getOpenCodexSupportUrl, getOpenCodexWebAppUrl, getOpenCodexWebBaseUrl, OPENCODEX_PRODUCT_NAME } from "./lib/opencodex/app-identity" import { getLaunchDirectory, isCliInstalled, @@ -37,13 +40,12 @@ import { getAllWindows, setIsQuitting, } from "./windows/main" -import { windowManager } from "./windows/window-manager" import { IS_DEV, AUTH_SERVER_PORT } from "./constants" // Deep link protocol (must match package.json build.protocols.schemes) // Use different protocol in dev to avoid conflicts with production app -const PROTOCOL = IS_DEV ? "twentyfirst-agents-dev" : "twentyfirst-agents" +const PROTOCOL = getOpenCodexProtocol(IS_DEV) // Set dev mode userData path BEFORE requestSingleInstanceLock() // This ensures dev and prod have separate instance locks @@ -81,108 +83,18 @@ if (app.isPackaged && !IS_DEV) { // In packaged app, ALWAYS use production URL to prevent localhost leaking into releases // In dev mode, allow override via MAIN_VITE_API_URL env variable export function getBaseUrl(): string { - if (app.isPackaged) { - return "https://21st.dev" - } - return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" + return getOpenCodexWebBaseUrl({ + OPENCODEX_WEB_URL: process.env.OPENCODEX_WEB_URL, + MAIN_VITE_API_URL: import.meta.env.MAIN_VITE_API_URL, + }) } export function getAppUrl(): string { - return process.env.ELECTRON_RENDERER_URL || "https://21st.dev/agents" -} - -// Auth manager singleton (use the one from auth-manager module) -let authManager: AuthManager - -export function getAuthManager(): AuthManager { - // First try to get from module, fallback to local variable for backwards compat - return getAuthManagerFromModule() || authManager -} - -// Handle auth code from deep link (exported for IPC handlers) -export async function handleAuthCode(code: string): Promise { - console.log("[Auth] Handling auth code:", code.slice(0, 8) + "...") - - try { - const authData = await authManager.exchangeCode(code) - console.log("[Auth] Success for user:", authData.user.email) - - // Track successful authentication - trackAuthCompleted(authData.user.id, authData.user.email) - - // Fetch and set subscription plan for analytics - try { - const planData = await authManager.fetchUserPlan() - if (planData) { - setSubscriptionPlan(planData.plan) - } - } catch (e) { - console.warn("[Auth] Failed to fetch user plan for analytics:", e) - } - - // Set desktop token cookie using persist:main partition - const ses = session.fromPartition("persist:main") - try { - // First remove any existing cookie to avoid HttpOnly conflict - await ses.cookies.remove(getBaseUrl(), "x-desktop-token") - await ses.cookies.set({ - url: getBaseUrl(), - name: "x-desktop-token", - value: authData.token, - expirationDate: Math.floor( - new Date(authData.expiresAt).getTime() / 1000, - ), - httpOnly: false, - secure: getBaseUrl().startsWith("https"), - sameSite: "lax" as const, - }) - console.log("[Auth] Desktop token cookie set") - } catch (cookieError) { - // Cookie setting is optional - auth data is already saved to disk - console.warn("[Auth] Cookie set failed (non-critical):", cookieError) - } - - // Notify all windows and reload them to show app - const windows = getAllWindows() - for (const win of windows) { - try { - if (win.isDestroyed()) continue - win.webContents.send("auth:success", authData.user) - - // Use stable window ID (main, window-2, etc.) instead of Electron's numeric ID - const stableId = windowManager.getStableId(win) - - if (process.env.ELECTRON_RENDERER_URL) { - // Pass window ID via query param for dev mode - const url = new URL(process.env.ELECTRON_RENDERER_URL) - url.searchParams.set("windowId", stableId) - win.loadURL(url.toString()) - } else { - // Pass window ID via hash for production - win.loadFile(join(__dirname, "../renderer/index.html"), { - hash: `windowId=${stableId}`, - }) - } - } catch (error) { - // Window may have been destroyed during iteration - console.warn("[Auth] Failed to reload window:", error) - } - } - // Focus the first window - windows[0]?.focus() - } catch (error) { - console.error("[Auth] Exchange failed:", error) - // Broadcast auth error to all windows (not just focused) - for (const win of getAllWindows()) { - try { - if (!win.isDestroyed()) { - win.webContents.send("auth:error", (error as Error).message) - } - } catch { - // Window destroyed during iteration - } - } - } + return getOpenCodexWebAppUrl({ + OPENCODEX_WEB_URL: process.env.OPENCODEX_WEB_URL, + MAIN_VITE_API_URL: import.meta.env.MAIN_VITE_API_URL, + ELECTRON_RENDERER_URL: process.env.ELECTRON_RENDERER_URL, + }) } // Handle deep link @@ -192,16 +104,7 @@ function handleDeepLink(url: string): void { try { const parsed = new URL(url) - // Handle auth callback: twentyfirst-agents://auth?code=xxx - if (parsed.pathname === "/auth" || parsed.host === "auth") { - const code = parsed.searchParams.get("code") - if (code) { - handleAuthCode(code) - return - } - } - - // Handle MCP OAuth callback: twentyfirst-agents://mcp-oauth?code=xxx&state=yyy + // Handle MCP OAuth callback: opencodex://mcp-oauth?code=xxx&state=yyy if (parsed.pathname === "/mcp-oauth" || parsed.host === "mcp-oauth") { const code = parsed.searchParams.get("code") const state = parsed.searchParams.get("state") @@ -287,8 +190,8 @@ console.log("[Protocol] =============================================") const FAVICON_SVG = `` const FAVICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}` -// Start local HTTP server for auth callbacks -// This catches http://localhost:{AUTH_SERVER_PORT}/auth/callback?code=xxx and /callback (for MCP OAuth) +// Start local HTTP server for MCP OAuth callbacks. +// This catches http://localhost:{AUTH_SERVER_PORT}/callback?code=xxx&state=yyy. const server = createServer((req, res) => { const url = new URL(req.url || "", `http://localhost:${AUTH_SERVER_PORT}`) @@ -299,87 +202,7 @@ const server = createServer((req, res) => { return } - if (url.pathname === "/auth/callback") { - const code = url.searchParams.get("code") - console.log( - "[Auth Server] Received callback with code:", - code?.slice(0, 8) + "...", - ) - - if (code) { - // Handle the auth code - handleAuthCode(code) - - // Send success response and close the browser tab - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(` - - - - - 1Code - Authentication - - - -
- -

Authentication successful

-

You can close this tab

-
- - -`) - } else { - res.writeHead(400, { "Content-Type": "text/plain" }) - res.end("Missing code parameter") - } - } else if (url.pathname === "/callback") { + if (url.pathname === "/callback") { // Handle MCP OAuth callback const code = url.searchParams.get("code") const state = url.searchParams.get("state") @@ -401,7 +224,7 @@ const server = createServer((req, res) => { - 1Code - MCP Authentication + ${getOpenCodexAuthPageTitle("MCP Authentication")} @@ -91,33 +92,10 @@ - +
OpenCodex local backend
+

+ This desktop shell no longer uses cloud sign-in. Start or repair the local OpenHarness backend from the main OpenCodex onboarding flow. +

- diff --git a/src/renderer/opencodex-shell-contract.test.ts b/src/renderer/opencodex-shell-contract.test.ts new file mode 100644 index 000000000..d336c111b --- /dev/null +++ b/src/renderer/opencodex-shell-contract.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" + +function readUtf8(relativePath: string): string { + return readFileSync(join(import.meta.dir, relativePath), "utf8") +} + +describe("OpenCodex renderer shell contract", () => { + test("renderer stops requesting cloud-auth desktop identity", () => { + const appSource = readUtf8("App.tsx") + const layoutSource = readUtf8("features/layout/agents-layout.tsx") + const profileTabSource = readUtf8("components/dialogs/settings-tabs/agents-profile-tab.tsx") + const modelsTabSource = readUtf8("components/dialogs/settings-tabs/agents-models-tab.tsx") + const mcpTabSource = readUtf8("components/dialogs/settings-tabs/agents-mcp-tab.tsx") + const mcpIndicatorSource = readUtf8("features/agents/ui/mcp-servers-indicator.tsx") + const activeChatSource = readUtf8("features/agents/main/active-chat.tsx") + const acpTransportSource = readUtf8("features/agents/lib/acp-chat-transport.ts") + const ipcTransportSource = readUtf8("features/agents/lib/ipc-chat-transport.ts") + const backendEditorSource = readUtf8("features/onboarding/opencodex-backend-editor.tsx") + const billingPageSource = readUtf8("features/onboarding/billing-method-page.tsx") + const onboardingIndexSource = readUtf8("features/onboarding/index.ts") + const sidebarSource = readUtf8("features/sidebar/agents-sidebar.tsx") + const agentsContentSource = readUtf8("features/agents/ui/agents-content.tsx") + const betaTabSource = readUtf8("components/dialogs/settings-tabs/agents-beta-tab.tsx") + const backendControlSource = readUtf8("features/opencodex/backend-control-view.tsx") + const rendererAtomsSource = readUtf8("lib/atoms/index.ts") + const agentsAtomsSource = readUtf8("features/agents/atoms/index.ts") + const loginPageSource = readUtf8("login.html") + const codexLoginHookPath = join( + import.meta.dir, + "features/agents/hooks/use-codex-login-flow.ts", + ) + const codexLoginContentPath = join( + import.meta.dir, + "features/agents/components/codex-login-content.tsx", + ) + + expect(appSource).not.toContain("window.desktopApi?.getUser") + expect(layoutSource).not.toContain("window.desktopApi.getUser()") + expect(layoutSource).not.toContain("window.desktopApi?.logout") + expect(layoutSource).not.toContain("ClaudeLoginModal") + expect(layoutSource).not.toContain("CodexLoginModal") + expect(layoutSource).not.toContain("claudeLoginModalConfigAtom") + expect(profileTabSource).not.toContain("window.desktopApi?.getUser") + expect(profileTabSource).not.toContain("window.desktopApi?.updateUser") + expect(modelsTabSource).not.toContain("trpc.codex.") + expect(modelsTabSource).not.toContain("trpc.claudeCode") + expect(modelsTabSource).not.toContain("trpc.anthropicAccounts") + expect(mcpTabSource).not.toContain("trpc.claude.") + expect(mcpTabSource).not.toContain("trpc.codex.") + expect(mcpIndicatorSource).not.toContain("trpc.claude.") + expect(mcpIndicatorSource).not.toContain("trpc.codex.") + expect(activeChatSource).not.toContain("trpcClient.claude.respondToolApproval") + expect(activeChatSource).not.toContain("new ACPChatTransport") + expect(activeChatSource).not.toContain("new IPCChatTransport") + expect(acpTransportSource).not.toContain("trpcClient.codex.") + expect(ipcTransportSource).not.toContain("trpcClient.claude.") + expect(mcpTabSource).toContain("trpc.opencodex.getAllMcpConfig") + expect(mcpIndicatorSource).toContain("trpc.opencodex.getMcpConfig") + expect(activeChatSource).toContain("trpcClient.opencodex.respondToolApproval") + expect(activeChatSource).toContain("createOpenCodexChatTransport") + expect(acpTransportSource).toContain("trpcClient.opencodex.") + expect(ipcTransportSource).toContain("trpcClient.opencodex.") + expect(modelsTabSource).toContain("trpc.opencodex.getBackendSurface") + expect(modelsTabSource).toContain("OpenCodexBackendEditor") + expect(billingPageSource).toContain("OpenCodexBackendEditor") + expect(backendEditorSource).toContain("window.desktopApi.getBackendHostState()") + expect(backendEditorSource).toContain("window.desktopApi.saveOpenCodexBackendConfig") + expect(backendEditorSource).toContain("Save Backend") + expect(onboardingIndexSource).not.toContain("AnthropicOnboardingPage") + expect(onboardingIndexSource).not.toContain("ApiKeyOnboardingPage") + expect(onboardingIndexSource).not.toContain("CodexOnboardingPage") + expect(sidebarSource).not.toContain("Log out") + expect(sidebarSource).not.toContain("AuthDialog") + expect(sidebarSource).not.toContain("Login") + expect(sidebarSource).not.toContain("remoteTrpc.automations") + expect(sidebarSource).not.toContain("getOpenCodexAutomationsUrl") + expect(sidebarSource).not.toContain("") + expect(sidebarSource).not.toContain("") + expect(sidebarSource).toContain("OpenCodex Backend") + expect(agentsContentSource).not.toContain("AutomationsView") + expect(agentsContentSource).not.toContain("AutomationsDetailView") + expect(agentsContentSource).not.toContain("InboxView") + expect(agentsContentSource).not.toContain("betaAutomationsEnabledAtom") + expect(agentsContentSource).not.toContain("remoteTrpc.automations") + expect(agentsContentSource).toContain("BackendControlView") + expect(betaTabSource).not.toContain("remoteTrpc.agents.getAgentsSubscription") + expect(betaTabSource).not.toContain("Automations & Inbox") + expect(betaTabSource).toContain("Backend Control Center") + expect(rendererAtomsSource).not.toContain("betaAutomationsEnabledAtom") + expect(rendererAtomsSource).not.toContain("automationDetailIdAtom") + expect(rendererAtomsSource).not.toContain("automationTemplateParamsAtom") + expect(rendererAtomsSource).not.toContain("inboxSelectedChatIdAtom") + expect(rendererAtomsSource).not.toContain("agentsInboxSidebarWidthAtom") + expect(rendererAtomsSource).not.toContain("inboxMobileViewModeAtom") + expect(agentsAtomsSource).not.toContain("\"automations\"") + expect(agentsAtomsSource).not.toContain("\"automations-detail\"") + expect(agentsAtomsSource).not.toContain("\"inbox\"") + expect(agentsAtomsSource).toContain("\"backend-control\"") + expect(backendControlSource).toContain("trpc.opencodex.getBackendSurface") + expect(backendControlSource).toContain("trpc.opencodex.getAllMcpConfig") + expect(backendControlSource).toContain("trpc.external.openInFinder") + expect(backendControlSource).toContain("OpenCodexBackendEditor") + expect(loginPageSource).not.toContain("onAuthSuccess") + expect(loginPageSource).not.toContain("startAuthFlow") + expect(loginPageSource).not.toContain("Sign in") + expect(existsSync(codexLoginHookPath)).toBe(false) + expect(existsSync(codexLoginContentPath)).toBe(false) + + expect(profileTabSource).toContain("window.desktopApi.getLocalProfile()") + expect(profileTabSource).toContain("window.desktopApi.updateLocalProfile({ displayName: trimmed })") + expect(layoutSource).toContain("window.desktopApi?.resetLocalWorkspace") + expect(sidebarSource).toContain("Reset local workspace") + expect(loginPageSource).toContain("OpenCodex local backend") + }) +}) diff --git a/src/shared/opencodex-backend-route.test.ts b/src/shared/opencodex-backend-route.test.ts new file mode 100644 index 000000000..d9de90b7b --- /dev/null +++ b/src/shared/opencodex-backend-route.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { + getOpenCodexBackendRouteTemplate, + getOpenCodexBackendRuntimeKind, + normalizeOpenCodexBackendRoute, + openCodexBackendRouteRequiresHost, + parseOpenCodexBackendRoute, +} from "./opencodex-backend-route" + +describe("OpenCodex backend route helpers", () => { + test("normalizes the approved subscription bridge route variants", () => { + expect( + normalizeOpenCodexBackendRoute({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }), + ).toEqual({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }) + + expect( + normalizeOpenCodexBackendRoute({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }), + ).toEqual({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }) + }) + + test("migrates the legacy narrow config shape into the canonical union route", () => { + expect( + parseOpenCodexBackendRoute({ + providerFamily: "openai-compatible", + baseUrl: " https://api.openai.com/v1 ", + model: " gpt-5.2 ", + apiKey: " sk-route-openai ", + }), + ).toEqual({ + kind: "openai-compatible-api", + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-route-openai", + }) + + expect( + parseOpenCodexBackendRoute({ + providerFamily: "custom", + baseUrl: " http://127.0.0.1:8000/v1 ", + model: " opencodex-default ", + apiKey: " sk-custom-endpoint ", + }), + ).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "sk-custom-endpoint", + }) + }) + + test("rejects invalid api-backed routes and identifies which routes need host launch materialization", () => { + expect( + normalizeOpenCodexBackendRoute({ + kind: "anthropic-compatible-api", + authSource: "api-key", + baseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-6", + apiKey: "sk-short", + }), + ).toBeUndefined() + + expect( + openCodexBackendRouteRequiresHost({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }), + ).toBe(false) + + expect( + openCodexBackendRouteRequiresHost({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "anthropic-compatible", + baseUrl: "https://proxy.example.com", + model: "claude-opus", + apiKey: "sk-ant-123456789012345678901", + }), + ).toBe(true) + }) + + test("provides stable templates and runtime mapping for all route kinds", () => { + expect(getOpenCodexBackendRouteTemplate("codex-subscription")).toEqual({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }) + + expect(getOpenCodexBackendRouteTemplate("custom-endpoint")).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "", + }) + + expect( + getOpenCodexBackendRuntimeKind({ + kind: "openai-compatible-api", + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-openai-route", + }), + ).toBe("codex") + + expect( + getOpenCodexBackendRuntimeKind({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }), + ).toBe("claude") + }) +}) \ No newline at end of file diff --git a/src/shared/opencodex-backend-route.ts b/src/shared/opencodex-backend-route.ts new file mode 100644 index 000000000..82d620d91 --- /dev/null +++ b/src/shared/opencodex-backend-route.ts @@ -0,0 +1,314 @@ +export type OpenCodexBackendProviderFamily = + | "openai-compatible" + | "anthropic-compatible" + +export type OpenCodexBackendRouteKind = + | "codex-subscription" + | "claude-subscription" + | "openai-compatible-api" + | "anthropic-compatible-api" + | "custom-endpoint" + +export type OpenCodexBackendRoute = + | { + kind: "codex-subscription" + authSource: "codex-local-auth" + } + | { + kind: "claude-subscription" + authSource: "claude-local-auth" + } + | { + kind: "openai-compatible-api" + authSource: "api-key" + baseUrl: string + model: string + apiKey: string + } + | { + kind: "anthropic-compatible-api" + authSource: "api-key" + baseUrl: string + model: string + apiKey: string + } + | { + kind: "custom-endpoint" + authSource: "api-key" + providerFamily: OpenCodexBackendProviderFamily + baseUrl: string + model: string + apiKey: string + } + +type LegacyOpenCodexBackendConfig = { + providerFamily: "openai-compatible" | "anthropic-compatible" | "custom" + baseUrl: string + model: string + apiKey: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null + } + + return value.trim() +} + +function isValidOpenAiStyleKey(apiKey: string): boolean { + return apiKey.startsWith("sk-") +} + +function isValidAnthropicKey(apiKey: string): boolean { + return apiKey.startsWith("sk-ant-") && apiKey.length > 20 +} + +function normalizeApiRouteFields( + input: Record, +): { + baseUrl: string + model: string + apiKey: string +} | null { + const baseUrl = normalizeText(input.baseUrl) + const model = normalizeText(input.model) + const apiKey = normalizeText(input.apiKey) + + if (!baseUrl || !model || !apiKey) { + return null + } + + return { baseUrl, model, apiKey } +} + +function normalizeUnionRoute(input: Record): OpenCodexBackendRoute | null { + const kind = normalizeText(input.kind) as OpenCodexBackendRouteKind | null + if (!kind) { + return null + } + + switch (kind) { + case "codex-subscription": + return input.authSource === "codex-local-auth" + ? { kind, authSource: "codex-local-auth" } + : null + case "claude-subscription": + return input.authSource === "claude-local-auth" + ? { kind, authSource: "claude-local-auth" } + : null + case "openai-compatible-api": { + if (input.authSource !== "api-key") { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields || !isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + return { kind, authSource: "api-key", ...fields } + } + case "anthropic-compatible-api": { + if (input.authSource !== "api-key") { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields || !isValidAnthropicKey(fields.apiKey)) { + return null + } + return { kind, authSource: "api-key", ...fields } + } + case "custom-endpoint": { + if (input.authSource !== "api-key") { + return null + } + const providerFamily = normalizeText( + input.providerFamily, + ) as OpenCodexBackendProviderFamily | null + if ( + providerFamily !== "openai-compatible" && + providerFamily !== "anthropic-compatible" + ) { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields) { + return null + } + const isValidKey = + providerFamily === "anthropic-compatible" + ? isValidAnthropicKey(fields.apiKey) + : isValidOpenAiStyleKey(fields.apiKey) + if (!isValidKey) { + return null + } + return { kind, authSource: "api-key", providerFamily, ...fields } + } + } +} + +function normalizeLegacyRoute( + input: Record, +): OpenCodexBackendRoute | null { + const providerFamily = normalizeText(input.providerFamily) + if ( + providerFamily !== "openai-compatible" && + providerFamily !== "anthropic-compatible" && + providerFamily !== "custom" + ) { + return null + } + + const fields = normalizeApiRouteFields(input) + if (!fields) { + return null + } + + if (providerFamily === "openai-compatible") { + if (!isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + return { + kind: "openai-compatible-api", + authSource: "api-key", + ...fields, + } + } + + if (providerFamily === "anthropic-compatible") { + if (!isValidAnthropicKey(fields.apiKey)) { + return null + } + return { + kind: "anthropic-compatible-api", + authSource: "api-key", + ...fields, + } + } + + if (!isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + + return { + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + ...fields, + } +} + +export function getOpenCodexBackendRouteTemplate( + kind: OpenCodexBackendRouteKind, +): OpenCodexBackendRoute { + switch (kind) { + case "codex-subscription": + return { kind, authSource: "codex-local-auth" } + case "claude-subscription": + return { kind, authSource: "claude-local-auth" } + case "anthropic-compatible-api": + return { + kind, + authSource: "api-key", + baseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-6", + apiKey: "", + } + case "custom-endpoint": + return { + kind, + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "", + } + default: + return { + kind, + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "", + } + } +} + +export function normalizeOpenCodexBackendRoute( + route: OpenCodexBackendRoute, +): OpenCodexBackendRoute | undefined { + return parseOpenCodexBackendRoute(route) ?? undefined +} + +export function parseOpenCodexBackendRoute( + value: unknown, +): OpenCodexBackendRoute | null { + if (!isRecord(value)) { + return null + } + + if ("kind" in value) { + return normalizeUnionRoute(value) + } + + return normalizeLegacyRoute(value) +} + +export function getOpenCodexBackendRouteTitle( + route: OpenCodexBackendRoute, +): string { + switch (route.kind) { + case "codex-subscription": + return "Codex Subscription" + case "claude-subscription": + return "Claude Subscription" + case "openai-compatible-api": + return "OpenAI-Compatible API" + case "anthropic-compatible-api": + return "Anthropic-Compatible API" + case "custom-endpoint": + return "Custom Endpoint" + } +} + +export function getOpenCodexBackendProviderFamily( + route: OpenCodexBackendRoute, +): OpenCodexBackendProviderFamily | null { + switch (route.kind) { + case "openai-compatible-api": + return "openai-compatible" + case "anthropic-compatible-api": + return "anthropic-compatible" + case "custom-endpoint": + return route.providerFamily + default: + return null + } +} + +export function getOpenCodexBackendRuntimeKind( + route: OpenCodexBackendRoute, +): "codex" | "claude" { + switch (route.kind) { + case "codex-subscription": + case "openai-compatible-api": + return "codex" + default: + return "claude" + } +} + +export function openCodexBackendRouteRequiresHost( + route: OpenCodexBackendRoute, +): boolean { + return ( + route.kind === "openai-compatible-api" || + route.kind === "anthropic-compatible-api" || + route.kind === "custom-endpoint" + ) +} + +export type { LegacyOpenCodexBackendConfig } \ No newline at end of file diff --git a/tests/wor44-branding.test.mjs b/tests/wor44-branding.test.mjs new file mode 100644 index 000000000..37c8bf357 --- /dev/null +++ b/tests/wor44-branding.test.mjs @@ -0,0 +1,121 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const root = join(__dirname, "..") + +function read(relativePath) { + return readFileSync(join(root, relativePath), "utf8") +} + +test("package metadata uses OpenCodex product branding", () => { + const packageJson = read("package.json") + assert.match(packageJson, /"description": "OpenCodex - UI-first local coding workstation"/) + assert.match(packageJson, /"productName": "OpenCodex"/) + assert.match(packageJson, /"name": "opencodex-desktop"/) +}) + +test("main process product chrome uses OpenCodex language", () => { + const mainSource = read("src/main/index.ts") + const windowsSource = read("src/main/windows/main.ts") + + assert.match(mainSource, /applicationName: "OpenCodex"/) + assert.match(mainSource, /label: "About OpenCodex"/) + assert.match(mainSource, /title>OpenCodex - Authentication1Code - Authentication { + const billing = read("src/renderer/features/onboarding/billing-method-page.tsx") + const anthropic = read("src/renderer/features/onboarding/anthropic-onboarding-page.tsx") + const codex = read("src/renderer/features/agents/components/codex-login-content.tsx") + const html = read("src/renderer/index.html") + const titleBar = read("src/renderer/components/windows-title-bar.tsx") + const sidebar = read("src/renderer/features/sidebar/agents-sidebar.tsx") + + assert.match(billing, /Set Up OpenCodex Backend/) + assert.match(anthropic, /Connect Anthropic Runtime/) + assert.match(codex, /Connect OpenCodex Runtime/) + assert.match(html, /OpenCodex<\/title>/) + assert.match(titleBar, />OpenCodex</) + assert.match(sidebar, /OpenCodex/) + + assert.doesNotMatch(billing, /Connect AI Provider/) + assert.doesNotMatch(billing, /Claude Code/) + assert.doesNotMatch(billing, /Codex Subscription/) + assert.doesNotMatch(anthropic, /Connect Claude Code/) + assert.doesNotMatch(codex, /Connect OpenAI Codex/) +}) + +test("settings and chooser surfaces present compatibility runtimes instead of Claude/Codex product brands", () => { + const models = read("src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx") + const mcp = read("src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx") + const activeChat = read("src/renderer/features/agents/main/active-chat.tsx") + const newChat = read("src/renderer/features/agents/main/new-chat-form.tsx") + const automations = read("src/renderer/features/automations/_components/utils.ts") + const templateCard = read("src/renderer/features/automations/_components/template-card.tsx") + const transport = read("src/renderer/features/agents/lib/ipc-chat-transport.ts") + const automationDetail = read("src/renderer/features/automations/automations-detail-view.tsx") + + assert.match(models, /Anthropic Runtime Accounts/) + assert.match(models, /OpenAI-Compatible Runtime/) + assert.match(mcp, /OpenAI-Compatible/) + assert.match(mcp, /Anthropic-Compatible/) + assert.match(activeChat, /Anthropic-Compatible/) + assert.match(newChat, /OpenAI-Compatible/) + assert.match(automations, /run OpenCodex runtime/) + assert.match(templateCard, /run OpenCodex runtime/) + assert.match(transport, /Anthropic runtime usage limit/) + assert.match(transport, /Anthropic-compatible runtime/) + assert.match(automationDetail, /Repository where the OpenCodex runtime will make changes/) + + assert.doesNotMatch(models, /Codex Account/) + assert.doesNotMatch(models, /Manage your Codex account/) + assert.doesNotMatch(mcp, />OpenAI Codex</) + assert.doesNotMatch(mcp, />Claude Code</) + assert.doesNotMatch(activeChat, /name: "Claude Code"/) + assert.doesNotMatch(newChat, /name: "OpenAI Codex"/) + assert.doesNotMatch(transport, /Claude Code/) + assert.doesNotMatch(automationDetail, /Repository where Claude Code will make changes/) +}) + +test("renderer external links route through OpenCodex link constants instead of scattered legacy URLs", () => { + const links = read("src/renderer/lib/opencodex-links.ts") + const helpPopover = read("src/renderer/features/agents/components/agents-help-popover.tsx") + const sidebar = read("src/renderer/features/sidebar/agents-sidebar.tsx") + const justUpdated = read("src/renderer/lib/hooks/use-just-updated.ts") + const updateBanner = read("src/renderer/components/update-banner.tsx") + const envTypes = read("src/env.d.ts") + + assert.match(links, /VITE_OPENCODEX_COMMUNITY_URL/) + assert.match(links, /VITE_OPENCODEX_CHANGELOG_URL/) + assert.match(links, /VITE_OPENCODEX_AGENTS_CHANGELOG_URL/) + assert.match(links, /VITE_OPENCODEX_CHANGELOG_FEED_URL/) + + assert.match(helpPopover, /OPENCODEX_CHANGELOG_FEED_URL/) + assert.match(helpPopover, /OPENCODEX_COMMUNITY_URL/) + assert.match(helpPopover, /buildOpenCodexAgentsChangelogUrl/) + assert.match(sidebar, /OPENCODEX_COMMUNITY_URL/) + assert.match(justUpdated, /buildOpenCodexChangelogUrl/) + assert.match(updateBanner, /openChangelog\(\)/) + assert.match(envTypes, /VITE_OPENCODEX_COMMUNITY_URL/) + assert.match(envTypes, /VITE_OPENCODEX_CHANGELOG_URL/) + + assert.doesNotMatch(helpPopover, /1code\.dev/) + assert.doesNotMatch(helpPopover, /discord\.gg\/8ektTZGnj4/) + assert.doesNotMatch(sidebar, /discord\.gg\/8ektTZGnj4/) + assert.doesNotMatch(justUpdated, /1code\.dev/) + assert.doesNotMatch(updateBanner, /1code\.dev/) +})