diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7f180cb2f..4c719884b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -315,6 +315,11 @@ jobs: OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} PAWWORK_FEEDBACK_FORM_URL: ${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }} + - name: Check desktop runtime imports + if: ${{ inputs.phase != 'finalize' }} + run: bun ./scripts/runtime-import-guard.ts + working-directory: packages/desktop-electron + - name: Setup Apple API Key if: runner.os == 'macOS' run: printenv APPLE_API_KEY_CONTENT > $RUNNER_TEMP/apple-api-key.p8 @@ -364,6 +369,32 @@ jobs: env: OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} + - name: Smoke signed macOS app + if: ${{ runner.os == 'macOS' && (inputs.phase == 'submit' || inputs.phase == 'full') }} + run: | + set -euo pipefail + + case "$OPENCODE_CHANNEL" in + dev) APP_NAME="PawWork Dev" ;; + beta) APP_NAME="PawWork Beta" ;; + prod) APP_NAME="PawWork" ;; + *) echo "Unsupported channel: $OPENCODE_CHANNEL"; exit 1 ;; + esac + + case "${{ matrix.arch_label }}" in + arm64) APP_OUT_DIR="dist/mac-arm64" ;; + x64) APP_OUT_DIR="dist/mac" ;; + *) echo "Unsupported arch: ${{ matrix.arch_label }}"; exit 1 ;; + esac + + APP_PATH="$APP_OUT_DIR/$APP_NAME.app" + EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME" + bun ./scripts/ci-smoke.ts packaged "$OPENCODE_CHANNEL" "$EXECUTABLE_PATH" + working-directory: packages/desktop-electron + timeout-minutes: 2 + env: + OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }} + - name: Prepare signed app artifact if: ${{ runner.os == 'macOS' && inputs.phase == 'submit' }} run: | diff --git a/.github/workflows/desktop-smoke.yml b/.github/workflows/desktop-smoke.yml index d27070a0a..f49db8a50 100644 --- a/.github/workflows/desktop-smoke.yml +++ b/.github/workflows/desktop-smoke.yml @@ -134,6 +134,10 @@ jobs: env: OPENCODE_CHANNEL: dev + - name: Check desktop runtime imports + run: bun ./scripts/runtime-import-guard.ts + working-directory: packages/desktop-electron + - name: Launch desktop smoke app run: bun run smoke:ci working-directory: packages/desktop-electron @@ -190,6 +194,14 @@ jobs: grep -q "Signature=adhoc" /tmp/pawwork-codesign.txt working-directory: packages/desktop-electron + - name: Launch packaged desktop smoke app + run: | + set -euo pipefail + + EXECUTABLE_PATH="dist/mac-arm64/PawWork Dev.app/Contents/MacOS/PawWork Dev" + bun ./scripts/ci-smoke.ts packaged dev "$EXECUTABLE_PATH" + working-directory: packages/desktop-electron + # Aggregator for the `dev` branch ruleset. The required check on GitHub # is `desktop-smoke / check`; every new job added above MUST be listed # in `needs:` below, otherwise its failure will not block merge. diff --git a/packages/desktop-electron/scripts/ci-smoke.test.ts b/packages/desktop-electron/scripts/ci-smoke.test.ts index ad8031f35..db01b2909 100644 --- a/packages/desktop-electron/scripts/ci-smoke.test.ts +++ b/packages/desktop-electron/scripts/ci-smoke.test.ts @@ -1,13 +1,22 @@ import { describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" import path from "node:path" import { desktopShellMainSelector, titlebarShellSelector } from "../src/renderer/ci-smoke-selectors" -import { buildSmokeEnv, requiredSelectors, resolveCiSmokeReadyFile, resolveMainEntry } from "./ci-smoke" +import { + appIdForSmoke, + buildSmokeEnv, + parseSmokeArgs, + requiredSelectors, + resolveCiSmokeReadyFile, + resolveLaunchCommand, + resolveMainEntry, +} from "./ci-smoke" describe("ci smoke helpers", () => { test("resolveMainEntry points at the built Electron main process bundle", () => { - expect(resolveMainEntry().endsWith(path.join("packages", "desktop-electron", "out", "main", "index.js"))).toBe( - true, - ) + expect(resolveMainEntry().endsWith(path.join("packages", "desktop-electron", "out", "main", "index.js"))).toBe(true) }) test("buildSmokeEnv isolates the app state in a temporary home", () => { @@ -33,4 +42,99 @@ describe("ci smoke helpers", () => { path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop.dev", "ci-smoke-ready.json"), ) }) + + test("appIdForSmoke uses dev app data for raw runs and channel app IDs for packaged runs", () => { + expect(appIdForSmoke("dev", "raw")).toBe("ai.pawwork.desktop.dev") + expect(appIdForSmoke("prod", "raw")).toBe("ai.pawwork.desktop.dev") + expect(appIdForSmoke("dev", "packaged")).toBe("ai.pawwork.desktop.dev") + expect(appIdForSmoke("beta", "packaged")).toBe("ai.pawwork.desktop.beta") + expect(appIdForSmoke("prod", "packaged")).toBe("ai.pawwork.desktop") + }) + + test("resolveCiSmokeReadyFile follows packaged channel app IDs", () => { + expect(resolveCiSmokeReadyFile("/tmp/pawwork-ci-smoke", { channel: "prod", mode: "packaged" })).toBe( + path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop", "ci-smoke-ready.json"), + ) + expect(resolveCiSmokeReadyFile("/tmp/pawwork-ci-smoke", { channel: "beta", mode: "packaged" })).toBe( + path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop.beta", "ci-smoke-ready.json"), + ) + }) + + test("buildSmokeEnv carries the requested channel into the child process", () => { + const env = buildSmokeEnv("/tmp/pawwork-ci-smoke", "prod") + + expect(env.OPENCODE_CHANNEL).toBe("prod") + expect(env.PAWWORK_CI_SMOKE).toBe("true") + expect(env.PAWWORK_CI_SMOKE_HOME).toBe("/tmp/pawwork-ci-smoke") + }) + + test("parseSmokeArgs defaults to raw dev mode", () => { + expect(parseSmokeArgs([])).toEqual({ mode: "raw", channel: "dev" }) + }) + + test("parseSmokeArgs accepts a packaged executable path", () => { + const dir = mkdtempSync(path.join(tmpdir(), "pawwork-ci-smoke-")) + try { + const executablePath = path.join(dir, "PawWork") + writeFileSync(executablePath, "") + + expect(parseSmokeArgs(["packaged", "prod", executablePath])).toEqual({ + mode: "packaged", + channel: "prod", + executablePath, + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("parseSmokeArgs rejects packaged mode without an executable path", () => { + expect(() => parseSmokeArgs(["packaged", "dev"])).toThrow("Packaged smoke requires an executable path") + }) + + test("parseSmokeArgs rejects packaged mode when the executable path is missing", () => { + expect(() => parseSmokeArgs(["packaged", "dev", "/tmp/pawwork-missing-executable"])).toThrow( + "Packaged smoke executable not found: /tmp/pawwork-missing-executable", + ) + }) + + test("resolveLaunchCommand uses Electron for raw runs and the app executable for packaged runs", () => { + const raw = resolveLaunchCommand({ mode: "raw", channel: "dev" }) + expect(raw.args).toEqual([resolveMainEntry()]) + expect(raw.command).toContain("electron") + + const packaged = resolveLaunchCommand({ + mode: "packaged", + channel: "dev", + executablePath: "/tmp/PawWork Dev.app/Contents/MacOS/PawWork Dev", + }) + expect(packaged).toEqual({ + command: "/tmp/PawWork Dev.app/Contents/MacOS/PawWork Dev", + args: [], + }) + }) + + test("packaged smoke reports spawn failures with launch context", () => { + const dir = mkdtempSync(path.join(tmpdir(), "pawwork-ci-smoke-")) + try { + const executablePath = path.join(dir, "PawWork") + writeFileSync(executablePath, "") + chmodSync(executablePath, 0o755) + + const result = spawnSync( + process.execPath, + [path.join(import.meta.dir, "ci-smoke.ts"), "packaged", "dev", executablePath], + { + encoding: "utf8", + timeout: 5_000, + }, + ) + + expect(result.status).not.toBe(0) + expect(`${result.stdout}${result.stderr}`).toContain("Failed to launch desktop app:") + expect(`${result.stdout}${result.stderr}`).toContain(executablePath) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/packages/desktop-electron/scripts/ci-smoke.ts b/packages/desktop-electron/scripts/ci-smoke.ts index 20728bc03..a05011ca6 100644 --- a/packages/desktop-electron/scripts/ci-smoke.ts +++ b/packages/desktop-electron/scripts/ci-smoke.ts @@ -11,11 +11,53 @@ import { desktopShellMainSelector, titlebarShellSelector } from "../src/renderer export const requiredSelectors = [titlebarShellSelector, desktopShellMainSelector] const require = createRequire(import.meta.url) +export type SmokeChannel = "dev" | "beta" | "prod" +export type SmokeMode = "raw" | "packaged" + +export type SmokeTarget = + | { mode: "raw"; channel: SmokeChannel } + | { mode: "packaged"; channel: SmokeChannel; executablePath: string } + +type LaunchedApp = { + child: ChildProcessWithoutNullStreams + spawnError: { current: Error | undefined } +} + +const APP_ID_BY_CHANNEL: Record = { + dev: "ai.pawwork.desktop.dev", + beta: "ai.pawwork.desktop.beta", + prod: "ai.pawwork.desktop", +} + +function parseChannel(raw: string | undefined): SmokeChannel { + if (raw === undefined || raw === "") return "dev" + if (raw === "dev" || raw === "beta" || raw === "prod") return raw + throw new Error(`Unsupported smoke channel: ${raw}`) +} + +export function appIdForSmoke(channel: SmokeChannel, mode: SmokeMode) { + if (mode === "raw") return APP_ID_BY_CHANNEL.dev + return APP_ID_BY_CHANNEL[channel] +} + +export function parseSmokeArgs(argv: string[]): SmokeTarget { + const mode = argv[0] as SmokeMode | undefined + if (mode === undefined || mode === "raw") { + return { mode: "raw", channel: parseChannel(argv[1]) } + } + if (mode !== "packaged") throw new Error(`Unsupported smoke mode: ${mode}`) + + const executablePath = argv[2] + if (!executablePath) throw new Error("Packaged smoke requires an executable path") + if (!existsSync(executablePath)) throw new Error(`Packaged smoke executable not found: ${executablePath}`) + return { mode, channel: parseChannel(argv[1]), executablePath } +} + export function resolveMainEntry() { return resolve(import.meta.dir, "../out/main/index.js") } -export function buildSmokeEnv(homeDir: string) { +export function buildSmokeEnv(homeDir: string, channel: SmokeChannel = "dev") { return { ...process.env, CI: "true", @@ -26,18 +68,27 @@ export function buildSmokeEnv(homeDir: string) { XDG_CACHE_HOME: homeDir, XDG_CONFIG_HOME: homeDir, XDG_STATE_HOME: homeDir, - OPENCODE_CHANNEL: "dev", + OPENCODE_CHANNEL: channel, } } -export function resolveCiSmokeReadyFile(homeDir: string) { - return join(homeDir, "ai.pawwork.desktop.dev", "ci-smoke-ready.json") +export function resolveCiSmokeReadyFile(homeDir: string, options: { channel?: SmokeChannel; mode?: SmokeMode } = {}) { + const channel = options.channel ?? "dev" + const mode = options.mode ?? "raw" + return join(homeDir, appIdForSmoke(channel, mode), "ci-smoke-ready.json") } function resolveElectronBinary() { return require("electron/index.js") as string } +export function resolveLaunchCommand(target: SmokeTarget) { + if (target.mode === "packaged") { + return { command: target.executablePath, args: [] as string[] } + } + return { command: resolveElectronBinary(), args: [resolveMainEntry()] } +} + function watchChildLogs(child: ChildProcessWithoutNullStreams) { const stdout = readline.createInterface({ input: child.stdout }) const stderr = readline.createInterface({ input: child.stderr }) @@ -60,11 +111,18 @@ function watchChildLogs(child: ChildProcessWithoutNullStreams) { } } -async function waitForCiSmokeReady(homeDir: string, child: ChildProcessWithoutNullStreams, recent: string[]) { - const readyFile = resolveCiSmokeReadyFile(homeDir) +async function waitForCiSmokeReady( + homeDir: string, + target: SmokeTarget, + child: ChildProcessWithoutNullStreams, + spawnError: { current: Error | undefined }, + recent: string[], +) { + const readyFile = resolveCiSmokeReadyFile(homeDir, { channel: target.channel, mode: target.mode }) const timeoutAt = Date.now() + 60_000 while (Date.now() < timeoutAt) { + if (spawnError.current) throw new Error(`Failed to launch desktop app: ${spawnError.current.message}`) if (existsSync(readyFile)) return if (child.exitCode !== null || child.signalCode !== null) { @@ -79,11 +137,22 @@ async function waitForCiSmokeReady(homeDir: string, child: ChildProcessWithoutNu throw new Error(`Timed out waiting for the desktop app to report CI smoke readiness${tail}`) } -function launchApp(homeDir: string) { - return spawn(resolveElectronBinary(), [resolveMainEntry()], { - env: buildSmokeEnv(homeDir), - stdio: ["ignore", "pipe", "pipe"], - }) +function launchApp(homeDir: string, target: SmokeTarget): LaunchedApp { + const launch = resolveLaunchCommand(target) + const spawnError = { current: undefined as Error | undefined } + try { + const child = spawn(launch.command, launch.args, { + env: buildSmokeEnv(homeDir, target.channel), + stdio: ["ignore", "pipe", "pipe"], + }) + child.on("error", (error) => { + spawnError.current = error + }) + return { child, spawnError } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to launch desktop app: ${message}`) + } } async function stopChild(child: ChildProcessWithoutNullStreams) { @@ -99,12 +168,13 @@ async function stopChild(child: ChildProcessWithoutNullStreams) { } async function main() { + const target = parseSmokeArgs(Bun.argv.slice(2)) const homeDir = mkdtempSync(join(tmpdir(), "pawwork-ci-smoke-")) - const child = launchApp(homeDir) + const { child, spawnError } = launchApp(homeDir, target) const logs = watchChildLogs(child) try { - await waitForCiSmokeReady(homeDir, child, logs.recent) + await waitForCiSmokeReady(homeDir, target, child, spawnError, logs.recent) } finally { logs.close() await stopChild(child) diff --git a/packages/desktop-electron/scripts/runtime-import-guard.test.ts b/packages/desktop-electron/scripts/runtime-import-guard.test.ts new file mode 100644 index 000000000..c48464daf --- /dev/null +++ b/packages/desktop-electron/scripts/runtime-import-guard.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { + extractRuntimeImports, + findTsSourceRuntimeImports, + findWorkspacePackageJsonPath, + packageExportsTypeScriptSourceForSpecifier, + packageNameForSpecifier, + readBuiltRuntimeFiles, + runRuntimeImportGuard, +} from "./runtime-import-guard" + +describe("runtime import guard", () => { + test("extractRuntimeImports finds static and dynamic runtime imports", () => { + const source = ` + import "side-effect";import { NamedError } from "@opencode-ai/util/error" + import "@opencode-ai/shared/runtime";const cjs = require("@opencode-ai/sdk") + const lazy = import("@opencode-ai/plugin") + const templated = import(\`@opencode-ai/util/template\`) + export * from "@opencode-ai/util/re-export-all" + export { NamedError as ExportedNamedError } from "@opencode-ai/util/re-export-named" + export type { Foo as ExportedFoo } from "@opencode-ai/util/exported-type" + import type { Foo } from "@opencode-ai/util/foo" + const label = "@opencode-ai/util/not-an-import" + ` + + expect(extractRuntimeImports(source)).toEqual([ + { kind: "import", specifier: "side-effect" }, + { kind: "import", specifier: "@opencode-ai/util/error" }, + { kind: "import", specifier: "@opencode-ai/shared/runtime" }, + { kind: "require", specifier: "@opencode-ai/sdk" }, + { kind: "import", specifier: "@opencode-ai/plugin" }, + { kind: "import", specifier: "@opencode-ai/util/template" }, + { kind: "import", specifier: "@opencode-ai/util/re-export-all" }, + { kind: "import", specifier: "@opencode-ai/util/re-export-named" }, + ]) + }) + + test("packageNameForSpecifier returns the scoped package name", () => { + expect(packageNameForSpecifier("@opencode-ai/util/error")).toBe("@opencode-ai/util") + expect(packageNameForSpecifier("@opencode-ai/shared/runtime")).toBe("@opencode-ai/shared") + expect(packageNameForSpecifier("electron-store")).toBe("electron-store") + }) + + test("packageExportsTypeScriptSourceForSpecifier detects the imported runtime subpath", () => { + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { "./*": "./src/*.ts" } }, + "@opencode-ai/util", + "@opencode-ai/util/error", + "import", + ), + ).toBe(true) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { ".": "./src/index.ts", "./tool": "./src/tool.ts" } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "import", + ), + ).toBe(true) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { import: "./src/index.ts", require: "./dist/index.cjs" } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "import", + ), + ).toBe(true) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { import: "./src/index.ts", require: "./dist/index.cjs" } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "require", + ), + ).toBe(false) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { ".": { types: "./src/index.d.ts", import: "./dist/index.js" } } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "import", + ), + ).toBe(false) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { ".": "./dist/index.js", "./tool": "./src/tool.ts" } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "import", + ), + ).toBe(false) + expect( + packageExportsTypeScriptSourceForSpecifier( + { exports: { "./*": "./dist/*.js", "./foo/*": "./src/foo/*.ts" } }, + "@opencode-ai/plugin", + "@opencode-ai/plugin/foo/bar", + "import", + ), + ).toBe(true) + expect( + packageExportsTypeScriptSourceForSpecifier( + { main: "./dist/index.js" }, + "@opencode-ai/plugin", + "@opencode-ai/plugin", + "import", + ), + ).toBe(false) + }) + + test("findWorkspacePackageJsonPath resolves nested workspace packages such as sdk", () => { + const root = path.resolve(import.meta.dir, "..") + + expect(findWorkspacePackageJsonPath(root, "@opencode-ai/sdk")).toBe(path.resolve(root, "../sdk/js/package.json")) + }) + + test("findWorkspacePackageJsonPath discovers the workspace root from nested directories", async () => { + const repo = mkdtempSync(path.join(tmpdir(), "pawwork-runtime-guard-repo-")) + try { + await Bun.write(path.join(repo, "package.json"), JSON.stringify({ workspaces: { packages: ["packages/*"] } })) + mkdirSync(path.join(repo, "packages/util"), { recursive: true }) + mkdirSync(path.join(repo, "apps/desktop/scripts"), { recursive: true }) + await Bun.write(path.join(repo, "packages/util/package.json"), JSON.stringify({ name: "@opencode-ai/util" })) + + expect(findWorkspacePackageJsonPath(path.join(repo, "apps/desktop/scripts"), "@opencode-ai/util")).toBe( + path.join(repo, "packages/util/package.json"), + ) + } finally { + rmSync(repo, { recursive: true, force: true }) + } + }) + + test("readBuiltRuntimeFiles fails when no runtime JavaScript files were scanned", () => { + const dir = mkdtempSync(path.join(tmpdir(), "pawwork-runtime-guard-")) + try { + expect(() => readBuiltRuntimeFiles(dir)).toThrow("No Electron main/preload JavaScript output files found") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("readBuiltRuntimeFiles reads main and preload JavaScript output", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "pawwork-runtime-guard-")) + try { + mkdirSync(path.join(dir, "out/main"), { recursive: true }) + mkdirSync(path.join(dir, "out/preload"), { recursive: true }) + await Bun.write(path.join(dir, "out/main/index.js"), 'import "@opencode-ai/util/error"') + await Bun.write(path.join(dir, "out/preload/index.mjs"), 'import "@opencode-ai/shared/runtime"') + + expect(readBuiltRuntimeFiles(dir)).toEqual([ + { file: path.join("out/main/index.js"), source: 'import "@opencode-ai/util/error"' }, + { file: path.join("out/preload/index.mjs"), source: 'import "@opencode-ai/shared/runtime"' }, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test("findTsSourceRuntimeImports reports opencode runtime imports backed by TypeScript exports", () => { + const findings = findTsSourceRuntimeImports( + [ + { + file: "out/main/index.js", + source: 'import { NamedError } from "@opencode-ai/util/error"', + }, + { + file: "out/main/chunks/node.js", + source: 'const dependency = require("@opencode-ai/plugin")', + }, + { + file: "out/preload/index.mjs", + source: 'import Store from "electron-store"', + }, + ], + new Map([ + ["@opencode-ai/util", { exports: { "./*": "./src/*.ts" } }], + ["@opencode-ai/plugin", { exports: { import: "./src/index.ts", require: "./dist/index.cjs" } }], + ]), + ) + + expect(findings).toEqual([ + { + file: "out/main/index.js", + specifier: "@opencode-ai/util/error", + packageName: "@opencode-ai/util", + }, + ]) + }) + + test("runRuntimeImportGuard fails closed when an opencode package cannot be resolved", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "pawwork-runtime-guard-")) + try { + await Bun.write(path.join(dir, "package.json"), JSON.stringify({ workspaces: { packages: [] } })) + mkdirSync(path.join(dir, "out/main"), { recursive: true }) + await Bun.write(path.join(dir, "out/main/index.js"), 'import "@opencode-ai/missing"') + + expect(() => runRuntimeImportGuard(dir)).toThrow( + "Could not resolve package.json for runtime imports: @opencode-ai/missing", + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/desktop-electron/scripts/runtime-import-guard.ts b/packages/desktop-electron/scripts/runtime-import-guard.ts new file mode 100644 index 000000000..1c6e57f6c --- /dev/null +++ b/packages/desktop-electron/scripts/runtime-import-guard.ts @@ -0,0 +1,230 @@ +import fs from "node:fs" +import path from "node:path" + +type PackageJson = { + exports?: unknown + main?: string +} + +type SourceFile = { + file: string + source: string +} + +export type RuntimeImportKind = "import" | "require" + +export type RuntimeImport = { + kind: RuntimeImportKind + specifier: string +} + +export type RuntimeImportFinding = { + file: string + specifier: string + packageName: string +} + +const RUNTIME_IMPORT_RE = + /\bimport\s+(?!type\b)(?:[^'"`]*?\s+from\s*)?["']([^"']+)["']|\bexport\s+(?!type\b)(?:\*|\{[^}]*\})\s+from\s+["']([^"']+)["']|\bimport\s*\(\s*(?:["']([^"']+)["']|`([^`$]*)`)\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g + +export function extractRuntimeImports(source: string) { + const imports: RuntimeImport[] = [] + for (const match of source.matchAll(RUNTIME_IMPORT_RE)) { + const specifier = match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] + if (!specifier) continue + imports.push({ kind: match[5] ? "require" : "import", specifier }) + } + return imports +} + +export function packageNameForSpecifier(specifier: string) { + if (!specifier.startsWith("@")) return specifier.split("/")[0] ?? specifier + const parts = specifier.split("/") + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier +} + +function runtimeExportTargetStrings(value: unknown, kind: RuntimeImportKind): string[] { + if (typeof value === "string") return [value] + if (Array.isArray(value)) return value.flatMap((entry) => runtimeExportTargetStrings(entry, kind)) + if (!value || typeof value !== "object") return [] + + const entries = value as Record + const activeConditions = new Set(["node", kind, "default"]) + const hasConditionKeys = Object.keys(entries).some( + (key) => key === "node" || key === "import" || key === "require" || key === "default", + ) + if (hasConditionKeys) { + for (const [key, entry] of Object.entries(entries)) { + if (activeConditions.has(key)) return runtimeExportTargetStrings(entry, kind) + } + return [] + } + + return Object.entries(entries).flatMap(([key, entry]) => + key === "types" ? [] : runtimeExportTargetStrings(entry, kind), + ) +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function packageSubpath(packageName: string, specifier: string) { + if (specifier === packageName) return "." + return `.${specifier.slice(packageName.length)}` +} + +function exportEntryForSubpath(exports: unknown, subpath: string): unknown { + if (!exports || typeof exports !== "object" || Array.isArray(exports)) return subpath === "." ? exports : undefined + const entries = exports as Record + if (subpath === "." && !Object.keys(entries).some((key) => key.startsWith("."))) return entries + if (Object.hasOwn(entries, subpath)) return entries[subpath] + let bestMatch: { key: string; value: unknown } | undefined + for (const [key, value] of Object.entries(entries)) { + if (!key.includes("*")) continue + const pattern = new RegExp(`^${escapeRegExp(key).replace("\\*", "(.+)")}$`) + if (pattern.test(subpath) && (!bestMatch || key.length > bestMatch.key.length)) bestMatch = { key, value } + } + return bestMatch?.value +} + +export function packageExportsTypeScriptSourceForSpecifier( + json: PackageJson, + packageName: string, + specifier: string, + kind: RuntimeImportKind, +) { + const entry = exportEntryForSubpath(json.exports, packageSubpath(packageName, specifier)) + return runtimeExportTargetStrings(entry, kind).some( + (target) => /\.(?:ts|tsx)$/.test(target) && !/\.d\.ts$/.test(target), + ) +} + +export function findTsSourceRuntimeImports(files: SourceFile[], packageJsonByName: Map) { + const findings: RuntimeImportFinding[] = [] + + for (const file of files) { + for (const { kind, specifier } of extractRuntimeImports(file.source)) { + if (!specifier.startsWith("@opencode-ai/")) continue + const packageName = packageNameForSpecifier(specifier) + const packageJson = packageJsonByName.get(packageName) + if (!packageJson || !packageExportsTypeScriptSourceForSpecifier(packageJson, packageName, specifier, kind)) + continue + findings.push({ file: file.file, specifier, packageName }) + } + } + + return findings +} + +export function readBuiltRuntimeFiles(root: string) { + const result: SourceFile[] = [] + for (const relDir of ["out/main", "out/preload"]) { + const dir = path.join(root, relDir) + if (!fs.existsSync(dir)) continue + for (const file of walk(dir)) { + if (!/\.(?:cjs|mjs|js)$/.test(file)) continue + result.push({ + file: path.relative(root, file), + source: fs.readFileSync(file, "utf8"), + }) + } + } + if (result.length === 0) throw new Error("No Electron main/preload JavaScript output files found") + return result +} + +function findWorkspaceRoot(root: string) { + let current = path.resolve(root) + while (true) { + const packageJsonPath = path.join(current, "package.json") + if (fs.existsSync(packageJsonPath)) { + const json = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { workspaces?: { packages?: string[] } } + if (json.workspaces?.packages) return current + } + + const parent = path.dirname(current) + if (parent === current) throw new Error(`Could not find workspace root from ${root}`) + current = parent + } +} + +export function findWorkspacePackageJsonPath(root: string, packageName: string) { + const repoRoot = findWorkspaceRoot(root) + const rootPackageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { + workspaces?: { packages?: string[] } + } + const workspacePatterns = rootPackageJson.workspaces?.packages ?? [] + const packageJsonPaths = workspacePatterns.flatMap((pattern) => { + if (pattern.endsWith("/*")) { + const base = path.join(repoRoot, pattern.slice(0, -2)) + if (!fs.existsSync(base)) return [] + return fs + .readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(base, entry.name, "package.json")) + } + return [path.join(repoRoot, pattern, "package.json")] + }) + + return packageJsonPaths.find((packageJsonPath) => { + if (!fs.existsSync(packageJsonPath)) return false + const json = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { name?: string } + return json.name === packageName + }) +} + +function readPackageJson(root: string, packageName: string): PackageJson | undefined { + const nodeModulesPackageJson = path.join(root, "node_modules", ...packageName.split("/"), "package.json") + const packageJsonPath = fs.existsSync(nodeModulesPackageJson) + ? nodeModulesPackageJson + : findWorkspacePackageJsonPath(root, packageName) + if (!packageJsonPath) return undefined + return JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageJson +} + +function walk(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.flatMap((entry) => { + const full = path.join(dir, entry.name) + return entry.isDirectory() ? walk(full) : [full] + }) +} + +export function runRuntimeImportGuard(root = process.cwd()) { + const files = readBuiltRuntimeFiles(root) + const packageNames = new Set() + for (const file of files) { + for (const { specifier } of extractRuntimeImports(file.source)) { + if (specifier.startsWith("@opencode-ai/")) packageNames.add(packageNameForSpecifier(specifier)) + } + } + + const packageJsonByName = new Map() + const missingPackages: string[] = [] + for (const packageName of packageNames) { + const packageJson = readPackageJson(root, packageName) + if (packageJson) { + packageJsonByName.set(packageName, packageJson) + continue + } + missingPackages.push(packageName) + } + + if (missingPackages.length > 0) { + throw new Error(`Could not resolve package.json for runtime imports: ${missingPackages.sort().join(", ")}`) + } + + return findTsSourceRuntimeImports(files, packageJsonByName) +} + +if (import.meta.main) { + const findings = runRuntimeImportGuard(process.cwd()) + if (findings.length > 0) { + console.error("Electron main/preload output imports workspace packages that export TypeScript source:") + for (const finding of findings) { + console.error(`- ${finding.file}: ${finding.specifier} via ${finding.packageName}`) + } + process.exit(1) + } +} diff --git a/packages/opencode/test/github/build-workflow.test.ts b/packages/opencode/test/github/build-workflow.test.ts index 2f344a23e..47892cbd4 100644 --- a/packages/opencode/test/github/build-workflow.test.ts +++ b/packages/opencode/test/github/build-workflow.test.ts @@ -84,6 +84,8 @@ describe("release workflow", () => { const signedArtifactStep = steps.find((step) => step.name === "Upload signed app artifact") const nonMacArtifactStep = steps.find((step) => step.name === "Upload packaged app artifact") const buildElectronAppStep = steps.find((step) => step.name === "Build Electron app") + const runtimeImportGuardStep = steps.find((step) => step.name === "Check desktop runtime imports") + const setupAppleApiKeyStep = steps.find((step) => step.name === "Setup Apple API Key") const packageAppStep = steps.find((step) => step.name === "Package app") const packageVersionStep = steps.find((step) => step.id === "package_version") const downloadExistingMetadataStep = steps.find((step) => step.name === "Download existing updater metadata") @@ -91,6 +93,8 @@ describe("release workflow", () => { const finalizeLatestYmlStep = steps.find((step) => step.name === "Finalize updater metadata") const packageNotarizedStep = steps.find((step) => step.name === "Package notarized artifacts") const validateSelectedTargetStep = steps.find((step) => step.name === "Validate selected target") + const smokeSignedAppStep = steps.find((step) => step.name === "Smoke signed macOS app") + const packageSignedAppStep = steps.find((step) => step.name === "Package signed app") expect(parsed.name).toBe("release") expect(parsed.permissions).toEqual({ @@ -142,6 +146,11 @@ describe("release workflow", () => { OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", PAWWORK_FEEDBACK_FORM_URL: "${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }}", }) + expect(runtimeImportGuardStep?.if).toBe("${{ inputs.phase != 'finalize' }}") + expect(runtimeImportGuardStep?.run).toBe("bun ./scripts/runtime-import-guard.ts") + expect(runtimeImportGuardStep?.["working-directory"]).toBe("packages/desktop-electron") + expect(steps.indexOf(runtimeImportGuardStep!)).toBeGreaterThan(steps.indexOf(buildElectronAppStep!)) + expect(steps.indexOf(runtimeImportGuardStep!)).toBeLessThan(steps.indexOf(setupAppleApiKeyStep!)) expect(packageAppStep?.shell).toBe("bash") expect(packageAppStep?.env).toEqual({ OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", @@ -151,6 +160,21 @@ describe("release workflow", () => { expect(packageAppStep?.run).toContain('publish_flag="never"') expect(packageAppStep?.run).toContain('if [ "${{ inputs.phase || \'submit\' }}" = "full" ]; then') expect(packageAppStep?.run).toContain('publish_flag="always"') + expect(smokeSignedAppStep?.if).toBe( + "${{ runner.os == 'macOS' && (inputs.phase == 'submit' || inputs.phase == 'full') }}", + ) + expect(smokeSignedAppStep?.["working-directory"]).toBe("packages/desktop-electron") + expect(smokeSignedAppStep?.env).toEqual({ + OPENCODE_CHANNEL: "${{ inputs.channel || 'dev' }}", + }) + expect(smokeSignedAppStep?.run).toContain('case "$OPENCODE_CHANNEL" in') + expect(smokeSignedAppStep?.run).toContain('EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME"') + expect(smokeSignedAppStep?.run).toContain( + 'bun ./scripts/ci-smoke.ts packaged "$OPENCODE_CHANNEL" "$EXECUTABLE_PATH"', + ) + expect(packageSignedAppStep).toBeDefined() + expect(steps.indexOf(smokeSignedAppStep!)).toBeGreaterThan(steps.indexOf(packageSignedAppStep!)) + expect(steps.indexOf(smokeSignedAppStep!)).toBeLessThan(steps.indexOf(signedArtifactStep!)) expect(packageVersionStep?.run).toContain("version=$(node -p") expect(downloadExistingMetadataStep).toBeDefined() expect(downloadExistingMetadataStep?.run).toContain("gh release download") diff --git a/packages/opencode/test/github/desktop-smoke-workflow.test.ts b/packages/opencode/test/github/desktop-smoke-workflow.test.ts index 0ee217feb..6498316b4 100644 --- a/packages/opencode/test/github/desktop-smoke-workflow.test.ts +++ b/packages/opencode/test/github/desktop-smoke-workflow.test.ts @@ -21,6 +21,9 @@ describe("desktop smoke workflow", () => { const appSmokeStep = smokeSteps.find((step) => step.name === "Launch desktop smoke app") const packageStep = smokeSteps.find((step) => step.name === "Package desktop app") const smokeStep = smokeSteps.find((step) => step.name === "Smoke check app bundle") + const runtimeGuardStep = smokeSteps.find((step) => step.name === "Check desktop runtime imports") + const packagedSmokeStep = smokeSteps.find((step) => step.name === "Launch packaged desktop smoke app") + const buildStep = smokeSteps.find((step) => step.name === "Build desktop app") expect(parsed.name).toBe("desktop-smoke") expect(parsed.on?.push).toEqual({ branches: ["dev"] }) @@ -62,6 +65,18 @@ describe("desktop smoke workflow", () => { OPENCODE_CHANNEL: "dev", }) + expect(runtimeGuardStep?.run).toBe("bun ./scripts/runtime-import-guard.ts") + expect(runtimeGuardStep?.["working-directory"]).toBe("packages/desktop-electron") + expect(packagedSmokeStep?.run).toContain( + 'EXECUTABLE_PATH="dist/mac-arm64/PawWork Dev.app/Contents/MacOS/PawWork Dev"', + ) + expect(packagedSmokeStep?.run).toContain('bun ./scripts/ci-smoke.ts packaged dev "$EXECUTABLE_PATH"') + expect(packagedSmokeStep?.["working-directory"]).toBe("packages/desktop-electron") + expect(buildStep).toBeDefined() + expect(smokeSteps.indexOf(runtimeGuardStep!)).toBeGreaterThan(smokeSteps.indexOf(buildStep!)) + expect(smokeSteps.indexOf(runtimeGuardStep!)).toBeLessThan(smokeSteps.indexOf(appSmokeStep!)) + expect(smokeSteps.indexOf(packagedSmokeStep!)).toBeGreaterThan(smokeSteps.indexOf(smokeStep!)) + expect(smokeStep?.run).toContain("Expected app bundle at") expect(smokeStep?.run).toContain("Expected executable at") expect(smokeStep?.run).toContain("Expected Info.plist at") diff --git a/packages/opencode/test/github/workflow-parser.ts b/packages/opencode/test/github/workflow-parser.ts index 35548f350..eab322f8d 100644 --- a/packages/opencode/test/github/workflow-parser.ts +++ b/packages/opencode/test/github/workflow-parser.ts @@ -4,6 +4,7 @@ import fs from "node:fs" /** Parsed subset of a workflow step used by workflow contract tests. */ export type WorkflowStep = { "continue-on-error"?: boolean + "working-directory"?: string id?: string if?: string name?: string