diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts index 6a12bcd52..d4320975a 100644 --- a/packages/app/e2e/app/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "../fixtures" import { serverNamePattern, serverUrls } from "../utils" import { closeDialog, clickMenuItem } from "../actions" -const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" +const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl" test("can set a default server on web", async ({ page, gotoSession }) => { await page.addInitScript((key: string) => { diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 5af3f8895..20ea347a5 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -176,7 +176,7 @@ test("desktop right-panel collapses shell tab labels below the compact threshold test("legacy changes side-panel state restores into the review tab", async ({ page, gotoSession, slug }) => { await page.addInitScript(({ slug }) => { - const key = "opencode.global.dat:layout" + const key = "pawwork.global.dat:layout" const raw = localStorage.getItem(key) const parsed = (() => { if (!raw) return {} diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index fe2eb9c1a..15ceec054 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -548,7 +548,7 @@ async function seedStorage( extra: string[] model: { providerID: string; modelID: string } }) => { - const key = "opencode.global.dat:server" + const key = "pawwork.global.dat:server" const raw = localStorage.getItem(key) const parsed = (() => { if (!raw) return undefined @@ -586,7 +586,7 @@ async function seedStorage( } localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject })) - localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl) + localStorage.setItem("pawwork.settings.dat:defaultServerUrl", args.serverUrl) const win = window as E2EWindow win.__opencode_e2e = { @@ -595,7 +595,7 @@ async function seedStorage( prompt: { enabled: true }, terminal: { enabled: true, terminals: {} }, } - localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} })) + localStorage.setItem("pawwork.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} })) }, { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel }, ) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 9ade243d2..44830b738 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -16,8 +16,8 @@ import { test("@smoke new installs start with the PawWork theme", async ({ page, gotoSession }) => { await page.addInitScript(() => { - localStorage.removeItem("opencode-theme-id") - localStorage.removeItem("opencode-color-scheme") + localStorage.removeItem("pawwork-theme-id") + localStorage.removeItem("pawwork-color-scheme") }) await gotoSession() @@ -51,7 +51,7 @@ test('@smoke PawWork settings opens as a full-pane surface, not a dialog', async test("changing language updates settings labels", async ({ page, gotoSession }) => { await page.addInitScript(() => { - localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) + localStorage.setItem("pawwork.global.dat:language", JSON.stringify({ locale: "en" })) }) await gotoSession() @@ -85,11 +85,11 @@ test.skip("changing theme persists in localStorage", async () => { // and cannot exercise switching. Revisit once a second theme is added. }) -test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page, gotoSession }) => { +test("unknown theme ids migrate to pawwork and clear cached css", async ({ page, gotoSession }) => { await page.addInitScript(() => { - localStorage.setItem("opencode-theme-id", "dracula") - localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;") - localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;") + localStorage.setItem("pawwork-theme-id", "dracula") + localStorage.setItem("pawwork-theme-css-light", "--background-base:#fff;") + localStorage.setItem("pawwork-theme-css-dark", "--background-base:#000;") }) await gotoSession() @@ -99,7 +99,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page, await expect .poll(async () => { return await page.evaluate(() => { - return localStorage.getItem("opencode-theme-id") + return localStorage.getItem("pawwork-theme-id") }) }) .toBe("pawwork") @@ -107,7 +107,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page, await expect .poll(async () => { return await page.evaluate(() => { - return localStorage.getItem("opencode-theme-css-light") + return localStorage.getItem("pawwork-theme-css-light") }) }) .toBeNull() @@ -115,7 +115,7 @@ test("legacy theme ids migrate to pawwork and clear cached CSS", async ({ page, await expect .poll(async () => { return await page.evaluate(() => { - return localStorage.getItem("opencode-theme-css-dark") + return localStorage.getItem("pawwork-theme-css-dark") }) }) .toBeNull() @@ -316,8 +316,8 @@ test("clearing the UI font field restores the default placeholder and stack", as test("code font and UI font rehydrate after reload", async ({ page, gotoSession }) => { await page.addInitScript(() => { if (sessionStorage.getItem("settings-rehydrate-init")) return - localStorage.setItem("opencode-theme-id", "pawwork") - localStorage.setItem("opencode-color-scheme", "light") + localStorage.setItem("pawwork-theme-id", "pawwork") + localStorage.setItem("pawwork-color-scheme", "light") sessionStorage.setItem("settings-rehydrate-init", "1") }) diff --git a/packages/app/e2e/sidebar/sidebar-session-search.spec.ts b/packages/app/e2e/sidebar/sidebar-session-search.spec.ts index 15093d379..2159541eb 100644 --- a/packages/app/e2e/sidebar/sidebar-session-search.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-search.spec.ts @@ -11,7 +11,7 @@ test("command palette prioritizes pinned and recent PawWork sessions", async ({ await page.addInitScript((sessionID) => { localStorage.setItem( - "opencode.global.dat:layout.page", + "pawwork.global.dat:layout.page", JSON.stringify({ pawworkPinnedSessions: [sessionID], pawworkSortMode: "time", @@ -23,7 +23,7 @@ test("command palette prioritizes pinned and recent PawWork sessions", async ({ await expect .poll(() => page.evaluate(() => { - const raw = localStorage.getItem("opencode.global.dat:layout.page") + const raw = localStorage.getItem("pawwork.global.dat:layout.page") const next = raw ? (JSON.parse(raw) as { pawworkPinnedSessions?: string[] }).pawworkPinnedSessions : [] return next ?? [] }), diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts index 548328f35..07de5f1e8 100644 --- a/packages/app/e2e/sidebar/sidebar.spec.ts +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -33,7 +33,7 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p await expect(button).toHaveAttribute("aria-expanded", "false") const opened = await page.evaluate( - () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened, + () => JSON.parse(localStorage.getItem("pawwork.global.dat:layout") ?? "{}").sidebar?.opened, ) await expect(opened).toBe(false) }) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 17a878566..0737c9f85 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -59,5 +59,5 @@ export function sessionPath(directory: string, sessionID?: string) { export function workspacePersistKey(directory: string, key: string) { const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const sum = checksum(directory) ?? "0" - return `opencode.workspace.${head}.${sum}.dat:workspace:${key}` + return `pawwork.workspace.${head}.${sum}.dat:workspace:${key}` } diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 621d0fa3f..8e350c210 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -1,12 +1,12 @@ // Phase 1 ships only the `pawwork` theme, which is locked to light mode. // When a dark-capable theme is bundled, this file must gain a theme-aware -// guard so `opencode-color-scheme` is forced to "light" ONLY for light-only +// guard so `pawwork-color-scheme` is forced to "light" ONLY for light-only // themes; tracked in issue #23. ;(function () { - var key = "opencode-theme-id" - var schemeKey = "opencode-color-scheme" - var cssLightKey = "opencode-theme-css-light" - var cssDarkKey = "opencode-theme-css-dark" + var key = "pawwork-theme-id" + var schemeKey = "pawwork-color-scheme" + var cssLightKey = "pawwork-theme-css-light" + var cssDarkKey = "pawwork-theme-css-dark" try { var storedTheme = localStorage.getItem(key) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 27539bab6..377cfec9b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -135,6 +135,12 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { void window.api?.setTitlebar?.({ mode }) }} diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index d7799f9ac..211fca603 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -82,7 +82,7 @@ export function normalizeLocale(value: unknown): Locale { function readStoredLocale() { if (typeof localStorage !== "object") return try { - const raw = localStorage.getItem("opencode.global.dat:language") + const raw = localStorage.getItem("pawwork.global.dat:language") if (!raw) return const next = JSON.parse(raw) as { locale?: string } if (typeof next?.locale !== "string") return diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 59c35dda9..565fe2a48 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -513,7 +513,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont legacy: getLegacyTerminalStorageKeys(dir, params.id), at: new Date().toISOString(), }) - const store = platform.storage?.("opencode.global.dat") + const store = platform.storage?.("pawwork.global.dat") if (!store) return void Promise.resolve(store.setItem("debug:terminal-invalid-all", payload)).catch(() => undefined) }) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 6ec7784f9..a4c72a487 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -9,7 +9,7 @@ import { handleNotificationClick } from "@/utils/notification-click" import pkg from "../package.json" import { ServerConnection } from "./context/server" -const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" +const DEFAULT_SERVER_URL_KEY = "pawwork.settings.dat:defaultServerUrl" const getLocale = () => { if (typeof navigator !== "object") return "en" as const diff --git a/packages/app/src/hooks/use-providers.test.ts b/packages/app/src/hooks/use-providers.test.ts new file mode 100644 index 000000000..9a537253e --- /dev/null +++ b/packages/app/src/hooks/use-providers.test.ts @@ -0,0 +1,12 @@ +import { expect, mock, test } from "bun:test" + +mock.module("@solidjs/router", () => ({ + useParams: () => ({}), +})) + +const { popularProviders } = await import("./use-providers") + +test("popular providers keep OpenCode Zen and OpenCode Go visible", () => { + expect(popularProviders).toContain("opencode") + expect(popularProviders).toContain("opencode-go") +}) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 7d1cd0ebe..7f0618abf 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -141,7 +141,7 @@ export function TerminalPanel(props: { embedded?: boolean }) { active: terminal.active(), at: new Date().toISOString(), }) - const store = platform.storage?.("opencode.global.dat") + const store = platform.storage?.("pawwork.global.dat") if (store) { void Promise.resolve(store.setItem("debug:terminal-panel-invalid-all", payload)).catch(() => undefined) } diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts index 35f6ca859..21ecc5d03 100644 --- a/packages/app/src/theme-preload.test.ts +++ b/packages/app/src/theme-preload.test.ts @@ -26,32 +26,32 @@ describe("theme preload", () => { }) test("locks PawWork to light and rewrites stored color scheme", () => { - localStorage.setItem("opencode-theme-id", "pawwork") - localStorage.setItem("opencode-color-scheme", "dark") + localStorage.setItem("pawwork-theme-id", "pawwork") + localStorage.setItem("pawwork-color-scheme", "dark") run() expect(document.documentElement.dataset.theme).toBe("pawwork") expect(document.documentElement.dataset.colorScheme).toBe("light") - expect(localStorage.getItem("opencode-theme-id")).toBe("pawwork") - expect(localStorage.getItem("opencode-color-scheme")).toBe("light") + expect(localStorage.getItem("pawwork-theme-id")).toBe("pawwork") + expect(localStorage.getItem("pawwork-color-scheme")).toBe("light") }) for (const legacy of ["oc-1", "oc-2", "dracula", "nightowl", "amoled"]) { test(`migrates legacy "${legacy}" theme to pawwork and clears cached css`, () => { - localStorage.setItem("opencode-theme-id", legacy) - localStorage.setItem("opencode-color-scheme", "dark") - localStorage.setItem("opencode-theme-css-light", "--background-base:#ffffff;") - localStorage.setItem("opencode-theme-css-dark", "--background-base:#000000;") + localStorage.setItem("pawwork-theme-id", legacy) + localStorage.setItem("pawwork-color-scheme", "dark") + localStorage.setItem("pawwork-theme-css-light", "--background-base:#ffffff;") + localStorage.setItem("pawwork-theme-css-dark", "--background-base:#000000;") run() expect(document.documentElement.dataset.theme).toBe("pawwork") expect(document.documentElement.dataset.colorScheme).toBe("light") - expect(localStorage.getItem("opencode-theme-id")).toBe("pawwork") - expect(localStorage.getItem("opencode-color-scheme")).toBe("light") - expect(localStorage.getItem("opencode-theme-css-light")).toBeNull() - expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull() + expect(localStorage.getItem("pawwork-theme-id")).toBe("pawwork") + expect(localStorage.getItem("pawwork-color-scheme")).toBe("light") + expect(localStorage.getItem("pawwork-theme-css-light")).toBeNull() + expect(localStorage.getItem("pawwork-theme-css-dark")).toBeNull() }) } }) diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 673acd224..bf3bf4922 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -4,11 +4,17 @@ type PersistTestingType = typeof import("./persist").PersistTesting class MemoryStorage implements Storage { private values = new Map() + private failingSets = new Map() readonly events: string[] = [] readonly calls = { get: 0, set: 0, remove: 0 } clear() { this.values.clear() + this.failingSets.clear() + } + + failSet(key: string, times: number) { + this.failingSets.set(key, times) } get length() { @@ -22,22 +28,27 @@ class MemoryStorage implements Storage { getItem(key: string) { this.calls.get += 1 this.events.push(`get:${key}`) - if (key.startsWith("opencode.throw")) throw new Error("storage get failed") + if (key.startsWith("pawwork.throw")) throw new Error("storage get failed") return this.values.get(key) ?? null } setItem(key: string, value: string) { this.calls.set += 1 this.events.push(`set:${key}`) - if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") - if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + const remaining = this.failingSets.get(key) ?? 0 + if (remaining > 0) { + this.failingSets.set(key, remaining - 1) + throw new DOMException("quota", "QuotaExceededError") + } + if (key.startsWith("pawwork.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("pawwork.throw")) throw new Error("storage set failed") this.values.set(key, value) } removeItem(key: string) { this.calls.remove += 1 this.events.push(`remove:${key}`) - if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") + if (key.startsWith("pawwork.throw")) throw new Error("storage remove failed") this.values.delete(key) } } @@ -69,15 +80,15 @@ beforeEach(() => { describe("persist localStorage resilience", () => { test("does not cache values as persisted when quota write and eviction fail", () => { - const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") + const storageApi = persistTesting.localStorageWithPrefix("pawwork.quota.scope") storageApi.setItem("value", '{"value":1}') - expect(storage.getItem("opencode.quota.scope:value")).toBeNull() + expect(storage.getItem("pawwork.quota.scope:value")).toBeNull() expect(storageApi.getItem("value")).toBeNull() }) test("disables only the failing scope when storage throws", () => { - const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") + const bad = persistTesting.localStorageWithPrefix("pawwork.throw.scope") bad.setItem("value", '{"value":1}') const before = storage.calls.set @@ -85,13 +96,13 @@ describe("persist localStorage resilience", () => { expect(storage.calls.set).toBe(before) expect(bad.getItem("value")).toBeNull() - const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") + const healthy = persistTesting.localStorageWithPrefix("pawwork.safe.scope") healthy.setItem("value", '{"value":3}') - expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') + expect(storage.getItem("pawwork.safe.scope:value")).toBe('{"value":3}') }) test("failing fallback scope does not poison direct storage scope", () => { - const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") + const broken = persistTesting.localStorageWithPrefix("pawwork.throw.scope2") broken.setItem("value", '{"value":1}') const direct = persistTesting.localStorageDirect() @@ -100,6 +111,21 @@ describe("persist localStorage resilience", () => { expect(storage.getItem("direct-value")).toBe('{"value":5}') }) + test("quota eviction can remove legacy OpenCode local entries", () => { + storage.setItem("opencode.workspace.old.dat:value", "old workspace") + storage.setItem("opencode.global.dat:value", "old global") + storage.setItem("opencode.settings.dat:value", "old settings") + storage.failSet("pawwork.workspace.new.dat:value", 4) + + const storageApi = persistTesting.localStorageWithPrefix("pawwork.workspace.new.dat") + storageApi.setItem("value", '{"value":1}') + + expect(storage.getItem("opencode.workspace.old.dat:value")).toBeNull() + expect(storage.getItem("opencode.global.dat:value")).toBeNull() + expect(storage.getItem("opencode.settings.dat:value")).toBeNull() + expect(storage.getItem("pawwork.workspace.new.dat:value")).toBe('{"value":1}') + }) + test("normalizer rejects malformed JSON payloads", () => { const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}') expect(result).toBeUndefined() @@ -108,7 +134,7 @@ describe("persist localStorage resilience", () => { test("workspace storage sanitizes Windows filename characters", () => { const result = persistTesting.workspaceStorage("C:\\Users\\foo") - expect(result).toStartWith("opencode.workspace.") + expect(result).toStartWith("pawwork.workspace.") expect(result.endsWith(".dat")).toBeTrue() expect(/[:\\/]/.test(result)).toBeFalse() }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0c050d5db..0565f752f 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -20,8 +20,11 @@ type PersistTarget = { } const LEGACY_STORAGE = "default.dat" -const GLOBAL_STORAGE = "opencode.global.dat" -const LOCAL_PREFIX = "opencode." +const GLOBAL_STORAGE = "pawwork.global.dat" +const LOCAL_PREFIX = "pawwork." +const LEGACY_LOCAL_PREFIX = "opencode." +// The legacy prefix intentionally covers old workspace, global, and settings keys. +const EVICT_PREFIXES = [LOCAL_PREFIX, LEGACY_LOCAL_PREFIX] const fallback = new Map() const CACHE_MAX_ENTRIES = 500 @@ -111,7 +114,7 @@ function evict(storage: Storage, keep: string, value: string) { for (const index of indexes) { const name = storage.key(index) if (!name) continue - if (!name.startsWith(LOCAL_PREFIX)) continue + if (!EVICT_PREFIXES.some((prefix) => name.startsWith(prefix))) continue if (name === keep) continue const stored = storage.getItem(name) items.push({ key: name, size: stored?.length ?? 0 }) @@ -211,7 +214,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => function workspaceStorage(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const sum = checksum(dir) ?? "0" - return `opencode.workspace.${head}.${sum}.dat` + return `pawwork.workspace.${head}.${sum}.dat` } function localStorageWithPrefix(prefix: string): SyncStorage { diff --git a/packages/desktop-electron/scripts/ci-smoke.test.ts b/packages/desktop-electron/scripts/ci-smoke.test.ts index f9d2ce30d..ad8031f35 100644 --- a/packages/desktop-electron/scripts/ci-smoke.test.ts +++ b/packages/desktop-electron/scripts/ci-smoke.test.ts @@ -18,6 +18,9 @@ describe("ci smoke helpers", () => { expect(env.PAWWORK_CI_SMOKE_HOME).toBe("/tmp/pawwork-ci-smoke") expect(env.HOME).toBe("/tmp/pawwork-ci-smoke") expect(env.XDG_DATA_HOME).toBe("/tmp/pawwork-ci-smoke") + expect(env.XDG_CACHE_HOME).toBe("/tmp/pawwork-ci-smoke") + expect(env.XDG_CONFIG_HOME).toBe("/tmp/pawwork-ci-smoke") + expect(env.XDG_STATE_HOME).toBe("/tmp/pawwork-ci-smoke") expect(env.CI).toBe("true") }) diff --git a/packages/desktop-electron/scripts/ci-smoke.ts b/packages/desktop-electron/scripts/ci-smoke.ts index d0d75aa81..20728bc03 100644 --- a/packages/desktop-electron/scripts/ci-smoke.ts +++ b/packages/desktop-electron/scripts/ci-smoke.ts @@ -23,6 +23,9 @@ export function buildSmokeEnv(homeDir: string) { PAWWORK_CI_SMOKE: "true", PAWWORK_CI_SMOKE_HOME: homeDir, XDG_DATA_HOME: homeDir, + XDG_CACHE_HOME: homeDir, + XDG_CONFIG_HOME: homeDir, + XDG_STATE_HOME: homeDir, OPENCODE_CHANNEL: "dev", } } diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts index 1e21661c1..811ce23cd 100644 --- a/packages/desktop-electron/src/main/constants.ts +++ b/packages/desktop-electron/src/main/constants.ts @@ -1,10 +1,11 @@ import { app } from "electron" +import { PAWWORK_RUNTIME } from "./runtime-namespace" type Channel = "dev" | "beta" | "prod" const raw = import.meta.env.OPENCODE_CHANNEL export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev" -export const SETTINGS_STORE = "opencode.settings" +export const SETTINGS_STORE = PAWWORK_RUNTIME.settingsStore export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_ENABLED_KEY = "wslEnabled" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop-electron/src/main/index-sidecar-source.test.ts b/packages/desktop-electron/src/main/index-sidecar-source.test.ts new file mode 100644 index 000000000..72e37dc69 --- /dev/null +++ b/packages/desktop-electron/src/main/index-sidecar-source.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" + +describe("desktop sidecar source guard", () => { + test("publishes PawWork credentials and does not block on stale migration probes", async () => { + const source = await Bun.file(new URL("./index.ts", import.meta.url)).text() + + expect(source).toContain("username: PAWWORK_RUNTIME.serverUsername") + expect(source).toContain("const needsMigration = false") + expect(source).not.toContain("sqliteFileExists") + expect(source).not.toContain('username: "opencode"') + }) +}) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 189ed8700..342c7a89e 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" -import { existsSync, mkdirSync, writeFileSync } from "node:fs" +import { mkdirSync, writeFileSync } from "node:fs" import { createServer } from "node:net" import { homedir } from "node:os" import { dirname, join } from "node:path" @@ -50,6 +50,7 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { PAWWORK_RUNTIME } from "./runtime-namespace" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import { registerWindowLifecycle, @@ -199,10 +200,10 @@ function setInitStep(step: InitStep) { } async function initialize() { - // CI smoke only verifies that the desktop shell boots and the embedded - // sidecar is healthy; sqlite migration coverage stays outside this gate. - const needsMigration = !CI_SMOKE_ENABLED && !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined + // The embedded server owns DB initialization. The desktop shell must not + // block first launch on a migration progress event that the embedded runtime + // does not emit. + const needsMigration = false let overlay: BrowserWindow | null = null const port = await getSidecarPort() @@ -215,7 +216,7 @@ async function initialize() { server = listener serverReady.resolve({ url, - username: "opencode", + username: PAWWORK_RUNTIME.serverUsername, password, }) @@ -226,13 +227,8 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) - if (needsMigration) { - await sqliteDone?.promise - } - await Promise.race([ health.wait, delay(30_000).then(() => { @@ -377,12 +373,6 @@ async function getSidecarPort() { }) } -function sqliteFileExists() { - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - function setupAutoUpdater() { if (!UPDATER_ENABLED) return autoUpdater.logger = logger diff --git a/packages/desktop-electron/src/main/runtime-namespace.ts b/packages/desktop-electron/src/main/runtime-namespace.ts new file mode 100644 index 000000000..a90830d8c --- /dev/null +++ b/packages/desktop-electron/src/main/runtime-namespace.ts @@ -0,0 +1,21 @@ +import { join } from "node:path" + +export const PAWWORK_RUNTIME = { + client: "desktop", + serverUsername: "PawWork", + settingsStore: "pawwork.settings", + databaseName: "pawwork.db", +} as const + +export function runtimeRoots(userData: string) { + return { + data: join(userData, "data"), + cache: join(userData, "cache"), + config: join(userData, "config"), + state: join(userData, "state"), + } +} + +export function databasePathForUserData(userData: string) { + return join(runtimeRoots(userData).data, "pawwork", PAWWORK_RUNTIME.databaseName) +} diff --git a/packages/desktop-electron/src/main/server.test.ts b/packages/desktop-electron/src/main/server.test.ts new file mode 100644 index 000000000..a5f4514e6 --- /dev/null +++ b/packages/desktop-electron/src/main/server.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import path from "node:path" + +const userData = "/tmp/pawwork-user-data" + +mock.module("electron", () => ({ + app: { + getPath: (name: string) => (name === "userData" ? userData : `/tmp/${name}`), + isPackaged: false, + }, +})) + +mock.module("./store", () => ({ + store: { + get: () => null, + set: () => undefined, + delete: () => undefined, + }, +})) + +mock.module("./shell-env", () => ({ + getUserShell: () => null, + loadShellEnv: () => ({}), +})) + +const originalEnv = { ...process.env } + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key] + } + Object.assign(process.env, originalEnv) +}) + +describe("desktop server runtime namespace", () => { + test("prepares PawWork-owned server environment before embedded server import", async () => { + const { buildServerEnvForTest } = await import("./server") + + const env = buildServerEnvForTest("secret") + + expect(env.OPENCODE_CLIENT).toBe("desktop") + expect(env.OPENCODE_SERVER_USERNAME).toBe("PawWork") + expect(env.OPENCODE_SERVER_PASSWORD).toBe("secret") + expect(env.PAWWORK_RUNTIME_NAMESPACE).toBe("pawwork") + expect(env.XDG_DATA_HOME).toBe("/tmp/pawwork-user-data/data") + expect(env.XDG_CACHE_HOME).toBe("/tmp/pawwork-user-data/cache") + expect(env.XDG_CONFIG_HOME).toBe("/tmp/pawwork-user-data/config") + expect(env.XDG_STATE_HOME).toBe("/tmp/pawwork-user-data/state") + }) + + test("runtime roots keep Windows-shaped user data under PawWork", async () => { + const { databasePathForUserData, runtimeRoots } = await import("./runtime-namespace") + const root = "C:\\Users\\u\\AppData\\Roaming\\ai.pawwork.desktop.dev" + const roots = runtimeRoots(root) + + expect(roots).toEqual({ + data: path.join(root, "data"), + cache: path.join(root, "cache"), + config: path.join(root, "config"), + state: path.join(root, "state"), + }) + expect(databasePathForUserData(root)).toBe(path.join(root, "data", "pawwork", "pawwork.db")) + }) + + test("health check uses PawWork Basic Auth username", async () => { + let authorization = "" + const previousFetch = globalThis.fetch + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = new Headers(init?.headers).get("authorization") ?? "" + return new Response(null, { status: 200 }) + }) as typeof fetch + + try { + const { checkHealth } = await import("./server") + expect(await checkHealth("http://127.0.0.1:4096", "secret")).toBeTrue() + expect(Buffer.from(authorization.replace("Basic ", ""), "base64").toString("utf8")).toBe("PawWork:secret") + } finally { + globalThis.fetch = previousFetch + } + }) + + test("spawnLocalServer prepares env before importing the embedded server", async () => { + let captured: Record | undefined + + mock.module("virtual:opencode-server", () => { + captured = { + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_STATE_HOME: process.env.XDG_STATE_HOME, + PAWWORK_RUNTIME_NAMESPACE: process.env.PAWWORK_RUNTIME_NAMESPACE, + OPENCODE_CLIENT: process.env.OPENCODE_CLIENT, + OPENCODE_SERVER_USERNAME: process.env.OPENCODE_SERVER_USERNAME, + } + return { + Log: { init: async () => undefined }, + Server: { + listen: async () => ({ stop: async () => undefined }), + }, + } + }) + + const previousFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(null, { status: 200 })) as typeof fetch + + try { + const { spawnLocalServer } = await import("./server") + await spawnLocalServer("127.0.0.1", 4096, "secret") + expect(captured).toEqual({ + XDG_DATA_HOME: "/tmp/pawwork-user-data/data", + XDG_CACHE_HOME: "/tmp/pawwork-user-data/cache", + XDG_CONFIG_HOME: "/tmp/pawwork-user-data/config", + XDG_STATE_HOME: "/tmp/pawwork-user-data/state", + PAWWORK_RUNTIME_NAMESPACE: "pawwork", + OPENCODE_CLIENT: "desktop", + OPENCODE_SERVER_USERNAME: "PawWork", + }) + } finally { + globalThis.fetch = previousFetch + } + }) +}) diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index eef3a1696..dfa94d77a 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,5 +1,6 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" +import { PAWWORK_RUNTIME, runtimeRoots } from "./runtime-namespace" import { getUserShell, loadShellEnv } from "./shell-env" import { store } from "./store" @@ -55,22 +56,33 @@ export async function spawnLocalServer(hostname: string, port: number, password: return { listener, health: { wait } } } -function prepareServerEnv(password: string) { +function buildServerEnv(password: string) { const shell = process.platform === "win32" ? null : getUserShell() const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { + const roots = runtimeRoots(app.getPath("userData")) + return { ...process.env, ...shellEnv, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_CLIENT: PAWWORK_RUNTIME.client, + OPENCODE_SERVER_USERNAME: PAWWORK_RUNTIME.serverUsername, OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: app.getPath("userData"), + PAWWORK_RUNTIME_NAMESPACE: "pawwork", + XDG_DATA_HOME: roots.data, + XDG_CACHE_HOME: roots.cache, + XDG_CONFIG_HOME: roots.config, + XDG_STATE_HOME: roots.state, } - Object.assign(process.env, env) } +function prepareServerEnv(password: string) { + // Mutates the current process because the embedded server is imported in-process and reads env at module load. + Object.assign(process.env, buildServerEnv(password)) +} + +export const buildServerEnvForTest = buildServerEnv + export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL try { @@ -81,7 +93,7 @@ export async function checkHealth(url: string, password?: string | null): Promis const headers = new Headers() if (password) { - const auth = Buffer.from(`opencode:${password}`).toString("base64") + const auth = Buffer.from(`${PAWWORK_RUNTIME.serverUsername}:${password}`).toString("base64") headers.set("authorization", `Basic ${auth}`) } diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index cf2d25b11..d6f6c00b0 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,7 +4,7 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() -export function getStore(name = SETTINGS_STORE) { +export function getStore(name: string = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached const next = new Store({ name, fileExtension: "" }) diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts index 1b71531a1..b9b5a2d1f 100644 --- a/packages/desktop-electron/src/renderer/i18n/index.ts +++ b/packages/desktop-electron/src/renderer/i18n/index.ts @@ -86,7 +86,7 @@ export function initI18n(): Promise { if (cached) return cached const promise = (async () => { - const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null) + const raw = await window.api.storeGet("pawwork.global.dat", "language").catch(() => null) const value = parseStored(raw) const next = pickLocale(value) ?? state.locale diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 246b40cd7..4ae3b5755 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -296,7 +296,7 @@ listenForDeepLinks() render(() => { const platform = createPlatform() const loadLocale = async () => { - const current = await platform.storage?.("opencode.global.dat").getItem("language") + const current = await platform.storage?.("pawwork.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") const raw = current ?? legacy if (!raw) return diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 9c2f3a583..f9a2b2712 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -22,6 +22,7 @@ import { Skill } from "../skill" import { Effect, Context, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Runtime } from "@opencode-ai/shared/runtime" export namespace Agent { export const Info = z @@ -114,6 +115,7 @@ export namespace Agent { }) const user = Permission.fromConfig(cfg.permission ?? {}) + const projectPlansDir = Runtime.isPawWork() ? ".pawwork" : ".opencode" const agents: Record = { build: { @@ -145,7 +147,7 @@ export namespace Agent { }, edit: { "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", + [path.join(projectPlansDir, "plans", "*.md")]: "allow", [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1eb749455..bf2b41d88 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -46,12 +46,39 @@ import { Npm } from "@/npm" import { Filesystem } from "@/util/filesystem" import { Flock } from "@/util/flock" import { Installation } from "@/installation" +import { Runtime } from "@opencode-ai/shared/runtime" const log = Log.create({ service: "config" }) -const PROJECT_CONFIG_NAMES = ["opencode", "pawwork"] as const -const PROJECT_CONFIG_FILES = PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`]) -const GLOBAL_CONFIG_LOAD_FILES = ["config.json", "opencode.json", "opencode.jsonc", "pawwork.json", "pawwork.jsonc"] as const -const GLOBAL_CONFIG_UPDATE_FILES = ["pawwork.jsonc", "pawwork.json", "opencode.jsonc", "opencode.json", "config.json"] as const +const OPENCODE_PROJECT_CONFIG_NAMES = ["config", "opencode"] as const +const PAWWORK_PROJECT_CONFIG_NAMES = ["config", "opencode", "pawwork"] as const +const OPENCODE_PROJECT_CONFIG_FILES = OPENCODE_PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`]) +const PAWWORK_PROJECT_CONFIG_FILES = PAWWORK_PROJECT_CONFIG_NAMES.flatMap((name) => [`${name}.json`, `${name}.jsonc`]) +const PAWWORK_GLOBAL_CONFIG_FILES = ["pawwork.json", "pawwork.jsonc"] as const +const OPENCODE_GLOBAL_CONFIG_FILES = OPENCODE_PROJECT_CONFIG_FILES + +function globalConfigFiles() { + return Runtime.isPawWork() ? PAWWORK_GLOBAL_CONFIG_FILES : OPENCODE_GLOBAL_CONFIG_FILES +} + +function projectConfigNames() { + return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_NAMES : OPENCODE_PROJECT_CONFIG_NAMES +} + +function projectConfigFilesForDirectory(dir: string) { + const base = path.basename(dir) + if (base === ".pawwork") return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_FILES : [] + if (base === ".opencode" || dir === Flag.OPENCODE_CONFIG_DIR) { + return Runtime.isPawWork() ? PAWWORK_PROJECT_CONFIG_FILES : OPENCODE_PROJECT_CONFIG_FILES + } + return [] +} + +function shouldGenerateInDirectory(dir: string) { + const base = path.basename(dir) + if (Runtime.isPawWork() && base === ".opencode") return false + if (!Runtime.isPawWork() && base === ".pawwork") return false + return true +} type Package = { dependencies?: Record @@ -317,11 +344,25 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Config") {} function globalConfigFile() { - const candidates = GLOBAL_CONFIG_UPDATE_FILES.map((file) => path.join(Global.Path.config, file)) - for (const file of candidates) { + const candidates = globalConfigFiles().map((file) => path.join(Global.Path.config, file)) + for (const file of [...candidates].reverse()) { + if (existsSync(file)) return file + } + return path.join(Global.Path.config, Runtime.isPawWork() ? "pawwork.json" : "opencode.json") +} + +function projectConfigFile(dir: string) { + // OpenCode still writes existing legacy `config.*` files, but new project config uses `opencode.json`. + // PawWork writes only PawWork project filenames; shared project loading above preserves read compatibility. + const candidates = ( + Runtime.isPawWork() + ? ["pawwork.json", "pawwork.jsonc"] + : ["config.json", "config.jsonc", "opencode.json", "opencode.jsonc"] + ).map((file) => path.join(dir, file)) + for (const file of [...candidates].reverse()) { if (existsSync(file)) return file } - return path.join(Global.Path.config, "pawwork.json") + return path.join(dir, Runtime.isPawWork() ? "pawwork.json" : "opencode.json") } function patchJsonc(input: string, patch: unknown, path: string[] = []): string { @@ -408,7 +449,7 @@ const rawLayer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} - for (const file of GLOBAL_CONFIG_LOAD_FILES) { + for (const file of globalConfigFiles()) { result = pipe(result, mergeDeep(yield* loadFile(path.join(Global.Path.config, file)))) } @@ -421,7 +462,10 @@ const rawLayer = Layer.effect( if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" result = mergeDeep(result, rest) - await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) + await fsNode.writeFile( + path.join(Global.Path.config, Runtime.isPawWork() ? "pawwork.json" : "opencode.json"), + JSON.stringify(result, null, 2), + ) await fsNode.unlink(legacy) }) .catch(() => {}), @@ -535,7 +579,9 @@ const rawLayer = Layer.effect( } if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of yield* ConfigPaths.files(PROJECT_CONFIG_NAMES, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { + for (const file of yield* ConfigPaths.files(projectConfigNames(), ctx.directory, ctx.worktree).pipe( + Effect.orDie, + )) { yield* merge(file, yield* loadFile(file), "local") } } @@ -546,15 +592,23 @@ const rawLayer = Layer.effect( const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) - if (Flag.OPENCODE_CONFIG_DIR) { + const pawworkConfigDir = Flag.PAWWORK_CONFIG_DIR + if (Runtime.isPawWork() && pawworkConfigDir) { + log.debug("loading config from PAWWORK_CONFIG_DIR", { path: pawworkConfigDir }) + } else if (Flag.OPENCODE_CONFIG_DIR) { log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } const deps: Fiber.Fiber[] = [] for (const dir of directories) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of PROJECT_CONFIG_FILES) { + const configFiles = + Runtime.isPawWork() && pawworkConfigDir && dir === pawworkConfigDir + ? globalConfigFiles() + : projectConfigFilesForDirectory(dir) + + if (configFiles.length > 0) { + for (const file of configFiles) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) yield* merge(source, yield* loadFile(source)) @@ -564,16 +618,18 @@ const rawLayer = Layer.effect( } } - yield* ensureGitignore(dir).pipe(Effect.orDie) + if (shouldGenerateInDirectory(dir)) { + yield* ensureGitignore(dir).pipe(Effect.orDie) - const dep = yield* Effect.promise(async () => { - try { - await installDependencies(dir) - } catch (error) { - log.warn("background dependency install failed", { dir, error: String(error) }) - } - }).pipe(Effect.forkDetach) - deps.push(dep) + const dep = yield* Effect.promise(async () => { + try { + await installDependencies(dir) + } catch (error) { + log.warn("background dependency install failed", { dir, error: String(error) }) + } + }).pipe(Effect.forkDetach) + deps.push(dep) + } result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) @@ -639,7 +695,7 @@ const rawLayer = Layer.effect( const managedDir = ConfigManaged.managedConfigDir() if (existsSync(managedDir)) { - for (const file of PROJECT_CONFIG_FILES) { + for (const file of Runtime.isPawWork() ? globalConfigFiles() : OPENCODE_PROJECT_CONFIG_FILES) { const source = path.join(managedDir, file) yield* merge(source, yield* loadFile(source), "global") } @@ -736,11 +792,19 @@ const rawLayer = Layer.effect( const update = Effect.fn("Config.update")(function* (config: Info) { const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) - .pipe(Effect.orDie) + const file = projectConfigFile(dir) + const input = writable(config) + + if (!file.endsWith(".jsonc")) { + const existing = yield* loadFile(file) + yield* fs + .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), input), null, 2)) + .pipe(Effect.orDie) + } else { + const before = (yield* readConfigFile(file)) ?? "{}" + yield* fs.writeFileString(file, patchJsonc(before, input)).pipe(Effect.orDie) + } + yield* Effect.promise(() => Instance.dispose()) }) diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts index 0e378b45e..f074d975f 100644 --- a/packages/opencode/src/config/managed.ts +++ b/packages/opencode/src/config/managed.ts @@ -4,10 +4,13 @@ import { existsSync } from "fs" import os from "os" import path from "path" import { Log, Process } from "../util" +import { Runtime } from "@opencode-ai/shared/runtime" const log = Log.create({ service: "config" }) -const MANAGED_PLIST_DOMAIN = "ai.opencode.managed" +function managedPlistDomain() { + return Runtime.isPawWork() ? "ai.pawwork.managed" : "ai.opencode.managed" +} // Keys injected by macOS/MDM into the managed plist that are not OpenCode config const PLIST_META = new Set([ @@ -20,13 +23,14 @@ const PLIST_META = new Set([ ]) function systemManagedConfigDir(): string { + const app = Runtime.appName() switch (process.platform) { case "darwin": - return "/Library/Application Support/opencode" + return `/Library/Application Support/${app}` case "win32": - return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") + return path.join(process.env.ProgramData || "C:\\ProgramData", app) default: - return "/etc/opencode" + return `/etc/${app}` } } @@ -52,9 +56,10 @@ export async function readManagedPreferences() { if (process.platform !== "darwin") return const user = os.userInfo().username + const domain = managedPlistDomain() const paths = [ - path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`), - path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`), + path.join("/Library/Managed Preferences", user, `${domain}.plist`), + path.join("/Library/Managed Preferences", `${domain}.plist`), ] for (const plist of paths) { diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index ae9f30e31..ec2cbb9a6 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -10,6 +10,7 @@ import { unique } from "remeda" import { JsonError } from "./error" import * as Effect from "effect/Effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Runtime } from "@opencode-ai/shared/runtime" /** Find project config files while preserving root-to-leaf config precedence and per-directory alias order. */ export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( @@ -30,6 +31,20 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( /** Return every config directory that can contribute files, commands, agents, plugins, or config dependencies. */ export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) { const afs = yield* AppFileSystem.Service + if (Runtime.isPawWork()) { + return unique([ + Global.Path.config, + ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? yield* afs.up({ + targets: [".opencode", ".pawwork"], + start: directory, + stop: worktree, + }) + : []), + ...(Flag.PAWWORK_CONFIG_DIR ? [Flag.PAWWORK_CONFIG_DIR] : []), + ]) + } + return unique([ Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 01f25ed8d..4954bb982 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -20,6 +20,7 @@ export namespace Flag { export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_PURE: boolean export declare const OPENCODE_CONFIG_DIR: string | undefined + export declare const PAWWORK_CONFIG_DIR: string | undefined export declare const OPENCODE_PLUGIN_META_FILE: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") @@ -113,6 +114,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", { configurable: false, }) +// Dynamic getter for PAWWORK_CONFIG_DIR +// This must be evaluated at access time, not module load time, +// because tests and external tooling may set this env var at runtime +Object.defineProperty(Flag, "PAWWORK_CONFIG_DIR", { + get() { + return process.env["PAWWORK_CONFIG_DIR"] + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_PURE // This must be evaluated at access time, not module load time, // because the CLI can set this flag at runtime diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 869019e2c..e7db1952a 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -3,8 +3,9 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" import { Filesystem } from "../util/filesystem" +import { Runtime } from "@opencode-ai/shared/runtime" -const app = "opencode" +const app = Runtime.appName() const data = path.join(xdgData!, app) const cache = path.join(xdgCache!, app) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 8f110c6fb..930bda43f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,7 +28,6 @@ import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" import { DbCommand } from "./cli/cmd/db" import path from "path" -import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { errorMessage } from "./util/error" @@ -107,7 +106,7 @@ const cli = yargs(args) args: process.argv.slice(2), }) - const marker = path.join(Global.Path.data, "opencode.db") + const marker = Database.getChannelPath() if (!(await Filesystem.exists(marker))) { const tty = process.stderr.isTTY process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 05a7fa7db..ea7b2c056 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -10,6 +10,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global" import { Instance } from "../project/instance" import { Log } from "../util/log" +import { Runtime } from "@opencode-ai/shared/runtime" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" @@ -21,10 +22,15 @@ const FILES = [ "CONTEXT.md", // deprecated ] -function globalFiles() { +function configDir() { + return Runtime.isPawWork() ? Flag.PAWWORK_CONFIG_DIR : Flag.OPENCODE_CONFIG_DIR +} + +function globalInstructionFiles() { const files = [] - if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + const dir = configDir() + if (dir) { + files.push(path.join(dir, "AGENTS.md")) } files.push(path.join(Global.Path.config, "AGENTS.md")) if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { @@ -87,14 +93,16 @@ export const layer: Layer.Layer Effect.succeed([] as string[]))) } - if (!Flag.OPENCODE_CONFIG_DIR) { + const dir = configDir() + if (!dir) { + const env = Runtime.isPawWork() ? "PAWWORK_CONFIG_DIR" : "OPENCODE_CONFIG_DIR" log.warn( - `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, + `Skipping relative instruction "${instruction}" - no ${env} set while project config is disabled`, ) return [] } return yield* fs - .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR) + .globUp(instruction, dir, dir) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) }) @@ -132,7 +140,7 @@ export const layer: Layer.Layer { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 5d9a2c7f4..d39147b27 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -63,6 +63,27 @@ test("plan agent denies edits except .opencode/plans/*", async () => { }) }) +test("plan agent allows .pawwork plans in PawWork runtime mode", async () => { + await using tmp = await tmpdir() + const previous = process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const plan = await Agent.get("plan") + expect(plan).toBeDefined() + expect(Permission.evaluate("edit", ".pawwork/plans/foo.md", plan!.permission).action).toBe("allow") + expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("deny") + }, + }) + } finally { + if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previous + } +}) + test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 3604d9407..361f7b27c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -60,6 +60,19 @@ const listDirs = () => const ready = () => Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) +async function withPawWorkRuntime(fn: () => Promise) { + const previous = process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + await clear(true) + try { + await fn() + } finally { + if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previous + await clear(true) + } +} + async function waitFor(fn: () => Promise, timeout = 2000) { const start = Date.now() while (!(await fn())) { @@ -198,151 +211,161 @@ test("config entry names tolerate unnormalized roots", () => { }) test("loads PawWork project config aliases", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig( - dir, - { - $schema: "https://opencode.ai/config.json", - model: "test/pawwork-root", - }, - "pawwork.json", - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("test/pawwork-root") - }, + await withPawWorkRuntime(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "test/pawwork-root", + }, + "pawwork.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("test/pawwork-root") + }, + }) }) }) test("loads PawWork configs from .opencode directories", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const configDir = path.join(dir, ".opencode") - await fs.mkdir(configDir, { recursive: true }) - await writeConfig( - configDir, - { - $schema: "https://opencode.ai/config.json", - model: "test/pawwork-dir", - }, - "pawwork.json", - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("test/pawwork-dir") - }, - }) -}) - -test("loads and updates global PawWork config aliases", async () => { - await using globalTmp = await tmpdir() - await using tmp = await tmpdir() - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - await clear() - try { - await writeConfig( - globalTmp.path, - { - $schema: "https://opencode.ai/config.json", - username: "global-pawwork", + await withPawWorkRuntime(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const configDir = path.join(dir, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await writeConfig( + configDir, + { + $schema: "https://opencode.ai/config.json", + model: "test/pawwork-dir", + }, + "pawwork.json", + ) }, - "pawwork.json", - ) - + }) await Instance.provide({ directory: tmp.path, fn: async () => { const config = await load() - expect(config.username).toBe("global-pawwork") - - const updated = await Effect.runPromise( - Config.Service.use((svc) => svc.updateGlobal({ ...config, username: "updated-pawwork" })).pipe( - Effect.scoped, - Effect.provide(layer), - ), - ) - expect(updated.username).toBe("updated-pawwork") + expect(config.model).toBe("test/pawwork-dir") }, }) - - const content = await Filesystem.readJson<{ username?: string }>(path.join(globalTmp.path, "pawwork.json")) - expect(content.username).toBe("updated-pawwork") - expect(await Filesystem.exists(path.join(globalTmp.path, "opencode.jsonc"))).toBe(false) - } finally { - ;(Global.Path as { config: string }).config = prev - await clear() - } + }) }) -test("PawWork config aliases win consistently when OpenCode aliases coexist", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig( - dir, - { - $schema: "https://opencode.ai/config.json", - model: "test/opencode-root", - }, - "opencode.json", - ) +test("loads and updates global PawWork config aliases", async () => { + await withPawWorkRuntime(async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear() + try { await writeConfig( - dir, + globalTmp.path, { $schema: "https://opencode.ai/config.json", - model: "test/pawwork-root", + username: "global-pawwork", }, "pawwork.json", ) - }, + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.username).toBe("global-pawwork") + + const updated = await Effect.runPromise( + Config.Service.use((svc) => svc.updateGlobal({ ...config, username: "updated-pawwork" })).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) + expect(updated.username).toBe("updated-pawwork") + }, + }) + + const content = await Filesystem.readJson<{ username?: string }>(path.join(globalTmp.path, "pawwork.json")) + expect(content.username).toBe("updated-pawwork") + expect(await Filesystem.exists(path.join(globalTmp.path, "opencode.jsonc"))).toBe(false) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + } }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("test/pawwork-root") - }, +}) + +test("PawWork config aliases win consistently when OpenCode aliases coexist", async () => { + await withPawWorkRuntime(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "test/opencode-root", + }, + "opencode.json", + ) + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "test/pawwork-root", + }, + "pawwork.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("test/pawwork-root") + }, + }) }) }) test("PawWork .opencode aliases win consistently when OpenCode aliases coexist", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const configDir = path.join(dir, ".opencode") - await fs.mkdir(configDir, { recursive: true }) - await writeConfig( - configDir, - { - $schema: "https://opencode.ai/config.json", - model: "test/opencode-dir", - }, - "opencode.json", - ) - await writeConfig( - configDir, - { - $schema: "https://opencode.ai/config.json", - model: "test/pawwork-dir", - }, - "pawwork.json", - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("test/pawwork-dir") - }, + await withPawWorkRuntime(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const configDir = path.join(dir, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await writeConfig( + configDir, + { + $schema: "https://opencode.ai/config.json", + model: "test/opencode-dir", + }, + "opencode.json", + ) + await writeConfig( + configDir, + { + $schema: "https://opencode.ai/config.json", + model: "test/pawwork-dir", + }, + "pawwork.json", + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("test/pawwork-dir") + }, + }) }) }) @@ -1255,7 +1278,7 @@ test("updates config and writes to file", async () => { const newConfig = { model: "updated/model" } await save(newConfig as any) - const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json")) + const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "opencode.json")) expect(writtenConfig.model).toBe("updated/model") }, }) @@ -1874,29 +1897,31 @@ test("managed settings override project settings", async () => { }) test("managed settings honor PawWork config aliases", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig(dir, { - $schema: "https://opencode.ai/config.json", - model: "user/model", - }) - }, - }) + await withPawWorkRuntime(async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "user/model", + }) + }, + }) - await writeManagedSettings( - { - $schema: "https://opencode.ai/config.json", - model: "managed/pawwork", - }, - "pawwork.json", - ) + await writeManagedSettings( + { + $schema: "https://opencode.ai/config.json", + model: "managed/pawwork", + }, + "pawwork.json", + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("managed/pawwork") - }, + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("managed/pawwork") + }, + }) }) }) diff --git a/packages/opencode/test/config/pawwork-global-config.test.ts b/packages/opencode/test/config/pawwork-global-config.test.ts new file mode 100644 index 000000000..9c7b00282 --- /dev/null +++ b/packages/opencode/test/config/pawwork-global-config.test.ts @@ -0,0 +1,469 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Account } from "../../src/account" +import { Auth } from "../../src/auth" +import { Config, ConfigManaged } from "../../src/config" +import { ConfigPaths } from "../../src/config/paths" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { AppFileSystem } from "../../src/filesystem" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) + +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) + +const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), +) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const save = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) +const saveGlobal = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) +const clear = (wait = false) => + Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) +const listConfigDirs = (directory: string, worktree: string) => + Effect.runPromise(ConfigPaths.directories(directory, worktree).pipe(Effect.provide(AppFileSystem.defaultLayer))) + +const originalRuntimeNamespace = process.env.PAWWORK_RUNTIME_NAMESPACE + +beforeEach(async () => { + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + await clear(true) +}) + +afterEach(async () => { + await Instance.disposeAll() + await clear(true) + if (originalRuntimeNamespace === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = originalRuntimeNamespace +}) + +describe("default OpenCode config compatibility", () => { + test("keeps OPENCODE_CONFIG_DIR outside PawWork runtime mode", async () => { + await using opencodeConfig = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE + const previousOpenCode = process.env.OPENCODE_CONFIG_DIR + + delete process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path + + try { + const dirs = await listConfigDirs(project.path, project.path) + expect(dirs).toContain(opencodeConfig.path) + } finally { + if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime + if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previousOpenCode + } + }) + + test("keeps OpenCode managed config defaults outside PawWork runtime mode", () => { + const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE + const previousManaged = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + delete process.env.PAWWORK_RUNTIME_NAMESPACE + delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + + try { + const managed = ConfigManaged.managedConfigDir() + expect(path.basename(managed)).toBe("opencode") + } finally { + if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime + if (previousManaged === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previousManaged + } + }) + + test("PawWork runtime mode computes Global config under PawWork before module load", async () => { + await using root = await tmpdir() + const project = path.join(root.path, "project") + const script = ` + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + process.env.XDG_CONFIG_HOME = ${JSON.stringify(root.path)} + const { ConfigPaths } = await import("./src/config/paths.ts") + const { AppFileSystem } = await import("./src/filesystem/index.ts") + const { Effect } = await import("effect") + const dirs = await Effect.runPromise( + ConfigPaths.directories(${JSON.stringify(project)}, ${JSON.stringify(project)}).pipe(Effect.provide(AppFileSystem.defaultLayer)), + ) + console.log(JSON.stringify(dirs[0])) + ` + const result = Bun.spawnSync({ + cmd: [process.execPath, "--eval", script], + cwd: path.join(import.meta.dir, "..", ".."), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + expect(JSON.parse(Buffer.from(result.stdout).toString())).toBe(path.join(root.path, "pawwork")) + }) + + test("keeps legacy project .opencode config.json compatibility", async () => { + await using project = await tmpdir({ git: true }) + + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await Filesystem.write(path.join(configDir, "config.json"), JSON.stringify({ model: "compat/config" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("compat/config") + }, + }) + }) + + test("OpenCode runtime ignores PawWork project config aliases", async () => { + await using project = await tmpdir({ git: true }) + const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE + delete process.env.PAWWORK_RUNTIME_NAMESPACE + + try { + await Filesystem.write(path.join(project.path, "pawwork.json"), JSON.stringify({ model: "leaked/root" })) + await fs.mkdir(path.join(project.path, ".pawwork"), { recursive: true }) + await Filesystem.write( + path.join(project.path, ".pawwork", "pawwork.json"), + JSON.stringify({ model: "leaked/directory" }), + ) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).not.toBe("leaked/root") + expect(config.model).not.toBe("leaked/directory") + }, + }) + } finally { + if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime + } + }) + + test("OpenCode runtime ignores PawWork global config aliases", async () => { + await using global = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousRuntime = process.env.PAWWORK_RUNTIME_NAMESPACE + const previousConfig = Global.Path.config + delete process.env.PAWWORK_RUNTIME_NAMESPACE + ;(Global.Path as { config: string }).config = global.path + + try { + await Filesystem.write(path.join(global.path, "pawwork.json"), JSON.stringify({ model: "leaked/global" })) + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).not.toBe("leaked/global") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = previousConfig + if (previousRuntime === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previousRuntime + } + }) +}) + +describe("PawWork global config isolation", () => { + test("does not discover home-level .opencode config implicitly", async () => { + await using home = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home.path + + try { + const homeConfigDir = path.join(home.path, ".opencode") + await fs.mkdir(homeConfigDir, { recursive: true }) + await Filesystem.write(path.join(homeConfigDir, "opencode.json"), JSON.stringify({ model: "leaked/model" })) + + const dirs = await listConfigDirs(project.path, project.path) + + expect(dirs).not.toContain(homeConfigDir) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).not.toBe("leaked/model") + }, + }) + } finally { + if (previousHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = previousHome + } + }) + + test("ignores OPENCODE_CONFIG_DIR as an implicit OpenCode global config path", async () => { + await using opencodeConfig = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousOpenCode = process.env.OPENCODE_CONFIG_DIR + const previousPawWork = process.env.PAWWORK_CONFIG_DIR + process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path + delete process.env.PAWWORK_CONFIG_DIR + + try { + await Filesystem.write(path.join(opencodeConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" })) + + const dirs = await listConfigDirs(project.path, project.path) + expect(dirs).not.toContain(opencodeConfig.path) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).not.toBe("leaked/env") + }, + }) + } finally { + if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previousOpenCode + if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR + else process.env.PAWWORK_CONFIG_DIR = previousPawWork + } + }) + + test("PAWWORK_CONFIG_DIR reads PawWork filenames and ignores OpenCode filenames", async () => { + await using pawworkConfig = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousOpenCode = process.env.OPENCODE_CONFIG_DIR + const previousPawWork = process.env.PAWWORK_CONFIG_DIR + delete process.env.OPENCODE_CONFIG_DIR + process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path + + try { + await Filesystem.write(path.join(pawworkConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" })) + await Filesystem.write(path.join(pawworkConfig.path, "pawwork.json"), JSON.stringify({ model: "expected/model" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("expected/model") + }, + }) + } finally { + if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previousOpenCode + if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR + else process.env.PAWWORK_CONFIG_DIR = previousPawWork + } + }) + + test("PAWWORK_CONFIG_DIR with only opencode.json does not affect config", async () => { + await using pawworkConfig = await tmpdir() + await using project = await tmpdir({ git: true }) + const previousOpenCode = process.env.OPENCODE_CONFIG_DIR + const previousPawWork = process.env.PAWWORK_CONFIG_DIR + delete process.env.OPENCODE_CONFIG_DIR + process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path + + try { + await Filesystem.write(path.join(pawworkConfig.path, "opencode.json"), JSON.stringify({ model: "leaked/env" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).not.toBe("leaked/env") + }, + }) + } finally { + if (previousOpenCode === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = previousOpenCode + if (previousPawWork === undefined) delete process.env.PAWWORK_CONFIG_DIR + else process.env.PAWWORK_CONFIG_DIR = previousPawWork + } + }) + + test("loads project .pawwork config directories", async () => { + await using project = await tmpdir({ git: true }) + const pawworkDir = path.join(project.path, ".pawwork") + await fs.mkdir(pawworkDir, { recursive: true }) + await Filesystem.write(path.join(pawworkDir, "pawwork.json"), JSON.stringify({ model: "project/pawwork" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const dirs = await listConfigDirs(project.path, project.path) + expect(dirs).toContain(pawworkDir) + + const config = await load() + expect(config.model).toBe("project/pawwork") + }, + }) + }) + + test("project .opencode directories stay read-only for dependency installs", async () => { + await using project = await tmpdir({ git: true }) + const opencodeDir = path.join(project.path, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write(path.join(opencodeDir, "opencode.json"), JSON.stringify({ model: "compat/model" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("compat/model") + + expect(await Bun.file(path.join(opencodeDir, "package.json")).exists()).toBeFalse() + expect(await Bun.file(path.join(opencodeDir, ".gitignore")).exists()).toBeFalse() + }, + }) + }) + + test("project config update writes pawwork.json and reloads it", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await save({ model: "project/model" }) + + expect(await Bun.file(path.join(project.path, "config.json")).exists()).toBeFalse() + expect(await Bun.file(path.join(project.path, "pawwork.json")).exists()).toBeTrue() + + const after = await load() + expect(after.model).toBe("project/model") + }, + }) + }) + + test("project config update writes to the active PawWork jsonc file", async () => { + await using project = await tmpdir({ git: true }) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await Filesystem.write(path.join(project.path, "pawwork.json"), JSON.stringify({ model: "json/model" })) + await Filesystem.write(path.join(project.path, "pawwork.jsonc"), JSON.stringify({ model: "jsonc/model" })) + + const before = await load() + expect(before.model).toBe("jsonc/model") + + await save({ model: "updated/project" }) + const after = await load() + expect(after.model).toBe("updated/project") + expect(JSON.parse(await Bun.file(path.join(project.path, "pawwork.jsonc")).text()).model).toBe( + "updated/project", + ) + }, + }) + }) + + test("global config update writes pawwork.json and ignores app-level opencode.json", async () => { + await using project = await tmpdir({ git: true }) + await using global = await tmpdir() + const globalDir = global.path + const previousConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalDir + + try { + await Instance.provide({ + directory: project.path, + fn: async () => { + await Filesystem.write(path.join(globalDir, "opencode.json"), JSON.stringify({ model: "leaked/global" })) + + const before = await load() + expect(before.model).not.toBe("leaked/global") + + await saveGlobal({ model: "test/model" }) + const configPath = path.join(globalDir, "pawwork.json") + expect(await Bun.file(configPath).exists()).toBeTrue() + expect(JSON.parse(await Bun.file(configPath).text()).model).toBe("test/model") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = previousConfig + } + }) + + test("global config update writes to the active PawWork config file", async () => { + await using project = await tmpdir({ git: true }) + await using global = await tmpdir() + const globalDir = global.path + const previousConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalDir + + try { + await Instance.provide({ + directory: project.path, + fn: async () => { + await Filesystem.write(path.join(globalDir, "pawwork.json"), JSON.stringify({ model: "json/model" })) + await Filesystem.write(path.join(globalDir, "pawwork.jsonc"), JSON.stringify({ model: "jsonc/model" })) + + const before = await load() + expect(before.model).toBe("jsonc/model") + + await saveGlobal({ model: "updated/model" }) + const after = await load() + expect(after.model).toBe("updated/model") + expect(JSON.parse(await Bun.file(path.join(globalDir, "pawwork.jsonc")).text()).model).toBe("updated/model") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = previousConfig + } + }) + + test("managed config defaults use PawWork-owned locations", () => { + const previous = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + + try { + const managed = ConfigManaged.managedConfigDir() + expect(path.basename(managed)).toBe("pawwork") + } finally { + if (previous === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previous + } + }) + + test("managed config ignores opencode.json in PawWork runtime mode", async () => { + await using managed = await tmpdir() + await using project = await tmpdir({ git: true }) + const previous = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = managed.path + + try { + await Filesystem.write(path.join(managed.path, "opencode.json"), JSON.stringify({ model: "leaked/managed" })) + await Filesystem.write(path.join(managed.path, "pawwork.json"), JSON.stringify({ model: "expected/managed" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("expected/managed") + }, + }) + } finally { + if (previous === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previous + } + }) +}) diff --git a/packages/opencode/test/global/runtime-namespace.test.ts b/packages/opencode/test/global/runtime-namespace.test.ts new file mode 100644 index 000000000..1bd2ac051 --- /dev/null +++ b/packages/opencode/test/global/runtime-namespace.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import path from "path" + +function readGlobalPath(namespace?: string) { + const script = ` + process.env.XDG_DATA_HOME = "/tmp/pawwork-runtime-test/share" + process.env.XDG_CACHE_HOME = "/tmp/pawwork-runtime-test/cache" + process.env.XDG_CONFIG_HOME = "/tmp/pawwork-runtime-test/config" + process.env.XDG_STATE_HOME = "/tmp/pawwork-runtime-test/state" + if (${JSON.stringify(namespace)} !== undefined) { + process.env.PAWWORK_RUNTIME_NAMESPACE = ${JSON.stringify(namespace)} + } + const { Global } = await import("./src/global/index.ts") + console.log(JSON.stringify(Global.Path)) + ` + const result = Bun.spawnSync({ + cmd: [process.execPath, "--eval", script], + cwd: path.join(import.meta.dir, "..", ".."), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }) + + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + return JSON.parse(Buffer.from(result.stdout).toString()) as Record +} + +describe("Global runtime namespace", () => { + test("defaults to OpenCode namespace outside PawWork desktop", () => { + const paths = readGlobalPath() + + expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/opencode") + expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/opencode") + expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/opencode") + expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/opencode") + }) + + test("uses PawWork namespace when enabled", () => { + const paths = readGlobalPath("pawwork") + + expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/pawwork") + expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/pawwork") + expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/pawwork") + expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/pawwork") + expect(paths.bin).toBe("/tmp/pawwork-runtime-test/cache/pawwork/bin") + expect(paths.log).toBe("/tmp/pawwork-runtime-test/share/pawwork/log") + }) +}) diff --git a/packages/opencode/test/index-runtime-namespace.test.ts b/packages/opencode/test/index-runtime-namespace.test.ts new file mode 100644 index 000000000..816ef1269 --- /dev/null +++ b/packages/opencode/test/index-runtime-namespace.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test" + +describe("startup migration marker", () => { + test("uses the namespaced database path instead of a hard-coded OpenCode database", async () => { + const source = await Bun.file(new URL("../src/index.ts", import.meta.url)).text() + + expect(source).toMatch(/\bDatabase\.getChannelPath\(\)/) + expect(source).not.toMatch(/path\.join\(\s*Global\.Path\.data\s*,\s*["'`]opencode\.db["'`]\s*\)/) + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 2bc573f1b..cdcd12ffd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -61,6 +61,29 @@ async function defaultModel() { return run((provider) => provider.defaultModel()) } +test("OpenCode Zen and OpenCode Go providers remain discoverable in PawWork runtime mode", async () => { + await using tmp = await tmpdir({ git: true }) + const previous = process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + + try { + await Instance.provide({ + directory: tmp.path, + init: async () => { + set("OPENCODE_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await list() + expect(providers[ProviderID.make("opencode")]?.name).toBe("OpenCode Zen") + expect(providers[ProviderID.make("opencode-go")]?.name).toBe("OpenCode Go") + }, + }) + } finally { + if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previous + } +}) + function paid(providers: Awaited>) { const item = providers[ProviderID.make("opencode")] expect(item).toBeDefined() diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index c46bbd20b..c25ad571d 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -385,3 +385,145 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { } }) }) + +describe("Instruction.systemPaths PawWork runtime config dir", () => { + const original = { + opencodeConfigDir: process.env.OPENCODE_CONFIG_DIR, + pawworkConfigDir: process.env.PAWWORK_CONFIG_DIR, + runtimeNamespace: process.env.PAWWORK_RUNTIME_NAMESPACE, + disableProjectConfig: process.env.OPENCODE_DISABLE_PROJECT_CONFIG, + } + + afterEach(() => { + if (original.opencodeConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = original.opencodeConfigDir + if (original.pawworkConfigDir === undefined) delete process.env.PAWWORK_CONFIG_DIR + else process.env.PAWWORK_CONFIG_DIR = original.pawworkConfigDir + if (original.runtimeNamespace === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = original.runtimeNamespace + if (original.disableProjectConfig === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = original.disableProjectConfig + }) + + test("ignores OPENCODE_CONFIG_DIR AGENTS.md in PawWork runtime mode", async () => { + await using profileTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# OpenCode Profile Instructions") + }, + }) + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + process.env.OPENCODE_CONFIG_DIR = profileTmp.path + delete process.env.PAWWORK_CONFIG_DIR + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }), + ), + ), + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) + + test("prefers PAWWORK_CONFIG_DIR AGENTS.md over global when both exist", async () => { + await using profileTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# PawWork Profile Instructions") + }, + }) + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + delete process.env.OPENCODE_CONFIG_DIR + process.env.PAWWORK_CONFIG_DIR = profileTmp.path + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + }), + ), + ), + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) + + test("resolves relative instruction paths from PAWWORK_CONFIG_DIR when project config is disabled", async () => { + await using pawworkConfig = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "rules", "extra.md"), "# PawWork Relative Instructions") + }, + }) + await using opencodeConfig = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "rules", "extra.md"), "# OpenCode Relative Instructions") + }, + }) + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "pawwork.json"), JSON.stringify({ instructions: ["rules/extra.md"] })) + }, + }) + await using projectTmp = await tmpdir() + + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" + process.env.OPENCODE_CONFIG_DIR = opencodeConfig.path + process.env.PAWWORK_CONFIG_DIR = pawworkConfig.path + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: () => + run( + Instruction.Service.use((svc) => + Effect.gen(function* () { + const rules = yield* svc.system() + expect(rules).toContain( + `Instructions from: ${path.join(pawworkConfig.path, "rules", "extra.md")}\n# PawWork Relative Instructions`, + ) + expect(rules.join("\n")).not.toContain("OpenCode Relative Instructions") + }), + ), + ), + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index cfd6da26d..20a129d56 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -11,6 +11,28 @@ import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) +describe("PawWork runtime namespace", () => { + test("plan files use .pawwork in git projects", async () => { + await using tmp = await tmpdir({ git: true }) + const previous = process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(SessionNs.plan({ slug: "test-plan", time: { created: 123 } })).toBe( + path.join(tmp.path, ".pawwork", "plans", "123-test-plan.md"), + ) + }, + }) + } finally { + if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previous + } + }) +}) + describe("session.created event", () => { test("should emit session.created event when session is created", async () => { await Instance.provide({ diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts index f6b605559..bfe94680c 100644 --- a/packages/opencode/test/storage/db.test.ts +++ b/packages/opencode/test/storage/db.test.ts @@ -6,9 +6,24 @@ import { Database } from "../../src/storage/db" describe("Database.Path", () => { test("returns database path for the current channel", () => { - const expected = ["latest", "beta"].includes(Installation.CHANNEL) + const expected = ["latest", "beta", "prod"].includes(Installation.CHANNEL) ? path.join(Global.Path.data, "opencode.db") : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) expect(Database.getChannelPath()).toBe(expected) }) + + test("uses PawWork database name when PawWork runtime namespace is enabled", () => { + const previous = process.env.PAWWORK_RUNTIME_NAMESPACE + process.env.PAWWORK_RUNTIME_NAMESPACE = "pawwork" + + try { + const expected = ["latest", "beta", "prod"].includes(Installation.CHANNEL) + ? path.join(Global.Path.data, "pawwork.db") + : path.join(Global.Path.data, `pawwork-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) + expect(Database.getChannelPath()).toBe(expected) + } finally { + if (previous === undefined) delete process.env.PAWWORK_RUNTIME_NAMESPACE + else process.env.PAWWORK_RUNTIME_NAMESPACE = previous + } + }) }) diff --git a/packages/shared/src/global.ts b/packages/shared/src/global.ts index 538cc091b..d872b3b33 100644 --- a/packages/shared/src/global.ts +++ b/packages/shared/src/global.ts @@ -2,6 +2,7 @@ import path from "path" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" +import { Runtime } from "./runtime" export namespace Global { export class Service extends Context.Service()("@opencode/Global") {} @@ -19,7 +20,7 @@ export namespace Global { export const layer = Layer.effect( Service, Effect.gen(function* () { - const app = "opencode" + const app = Runtime.appName() const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() const data = path.join(xdgData!, app) const cache = path.join(xdgCache!, app) diff --git a/packages/shared/src/runtime.ts b/packages/shared/src/runtime.ts new file mode 100644 index 000000000..d93ccf3ae --- /dev/null +++ b/packages/shared/src/runtime.ts @@ -0,0 +1,9 @@ +export namespace Runtime { + export function isPawWork() { + return process.env.PAWWORK_RUNTIME_NAMESPACE?.startsWith("pawwork") ?? false + } + + export function appName() { + return isPawWork() ? "pawwork" : "opencode" + } +} diff --git a/packages/shared/test/global.test.ts b/packages/shared/test/global.test.ts new file mode 100644 index 000000000..82f8e5a4b --- /dev/null +++ b/packages/shared/test/global.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import os from "node:os" +import path from "path" + +function readSharedGlobalPath(namespace?: string) { + const root = path.join(os.tmpdir(), "pawwork-shared-runtime-test") + const data = path.join(root, "share") + const cache = path.join(root, "cache") + const config = path.join(root, "config") + const state = path.join(root, "state") + const script = ` + process.env.XDG_DATA_HOME = ${JSON.stringify(data)} + process.env.XDG_CACHE_HOME = ${JSON.stringify(cache)} + process.env.XDG_CONFIG_HOME = ${JSON.stringify(config)} + process.env.XDG_STATE_HOME = ${JSON.stringify(state)} + if (${JSON.stringify(namespace)} !== undefined) { + process.env.PAWWORK_RUNTIME_NAMESPACE = ${JSON.stringify(namespace)} + } else { + delete process.env.PAWWORK_RUNTIME_NAMESPACE + } + const { Effect } = await import("effect") + const { Global } = await import("./src/global.ts") + const paths = await Effect.gen(function* () { + return yield* Global.Service + }).pipe(Effect.provide(Global.layer), Effect.runPromise) + console.log(JSON.stringify(paths)) + ` + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: path.join(import.meta.dir, ".."), + env: { ...process.env }, + }) + + if (result.status !== 0) throw new Error(result.stderr.toString()) + return { + paths: JSON.parse(result.stdout.toString()) as Record, + root: { data, cache, config, state }, + } +} + +describe("shared Global runtime namespace", () => { + test("defaults to OpenCode namespace outside PawWork desktop", () => { + const { paths, root } = readSharedGlobalPath() + expect(paths.data).toBe(path.join(root.data, "opencode")) + }) + + test("uses PawWork namespace when enabled", () => { + const { paths, root } = readSharedGlobalPath("pawwork") + + expect(paths.data).toBe(path.join(root.data, "pawwork")) + expect(paths.cache).toBe(path.join(root.cache, "pawwork")) + expect(paths.config).toBe(path.join(root.config, "pawwork")) + expect(paths.state).toBe(path.join(root.state, "pawwork")) + }) + + test("accepts PawWork variant namespaces", () => { + const { paths, root } = readSharedGlobalPath("pawwork-dev") + + expect(paths.data).toBe(path.join(root.data, "pawwork")) + }) +}) diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index e1c92f243..da773bf74 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -8,11 +8,18 @@ import type { DesktopTheme } from "./types" export type ColorScheme = "light" | "dark" | "system" -const STORAGE_KEYS = { - THEME_ID: "opencode-theme-id", - COLOR_SCHEME: "opencode-color-scheme", - THEME_CSS_LIGHT: "opencode-theme-css-light", - THEME_CSS_DARK: "opencode-theme-css-dark", +export type ThemeStorageKeys = { + themeId: string + colorScheme: string + cssLight: string + cssDark: string +} + +const DEFAULT_STORAGE_KEYS: ThemeStorageKeys = { + themeId: "opencode-theme-id", + colorScheme: "opencode-color-scheme", + cssLight: "opencode-theme-css-light", + cssDark: "opencode-theme-css-dark", } as const const THEME_STYLE_ID = "oc-theme" @@ -69,9 +76,9 @@ function drop(key: string) { } catch {} } -function clear() { - drop(STORAGE_KEYS.THEME_CSS_LIGHT) - drop(STORAGE_KEYS.THEME_CSS_DARK) +function clear(keys: ThemeStorageKeys) { + drop(keys.cssLight) + drop(keys.cssDark) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -97,14 +104,14 @@ function shouldCacheTheme(themeId: string) { return themeId !== DEFAULT_THEME_ID } -function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark") { +function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark", keys: ThemeStorageKeys) { const isDark = mode === "dark" const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) if (shouldCacheTheme(themeId)) { - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + write(isDark ? keys.cssDark : keys.cssLight, css) } const fullCss = `:root { @@ -119,23 +126,28 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da document.documentElement.dataset.colorScheme = mode } -function cacheThemeVariants(theme: DesktopTheme, themeId: string) { +function cacheThemeVariants(theme: DesktopTheme, themeId: string, keys: ThemeStorageKeys) { if (!shouldCacheTheme(themeId)) return for (const mode of ["light", "dark"] as const) { const isDark = mode === "dark" const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + write(isDark ? keys.cssDark : keys.cssLight, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", - init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { - const rawStoredTheme = read(STORAGE_KEYS.THEME_ID) + init: (props: { + defaultTheme?: string + storageKeys?: Partial + onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void + }) => { + const storageKeys = { ...DEFAULT_STORAGE_KEYS, ...props.storageKeys } + const rawStoredTheme = read(storageKeys.themeId) const storedTheme = rawStoredTheme && knownThemes().has(rawStoredTheme) ? rawStoredTheme : null - const storedScheme = read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + const storedScheme = read(storageKeys.colorScheme) as ColorScheme | null const firstInstall = !rawStoredTheme && !storedScheme const fallbackDefault = props.defaultTheme && knownThemes().has(props.defaultTheme) ? props.defaultTheme : DEFAULT_THEME_ID @@ -178,7 +190,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { - applyThemeCss(theme, themeId, mode) + applyThemeCss(theme, themeId, mode, storageKeys) props.onThemeApplied?.(theme, mode) } @@ -194,22 +206,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) const onStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + if (e.key === storageKeys.themeId && e.newValue) { const next = e.newValue if (!knownThemes().has(next) && !store.themes[next]) return setStore("themeId", next) if (next === DEFAULT_THEME_ID) { setStore("colorScheme", "light") setStore("mode", "light") - clear() + clear(storageKeys) return } void load(next).then((theme) => { if (!theme || store.themeId !== next) return - cacheThemeVariants(theme, next) + cacheThemeVariants(theme, next, storageKeys) }) } - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + if (e.key === storageKeys.colorScheme && e.newValue) { const nextScheme = store.themeId === DEFAULT_THEME_ID ? "light" : (e.newValue as ColorScheme) setStore("colorScheme", nextScheme) setStore("mode", resolveMode(store.themeId, nextScheme)) @@ -226,27 +238,27 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } makeEventListener(mediaQuery, "change", onMedia) - const rawTheme = read(STORAGE_KEYS.THEME_ID) - const rawScheme = read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + const rawTheme = read(storageKeys.themeId) + const rawScheme = read(storageKeys.colorScheme) as ColorScheme | null const firstInstall = !rawTheme && !rawScheme const candidate = rawTheme ?? props.defaultTheme const savedTheme = candidate && knownThemes().has(candidate) ? candidate : DEFAULT_THEME_ID const savedScheme = savedTheme === DEFAULT_THEME_ID ? "light" : ((rawScheme ?? (firstInstall ? "light" : "system")) as ColorScheme) if (rawTheme && rawTheme !== savedTheme) { - write(STORAGE_KEYS.THEME_ID, savedTheme) - clear() + write(storageKeys.themeId, savedTheme) + clear(storageKeys) } if (savedTheme === DEFAULT_THEME_ID) { - write(STORAGE_KEYS.THEME_ID, savedTheme) - write(STORAGE_KEYS.COLOR_SCHEME, "light") + write(storageKeys.themeId, savedTheme) + write(storageKeys.colorScheme, "light") } if (savedTheme !== store.themeId) setStore("themeId", savedTheme) if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) setStore("mode", resolveMode(savedTheme, savedScheme)) void load(savedTheme).then((theme) => { if (!theme || store.themeId !== savedTheme) return - cacheThemeVariants(theme, savedTheme) + cacheThemeVariants(theme, savedTheme, storageKeys) }) }) @@ -265,22 +277,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (id === DEFAULT_THEME_ID) { setStore("colorScheme", "light") setStore("mode", "light") - write(STORAGE_KEYS.THEME_ID, id) - write(STORAGE_KEYS.COLOR_SCHEME, "light") - clear() + write(storageKeys.themeId, id) + write(storageKeys.colorScheme, "light") + clear(storageKeys) return } void load(id).then((theme) => { if (!theme || store.themeId !== id) return - cacheThemeVariants(theme, id) - write(STORAGE_KEYS.THEME_ID, id) + cacheThemeVariants(theme, id, storageKeys) + write(storageKeys.themeId, id) }) } const setColorScheme = (scheme: ColorScheme) => { const next = store.themeId === DEFAULT_THEME_ID ? "light" : scheme setStore("colorScheme", next) - write(STORAGE_KEYS.COLOR_SCHEME, next) + write(storageKeys.colorScheme, next) setStore("mode", resolveMode(store.themeId, next)) } diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index d7fe4cbfe..716dc0d1b 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -31,6 +31,6 @@ export { export { resolveThemeVariant, resolveTheme, themeToCss } from "./resolve" export { applyTheme, loadThemeFromUrl, getActiveTheme, removeTheme, setColorScheme } from "./loader" -export { ThemeProvider, useTheme, type ColorScheme } from "./context" +export { ThemeProvider, useTheme, type ColorScheme, type ThemeStorageKeys } from "./context" export { DEFAULT_THEMES, pawworkTheme } from "./default-themes" diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts index 4d48000da..4ad15a509 100644 --- a/packages/ui/src/theme/loader.ts +++ b/packages/ui/src/theme/loader.ts @@ -2,7 +2,7 @@ import type { DesktopTheme, ResolvedTheme } from "./types" import { resolveThemeVariant, themeToCss } from "./resolve" let activeTheme: DesktopTheme | null = null -const THEME_STYLE_ID = "opencode-theme" +const THEME_STYLE_ID = "oc-theme" function ensureLoaderStyleElement(): HTMLStyleElement { const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null