diff --git a/.codex/local-environment.toml b/.codex/local-environment.toml new file mode 100644 index 00000000..0581be31 --- /dev/null +++ b/.codex/local-environment.toml @@ -0,0 +1,23 @@ +version = 1 +name = "SimDeck" + +[setup] +script = ''' +cd "$CODEX_WORKTREE_PATH" +npm run codex:setup +''' + +[cleanup] +script = ''' +cd "$CODEX_WORKTREE_PATH" +npm run codex:cache:save +''' + +[[actions]] +name = "Build and Restart Daemon" +icon = "run" +command = ''' +cd "$CODEX_WORKTREE_PATH" +npm run codex:run +''' +platform = "darwin" diff --git a/AGENTS.md b/AGENTS.md index e1a46833..c1e548e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,15 @@ npm run package:vscode This now builds the Rust server in `server/` and copies the resulting binary to `build/simdeck`. +Codex worktrees can use the checked-in local environment config at +`.codex/local-environment.toml`. Its setup runs `npm run codex:setup`, which +hydrates root/client `node_modules` and `server/target` from the shared cache +under `~/.cache/simdeck/codex-worktree-cache` or from another SimDeck checkout +before falling back to `npm ci` for missing package installs and ensuring +Homebrew `pkgconf`/`x264` are available for native builds. Its Run action +executes `npm run codex:run`, which builds the CLI and client, saves fresh +caches, and restarts the workspace-local daemon. + Run the local daemon: ```sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad96622f..dc47a6ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,40 @@ npx skills add NativeScript/SimDeck --skill simdeck -a codex -g The npm postinstall message also prints this command after a global install. +## Codex local worktrees + +This repo includes a Codex local environment at +`.codex/local-environment.toml`. Use it when creating Codex worktrees for +SimDeck. The setup script runs: + +```sh +npm run codex:setup +``` + +That hydrates the root `node_modules`, `client/node_modules`, and +`server/target` from `~/.cache/simdeck/codex-worktree-cache` or a matching +existing SimDeck checkout. If either `node_modules` directory is still missing, +it falls back to `npm ci` for that package so lockfiles stay unchanged. On +macOS it also ensures the Homebrew `pkgconf` and `x264` packages are available +for the native Rust build. Set +`SIMDECK_CODEX_SKIP_BREW=1` if you want setup to report missing Homebrew +packages instead of installing them. + +The cleanup script saves fresh caches with: + +```sh +npm run codex:cache:save +``` + +The environment also exposes a **Build and Restart Daemon** Run action: + +```sh +npm run codex:run +``` + +It builds the Rust CLI and React client, saves the refreshed caches, and runs +`./build/simdeck daemon restart` for the current workspace. + ## Releasing Releases are published from the `Release` GitHub Actions workflow at diff --git a/package.json b/package.json index 1d87cea2..c6746e9f 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,9 @@ "test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs scripts/studio-host-provider.test.mjs", "test:stress": "node scripts/stress/simdeck.mjs", "bench:encoder:build": "scripts/bench/build-encoder-benchmark.sh", + "codex:setup": "node scripts/codex-setup.mjs", + "codex:cache:save": "node scripts/codex-worktree-cache.mjs save --best-effort", + "codex:run": "node scripts/codex-run.mjs", "ci": "npm run lint && npm run build:all && npm run test && npm run package:vscode-extension", "dev": "npm run build:cli && node scripts/dev.mjs", "preview:swiftui": "node scripts/experimental/swiftui-preview.mjs", diff --git a/scripts/codex-run.mjs b/scripts/codex-run.mjs new file mode 100755 index 00000000..2cec4845 --- /dev/null +++ b/scripts/codex-run.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +run("node", ["scripts/codex-setup.mjs", "--skip-npm"]); +run("npm", ["run", "build:cli"]); +run("npm", ["run", "build:client"]); +run("node", ["scripts/codex-worktree-cache.mjs", "save", "--best-effort"]); + +const daemonArgs = ["daemon", "restart"]; +pushOptionalEnv(daemonArgs, "--port", "SIMDECK_DAEMON_PORT"); +pushOptionalEnv(daemonArgs, "--bind", "SIMDECK_DAEMON_BIND"); +pushOptionalEnv(daemonArgs, "--advertise-host", "SIMDECK_ADVERTISE_HOST"); +pushOptionalEnv(daemonArgs, "--video-codec", "SIMDECK_VIDEO_CODEC"); +pushOptionalEnv(daemonArgs, "--stream-quality", "SIMDECK_STREAM_QUALITY"); +pushOptionalEnv(daemonArgs, "--local-stream-fps", "SIMDECK_LOCAL_STREAM_FPS"); +if (truthy(process.env.SIMDECK_LOW_LATENCY)) { + daemonArgs.push("--low-latency"); +} + +run("./build/simdeck", daemonArgs); + +function run(command, args) { + console.log(`\n$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: "inherit", + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function pushOptionalEnv(args, flag, envName) { + const value = process.env[envName]?.trim(); + if (value) { + args.push(flag, value); + } +} + +function truthy(value) { + return value === "1" || value === "true" || value === "yes"; +} diff --git a/scripts/codex-setup.mjs b/scripts/codex-setup.mjs new file mode 100755 index 00000000..15d60654 --- /dev/null +++ b/scripts/codex-setup.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const args = process.argv.slice(2); +const skipNpm = args.includes("--skip-npm"); +const skipCache = args.includes("--skip-cache"); + +if (!skipCache) { + run("node", ["scripts/codex-worktree-cache.mjs", "hydrate"]); +} + +ensureNativeBuildDependencies(); + +if (!skipNpm) { + ensureNodeModules(".", "root"); + ensureNodeModules("client", "client"); +} + +function ensureNativeBuildDependencies() { + if (process.platform !== "darwin") { + return; + } + + if (!commandSucceeds("pkg-config", ["--version"])) { + installBrewPackage("pkgconf", "pkg-config"); + } + + if (!commandSucceeds("pkg-config", ["--exists", "x264"])) { + installBrewPackage("x264", "x264 pkg-config metadata"); + } +} + +function installBrewPackage(formula, label) { + if (truthy(process.env.SIMDECK_CODEX_SKIP_BREW)) { + throw new Error( + `Missing ${label}. Install it with \`brew install ${formula}\` or unset SIMDECK_CODEX_SKIP_BREW.`, + ); + } + if (!commandSucceeds("brew", ["--version"])) { + throw new Error( + `Missing ${label}, and Homebrew is not available to install ${formula}.`, + ); + } + run("brew", ["install", formula]); +} + +function ensureNodeModules(prefix, label) { + const modulesPath = + prefix === "." + ? resolve(ROOT, "node_modules") + : resolve(ROOT, prefix, "node_modules"); + if (existsAndHasContent(modulesPath)) { + console.log( + `[setup] skip ${label} npm install; node_modules already exists`, + ); + return; + } + + const args = prefix === "." ? ["ci"] : ["ci", "--prefix", prefix]; + run("npm", args); +} + +function existsAndHasContent(path) { + try { + const stats = statSync(path); + if (stats.isDirectory()) { + return readdirSync(path).length > 0; + } + return stats.size > 0; + } catch { + return false; + } +} + +function commandSucceeds(command, args) { + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: "ignore", + env: process.env, + }); + return result.status === 0; +} + +function run(command, args) { + console.log(`\n$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: "inherit", + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function truthy(value) { + return value === "1" || value === "true" || value === "yes"; +} diff --git a/scripts/codex-worktree-cache.mjs b/scripts/codex-worktree-cache.mjs new file mode 100755 index 00000000..fd6d12b8 --- /dev/null +++ b/scripts/codex-worktree-cache.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { + constants, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const ROOT = repoRoot(); +const PROJECT_NAME = basename(ROOT); +const CACHE_ROOT = + process.env.SIMDECK_CODEX_CACHE_ROOT || + join(homedir(), ".cache", "simdeck", "codex-worktree-cache"); + +const args = process.argv.slice(2); +const command = args[0] || "hydrate"; +const force = args.includes("--force"); +const bestEffort = args.includes("--best-effort") || command === "hydrate"; + +if (!["hydrate", "save", "status"].includes(command)) { + console.error( + "Usage: codex-worktree-cache.mjs [--force]", + ); + process.exit(2); +} + +const entries = buildEntries(); + +try { + if (command === "hydrate") { + hydrateEntries(entries); + } else if (command === "save") { + saveEntries(entries); + } else { + printStatus(entries); + } +} catch (error) { + if (!bestEffort) { + throw error; + } + console.warn(`[cache] ${describeError(error)}`); +} + +function hydrateEntries(entries) { + const sourceRoots = cacheSourceRoots(); + for (const entry of entries) { + const destination = join(ROOT, entry.destination); + if (existsAndHasContent(destination) && !force) { + log(`skip ${entry.label}; ${entry.destination} already exists`); + continue; + } + + const source = findHydrationSource(entry, sourceRoots); + if (!source) { + log(`miss ${entry.label}`); + continue; + } + + copyIntoPlace(source.path, destination); + log(`hydrated ${entry.label} from ${source.description}`); + } +} + +function saveEntries(entries) { + mkdirSync(CACHE_ROOT, { recursive: true }); + for (const entry of entries) { + const source = join(ROOT, entry.destination); + if (!existsAndHasContent(source)) { + log(`skip ${entry.label}; ${entry.destination} is missing`); + continue; + } + + copyIntoPlace(source, entry.cachePath, { replace: true }); + log(`saved ${entry.label}`); + } +} + +function printStatus(entries) { + console.log(`Cache root: ${CACHE_ROOT}`); + for (const entry of entries) { + const destination = join(ROOT, entry.destination); + console.log( + `${entry.label}: destination=${existsAndHasContent(destination) ? "present" : "missing"} cache=${ + existsAndHasContent(entry.cachePath) ? "present" : "missing" + }`, + ); + } +} + +function buildEntries() { + const rootLockHash = hashFiles(["package-lock.json"]); + const clientLockHash = hashFiles(["client/package-lock.json"]); + const cargoHash = hashFiles(["server/Cargo.toml", "server/Cargo.lock"]); + const rustHost = rustHostTriple(); + + return [ + { + label: "root node_modules", + destination: "node_modules", + cachePath: join(CACHE_ROOT, "node", "root", rootLockHash, "node_modules"), + sourceLockFiles: ["package-lock.json"], + }, + { + label: "client node_modules", + destination: "client/node_modules", + cachePath: join( + CACHE_ROOT, + "node", + "client", + clientLockHash, + "node_modules", + ), + sourceLockFiles: ["client/package-lock.json"], + }, + { + label: "Rust target", + destination: "server/target", + cachePath: join(CACHE_ROOT, "rust", rustHost, cargoHash, "target"), + sourceLockFiles: ["server/Cargo.toml", "server/Cargo.lock"], + }, + ]; +} + +function findHydrationSource(entry, sourceRoots) { + if (existsAndHasContent(entry.cachePath)) { + return { path: entry.cachePath, description: entry.cachePath }; + } + + for (const root of sourceRoots) { + if (!locksMatch(root, entry.sourceLockFiles)) { + continue; + } + const candidate = join(root, entry.destination); + if (existsAndHasContent(candidate)) { + return { path: candidate, description: root }; + } + } + + return null; +} + +function cacheSourceRoots() { + const roots = []; + const explicitSource = process.env.SIMDECK_CACHE_SOURCE; + if (explicitSource) { + roots.push(resolve(explicitSource)); + } + + const commonRoot = mainCheckoutRoot(); + if (commonRoot) { + roots.push(commonRoot); + } + + const codexWorktrees = join(homedir(), ".codex", "worktrees"); + if (existsSync(codexWorktrees)) { + for (const id of readdirSync(codexWorktrees)) { + const candidate = join(codexWorktrees, id, PROJECT_NAME); + if (candidate !== ROOT && existsSync(candidate)) { + roots.push(candidate); + } + } + } + + return [...new Set(roots)] + .filter((root) => root !== ROOT && existsSync(root)) + .sort((left, right) => mtimeMs(right) - mtimeMs(left)); +} + +function locksMatch(candidateRoot, lockFiles) { + for (const lockFile of lockFiles) { + const current = join(ROOT, lockFile); + const candidate = join(candidateRoot, lockFile); + if (!existsSync(current) || !existsSync(candidate)) { + return false; + } + if (hashPath(current) !== hashPath(candidate)) { + return false; + } + } + return true; +} + +function copyIntoPlace(source, destination, { replace = false } = {}) { + if (replace) { + mkdirSync(dirname(destination), { recursive: true }); + } else if (existsSync(destination)) { + if (force) { + rmSync(destination, { recursive: true, force: true }); + } else { + return; + } + } + + const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`; + rmSync(temporary, { recursive: true, force: true }); + mkdirSync(dirname(temporary), { recursive: true }); + + try { + clonePath(source, temporary); + if (replace) { + rmSync(destination, { recursive: true, force: true }); + } + renameSync(temporary, destination); + } catch (error) { + rmSync(temporary, { recursive: true, force: true }); + if (!bestEffort) { + throw error; + } + console.warn(`[cache] failed to copy ${source}: ${describeError(error)}`); + } +} + +function clonePath(source, destination) { + const stats = statSync(source); + if (stats.isFile()) { + try { + copyFileSync(source, destination, constants.COPYFILE_FICLONE); + } catch { + copyFileSync(source, destination); + } + return; + } + + try { + cpSync(source, destination, { + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + mode: constants.COPYFILE_FICLONE, + }); + } catch { + cpSync(source, destination, { + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + }); + } +} + +function existsAndHasContent(path) { + try { + const stats = statSync(path); + if (stats.isDirectory()) { + return readdirSync(path).length > 0; + } + return stats.size > 0; + } catch { + return false; + } +} + +function repoRoot() { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + }); + if (result.status === 0) { + return result.stdout.trim(); + } + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +function mainCheckoutRoot() { + const result = spawnSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: ROOT, encoding: "utf8" }, + ); + if (result.status !== 0) { + return null; + } + + const gitCommonDir = result.stdout.trim(); + if (basename(gitCommonDir) !== ".git") { + return null; + } + return dirname(gitCommonDir); +} + +function hashFiles(paths) { + const hash = createHash("sha256"); + for (const path of paths) { + hash.update(path); + hash.update("\0"); + hash.update(readFileSync(join(ROOT, path))); + hash.update("\0"); + } + return hash.digest("hex").slice(0, 16); +} + +function hashPath(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function rustHostTriple() { + const result = spawnSync("rustc", ["-vV"], { encoding: "utf8" }); + if (result.status !== 0) { + return `${process.platform}-${process.arch}`; + } + const host = result.stdout + .split("\n") + .find((line) => line.startsWith("host: ")) + ?.slice("host: ".length) + .trim(); + return host || `${process.platform}-${process.arch}`; +} + +function mtimeMs(path) { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} + +function log(message) { + console.log(`[cache] ${message}`); +} + +function describeError(error) { + return error instanceof Error ? error.message : String(error); +}