Files

1252 lines
49 KiB
JavaScript

// Desktop workspace persistence and bootstrap configuration. This module owns
// on-disk workspace state, per-workspace openwork.json files, remote workspace
// normalization/discovery, and the workspace-facing command operations.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
desktopBootstrapPath as resolveDesktopBootstrapPath,
legacyDesktopBootstrapPath as resolveLegacyDesktopBootstrapPath,
normalizeWorkspaceRootPath,
openworkServerConfigPath as resolveOpenworkServerConfigPath,
} from "@openwork/paths";
import { openworkWorkspaceDisplayName, selectOpenworkWorkspaceForConnection } from "./remote-workspace.mjs";
import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs";
const EMPTY_WORKSPACE_LIST = Object.freeze({
selectedId: "",
watchedId: null,
activeId: null,
workspaces: [],
});
function execResult(ok, stdout = "", stderr = "", status = ok ? 0 : 1) {
return { ok, status, stdout, stderr };
}
async function pathExists(targetPath) {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
function parseFirstJsonObject(raw) {
let inString = false;
let escaped = false;
let depth = 0;
let start = -1;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
if (depth === 0) start = index;
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0 && start >= 0) {
try {
return { ok: true, value: JSON.parse(raw.slice(start, index + 1)) };
} catch {
return { ok: false, value: null };
}
}
}
}
return { ok: false, value: null };
}
async function writeJsonFileAtomic(outputPath, value) {
const content = `${JSON.stringify(value, null, 2)}\n`;
JSON.parse(content);
await mkdir(path.dirname(outputPath), { recursive: true });
const tempPath = `${outputPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tempPath, content, "utf8");
await rename(tempPath, outputPath);
}
async function readJsonFile(targetPath, fallback) {
try {
const raw = await readFile(targetPath, "utf8");
try {
return JSON.parse(raw);
} catch (error) {
const recovered = parseFirstJsonObject(raw);
if (recovered.ok) {
console.warn(`[json] recovered ${targetPath} from trailing invalid data`, error);
await writeJsonFileAtomic(targetPath, recovered.value);
return recovered.value;
}
throw error;
}
} catch {
return fallback;
}
}
// Resolved once at module load so a mid-session process.env mutation
// (runtime.mjs buildChildEnv -> Object.assign(process.env)) can never retarget
// reads to a different file.
const DEFAULT_DESKTOP_BOOTSTRAP_PATH = resolveDesktopBootstrapPath({ homeDir: os.homedir() });
// Older builds resolved the default as ~/.config on every OS, ignoring
// LOCALAPPDATA and XDG_CONFIG_HOME. Keep reading that file when the canonical one
// is missing so existing installs keep their deployment config.
const LEGACY_DESKTOP_BOOTSTRAP_PATH = resolveLegacyDesktopBootstrapPath({ homeDir: os.homedir() });
const HOSTED_DESKTOP_WEB_URL = "https://app.openworklabs.com";
const HOSTED_DESKTOP_API_URL = "https://api.openworklabs.com";
function bootstrapUrlOrigin(value) {
if (typeof value !== "string" || !value.trim()) return "";
try {
return new URL(value.trim()).origin;
} catch {
return value.trim().replace(/\/+$/, "");
}
}
function isHostedDesktopBootstrapConfig(config) {
const baseUrlOrigin = bootstrapUrlOrigin(config?.baseUrl);
return baseUrlOrigin === HOSTED_DESKTOP_WEB_URL || baseUrlOrigin === HOSTED_DESKTOP_API_URL;
}
export function createWorkspaceStore({
app,
defaultDenBaseUrl,
defaultRequireSignin,
forceRequireSignin,
}) {
function desktopBootstrapPath() {
if (process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim()) {
return resolveDesktopBootstrapPath({ env: process.env, homeDir: os.homedir(), userDataDir: app.getPath("userData") });
}
// Dev mode swaps process.env.HOME to the sandboxed dev-data home midway
// through startup (runtime.mjs buildChildEnv -> Object.assign(process.env)),
// which changes what os.homedir() returns. Resolve the dev-data home
// deterministically so early and late IPC reads target the same file.
if (process.env.OPENWORK_DEV_MODE === "1") {
return resolveDesktopBootstrapPath({ env: process.env, homeDir: os.homedir(), userDataDir: app.getPath("userData") });
}
return DEFAULT_DESKTOP_BOOTSTRAP_PATH;
}
function legacyDesktopBootstrapPath() {
// An explicit bootstrap path defines an isolated installation boundary.
// Never let a legacy global config cross that boundary: it may contain a
// completed activation from another distribution or deployment.
if (process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim()) return null;
const primary = desktopBootstrapPath();
if (primary === DEFAULT_DESKTOP_BOOTSTRAP_PATH && LEGACY_DESKTOP_BOOTSTRAP_PATH !== primary) {
return LEGACY_DESKTOP_BOOTSTRAP_PATH;
}
return null;
}
function workspaceStatePath() {
const override = process.env.OPENWORK_DESKTOP_WORKSPACE_STATE_PATH?.trim();
if (override) return path.resolve(override);
return path.join(app.getPath("userData"), "openwork-workspaces.json");
}
function openworkServerTokenStorePath() {
const override = process.env.OPENWORK_SERVER_TOKEN_STORE_PATH?.trim();
if (override) return path.resolve(override);
return path.join(app.getPath("userData"), "openwork-server-tokens.json");
}
function openworkServerConfigPath() {
return resolveOpenworkServerConfigPath({ env: process.env, homeDir: os.homedir() });
}
// Earlier Electron alpha builds copied Tauri's openwork-workspaces.json into
// an Electron-only workspace-state.json. Keep importing that file when the
// shared canonical file is missing, but write openwork-workspaces.json going
// forward so Tauri rollback and Electron both read the same desktop state.
function legacyElectronWorkspaceStatePath() {
return path.join(app.getPath("userData"), "workspace-state.json");
}
async function migrateLegacyElectronWorkspaceStateIfNeeded() {
const current = workspaceStatePath();
const legacy = legacyElectronWorkspaceStatePath();
try {
if (existsSync(current)) return false;
if (!existsSync(legacy)) return false;
await mkdir(path.dirname(current), { recursive: true });
const raw = await readFile(legacy, "utf8");
await writeFile(current, raw, "utf8");
console.info("[migration] copied workspace-state.json to openwork-workspaces.json");
return true;
} catch (error) {
console.warn("[migration] legacy Electron workspace-state copy failed", error);
return false;
}
}
function normalizeDesktopBootstrapConfig(input) {
const baseUrl = typeof input?.baseUrl === "string" ? input.baseUrl.trim() : "";
if (!baseUrl) {
throw new Error("baseUrl is required");
}
// The handoff grant is a one-time, short-lived (~5 min) desktop sign-in
// token written to this machine-local config by the bootstrap CLI. The app
// exchanges it once on boot and then rewrites this file with `handoff: null`
// (see den-auth-provider) so it is never reusable. It is intentionally kept
// in plaintext here because it is single-use and local-only; do not persist
// long-lived secrets in this file.
const handoffInput = input?.handoff;
const handoff = handoffInput && typeof handoffInput === "object"
? {
grant: typeof handoffInput.grant === "string" ? handoffInput.grant.trim() : "",
denBaseUrl: typeof handoffInput.denBaseUrl === "string" ? handoffInput.denBaseUrl.trim() : "",
orgId: typeof handoffInput.orgId === "string" ? handoffInput.orgId.trim() : "",
orgName: typeof handoffInput.orgName === "string" ? handoffInput.orgName.trim() : "",
orgSlug: typeof handoffInput.orgSlug === "string" ? handoffInput.orgSlug.trim() : "",
skillId: typeof handoffInput.skillId === "string" ? handoffInput.skillId.trim() : "",
skillTitle: typeof handoffInput.skillTitle === "string" ? handoffInput.skillTitle.trim() : "",
createdAt: typeof handoffInput.createdAt === "string" ? handoffInput.createdAt.trim() : "",
}
: null;
const normalizedHandoff = handoff?.grant && handoff.denBaseUrl && handoff.orgId && handoff.orgName && handoff.skillId && handoff.skillTitle
? handoff
: null;
const preparedInput = input?.prepared;
const prepared = preparedInput && typeof preparedInput === "object"
? {
orgId: typeof preparedInput.orgId === "string" ? preparedInput.orgId.trim() : "",
orgName: typeof preparedInput.orgName === "string" ? preparedInput.orgName.trim() : "",
orgSlug: typeof preparedInput.orgSlug === "string" ? preparedInput.orgSlug.trim() : "",
skillId: typeof preparedInput.skillId === "string" ? preparedInput.skillId.trim() : "",
skillTitle: typeof preparedInput.skillTitle === "string" ? preparedInput.skillTitle.trim() : "",
skillsDir: typeof preparedInput.skillsDir === "string" ? preparedInput.skillsDir.trim() : "",
skillPath: typeof preparedInput.skillPath === "string" ? preparedInput.skillPath.trim() : "",
preparedAt: typeof preparedInput.preparedAt === "string" ? preparedInput.preparedAt.trim() : "",
}
: null;
const normalizedPrepared = prepared?.orgId && prepared.orgName && prepared.skillId && prepared.skillTitle && prepared.skillPath
? prepared
: null;
const claimLinksInput = Array.isArray(input?.claimLinks) ? input.claimLinks : [];
const claimLinks = claimLinksInput.flatMap((link) => {
if (!link || typeof link !== "object") return [];
const id = typeof link.id === "string" ? link.id.trim() : "";
const role = typeof link.role === "string" ? link.role.trim() : "";
const token = typeof link.token === "string" ? link.token.trim() : "";
const url = typeof link.url === "string" ? link.url.trim() : "";
const expiresAt = typeof link.expiresAt === "string" ? link.expiresAt.trim() : "";
return id && role && url && expiresAt ? [{ id, role, ...(token ? { token } : {}), url, expiresAt }] : [];
});
const writtenAt = typeof input?.writtenAt === "string" ? input.writtenAt.trim() : "";
const apiBaseUrl = typeof input?.apiBaseUrl === "string" ? input.apiBaseUrl.trim() : "";
const brandAppName = typeof input?.brandAppName === "string" ? input.brandAppName.trim().slice(0, 64) : "";
const brandLogoUrl = typeof input?.brandLogoUrl === "string" ? input.brandLogoUrl.trim() : "";
const brandIconUrl = typeof input?.brandIconUrl === "string" ? input.brandIconUrl.trim() : "";
const enterpriseActivationInput = input?.enterpriseActivation;
const enterpriseActivation = enterpriseActivationInput && typeof enterpriseActivationInput === "object"
? {
activatedAt: typeof enterpriseActivationInput.activatedAt === "string"
? enterpriseActivationInput.activatedAt.trim()
: "",
denBaseUrl: typeof enterpriseActivationInput.denBaseUrl === "string"
? enterpriseActivationInput.denBaseUrl.trim()
: "",
}
: null;
const normalizedEnterpriseActivation = enterpriseActivation?.activatedAt && enterpriseActivation.denBaseUrl
? enterpriseActivation
: null;
return {
baseUrl,
...(apiBaseUrl ? { apiBaseUrl } : {}),
requireSignin: forceRequireSignin || input?.requireSignin === true,
// Only an explicit policy is carried. The artifact default is never
// materialized here: desktop-bootstrap.json is shared by both flavors
// (one application identifier, one user-data directory), so persisting
// the enterprise default would gate the public artifact on the same
// machine. Consumers fall back to their own build default when the key
// is absent, which is exactly the documented precedence.
...(typeof input?.requireActivation === "boolean"
? { requireActivation: input.requireActivation }
: {}),
...(brandAppName ? { brandAppName } : {}),
...(brandLogoUrl ? { brandLogoUrl } : {}),
...(brandIconUrl ? { brandIconUrl } : {}),
...(writtenAt ? { writtenAt } : {}),
...(claimLinks.length > 0 ? { claimLinks } : {}),
...(normalizedHandoff ? { handoff: normalizedHandoff } : {}),
...(normalizedPrepared ? { prepared: normalizedPrepared } : {}),
...(normalizedEnterpriseActivation ? { enterpriseActivation: normalizedEnterpriseActivation } : {}),
};
}
function desktopBootstrapCandidateTimeMs(candidate) {
const writtenAt = typeof candidate.parsed?.writtenAt === "string" ? candidate.parsed.writtenAt.trim() : "";
const writtenAtMs = writtenAt ? Date.parse(writtenAt) : Number.NaN;
return Number.isFinite(writtenAtMs) ? writtenAtMs : candidate.mtimeMs;
}
function compareDesktopBootstrapCandidates(left, right) {
const classDifference = Number(!isHostedDesktopBootstrapConfig(left.normalized)) - Number(!isHostedDesktopBootstrapConfig(right.normalized));
return classDifference || desktopBootstrapCandidateTimeMs(left) - desktopBootstrapCandidateTimeMs(right);
}
async function readDesktopBootstrapCandidate(candidatePath) {
let exists = false;
let mtimeMs = 0;
try {
const stats = await stat(candidatePath);
exists = true;
mtimeMs = stats.mtimeMs;
} catch {
// Missing paths are normal; keep the read error below for diagnostics.
}
try {
const raw = await readFile(candidatePath, "utf8");
const parsed = JSON.parse(raw);
return {
ok: true,
path: candidatePath,
exists: true,
raw,
parsed,
normalized: normalizeDesktopBootstrapConfig(parsed),
mtimeMs,
error: null,
};
} catch (error) {
return {
ok: false,
path: candidatePath,
exists,
raw: null,
parsed: null,
normalized: null,
mtimeMs,
error,
};
}
}
function readDesktopBootstrapCandidateSync(candidatePath) {
let exists = false;
let mtimeMs = 0;
try {
const stats = statSync(candidatePath);
exists = true;
mtimeMs = stats.mtimeMs;
} catch {
// Missing paths are normal; keep the read error below for diagnostics.
}
try {
const raw = readFileSync(candidatePath, "utf8");
const parsed = JSON.parse(raw);
return {
ok: true,
path: candidatePath,
exists: true,
raw,
parsed,
normalized: normalizeDesktopBootstrapConfig(parsed),
mtimeMs,
error: null,
};
} catch (error) {
return {
ok: false,
path: candidatePath,
exists,
raw: null,
parsed: null,
normalized: null,
mtimeMs,
error,
};
}
}
async function migrateLegacyDesktopBootstrapConfig(configPath, legacyCandidate) {
try {
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, legacyCandidate.raw, "utf8");
console.info("[desktop-bootstrap] migrated legacy config", {
from: legacyCandidate.path,
to: configPath,
});
} catch (migrationError) {
console.warn("[desktop-bootstrap] legacy config migration failed", migrationError);
}
}
async function getDesktopBootstrapConfig() {
const configPath = desktopBootstrapPath();
const primary = await readDesktopBootstrapCandidate(configPath);
const legacyPath = legacyDesktopBootstrapPath();
const legacy = legacyPath ? await readDesktopBootstrapCandidate(legacyPath) : null;
if (primary.ok && legacy?.ok) {
if (compareDesktopBootstrapCandidates(legacy, primary) > 0) {
await migrateLegacyDesktopBootstrapConfig(configPath, legacy);
return { ...legacy.normalized, fromFile: true };
}
return { ...primary.normalized, fromFile: true };
}
if (primary.ok) return { ...primary.normalized, fromFile: true };
if (legacy?.ok) {
await migrateLegacyDesktopBootstrapConfig(configPath, legacy);
return { ...legacy.normalized, fromFile: true };
}
console.warn("[desktop-bootstrap] falling back to defaults", {
path: configPath,
error: primary.error instanceof Error ? primary.error.message : String(primary.error),
});
return {
baseUrl: defaultDenBaseUrl,
requireSignin: defaultRequireSignin,
fromFile: false,
};
}
function readDesktopBootstrapConfigSync() {
const configPath = desktopBootstrapPath();
const primary = readDesktopBootstrapCandidateSync(configPath);
const legacyPath = legacyDesktopBootstrapPath();
const legacy = legacyPath ? readDesktopBootstrapCandidateSync(legacyPath) : null;
if (primary.ok && legacy?.ok) {
return {
...(compareDesktopBootstrapCandidates(legacy, primary) > 0 ? legacy.normalized : primary.normalized),
fromFile: true,
};
}
if (primary.ok) return { ...primary.normalized, fromFile: true };
if (legacy?.ok) return { ...legacy.normalized, fromFile: true };
return {
baseUrl: defaultDenBaseUrl,
requireSignin: defaultRequireSignin,
fromFile: false,
};
}
async function debugDesktopBootstrapConfig() {
const configPath = desktopBootstrapPath();
const legacyPath = legacyDesktopBootstrapPath();
const result = {
path: configPath,
legacyPath,
legacyExists: legacyPath ? existsSync(legacyPath) : false,
home: os.homedir(),
envHome: process.env.HOME ?? null,
envOverride: process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH ?? null,
exists: existsSync(configPath),
raw: null,
parsed: null,
normalized: null,
error: null,
};
try {
result.raw = await readFile(configPath, "utf8");
result.parsed = JSON.parse(result.raw);
result.normalized = normalizeDesktopBootstrapConfig(result.parsed);
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
}
return result;
}
async function setDesktopBootstrapConfig(config) {
const normalized = normalizeDesktopBootstrapConfig(config);
const outputPath = desktopBootstrapPath();
const stamped = { ...normalized, writtenAt: new Date().toISOString() };
await writeJsonFileAtomic(outputPath, stamped);
return stamped;
}
async function clearDesktopBootstrapFiles() {
await rm(desktopBootstrapPath(), { force: true });
const legacyPath = legacyDesktopBootstrapPath();
if (legacyPath) {
await rm(legacyPath, { force: true });
}
}
async function clearDesktopBootstrapConfig() {
await clearDesktopBootstrapFiles();
return undefined;
}
function defaultWorkspaceOpenworkConfig(workspacePath, preset = null) {
return {
version: 1,
workspace: workspacePath
? {
name: path.basename(workspacePath) || "Workspace",
createdAt: Date.now(),
preset: preset || null,
}
: null,
authorizedRoots: workspacePath ? [workspacePath] : [],
reload: null,
};
}
async function normalizeLocalWorkspacePath(rawPath) {
const trimmed = String(rawPath ?? "").trim();
if (!trimmed) return "";
const expanded = trimmed === "~"
? os.homedir()
: trimmed.startsWith("~/") || trimmed.startsWith("~\\")
? path.join(os.homedir(), trimmed.slice(2))
: trimmed;
const normalized = normalizeWorkspaceRootPath(expanded, { platform: process.platform });
const resolved = path.resolve(normalized);
return realpath(resolved).catch(() => resolved);
}
function normalizeWorkspacePathKey(value) {
try {
const normalized = normalizeWorkspaceRootPath(value, { platform: process.platform });
return normalized ? path.resolve(normalized).replace(/\\/g, "/").toLowerCase() : "";
} catch {
return "";
}
}
function normalizeRecoveredWorkspacePath(value) {
try {
return normalizeWorkspaceRootPath(value, { platform: process.platform });
} catch {
return "";
}
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
async function recoverWorkspacesFromTokenStore() {
const store = await readJsonFile(openworkServerTokenStorePath(), null);
if (!isRecord(store) || !isRecord(store.workspaces)) return [];
const candidates = [];
for (const [rawPath, entry] of Object.entries(store.workspaces)) {
const normalizedInput = normalizeRecoveredWorkspacePath(rawPath);
if (!normalizedInput) continue;
const workspacePath = await normalizeLocalWorkspacePath(normalizedInput);
if (!(await pathExists(workspacePath))) continue;
candidates.push({
path: workspacePath,
updatedAt: isRecord(entry) && typeof entry.updatedAt === "number" ? entry.updatedAt : 0,
});
}
candidates.sort((left, right) => right.updatedAt - left.updatedAt);
const seen = new Set();
return candidates.flatMap((candidate) => {
const key = normalizeWorkspacePathKey(candidate.path);
if (!key || seen.has(key)) return [];
seen.add(key);
return [normalizeWorkspaceEntry({
id: localWorkspaceId(candidate.path),
name: path.basename(candidate.path) || "Workspace",
displayName: path.basename(candidate.path) || "Workspace",
path: candidate.path,
preset: "starter",
workspaceType: "local",
})];
});
}
async function forgetWorkspaceToken(workspacePath) {
const workspaceKey = normalizeWorkspacePathKey(workspacePath);
if (!workspaceKey) return;
const store = await readJsonFile(openworkServerTokenStorePath(), null);
if (!isRecord(store) || !isRecord(store.workspaces)) return;
const workspaces = { ...store.workspaces };
let changed = false;
for (const storedPath of Object.keys(workspaces)) {
if (normalizeWorkspacePathKey(normalizeRecoveredWorkspacePath(storedPath)) !== workspaceKey) continue;
delete workspaces[storedPath];
changed = true;
}
if (changed) {
await writeJsonFileAtomic(openworkServerTokenStorePath(), { ...store, workspaces });
}
}
async function recoverWorkspacesFromServerConfig() {
const config = await readJsonFile(openworkServerConfigPath(), null);
if (!isRecord(config) || !Array.isArray(config.workspaces)) return [];
const seen = new Set();
const workspaces = [];
for (const entry of config.workspaces) {
if (!isRecord(entry)) continue;
const workspaceType = entry.workspaceType === "remote" ? "remote" : "local";
const rawPath = typeof entry.path === "string" ? entry.path.trim() : "";
const normalizedPath = workspaceType === "local"
? await normalizeLocalWorkspacePath(normalizeRecoveredWorkspacePath(rawPath))
: rawPath;
if (workspaceType === "local" && (!normalizedPath || !(await pathExists(normalizedPath)))) continue;
const baseUrl = typeof entry.baseUrl === "string" ? entry.baseUrl.trim() : "";
const directory = typeof entry.directory === "string" && entry.directory.trim() ? entry.directory.trim() : null;
const remoteType = entry.remoteType === "opencode" ? "opencode" : "openwork";
const openworkWorkspaceId = typeof entry.openworkWorkspaceId === "string" ? entry.openworkWorkspaceId.trim() : "";
const id = typeof entry.id === "string" && entry.id.trim()
? entry.id.trim()
: workspaceType === "remote"
? remoteType === "openwork"
? openworkRemoteWorkspaceId(baseUrl, openworkWorkspaceId)
: remoteWorkspaceId(baseUrl, directory)
: localWorkspaceId(normalizedPath);
const key = workspaceType === "remote" ? id : normalizeWorkspacePathKey(normalizedPath);
if (!key || seen.has(key)) continue;
seen.add(key);
workspaces.push(normalizeWorkspaceEntry({
...entry,
id,
path: normalizedPath,
name: typeof entry.name === "string" && entry.name.trim()
? entry.name.trim()
: path.basename(normalizedPath) || "Workspace",
displayName: typeof entry.displayName === "string" ? entry.displayName : undefined,
preset: typeof entry.preset === "string" && entry.preset.trim() ? entry.preset.trim() : "starter",
workspaceType,
...(workspaceType === "remote" ? { remoteType, baseUrl, directory } : {}),
}));
}
return workspaces;
}
async function recoverWorkspacesFromKnownState() {
const fromServerConfig = await recoverWorkspacesFromServerConfig();
if (fromServerConfig.length > 0) return fromServerConfig;
return recoverWorkspacesFromTokenStore();
}
function stableWorkspaceId(value) {
return `ws_${createHash("sha256").update(String(value)).digest("hex").slice(0, 12)}`;
}
function localWorkspaceId(workspacePath) {
return stableWorkspaceId(workspacePath);
}
function remoteWorkspaceId(baseUrl, directory) {
const key = String(directory ?? "").trim()
? `remote::${baseUrl}::${String(directory).trim()}`
: `remote::${baseUrl}`;
return stableWorkspaceId(key);
}
function parseOpenworkWorkspaceIdFromUrl(input) {
const raw = String(input ?? "").trim();
if (!raw) return null;
try {
const url = new URL(raw);
const segments = url.pathname.split("/").filter(Boolean);
const workspaceIndex = segments.indexOf("workspace");
const legacyIndex = segments.indexOf("w");
const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex;
return mountIndex >= 0 && segments[mountIndex + 1]
? decodeURIComponent(segments[mountIndex + 1])
: null;
} catch {
const match = raw.match(/\/(?:workspace|w)\/([^/?#]+)/);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
}
function stripOpenworkWorkspaceMount(input) {
const raw = String(input ?? "").trim();
if (!raw) return null;
try {
const url = new URL(raw);
const segments = url.pathname.split("/").filter(Boolean);
const workspaceIndex = segments.indexOf("workspace");
const legacyIndex = segments.indexOf("w");
const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex;
if (mountIndex >= 0 && segments[mountIndex + 1]) {
const prefix = segments.slice(0, mountIndex).join("/");
url.pathname = prefix ? `/${prefix}` : "/";
}
return url.toString().replace(/\/+$/, "");
} catch {
return raw.replace(/\/(?:workspace|w)\/[^/?#]+.*$/, "").replace(/\/+$/, "") || raw;
}
}
function openworkRemoteWorkspaceId(hostUrl, workspaceId) {
const remoteWorkspaceId = String(workspaceId ?? "").trim() || parseOpenworkWorkspaceIdFromUrl(hostUrl);
if (remoteWorkspaceId) return `rem_${remoteWorkspaceId}`;
return `rem_${createHash("sha256").update(`openwork::${hostUrl}`).digest("hex").slice(0, 12)}`;
}
async function fetchOpenworkWorkspaceList(hostUrl, token, hostToken) {
const url = `${String(hostUrl ?? "").replace(/\/+$/, "")}/workspaces`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8_000);
const headers = new Headers();
const bearerToken = String(token ?? "").trim();
const hostAuthToken = String(hostToken ?? "").trim();
if (bearerToken) headers.set("Authorization", `Bearer ${bearerToken}`);
if (hostAuthToken) headers.set("X-OpenWork-Host-Token", hostAuthToken);
try {
const electron = await import("electron").catch(() => null);
const fetcher = typeof electron?.net?.fetch === "function" ? electron.net.fetch.bind(electron.net) : fetch;
const response = await fetcher(url, {
headers,
signal: controller.signal,
credentials: "omit",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`OpenWork workspace discovery failed (${response.status} ${response.statusText || "HTTP error"})`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}
async function discoverOpenworkWorkspace({ hostUrl, token, hostToken, directory }) {
const list = await fetchOpenworkWorkspaceList(hostUrl, token, hostToken);
return selectOpenworkWorkspaceForConnection(list, directory);
}
function normalizeWorkspaceEntry(input) {
return {
id: String(input.id),
name: String(input.name ?? "Workspace"),
path: String(input.path ?? ""),
preset: String(input.preset ?? "starter"),
workspaceType: input.workspaceType === "remote" ? "remote" : "local",
remoteType: input.remoteType ?? null,
baseUrl: input.baseUrl ?? null,
directory: input.directory ?? null,
displayName: input.displayName ?? null,
openworkHostUrl: input.openworkHostUrl ?? null,
openworkToken: input.openworkToken ?? null,
openworkClientToken: input.openworkClientToken ?? null,
openworkHostToken: input.openworkHostToken ?? null,
openworkWorkspaceId: input.openworkWorkspaceId ?? null,
openworkWorkspaceName: input.openworkWorkspaceName ?? null,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
};
}
async function readWorkspaceOpenworkConfig(workspacePath) {
const openworkPath = path.join(workspacePath, ".opencode", "openwork.json");
if (!(await pathExists(openworkPath))) {
return defaultWorkspaceOpenworkConfig(workspacePath);
}
const raw = await readFile(openworkPath, "utf8");
return JSON.parse(raw);
}
async function writeWorkspaceOpenworkConfig(workspacePath, config) {
const openworkPath = path.join(workspacePath, ".opencode", "openwork.json");
await mkdir(path.dirname(openworkPath), { recursive: true });
await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return execResult(true, `Wrote ${openworkPath}`);
}
async function writeWorkspaceState(nextState) {
const outputPath = workspaceStatePath();
const selectedId = String(nextState?.selectedId ?? nextState?.activeId ?? "");
const watchedId = typeof nextState?.watchedId === "string" ? nextState.watchedId : "";
const output = {
...nextState,
// Tauri's Rust state uses selectedWorkspaceId/watchedWorkspaceId on disk
// with activeId as a legacy alias. Keep Electron's selectedId/watchedId
// too so older Electron builds can still read the same file.
selectedId,
selectedWorkspaceId: selectedId,
watchedId: watchedId || null,
watchedWorkspaceId: watchedId,
activeId: selectedId || null,
};
await writeJsonFileAtomic(outputPath, output);
return output;
}
async function readWorkspaceState() {
const workspaceStateExists = existsSync(workspaceStatePath());
const state = await readJsonFile(workspaceStatePath(), EMPTY_WORKSPACE_LIST);
let selectedId =
typeof state?.selectedId === "string"
? state.selectedId
: typeof state?.selectedWorkspaceId === "string"
? state.selectedWorkspaceId
: typeof state?.activeId === "string"
? state.activeId
: "";
let watchedId =
typeof state?.watchedId === "string"
? state.watchedId
: typeof state?.watchedWorkspaceId === "string"
? state.watchedWorkspaceId
: null;
let activeId = typeof state?.activeId === "string" ? state.activeId : null;
let workspaces = Array.isArray(state?.workspaces) ? state.workspaces : [];
let changed = false;
if (!workspaceStateExists && process.env.OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY !== "1") {
const recoveredWorkspaces = await recoverWorkspacesFromKnownState();
if (recoveredWorkspaces.length > 0) {
const selectedWorkspace = recoveredWorkspaces[0];
console.info("[migration] recovered desktop workspaces from persisted OpenWork state", {
count: recoveredWorkspaces.length,
selectedWorkspaceId: selectedWorkspace.id,
});
selectedId = selectedWorkspace.id;
watchedId = selectedWorkspace.id;
activeId = selectedWorkspace.id;
workspaces = recoveredWorkspaces;
changed = true;
}
}
const idMap = new Map();
const migratedWorkspaces = workspaces.map((entry) => {
const workspace = entry && typeof entry === "object" ? entry : normalizeWorkspaceEntry(entry ?? {});
if (workspace.workspaceType !== "remote" || workspace.remoteType !== "openwork") return workspace;
const remoteWorkspaceId = String(workspace.openworkWorkspaceId ?? "").trim()
|| parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl)
|| parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl);
if (!remoteWorkspaceId) return workspace;
const hostUrl = stripOpenworkWorkspaceMount(workspace.openworkHostUrl) || stripOpenworkWorkspaceMount(workspace.baseUrl);
const nextId = openworkRemoteWorkspaceId(hostUrl ?? workspace.baseUrl, remoteWorkspaceId);
idMap.set(workspace.id, nextId);
const nextWorkspace = {
...workspace,
id: nextId,
baseUrl: hostUrl,
openworkWorkspaceId: remoteWorkspaceId,
openworkHostUrl: hostUrl,
};
if (workspace.id !== nextWorkspace.id || workspace.baseUrl !== nextWorkspace.baseUrl || workspace.openworkWorkspaceId !== nextWorkspace.openworkWorkspaceId || workspace.openworkHostUrl !== nextWorkspace.openworkHostUrl) {
changed = true;
}
return nextWorkspace;
});
// Older desktop state can contain multiple OpenWork remote entries that
// normalize to the same rem_<workspaceId> after stripping worker mounts.
// Collapse them here so React never receives duplicate workspace keys.
const workspaceIndexById = new Map();
const dedupedWorkspaces = [];
for (const workspace of migratedWorkspaces) {
const workspaceId = String(workspace?.id ?? "").trim();
if (!workspaceId) {
dedupedWorkspaces.push(workspace);
continue;
}
const existingIndex = workspaceIndexById.get(workspaceId);
if (existingIndex === undefined) {
workspaceIndexById.set(workspaceId, dedupedWorkspaces.length);
dedupedWorkspaces.push(workspace);
continue;
}
// Keep the later entry: normal mutations replace-then-push refreshed
// remote workspaces, and there is no persisted updatedAt to compare.
dedupedWorkspaces[existingIndex] = workspace;
changed = true;
}
const migratedSelectedId = idMap.get(selectedId) ?? selectedId;
const migratedWatchedId = watchedId ? idMap.get(watchedId) ?? watchedId : null;
const migratedActiveId = activeId ? idMap.get(activeId) ?? activeId : null;
if (migratedSelectedId !== selectedId || migratedWatchedId !== watchedId || migratedActiveId !== activeId) changed = true;
const nextState = {
selectedId: migratedSelectedId,
watchedId: migratedWatchedId,
activeId: migratedActiveId,
workspaces: dedupedWorkspaces,
};
if (changed) {
return writeWorkspaceState(nextState);
}
return nextState;
}
async function mutateWorkspaceState(mutator) {
const current = await readWorkspaceState();
const next = await mutator({ ...current, workspaces: [...current.workspaces] });
return writeWorkspaceState(next);
}
async function listLocalWorkspacePaths() {
return (await readWorkspaceState())
.workspaces
.filter((entry) => entry?.workspaceType !== "remote")
.map((entry) => String(entry?.path ?? "").trim())
.filter(Boolean);
}
// Binary transfers must derive authority only from app-owned state in
// userData, never from workspace-writable configuration, so this list is
// intentionally not exposed to that surface (see listLocalWorkspacePaths).
async function listRemoteWorkspaceUrlPrefixes() {
const prefixes = new Set();
for (const workspace of (await readWorkspaceState()).workspaces) {
if (workspace?.workspaceType !== "remote") continue;
for (const value of [workspace.baseUrl, workspace.openworkHostUrl]) {
const raw = typeof value === "string" ? value.trim() : "";
if (raw) prefixes.add(raw);
}
}
return [...prefixes];
}
function workspacePathKey(workspace) {
return normalizeWorkspacePathKey(workspace.path);
}
async function setSelectedWorkspace(workspaceId) {
return mutateWorkspaceState((state) => {
state.selectedId = workspaceId;
state.activeId = workspaceId || null;
return state;
});
}
async function setRuntimeActiveWorkspace(workspaceId) {
return mutateWorkspaceState((state) => {
state.watchedId = typeof workspaceId === "string" && workspaceId.trim() ? workspaceId : null;
return state;
});
}
async function createWorkspace(input = {}) {
const rawFolderPath = String(input.folderPath ?? "").trim();
if (!rawFolderPath) throw new Error("folderPath is required");
const folderPath = await normalizeLocalWorkspacePath(rawFolderPath);
await mkdir(folderPath, { recursive: true });
const preset = String(input.preset ?? "starter");
const workspace = normalizeWorkspaceEntry({
id: localWorkspaceId(folderPath),
name: String(input.name ?? (path.basename(folderPath) || "Workspace")),
displayName: String(input.name ?? (path.basename(folderPath) || "Workspace")),
path: folderPath,
preset,
workspaceType: "local",
});
await mkdir(path.join(folderPath, ".opencode"), { recursive: true });
await writeWorkspaceOpenworkConfig(folderPath, defaultWorkspaceOpenworkConfig(folderPath, preset));
return mutateWorkspaceState((state) => {
const key = workspacePathKey(workspace);
state.workspaces = state.workspaces.filter(
(entry) => entry.id !== workspace.id && normalizeWorkspacePathKey(entry.path) !== key,
);
state.workspaces.push(workspace);
state.selectedId = workspace.id;
state.activeId = workspace.id;
state.watchedId = workspace.id;
return state;
});
}
async function createRemoteWorkspace(input = {}) {
const baseUrl = String(input.baseUrl ?? "").trim();
if (!baseUrl) throw new Error("baseUrl is required");
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
throw new Error("baseUrl must start with http:// or https://");
}
const remoteType = input.remoteType === "opencode" ? "opencode" : "openwork";
const directory = typeof input.directory === "string" && input.directory.trim() ? input.directory.trim() : null;
const rawOpenworkHostUrl = typeof input.openworkHostUrl === "string" && input.openworkHostUrl.trim()
? input.openworkHostUrl.trim()
: null;
const openworkHostUrl = remoteType === "openwork"
? stripOpenworkWorkspaceMount(rawOpenworkHostUrl ?? baseUrl)
: rawOpenworkHostUrl;
const openworkWorkspaceId = typeof input.openworkWorkspaceId === "string" && input.openworkWorkspaceId.trim()
? input.openworkWorkspaceId.trim()
: remoteType === "openwork"
? parseOpenworkWorkspaceIdFromUrl(rawOpenworkHostUrl) || parseOpenworkWorkspaceIdFromUrl(baseUrl)
: null;
let resolvedOpenworkWorkspaceId = openworkWorkspaceId;
let resolvedOpenworkWorkspaceName = input.openworkWorkspaceName ?? null;
if (remoteType === "openwork" && !resolvedOpenworkWorkspaceId) {
const discovered = await discoverOpenworkWorkspace({
hostUrl: openworkHostUrl ?? baseUrl,
token: input.openworkToken,
hostToken: input.openworkHostToken,
directory,
});
if (!discovered?.id) {
throw new Error(
directory
? `OpenWork server has no workspace matching ${directory}.`
: "OpenWork server returned no workspaces.",
);
}
resolvedOpenworkWorkspaceId = String(discovered.id).trim();
resolvedOpenworkWorkspaceName = openworkWorkspaceDisplayName(discovered);
}
const id = remoteType === "openwork"
? openworkRemoteWorkspaceId(openworkHostUrl ?? baseUrl, resolvedOpenworkWorkspaceId)
: remoteWorkspaceId(baseUrl, directory);
const workspace = normalizeWorkspaceEntry({
id,
name: String(input.displayName ?? resolvedOpenworkWorkspaceName ?? "Remote workspace"),
displayName: input.displayName ?? null,
path: directory ?? "",
preset: "remote",
workspaceType: "remote",
remoteType,
baseUrl: remoteType === "openwork" ? (openworkHostUrl ?? baseUrl) : baseUrl,
directory,
openworkHostUrl,
openworkToken: input.openworkToken ?? null,
openworkClientToken: input.openworkClientToken ?? null,
openworkHostToken: input.openworkHostToken ?? null,
openworkWorkspaceId: resolvedOpenworkWorkspaceId,
openworkWorkspaceName: resolvedOpenworkWorkspaceName,
sandboxBackend: input.sandboxBackend ?? null,
sandboxRunId: input.sandboxRunId ?? null,
sandboxContainerName: input.sandboxContainerName ?? null,
});
return mutateWorkspaceState((state) => {
state.workspaces = state.workspaces.filter((entry) => entry.id !== workspace.id);
state.workspaces.push(workspace);
state.selectedId = workspace.id;
state.activeId = workspace.id;
return state;
});
}
async function updateRemoteWorkspace(input = {}) {
const workspaceId = String(input.workspaceId ?? "").trim();
if (!workspaceId) throw new Error("workspaceId is required");
const { workspaceId: _workspaceId, ...patch } = input;
return mutateWorkspaceState(async (state) => {
const existing = state.workspaces.find((entry) => entry.id === workspaceId);
if (!existing) return state;
let nextWorkspace = { ...existing, ...patch };
const nextRemoteType = nextWorkspace.remoteType === "opencode" ? "opencode" : "openwork";
if (nextRemoteType === "openwork") {
const rawHostUrl = typeof nextWorkspace.openworkHostUrl === "string" && nextWorkspace.openworkHostUrl.trim()
? nextWorkspace.openworkHostUrl.trim()
: null;
const nextBaseUrl = String(nextWorkspace.baseUrl ?? "").trim();
const hostUrl = stripOpenworkWorkspaceMount(rawHostUrl ?? nextBaseUrl);
const directory = typeof nextWorkspace.directory === "string" && nextWorkspace.directory.trim()
? nextWorkspace.directory.trim()
: null;
const parsedWorkspaceId = parseOpenworkWorkspaceIdFromUrl(rawHostUrl) || parseOpenworkWorkspaceIdFromUrl(nextBaseUrl);
let remoteWorkspaceId = parsedWorkspaceId || (
typeof nextWorkspace.openworkWorkspaceId === "string" && nextWorkspace.openworkWorkspaceId.trim()
? nextWorkspace.openworkWorkspaceId.trim()
: null
);
let remoteWorkspaceName = nextWorkspace.openworkWorkspaceName ?? null;
if (!remoteWorkspaceId) {
const discovered = await discoverOpenworkWorkspace({
hostUrl: hostUrl ?? nextBaseUrl,
token: nextWorkspace.openworkToken,
hostToken: nextWorkspace.openworkHostToken,
directory,
});
if (!discovered?.id) {
throw new Error(
directory
? `OpenWork server has no workspace matching ${directory}.`
: "OpenWork server returned no workspaces.",
);
}
remoteWorkspaceId = String(discovered.id).trim();
remoteWorkspaceName = openworkWorkspaceDisplayName(discovered);
}
const nextId = openworkRemoteWorkspaceId(hostUrl ?? nextBaseUrl, remoteWorkspaceId);
nextWorkspace = normalizeWorkspaceEntry({
...nextWorkspace,
id: nextId,
baseUrl: hostUrl ?? nextBaseUrl,
openworkHostUrl: hostUrl,
directory,
remoteType: "openwork",
openworkWorkspaceId: remoteWorkspaceId,
openworkWorkspaceName: remoteWorkspaceName,
});
if (nextId !== workspaceId) {
if (state.selectedId === workspaceId) state.selectedId = nextId;
if (state.activeId === workspaceId) state.activeId = nextId;
if (state.watchedId === workspaceId) state.watchedId = nextId;
}
}
state.workspaces = state.workspaces.map((entry) =>
entry.id === workspaceId ? nextWorkspace : entry,
);
return state;
});
}
async function updateWorkspaceDisplayName(input = {}) {
const workspaceId = String(input.workspaceId ?? "").trim();
if (!workspaceId) throw new Error("workspaceId is required");
return mutateWorkspaceState((state) => {
state.workspaces = state.workspaces.map((entry) =>
entry.id === workspaceId ? { ...entry, displayName: input.displayName ?? null } : entry,
);
return state;
});
}
async function forgetWorkspace(workspaceId) {
if (!workspaceId) throw new Error("workspaceId is required");
let workspacePath = "";
const nextState = await mutateWorkspaceState((state) => {
const workspace = state.workspaces.find((entry) => entry.id === workspaceId);
if (workspace?.workspaceType !== "remote") workspacePath = String(workspace?.path ?? "");
state.workspaces = state.workspaces.filter((entry) => entry.id !== workspaceId);
if (state.selectedId === workspaceId) state.selectedId = "";
if (state.activeId === workspaceId) state.activeId = null;
if (state.watchedId === workspaceId) state.watchedId = null;
return state;
});
await forgetWorkspaceToken(workspacePath);
return nextState;
}
async function addAuthorizedRoot(input = {}) {
const workspacePath = String(input.workspacePath ?? "").trim();
const authorizedRoot = String(input.folderPath ?? input.authorizedRoot ?? "").trim();
if (!workspacePath || !authorizedRoot) {
throw new Error("workspacePath and folderPath are required");
}
const config = await readWorkspaceOpenworkConfig(workspacePath);
if (!Array.isArray(config.authorizedRoots)) {
config.authorizedRoots = [];
}
if (!config.authorizedRoots.includes(authorizedRoot)) {
config.authorizedRoots.push(authorizedRoot);
}
return writeWorkspaceOpenworkConfig(workspacePath, config);
}
async function exportConfig(input = {}) {
const workspaceId = String(input.workspaceId ?? "").trim();
const outputPath = String(input.outputPath ?? "").trim();
if (!workspaceId) throw new Error("workspaceId is required");
if (!outputPath) throw new Error("outputPath is required");
const state = await readWorkspaceState();
const workspace = state.workspaces.find((entry) => entry.id === workspaceId);
if (!workspace) throw new Error("Unknown workspaceId");
return exportWorkspaceConfig({ workspace, outputPath });
}
async function importConfig(input = {}) {
const archivePath = String(input.archivePath ?? "").trim();
const targetDirRaw = String(input.targetDir ?? "").trim();
if (!archivePath) throw new Error("archivePath is required");
if (!targetDirRaw) throw new Error("targetDir is required");
const targetDir = await normalizeLocalWorkspacePath(targetDirRaw);
const imported = await importWorkspaceConfig({
archivePath,
targetDir,
name: input.name ?? null,
});
const workspace = normalizeWorkspaceEntry({
id: localWorkspaceId(targetDir),
name: imported.workspaceName,
displayName: null,
path: targetDir,
preset: imported.preset,
workspaceType: "local",
});
return mutateWorkspaceState((state) => {
const key = workspacePathKey(workspace);
state.workspaces = state.workspaces.filter(
(entry) => entry.id !== workspace.id && normalizeWorkspacePathKey(entry.path) !== key,
);
state.workspaces.push(workspace);
state.selectedId = workspace.id;
state.activeId = workspace.id;
state.watchedId = workspace.id;
return state;
});
}
async function resetOpenworkState() {
await rm(workspaceStatePath(), { force: true });
await clearDesktopBootstrapFiles();
return undefined;
}
return {
addAuthorizedRoot,
createRemoteWorkspace,
createWorkspace,
clearDesktopBootstrapConfig,
debugDesktopBootstrapConfig,
defaultWorkspaceOpenworkConfig,
exportConfig,
forgetWorkspace,
getDesktopBootstrapConfig,
importConfig,
listLocalWorkspacePaths,
listRemoteWorkspaceUrlPrefixes,
migrateLegacyElectronWorkspaceStateIfNeeded,
readDesktopBootstrapConfigSync,
readWorkspaceOpenworkConfig,
readWorkspaceState,
resetOpenworkState,
setDesktopBootstrapConfig,
setRuntimeActiveWorkspace,
setSelectedWorkspace,
updateRemoteWorkspace,
updateWorkspaceDisplayName,
writeWorkspaceOpenworkConfig,
writeWorkspaceState,
};
}