diff --git a/.gitignore b/.gitignore index d8055b3..368f0b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# prepack stash (restored by postpack; transient) +.publish-stash/ + +# npm pack output +omp-deck-*.tgz + node_modules dist .DS_Store diff --git a/README.md b/README.md index 2981ea8..ebbf047 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,21 @@ omp-deck is the cockpit that holds all of that. The chat surface stays at parity ## Quickstart +### Global install (recommended) + +Requires [Bun](https://bun.sh) ≥ 1.3.14. + +```sh +npm install -g omp-deck +omp-deck +``` + +Boots on . Data lives in `~/.omp-deck/` (override with `OMP_DECK_DATA_DIR`). Your existing `~/.omp/agent` is picked up automatically — no re-auth. + +Other knobs: `OMP_DECK_PORT`, `OMP_DECK_HOST`, `OMP_DECK_DB_PATH`, `OMP_DECK_UPLOADS_ROOT`, `OMP_DECK_WEB_DIST` — see [docs/configuration.md](./docs/configuration.md). Or run `bunx omp-deck` if you'd rather not install globally. + +### From source + If `omp` already works in a terminal on this machine: ```sh @@ -69,7 +84,7 @@ bun install bun run dev ``` -Open . Your existing `~/.omp/agent` is picked up automatically — no re-auth. +Open . On **Windows**, you can also double-click `Start-OMP-Deck.cmd` from the repo root — it boots the server on `:8787`, starts the Vite app on `:5173`, opens the deck in your browser, and writes logs under `.logs/`. On **macOS / Linux**, the sibling is `bash Start-OMP-Deck.sh start` (`stop` / `status` subcommands too); bare invocation runs foreground, same as `bun run dev`. diff --git a/bin/omp-deck.mjs b/bin/omp-deck.mjs new file mode 100644 index 0000000..e8c5c22 --- /dev/null +++ b/bin/omp-deck.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// omp-deck CLI entrypoint. +// +// This is a tiny Node-runnable shim. It checks for Bun on PATH (the deck is a +// Bun-native server) and spawns the bundled server, inheriting stdio + signals +// + exit code. Default data directory is ~/.omp-deck; overridable via +// OMP_DECK_DATA_DIR or the existing OMP_DECK_DB_PATH / OMP_DECK_UPLOADS_ROOT +// env vars. Default web dist is the bundled `apps/web/dist/` shipped in the +// package; overridable via OMP_DECK_WEB_DIST. +// +// Why Node, not Bun: the user may not have Bun yet — we want to print an +// actionable install message instead of an ENOENT. + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +// Package root is the directory containing `bin/`. +const PKG_ROOT = path.resolve(HERE, ".."); +const SERVER_ENTRY = path.join(PKG_ROOT, "apps", "server", "src", "index.ts"); +const WEB_DIST = path.join(PKG_ROOT, "apps", "web", "dist"); +const STARTER_SKILLS = path.join(PKG_ROOT, "starter-skills"); +const STARTER_EXTENSIONS = path.join(PKG_ROOT, "starter-extensions"); + +function fail(msg) { + console.error(`omp-deck: ${msg}`); + process.exit(1); +} + +function ensureBun() { + const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["bun"], { + stdio: ["ignore", "pipe", "ignore"], + }); + if (probe.status === 0 && probe.stdout.toString().trim().length > 0) return; + console.error("omp-deck requires Bun (https://bun.sh) — not found on PATH."); + console.error(""); + console.error("Install:"); + console.error(" curl -fsSL https://bun.sh/install | bash (macOS / Linux)"); + console.error(" powershell -c \"irm bun.sh/install.ps1 | iex\" (Windows)"); + console.error(""); + console.error("Then re-run: omp-deck"); + process.exit(127); +} + +function resolveDataDir() { + const explicit = process.env.OMP_DECK_DATA_DIR?.trim(); + if (explicit) return path.resolve(explicit); + return path.join(os.homedir(), ".omp-deck"); +} + +function main() { + if (!existsSync(SERVER_ENTRY)) { + fail(`server entry missing at ${SERVER_ENTRY} — broken install?`); + } + ensureBun(); + + const dataDir = resolveDataDir(); + mkdirSync(dataDir, { recursive: true }); + + const env = { ...process.env }; + // Only set defaults — let user overrides win. + env.OMP_DECK_DB_PATH ??= path.join(dataDir, "deck.db"); + env.OMP_DECK_UPLOADS_ROOT ??= path.join(dataDir, "uploads"); + env.OMP_DECK_WEB_DIST ??= WEB_DIST; + env.OMP_DECK_STARTER_SKILLS_DIR ??= STARTER_SKILLS; + env.OMP_DECK_STARTER_EXTENSIONS_DIR ??= STARTER_EXTENSIONS; + // Default cwd: the data dir, not wherever the user happened to invoke from. + // The agent's own session cwd is independent and still defaults to $HOME. + env.OMP_DECK_DEFAULT_CWD ??= os.homedir(); + + const args = process.argv.slice(2); + const child = spawn("bun", [SERVER_ENTRY, ...args], { + stdio: "inherit", + env, + // Bun resolves relative imports against the script path; cwd here only + // influences where Bun looks for bunfig.toml — keep it at package root + // so workspace settings (if any) apply. + cwd: PKG_ROOT, + }); + + function forward(sig) { + try { + child.kill(sig); + } catch { + /* child already exited */ + } + } + process.on("SIGINT", () => forward("SIGINT")); + process.on("SIGTERM", () => forward("SIGTERM")); + + child.on("exit", (code, signal) => { + if (signal) { + // Re-raise the signal in this process so the parent shell sees it. + process.kill(process.pid, signal); + } else { + process.exit(code ?? 0); + } + }); + child.on("error", (err) => { + fail(`failed to spawn bun: ${err.message}`); + }); +} + +main(); diff --git a/package.json b/package.json index 6282a00..1c25248 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omp-deck", "version": "0.5.0", - "private": true, + "private": false, "description": "Cockpit web UI for the omp (oh-my-pi) coding agent — multi-session chat, kanban, cron routines, inbox, messaging bridges, and a marketplace browser. Designed to be served loopback-only behind a Tailscale gate or SSH tunnel.", "keywords": [ "omp", @@ -40,11 +40,42 @@ "build": "bun run --filter '@omp-deck/web' build && bun run --filter '@omp-deck/server' build", "start": "bun run --filter '@omp-deck/server' start", "typecheck": "bun run --filter='@omp-deck/*' typecheck", - "clean": "rm -rf apps/*/dist apps/*/node_modules apps/bridges/*/node_modules packages/*/node_modules node_modules" + "clean": "rm -rf apps/*/dist apps/*/node_modules apps/bridges/*/node_modules packages/*/node_modules node_modules", + "prepack": "node scripts/prepack.mjs", + "postpack": "node scripts/postpack.mjs" }, + "bin": { + "omp-deck": "./bin/omp-deck.mjs" + }, + "files": [ + "bin", + "apps/server/src", + "apps/server/package.json", + "apps/web/dist", + "starter-skills", + "starter-extensions", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], "engines": { - "bun": ">=1.3.14" + "bun": ">=1.3.14", + "node": ">=18" }, + "dependencies": { + "@oh-my-pi/pi-ai": "15.1.7", + "@oh-my-pi/pi-coding-agent": "15.1.7", + "@omp-deck/protocol": "0.5.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "croner": "^10.0.1", + "hono": "^4.6.14", + "quickjs-emscripten": "^0.31.0", + "yaml": "^2.9.0" + }, + "bundledDependencies": [ + "@omp-deck/protocol" + ], "devDependencies": { "typescript": "^5.6.3" } diff --git a/scripts/postpack.mjs b/scripts/postpack.mjs new file mode 100644 index 0000000..eddb611 --- /dev/null +++ b/scripts/postpack.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Post-pack cleanup. Reverses the prepack changes so the dev workflow keeps +// working after a local `npm pack` / `npm publish`. Safe to run twice. +// +// Two jobs (mirror prepack): +// 1. Restore stashed files (paper-trading templates, tests, source maps) +// from `.publish-stash/` back to their original paths, using the +// manifest written by prepack. +// 2. Drop the materialized `node_modules/@omp-deck/protocol` and let bun +// restore the workspace symlink via `bun install`. + +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, ".."); +const PROTOCOL_DEST = path.join(ROOT, "node_modules", "@omp-deck", "protocol"); +const STASH = path.join(ROOT, ".publish-stash"); +const STASH_MANIFEST = path.join(STASH, "manifest.json"); + +// 1. Restore stashed files. +if (existsSync(STASH_MANIFEST)) { + const manifest = JSON.parse(readFileSync(STASH_MANIFEST, "utf8")); + let restored = 0; + for (const rel of manifest.stashed ?? []) { + const from = path.join(STASH, rel); + const to = path.join(ROOT, rel); + if (!existsSync(from)) continue; + mkdirSync(path.dirname(to), { recursive: true }); + renameSync(from, to); + restored += 1; + } + rmSync(STASH, { recursive: true, force: true }); + process.stdout.write(`postpack: restored ${restored} stashed file(s)\n`); +} + +// 2. Drop materialized protocol so the symlink can come back. +if (existsSync(PROTOCOL_DEST)) { + rmSync(PROTOCOL_DEST, { recursive: true, force: true }); + process.stdout.write("postpack: removed materialized @omp-deck/protocol\n"); +} + +const r = spawnSync("bun", ["install", "--frozen-lockfile"], { + cwd: ROOT, + stdio: "inherit", +}); +if (r.status !== 0) { + process.stderr.write( + "postpack: bun install failed; restore the workspace manually with `bun install`.\n", + ); +} diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs new file mode 100644 index 0000000..54efa9e --- /dev/null +++ b/scripts/prepack.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Pre-publish step. Runs automatically before `npm pack` and `npm publish`. +// +// Jobs: +// 1. Ensure the web app is built — the tarball ships pre-built static assets +// (apps/web/dist/) so the installed package doesn't need a build step. +// 2. Materialize `node_modules/@omp-deck/protocol` as a real directory copy +// (not a workspace symlink) so npm's `bundledDependencies` mechanism +// picks it up in the tarball. +// 3. Stash files that live under allowed `files` paths but MUST NOT ship: +// - apps/server/src/templates/paper-trading-*.yaml — operator-private +// routine templates (also gitignored) +// - **/*.test.ts — bloat, no runtime value +// - apps/web/dist/**/*.map — bloat +// `.npmignore` cannot exclude files matched by the `files` allowlist +// (npm's documented behavior), so we physically move them aside. +// +// `scripts/postpack.mjs` reverses (2) and (3) so the dev workflow keeps +// working after a local `npm pack` / `npm publish`. + +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, ".."); +const PROTOCOL_SRC = path.join(ROOT, "packages", "protocol"); +const PROTOCOL_DEST = path.join(ROOT, "node_modules", "@omp-deck", "protocol"); +const WEB_DIST = path.join(ROOT, "apps", "web", "dist"); +const STASH = path.join(ROOT, ".publish-stash"); +const STASH_MANIFEST = path.join(STASH, "manifest.json"); + +// Files that must NOT ship even though they sit under `files`-allowed paths. +// Each entry is a (absolute-source-path, predicate) pair built fresh per run. +function collectExclusions() { + const hits = []; + + const templatesDir = path.join(ROOT, "apps", "server", "src", "templates"); + if (existsSync(templatesDir)) { + for (const name of readdirSync(templatesDir)) { + if (name.startsWith("paper-trading-") && name.endsWith(".yaml")) { + hits.push(path.join(templatesDir, name)); + } + } + } + + const serverSrc = path.join(ROOT, "apps", "server", "src"); + walkTs(serverSrc, (p) => { + if (p.endsWith(".test.ts")) hits.push(p); + }); + + if (existsSync(WEB_DIST)) { + walkAll(WEB_DIST, (p) => { + if (p.endsWith(".map")) hits.push(p); + }); + } + + return hits; +} + +function walkTs(dir, cb) { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkTs(full, cb); + else if (entry.isFile() && entry.name.endsWith(".ts")) cb(full); + } +} + +function walkAll(dir, cb) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkAll(full, cb); + else if (entry.isFile()) cb(full); + } +} + +function step(msg, fn) { + process.stdout.write(`prepack: ${msg}... `); + try { + fn(); + process.stdout.write("ok\n"); + } catch (err) { + process.stdout.write("FAIL\n"); + throw err; + } +} + +step("build web", () => { + if (existsSync(path.join(WEB_DIST, "index.html"))) { + // Already built — skip the 7s rebuild. Run `bun run clean` if you want + // to force a fresh build before publishing. + return; + } + const r = spawnSync("bun", ["run", "--filter", "@omp-deck/web", "build"], { + cwd: ROOT, + stdio: "inherit", + }); + if (r.status !== 0) throw new Error(`web build exited with ${r.status}`); +}); + +step("materialize @omp-deck/protocol", () => { + mkdirSync(path.dirname(PROTOCOL_DEST), { recursive: true }); + // If it's a symlink (workspace dev layout) or a stale copy, remove first. + if (existsSync(PROTOCOL_DEST) || isSymlink(PROTOCOL_DEST)) { + rmSync(PROTOCOL_DEST, { recursive: true, force: true }); + } + cpSync(PROTOCOL_SRC, PROTOCOL_DEST, { + recursive: true, + // Skip the dev junk; we only need source + package.json. + filter: (src) => { + const base = path.basename(src); + return base !== "node_modules" && base !== ".turbo" && !base.endsWith(".test.ts"); + }, + }); + + // Rewrite the bundled package.json: drop `private` (npm bundling refuses + // some private deps in strict modes) and drop `dependencies` / `devDeps` + // so npm doesn't think the bundled package's transitive deps are + // "already satisfied" and skip installing them. The root package.json + // declares the same deps (ajv, ajv-formats) so they get hoisted and + // resolved via the normal node_modules walk. + const pkgPath = path.join(PROTOCOL_DEST, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + delete pkg.private; + delete pkg.dependencies; + delete pkg.devDependencies; + delete pkg.scripts; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, "\t")}\n`); +}); + +step("stash files that must not ship", () => { + // Wipe any prior stash before starting (a previous failed pack may have + // left one). Files inside are restored by postpack via manifest. + if (existsSync(STASH)) rmSync(STASH, { recursive: true, force: true }); + mkdirSync(STASH, { recursive: true }); + + const manifest = []; + for (const src of collectExclusions()) { + const rel = path.relative(ROOT, src); + const dest = path.join(STASH, rel); + mkdirSync(path.dirname(dest), { recursive: true }); + renameSync(src, dest); + manifest.push(rel); + } + writeFileSync(STASH_MANIFEST, JSON.stringify({ stashed: manifest }, null, "\t")); +}); + +function isSymlink(p) { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +}