From c6d11018a08a7b7280eebf77f8f70c0e38165a26 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:23:53 +0800 Subject: [PATCH] Refresh desktop integration control plane (#123) * feat(dev): add desktop tools-dev control plane * refactor(sidecar): split Open Design contracts Move Open Design-specific sidecar protocol definitions into @open-design/contracts so sidecar and platform can remain descriptor-driven primitives. * refactor(daemon): organize package sources Keep daemon app code, tests, and sidecar entrypoints in separate package directories so each layer can be built and verified independently. * chore(repo): streamline maintenance entrypoints Centralize agent guidance by directory and reduce root command chains while preserving the existing build scope. * docs: translate agent guidance to English * fix(sidecar): tolerate stale IPC sockets Remove stale Unix socket files only after confirming no listener is active, so tools-dev can restart after unclean shutdowns. --- .gitignore | 2 + AGENTS.md | 150 ++- CONTRIBUTING.md | 12 +- CONTRIBUTING.zh-CN.md | 12 +- QUICKSTART.md | 62 +- README.ko.md | 20 +- README.md | 20 +- README.zh-CN.md | 20 +- apps/AGENTS.md | 38 + apps/daemon/package.json | 9 +- apps/daemon/sidecar/index.ts | 24 + apps/daemon/sidecar/server.ts | 129 +++ apps/daemon/{ => src}/acp.ts | 0 apps/daemon/{ => src}/agents.ts | 0 apps/daemon/{ => src}/artifact-manifest.ts | 0 apps/daemon/{ => src}/claude-design-import.ts | 0 apps/daemon/{ => src}/claude-stream.ts | 0 apps/daemon/{ => src}/cli.ts | 0 apps/daemon/{ => src}/copilot-stream.ts | 0 apps/daemon/{ => src}/db.ts | 0 .../daemon/{ => src}/design-system-preview.ts | 0 .../{ => src}/design-system-showcase.ts | 0 apps/daemon/{ => src}/design-systems.ts | 0 apps/daemon/{ => src}/document-preview.ts | 0 apps/daemon/{ => src}/frontmatter.ts | 0 apps/daemon/{ => src}/json-event-stream.ts | 0 apps/daemon/{ => src}/lint-artifact.ts | 0 apps/daemon/{ => src}/projects.ts | 0 apps/daemon/{ => src}/server.ts | 2 +- apps/daemon/{ => src}/skills.ts | 0 .../{ => tests}/artifact-manifest.test.ts | 2 +- .../{ => tests}/json-event-stream.test.ts | 2 +- apps/daemon/{ => tests}/server-paths.test.ts | 6 +- apps/daemon/{ => tests}/sse-response.test.ts | 2 +- apps/daemon/tsconfig.json | 5 +- apps/daemon/tsconfig.sidecar.json | 15 + apps/daemon/tsconfig.tests.json | 16 + apps/daemon/vitest.config.ts | 2 +- apps/desktop/package.json | 33 + apps/desktop/src/main/index.ts | 154 +++ apps/desktop/src/main/runtime.ts | 231 +++++ apps/desktop/tsconfig.json | 21 + apps/packaged/README.md | 5 + apps/web/app/[[...slug]]/page.tsx | 2 +- apps/web/next.config.ts | 25 +- apps/web/package.json | 5 +- apps/web/sidecar/index.ts | 24 + apps/web/sidecar/server.ts | 192 ++++ apps/web/src/providers/daemon.ts | 2 +- apps/web/tsconfig.json | 1 + design-systems/README.md | 2 +- docs/agent-adapters.md | 4 +- docs/architecture.md | 4 +- docs/roadmap.md | 2 +- docs/spec.md | 4 +- e2e/package.json | 2 +- e2e/playwright.config.ts | 18 +- e2e/tests/structured-streams.test.ts | 4 +- package.json | 29 +- packages/AGENTS.md | 34 + packages/platform/esbuild.config.mjs | 11 + packages/platform/package.json | 31 + packages/platform/src/index.test.ts | 82 ++ packages/platform/src/index.ts | 372 +++++++ packages/platform/tsconfig.json | 21 + packages/sidecar-proto/esbuild.config.mjs | 13 + packages/sidecar-proto/package.json | 31 + packages/sidecar-proto/src/index.test.ts | 77 ++ packages/sidecar-proto/src/index.ts | 403 ++++++++ packages/sidecar-proto/tsconfig.json | 21 + packages/sidecar/esbuild.config.mjs | 11 + packages/sidecar/package.json | 31 + packages/sidecar/src/index.test.ts | 132 +++ packages/sidecar/src/index.ts | 565 +++++++++++ packages/sidecar/tsconfig.json | 21 + pnpm-lock.yaml | 954 +++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/check-residual-js.ts | 19 +- scripts/dev-all.ts | 88 -- scripts/postinstall.mjs | 43 + scripts/resolve-dev-ports.ts | 63 -- .../spec.md | 66 +- specs/current/maintainability-roadmap.md | 4 +- specs/current/runtime-adapter.md | 18 +- tools/AGENTS.md | 32 + tools/dev/bin/tools-dev.mjs | 18 + tools/dev/esbuild.config.mjs | 18 + tools/dev/package.json | 29 + tools/dev/src/config.ts | 186 ++++ tools/dev/src/index.ts | 711 +++++++++++++ tools/dev/src/sidecar-client.ts | 80 ++ tools/dev/tsconfig.json | 18 + tools/pack/README.md | 5 + 93 files changed, 5160 insertions(+), 363 deletions(-) create mode 100644 apps/AGENTS.md create mode 100644 apps/daemon/sidecar/index.ts create mode 100644 apps/daemon/sidecar/server.ts rename apps/daemon/{ => src}/acp.ts (100%) rename apps/daemon/{ => src}/agents.ts (100%) rename apps/daemon/{ => src}/artifact-manifest.ts (100%) rename apps/daemon/{ => src}/claude-design-import.ts (100%) rename apps/daemon/{ => src}/claude-stream.ts (100%) rename apps/daemon/{ => src}/cli.ts (100%) rename apps/daemon/{ => src}/copilot-stream.ts (100%) rename apps/daemon/{ => src}/db.ts (100%) rename apps/daemon/{ => src}/design-system-preview.ts (100%) rename apps/daemon/{ => src}/design-system-showcase.ts (100%) rename apps/daemon/{ => src}/design-systems.ts (100%) rename apps/daemon/{ => src}/document-preview.ts (100%) rename apps/daemon/{ => src}/frontmatter.ts (100%) rename apps/daemon/{ => src}/json-event-stream.ts (100%) rename apps/daemon/{ => src}/lint-artifact.ts (100%) rename apps/daemon/{ => src}/projects.ts (100%) rename apps/daemon/{ => src}/server.ts (99%) rename apps/daemon/{ => src}/skills.ts (100%) rename apps/daemon/{ => tests}/artifact-manifest.test.ts (94%) rename apps/daemon/{ => tests}/json-event-stream.test.ts (98%) rename apps/daemon/{ => tests}/server-paths.test.ts (71%) rename apps/daemon/{ => tests}/sse-response.test.ts (99%) create mode 100644 apps/daemon/tsconfig.sidecar.json create mode 100644 apps/daemon/tsconfig.tests.json create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src/main/index.ts create mode 100644 apps/desktop/src/main/runtime.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/packaged/README.md create mode 100644 apps/web/sidecar/index.ts create mode 100644 apps/web/sidecar/server.ts create mode 100644 packages/AGENTS.md create mode 100644 packages/platform/esbuild.config.mjs create mode 100644 packages/platform/package.json create mode 100644 packages/platform/src/index.test.ts create mode 100644 packages/platform/src/index.ts create mode 100644 packages/platform/tsconfig.json create mode 100644 packages/sidecar-proto/esbuild.config.mjs create mode 100644 packages/sidecar-proto/package.json create mode 100644 packages/sidecar-proto/src/index.test.ts create mode 100644 packages/sidecar-proto/src/index.ts create mode 100644 packages/sidecar-proto/tsconfig.json create mode 100644 packages/sidecar/esbuild.config.mjs create mode 100644 packages/sidecar/package.json create mode 100644 packages/sidecar/src/index.test.ts create mode 100644 packages/sidecar/src/index.ts create mode 100644 packages/sidecar/tsconfig.json delete mode 100644 scripts/dev-all.ts create mode 100644 scripts/postinstall.mjs delete mode 100644 scripts/resolve-dev-ports.ts create mode 100644 tools/AGENTS.md create mode 100755 tools/dev/bin/tools-dev.mjs create mode 100644 tools/dev/esbuild.config.mjs create mode 100644 tools/dev/package.json create mode 100644 tools/dev/src/config.ts create mode 100644 tools/dev/src/index.ts create mode 100644 tools/dev/src/sidecar-client.ts create mode 100644 tools/dev/tsconfig.json create mode 100644 tools/pack/README.md diff --git a/.gitignore b/.gitignore index 037c637c0..174ab2829 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ out/ .next/ +.next-*/ +.tmp/ .DS_Store *.log .vite diff --git a/AGENTS.md b/AGENTS.md index f48b4cf3b..c84077a74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,121 @@ -# AGENTS.md +# Directory guide -## Project shape +This file is the single source of truth for agents entering this repository. Read this file first; after entering `apps/`, `packages/`, or `tools/`, read that layer's `AGENTS.md` for module-level details. Do not copy module details back into the root file; root stays focused on cross-repository boundaries, workflow, and commands. -- pnpm workspace with packages from `pnpm-workspace.yaml`: `apps/web`, `apps/daemon`, `packages/contracts`, and `e2e`. -- Runtime target is Node `~24` with `pnpm@10.33.2`; use Corepack so the pinned pnpm version from `package.json` is selected. -- `apps/web` is a Next.js 16 App Router + React 18 client. Entrypoints: `apps/web/app/`, main client shell `apps/web/src/App.tsx`. -- `packages/contracts` is the shared, pure TypeScript web/daemon contract layer for API DTOs, SSE events, task states, and unified errors. -- `apps/daemon` is the local Express + SQLite process and the `od` bin (`apps/daemon/dist/cli.js` after build). It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. -- `e2e` contains both Playwright UI specs (`e2e/specs`) and Vitest/jsdom integration tests (`e2e/tests`). +## Core documentation index -## Commands +- Product and onboarding: `README.md`, `README.zh-CN.md`, `QUICKSTART.md`. +- Contribution and environment: `CONTRIBUTING.md`, `CONTRIBUTING.zh-CN.md`. +- Architecture and protocols: `docs/spec.md`, `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`. +- Roadmap and references: `docs/roadmap.md`, `docs/references.md`, `specs/current/maintainability-roadmap.md`. +- Directory-level agent guidance: `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`. -- Install: `corepack enable && pnpm install` -- Full local dev: `pnpm dev:all` — starts daemon and Next together. Defaults are daemon `:7456`, web `:3000`; busy ports are probed forward and exported as `OD_PORT` / `NEXT_PORT`. -- Web only: `pnpm dev` from the root starts Next; pair it with `pnpm daemon` when API routes are needed. -- Production local path: `pnpm build` writes the static Next export to `apps/web/out/`; `pnpm start` builds and serves that export through the daemon. -- Main verification: `pnpm typecheck && pnpm test && pnpm build` -- Residual JavaScript check: `pnpm check:residual-js`; root `pnpm typecheck` runs it after package and support typechecks. -- Package tests: `pnpm --filter @open-design/web test`, `pnpm --filter @open-design/daemon test`, `pnpm --filter @open-design/e2e test` -- Focused Vitest: `pnpm --dir apps/web exec vitest run -c vitest.config.ts src/providers/sse.test.ts` (adjust package dir and test path as needed). -- Playwright UI: `pnpm test:ui`; headed: `pnpm test:ui:headed`. Playwright starts `pnpm dev:all` with isolated data under `e2e/.od-data` and strict dynamic ports. -- Live adapter smoke: `pnpm test:e2e:live` runs `e2e/scripts/runtime-adapter.e2e.live.test.ts` through Node strip-types. +## Workspace directories -## TypeScript and boundary conventions +- Workspace packages come from `pnpm-workspace.yaml`: `apps/*`, `packages/*`, `tools/*`, and `e2e`. +- `apps/web` is the Next.js 16 App Router + React 18 web runtime; do not restore `apps/nextjs`. +- `apps/daemon` is the local privileged daemon and `od` bin. It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. +- `apps/desktop` is the Electron shell; it discovers the web URL through sidecar IPC. +- `packages/contracts` is the pure TypeScript web/daemon app contract layer. +- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives. +- `tools/dev` is the only currently active local development lifecycle control plane. +- `e2e` contains Playwright UI specs and Vitest/jsdom integration tests. -- New project-owned entrypoints, modules, scripts, tests, reporters, and configs use TypeScript. The residual JavaScript allowlist is limited to generated output, vendored dependencies, and compatibility build artifacts such as `apps/daemon/dist/**/*.{js,mjs,cjs}`, `apps/web/.next/**/*.{js,mjs,cjs}`, and `apps/web/out/**/*.{js,mjs,cjs}`. -- Shared web/daemon contracts go in `packages/contracts`; keep this package free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. -- Keep UI-only state and presentation unions in `apps/web`; import daemon-facing API, SSE, task, and error contracts from `@open-design/contracts`. -- Keep local capability logic in `apps/daemon`: filesystem, SQLite, agent CLI spawning, task lifecycle, logs, artifacts, skills, design systems, and static serving. -- Runtime validation policy and schema enforcement belong to the later validation workstream; current shared contracts define the typed target shape. +## Inactive or placeholder directories -## Runtime data and ports +- `apps/nextjs` and `packages/shared` have been removed; do not recreate or reference them. +- `apps/packaged` is only a placeholder for future packaged app assembly; do not add active code there in this round. +- `tools/pack` is only a placeholder for future `tools-pack`; do not add commands or packaging logic there in this round. +- `.od/`, `.tmp/`, `e2e/.od-data`, Playwright reports, and agent scratch directories are local runtime data and must stay out of git. -- The daemon auto-creates local data under `.od/` by default: SQLite at `.od/app.sqlite`, per-project agent CWDs at `.od/projects//`, saved renders at `.od/artifacts/`. -- Keep `.od/`, `e2e/.od-data`, Playwright reports, and agent scratch dirs out of git; `.gitignore` already covers them. -- `OD_DATA_DIR` relocates daemon data relative to the repo root; Playwright uses this for isolated runs. -- In development, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the daemon port. In production, the daemon serves `apps/web/out/` directly. +# Development workflow -## Agent, skill, and design-system wiring +## Environment baseline -- The daemon scans `PATH` for local CLIs in `apps/daemon/agents.ts` and spawns them with `cwd` pinned to `.od/projects//`. -- Agent stdout parsing is per transport: Claude stream JSON, Copilot stream JSON, ACP JSON-RPC, or plain text. Changes to CLI args belong in `apps/daemon/agents.ts` and matching parser tests. -- Skills are folder bundles under `skills/` with `SKILL.md`; extended `od:` frontmatter is parsed by `apps/daemon/skills.ts`. Restart the daemon after adding or changing skill folders. -- Design systems are `design-systems/*/DESIGN.md`; `scripts/sync-design-systems.ts` re-imports upstream systems. -- Prompt composition lives in `apps/web/src/prompts/system.ts`, `discovery.ts`, and `directions.ts`; artifacts are parsed/rendered through `apps/web/src/artifacts/` and `apps/web/src/runtime/`. +- Runtime target is Node `~24` and `pnpm@10.33.2`; use Corepack so the pnpm version pinned in `package.json` is selected. +- New project-owned entrypoints, modules, scripts, tests, reporters, and configs should default to TypeScript. +- Residual JavaScript is limited to generated output, vendored dependencies, explicitly documented compatibility build artifacts, and the allowlist in `scripts/check-residual-js.ts`. -## Testing notes +## Local lifecycle -- Web Vitest includes `apps/web/src/**/*.test.{ts,tsx}` in a Node environment. -- Daemon Vitest includes `apps/daemon/**/*.test.{ts,tsx}` in a Node environment. -- E2E Vitest includes `e2e/tests/**/*.test.{ts,tsx}` in jsdom with automatic React JSX. -- Playwright uses Chromium only, writes reports under `e2e/reports/`, and reuses an existing server outside CI. +- Use `pnpm tools-dev` as the only local development lifecycle entry point. +- Do not add or restore root lifecycle aliases: `pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, or `pnpm start`. +- Ports are governed by `tools-dev` flags: `--daemon-port` and `--web-port`. +- `tools-dev` exports `OD_PORT` for the web proxy target and `OD_WEB_PORT` for the web listener; do not use `NEXT_PORT`. + +## Boundary constraints + +- App business logic must not know about sidecar/control-plane concepts. Keep sidecar awareness in `apps//sidecar` or the desktop sidecar entry wrapper. +- Shared web/daemon app contracts belong in `packages/contracts`; that package must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol. +- Sidecar process stamps must have exactly five fields: `app`, `mode`, `namespace`, `ipc`, and `source`. +- Orchestration layers (`tools-dev`, future `tools-pack`, packaged launchers) must call package primitives; do not hand-build `--od-stamp-*` args or process-scan regexes. +- Default runtime files live under `/.tmp///...`; POSIX IPC sockets are fixed at `/tmp/open-design/ipc//.sock`. + +## Validation strategy + +- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh. +- Before marking regular work ready, run at least `pnpm typecheck` and `pnpm test`; run `pnpm build` as well when build boundaries are involved. +- For the web/e2e loop, prefer `pnpm tools-dev run web --daemon-port --web-port `. +- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`. +- Stamp/namespace changes must validate two concurrent namespaces and run desktop `inspect eval` plus `inspect screenshot` for each namespace. +- Path/log changes must run `pnpm tools-dev logs --namespace --json` and confirm log paths are under `.tmp/tools-dev//...`. + +# Common commands + +```bash +pnpm install +pnpm tools-dev +pnpm tools-dev start web +pnpm tools-dev run web --daemon-port 17456 --web-port 17573 +pnpm tools-dev status --json +pnpm tools-dev logs --json +pnpm tools-dev inspect desktop status --json +pnpm tools-dev inspect desktop screenshot --path /tmp/open-design.png +pnpm tools-dev stop +pnpm tools-dev check +``` + +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm test:ui +pnpm test:ui:headed +pnpm test:e2e:live +pnpm check:residual-js +``` + +```bash +pnpm --filter @open-design/web typecheck +pnpm --filter @open-design/daemon test +pnpm --filter @open-design/desktop build +pnpm --filter @open-design/tools-dev build +pnpm -r --if-present run typecheck +pnpm -r --if-present run test +``` + +# FAQ + +## Why is there no root `pnpm dev` / `pnpm start`? + +To avoid starting daemon, web, and desktop through inconsistent env, port, namespace, or log paths. All local lifecycle flows must go through `pnpm tools-dev`. + +## Why should `apps/nextjs` not be restored? + +The current web runtime is `apps/web`. The historical `apps/nextjs` layout has been removed from the active repo shape; restoring it would reintroduce duplicate app boundaries and stale scripts. + +## How does desktop discover the web URL? + +Desktop queries runtime status through sidecar IPC. The web URL comes from `tools-dev` launch status, not from desktop guessing ports or reading web internals. + +## How are sidecar-proto, sidecar, and platform split? + +`@open-design/sidecar-proto` owns Open Design app/mode/source constants, namespace validation, stamp fields/flags, IPC message schema, status shapes, and error semantics. `@open-design/sidecar` provides only generic bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime files. `@open-design/platform` provides only generic OS process stamp serialization, command parsing, and process matching/search primitives, consuming the proto descriptor. + +## Where is data written? + +The daemon writes `.od/` by default: SQLite at `.od/app.sqlite`, agent CWDs under `.od/projects//`, and saved renders under `.od/artifacts/`. `OD_DATA_DIR` can relocate data relative to the repo root; Playwright uses it to isolate test data. + +## When is `pnpm install` required? + +Run `pnpm install` after changing package manifests, workspace layout, command entrypoints, bin/link-related content, or after adding/removing workspace packages. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2da50bd6..e0df39584 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ This guide tells you exactly where to look for each type of contribution and wha |---|---|---|---| | Make OD render a new kind of artifact (an invoice, an iOS Settings screen, a one-pager…) | a **Skill** | [`skills//`](skills/) | one folder, ~2 files | | Make OD speak a new brand's visual language | a **Design System** | [`design-systems//DESIGN.md`](design-systems/) | one Markdown file | -| Hook up a new coding-agent CLI | an **Agent adapter** | [`apps/daemon/agents.js`](apps/daemon/agents.js) | ~10 lines in one array | +| Hook up a new coding-agent CLI | an **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | ~10 lines in one array | | Add a feature, fix a bug, lift a UX pattern from [`open-codesign`][ocod] | code | `apps/web/src/`, `apps/daemon/` | normal PR | | Improve docs, port a section to 中文, fix typos | docs | `README.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | one PR | @@ -31,7 +31,7 @@ git clone https://github.com/nexu-io/open-design.git cd open-design corepack enable # selects the pinned pnpm from packageManager pnpm install -pnpm dev:all # daemon (:7456) + Next dev (:3000) +pnpm tools-dev run web # daemon + web foreground loop pnpm typecheck # tsc -b --noEmit pnpm build # production build ``` @@ -164,13 +164,13 @@ The 9-section schema is fixed — that's what skill bodies grep for. The first H 4. **No marketing fluff.** The brand's tagline is not a design token. Cut it. 5. **Slug uses ASCII** — `linear.app` becomes `linear-app`, `x.ai` becomes `x-ai`. The 69 imported systems already follow this convention; mirror it. -The 69 product systems we ship are imported from [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs). If your brand belongs upstream, **send the PR there first** — we'll pick it up automatically on the next sync. The `design-systems/` folder is for systems that don't fit upstream, plus our two hand-authored starters. +The 69 product systems we ship are imported from [`VoltAgent/awesome-design-md`][acd2] via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). If your brand belongs upstream, **send the PR there first** — we'll pick it up automatically on the next sync. The `design-systems/` folder is for systems that don't fit upstream, plus our two hand-authored starters. --- ## Adding a new coding-agent CLI -Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js): +Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts): ```javascript { @@ -183,7 +183,7 @@ Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [` } ``` -That's it — daemon will detect it on `PATH`, the picker shows it, the chat path works. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), wire a parser in [`apps/daemon/claude-stream.js`](apps/daemon/claude-stream.js) and set `streamFormat: 'claude-stream-json'`. +That's it — daemon will detect it on `PATH`, the picker shows it, the chat path works. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), wire a parser in [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) and set `streamFormat: 'claude-stream-json'`. Bar for merging: @@ -226,7 +226,7 @@ We don't enforce a CLA. Apache-2.0 covers us; your contribution is licensed unde Open an issue with: -- What you ran (the exact `pnpm dev:all` / `pnpm start` invocation). +- What you ran (the exact `pnpm tools-dev ...` invocation). - Which agent CLI was selected (or whether you were on the BYOK path). - The skill + design system pair that triggered it. - The relevant **daemon stderr tail** — most "the artifact never rendered" reports get diagnosed in 30 seconds when we can see `spawn ENOENT` or the CLI's actual error. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 9820190f0..f3649635a 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -14,7 +14,7 @@ |---|---|---|---| | 让 OD 渲染一种新的 artifact(一份发票、一个 iOS 设置页、一张 one-pager……) | 一个 **Skill** | [`skills//`](skills/) | 一个文件夹,约 2 个文件 | | 让 OD 说一种新品牌的视觉语言 | 一套 **Design System** | [`design-systems//DESIGN.md`](design-systems/) | 一个 Markdown 文件 | -| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`apps/daemon/agents.js`](apps/daemon/agents.js) | 一个数组里 ~10 行 | +| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | 一个数组里 ~10 行 | | 加功能、修 bug、从 [`open-codesign`][ocod] 移植一个 UX 模式 | 代码 | `apps/web/src/`、`apps/daemon/` | 普通 PR | | 改文档、补中文翻译、修错别字 | 文档 | `README.md`、`README.zh-CN.md`、`docs/`、`QUICKSTART.md` | 一个 PR | @@ -31,7 +31,7 @@ git clone https://github.com/nexu-io/open-design.git cd open-design corepack enable # 使用 packageManager 固定的 pnpm pnpm install -pnpm dev:all # daemon (:7456) + Next dev (:3000) +pnpm tools-dev run web # daemon + web 前台闭环 pnpm typecheck # tsc -b --noEmit pnpm build # 生产构建 ``` @@ -163,13 +163,13 @@ design-systems/your-brand/ 4. **不要营销废话。** 品牌的 tagline 不是设计 token。删掉。 5. **slug 用 ASCII** —— `linear.app` 写成 `linear-app`,`x.ai` 写成 `x-ai`。已经导入的 69 套都遵循这个约定,跟着写。 -我们内置的 69 套产品系统是通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 从 [`VoltAgent/awesome-design-md`][acd2] 导入的。如果你的品牌应该归属在上游,**请先把 PR 发到那里** —— 我们下一次同步会自动收上来。`design-systems/` 文件夹用来放那些**不适合归到上游**的系统、加上我们手写的两套 starter。 +我们内置的 69 套产品系统是通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入的。如果你的品牌应该归属在上游,**请先把 PR 发到那里** —— 我们下一次同步会自动收上来。`design-systems/` 文件夹用来放那些**不适合归到上游**的系统、加上我们手写的两套 starter。 --- ## 接入一个新的 coding-agent CLI -接入一个新 agent(比如某个新 shop 的 `foo-coder` CLI)就是在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项: +接入一个新 agent(比如某个新 shop 的 `foo-coder` CLI)就是在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项: ```javascript { @@ -182,7 +182,7 @@ design-systems/your-brand/ } ``` -完事 —— daemon 会在 `PATH` 上检测到它、picker 显示出来、对话路径就通了。如果这个 CLI 吐 **类型化事件**(像 Claude Code 的 `--output-format stream-json`),在 [`apps/daemon/claude-stream.js`](apps/daemon/claude-stream.js) 里写一个 parser,并把 `streamFormat` 设成 `'claude-stream-json'`。 +完事 —— daemon 会在 `PATH` 上检测到它、picker 显示出来、对话路径就通了。如果这个 CLI 吐 **类型化事件**(像 Claude Code 的 `--output-format stream-json`),在 [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts) 里写一个 parser,并把 `streamFormat` 设成 `'claude-stream-json'`。 合并硬线: @@ -225,7 +225,7 @@ design-systems/your-brand/ 开 issue 时请带上: -- 你跑的命令(精确到 `pnpm dev:all` / `pnpm start`)。 +- 你跑的命令(精确到 `pnpm tools-dev ...`)。 - 选中的 agent CLI 是哪个(或者你走的是 BYOK 路径)。 - 触发问题时的 skill + design system 组合。 - 相关的 **daemon stderr 末尾几行** —— 大多数「artifact 没渲染出来」的报告,看到 `spawn ENOENT` 或 CLI 实际报错后 30 秒就能定位。 diff --git a/QUICKSTART.md b/QUICKSTART.md index ae3180892..bceff657a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -33,8 +33,8 @@ corepack pnpm --version # should print 10.33.2 ```bash corepack enable pnpm install -pnpm dev:all # starts daemon (:7456) + Next dev (:3000) together -open http://localhost:3000 +pnpm tools-dev run web # starts daemon + web in the foreground +# open the web URL printed by tools-dev ``` On first load, the app detects your installed code-agent CLI (Claude Code / Codex / Gemini / OpenCode / Cursor Agent / Qwen), picks it automatically, and defaults to `web-prototype` skill + `Neutral Modern` design system. Type a prompt and hit **Send**. The agent streams into the left pane; the `` tag is parsed out and the HTML renders live on the right. When it finishes, click **Save to disk** to persist the artifact under `./.od/artifacts/-/index.html`. @@ -51,15 +51,19 @@ Pair a skill with a design system and a single prompt produces a layout-appropri ## Other scripts ```bash -pnpm daemon # just the daemon (no web UI build) -pnpm dev # just Next.js dev server on :3000 -pnpm build # production build + static export to apps/web/out/ -pnpm preview # build, then serve apps/web/out/ through the daemon locally -pnpm start # build + daemon serving apps/web/out/ (single-process prod mode) -pnpm typecheck # tsc -b --noEmit +pnpm tools-dev # daemon + web + desktop in the background +pnpm tools-dev start web # daemon + web in the background +pnpm tools-dev run web # daemon + web in the foreground (e2e/dev server) +pnpm tools-dev status # inspect managed runtimes +pnpm tools-dev logs # show daemon/web/desktop logs +pnpm tools-dev stop # stop managed runtimes +pnpm build # production build + static export to apps/web/out/ +pnpm typecheck # workspace typecheck ``` -Root scripts are orchestration only. App-specific commands live in workspace packages (`apps/web`, `apps/daemon`, and `e2e`), but the root aliases above are the normal entry points. +`pnpm tools-dev` is the only local lifecycle entry point. Do not use the removed legacy root aliases (`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`). + +During local development, `tools-dev` starts the daemon first, passes its port into `apps/web`, and `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to that daemon port so the App Router app can talk to the sibling Express process without CORS setup. For the daemon-only production mode, the daemon serves the static Next.js export itself at `http://localhost:7456`, so no reverse proxy is involved. @@ -84,8 +88,6 @@ location /api/ { } ``` -During local development, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the daemon port so the App Router app can talk to the sibling Express process without CORS setup. - ## Two execution modes | Mode | Picker value | How a request flows | @@ -113,13 +115,15 @@ Swap the skill or the design system in the top bar and the next send uses the ne open-design/ ├── apps/ │ ├── daemon/ # Node/Express — spawns local agents + serves APIs -│ │ ├── cli.js # `od` bin entry (also used by npm scripts) -│ │ ├── server.js # /api/agents /api/skills /api/design-systems /api/chat /api/upload /api/artifacts/save -│ │ ├── agents.js # PATH scanner for claude/codex/gemini/opencode/cursor-agent/qwen/copilot -│ │ ├── skills.js # SKILL.md loader (frontmatter parser) -│ │ ├── design-systems.js # DESIGN.md loader -│ │ └── frontmatter.js # tiny YAML-subset parser (no deps) -│ └── web/ # Next.js 16 App Router + React client +│ │ └── src/ +│ │ ├── cli.ts # `od` bin entry +│ │ ├── server.ts # /api/* + static serving +│ │ ├── agents.ts # PATH scanner for claude/codex/gemini/opencode/cursor-agent/qwen/copilot +│ │ ├── skills.ts # SKILL.md loader (frontmatter parser) +│ │ └── design-systems.ts # DESIGN.md loader +│ │ ├── sidecar/ # tools-dev daemon sidecar wrapper +│ │ └── tests/ # daemon package tests +│ ├── web/ # Next.js 16 App Router + React client │ ├── app/ # App Router entrypoints │ ├── src/ # shared React + TypeScript client/runtime modules │ │ ├── App.tsx # orchestrates mode / skill / DS pickers + send @@ -128,7 +132,15 @@ open-design/ │ │ ├── artifacts/ # streaming parser + manifests │ │ ├── runtime/ # iframe srcdoc, markdown, export helpers │ │ └── state/ # localStorage + daemon-backed project state -│ └── next.config.ts # dev rewrites + prod apps/web/out export config +│ ├── sidecar/ # tools-dev web sidecar wrapper +│ └── next.config.ts # tools-dev rewrites + prod apps/web/out export config +│ └── desktop/ # Electron runtime, launched/inspected by tools-dev +├── packages/ +│ ├── contracts/ # shared web/daemon app contracts +│ ├── sidecar-proto/ # Open Design sidecar protocol contract +│ ├── sidecar/ # generic sidecar runtime primitives +│ └── platform/ # generic process/platform primitives +├── tools/dev/ # `pnpm tools-dev` lifecycle and inspect CLI ├── e2e/ # Playwright UI + external integration/Vitest harness ├── skills/ # SKILL.md — drops in from any Claude Code skill repo │ ├── web-prototype/ # generic single-screen prototype (default for prototype mode) @@ -148,20 +160,20 @@ open-design/ │ ├── warm-editorial/ # Warm Editorial (starter) │ ├── README.md # catalog overview │ └── …69 product systems # claude · cohere · linear-app · vercel · stripe · airbnb … -├── scripts/sync-design-systems.mjs # re-import from upstream getdesign tarball +├── scripts/sync-design-systems.ts # re-import from upstream getdesign tarball ├── docs/ # product vision + spec ├── .od/ # runtime data (gitignored, auto-created) │ ├── app.sqlite # projects / conversations / messages / tabs │ ├── artifacts/ # one-off "Save to disk" renders │ └── projects// # per-project working dir + agent cwd -├── pnpm-workspace.yaml # apps/* + e2e -└── package.json # root orchestration scripts + `od` bin +├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e +└── package.json # root quality scripts + `od` bin ``` ## Troubleshooting - **"no agents found on PATH"** — install one of: `claude`, `codex`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `copilot`. Or switch to "Anthropic API · BYOK" in the top bar and paste a key in **Settings**. -- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/agents.js` `buildArgs` if you need to tweak. +- **daemon 500 on /api/chat** — check the daemon terminal for the stderr tail; usually the CLI rejected its args. Different CLIs take different argv shapes; see `apps/daemon/src/agents.ts` `buildArgs` if you need to tweak. - **artifact never renders** — the model produced text without wrapping in ``. Confirm the system prompt is going through (check daemon log) and consider switching to a more capable model or a stricter skill. ## Mapping back to the vision @@ -169,6 +181,6 @@ open-design/ This Quickstart is the runnable seed of the spec in [`docs/`](docs/). The spec describes where this grows (see [`docs/roadmap.md`](docs/roadmap.md)). Highlights: - `docs/architecture.md` describes the shipped stack: Next.js 16 App Router in front, local daemon behind it, and `apps/web/next.config.ts` rewrites in dev to keep the browser talking to the same `/api` surface. -- `docs/skills-protocol.md` describes the full `od:` frontmatter (typed inputs, sliders, capability gating). This MVP reads `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` only — extend `apps/daemon/skills.js` to add the rest. -- `docs/agent-adapters.md` foresees richer dispatch (capability detection, streaming tool-calls). Our `apps/daemon/agents.js` is a minimal dispatcher — enough to prove the wiring. +- `docs/skills-protocol.md` describes the full `od:` frontmatter (typed inputs, sliders, capability gating). This MVP reads `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires` only — extend `apps/daemon/src/skills.ts` to add the rest. +- `docs/agent-adapters.md` foresees richer dispatch (capability detection, streaming tool-calls). Our `apps/daemon/src/agents.ts` is a minimal dispatcher — enough to prove the wiring. - `docs/modes.md` lists four modes: prototype / deck / template / design-system. We ship skills for the first two; the picker already filters by `mode`. diff --git a/README.ko.md b/README.ko.md index 7046e3e72..b8773d520 100644 --- a/README.ko.md +++ b/README.ko.md @@ -22,7 +22,7 @@ Anthropic의 [Claude Design][cd](2026-04-17 출시, Opus 4.7 기반)은 LLM이 장문의 글쓰기를 멈추고 디자인 산출물을 직접 내놓기 시작했을 때 어떤 일이 일어나는지 보여주었습니다. 순식간에 화제가 되었지만, 여전히 **클로즈드 소스**, 유료, 클라우드 전용, Anthropic 모델과 Anthropic 내부 skill에 종속된 상태입니다. 체크아웃도, 자가 호스팅도, Vercel 배포도, 에이전트 교체도 불가능합니다. -**Open Design(OD)은 그 오픈소스 대안입니다.** 동일한 루프, 동일한 '아티팩트 우선' 사고방식, 락인 없음. 에이전트를 직접 만들지 않습니다 — 가장 강력한 코딩 에이전트는 이미 여러분의 노트북에 있습니다. 우리는 그것을 skill 기반 디자인 워크플로에 연결할 뿐입니다. 로컬에서는 `pnpm dev:all`로 실행하고, 웹 레이어는 Vercel에 배포할 수 있으며, 모든 레이어에서 BYOK(자체 키 사용)가 가능합니다. +**Open Design(OD)은 그 오픈소스 대안입니다.** 동일한 루프, 동일한 '아티팩트 우선' 사고방식, 락인 없음. 에이전트를 직접 만들지 않습니다 — 가장 강력한 코딩 에이전트는 이미 여러분의 노트북에 있습니다. 우리는 그것을 skill 기반 디자인 워크플로에 연결할 뿐입니다. 로컬에서는 `pnpm tools-dev`로 실행하고, 웹 레이어는 Vercel에 배포할 수 있으며, 모든 레이어에서 BYOK(자체 키 사용)가 가능합니다. `시드 라운드를 위한 매거진 스타일 피치덱 만들어줘`라고 입력하세요. 모델이 픽셀 하나 그리기 전에 **초기화 질문 폼**이 먼저 등장합니다. 에이전트는 5개의 엄선된 시각적 방향 중 하나를 선택합니다. 실시간 `TodoWrite` 계획 카드가 UI에 스트리밍됩니다. Daemon이 디스크에 실제 프로젝트 폴더를 생성하며, seed 템플릿, 레이아웃 라이브러리, 자가 점검 체크리스트가 포함됩니다. 에이전트는 **pre-flight를 강제**로 읽고, 자신의 출력물에 대해 **5차원 검토**를 실행하며, 몇 초 후 샌드박스 iframe에 렌더링되는 단일 ``를 내보냅니다. @@ -45,7 +45,7 @@ OD는 네 개의 오픈소스 프로젝트 어깨 위에 서 있습니다: | **시각적 방향** | 5개의 엄선된 학파(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — 각각 결정론적 OKLch 팔레트 + 폰트 스택 제공 | | **기기 프레임** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — 픽셀 정확도, 스킬 간 공유 | | **에이전트 런타임** | 로컬 daemon이 프로젝트 폴더에서 CLI를 실행 — 에이전트가 실제 디스크 환경에 대한 실제 `Read`, `Write`, `Bash`, `WebFetch` 도구를 사용 | -| **배포 대상** | 로컬(`pnpm dev:all`) · Vercel 웹 레이어 · 단일 프로세스 프로덕션(`pnpm start`) | +| **배포 대상** | 로컬(`pnpm tools-dev`) · Vercel 웹 레이어 · daemon 정적 서빙 프로덕션 | | **라이선스** | Apache-2.0 | [acd2]: https://github.com/VoltAgent/awesome-design-md @@ -262,8 +262,8 @@ cd open-design corepack enable corepack pnpm --version # 10.33.2가 출력되어야 합니다 pnpm install -pnpm dev:all # daemon (:7456) + Next dev (:3000) -open http://localhost:3000 +pnpm tools-dev run web # daemon + web foreground +# tools-dev가 출력한 web URL을 여세요 ``` 환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm`/`fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요. @@ -291,7 +291,7 @@ Daemon은 저장소 루트에 하나의 숨겨진 폴더를 소유합니다. 그 | 원하는 작업 | 방법 | |---|---| | 내용 확인 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` | -| 초기 상태로 재설정 | daemon 중지, `rm -rf .od`, `pnpm dev:all` 재실행 | +| 초기 상태로 재설정 | `pnpm tools-dev stop`, `rm -rf .od`, `pnpm tools-dev run web` 재실행 | | 다른 위치로 이동 | 아직 지원되지 않음 — 경로가 저장소 상대 경로로 하드코딩됨 | 전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.md`](QUICKSTART.md). @@ -374,7 +374,7 @@ open-design/ │ └── deck-framework.html ← 덱 기준선(nav / counter / print) │ ├── scripts/ -│ └── sync-design-systems.mjs ← 상위 awesome-design-md tarball 재가져오기 +│ └── sync-design-systems.ts ← 상위 awesome-design-md tarball 재가져오기 │ ├── docs/ │ ├── spec.md ← 제품 스펙, 시나리오, 차별화 @@ -424,7 +424,7 @@ open-design/ -라이브러리는 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져옵니다. 재실행하면 새로 고침됩니다. +라이브러리는 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져옵니다. 재실행하면 새로 고침됩니다. ## 시각적 방향 @@ -496,7 +496,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. | [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json` (타입 이벤트) | `copilot -p --allow-all-tools --output-format json` | | Anthropic API · BYOK | n/a | SSE 직접 | PATH에 CLI가 없을 때 브라우저 폴백 | -새 CLI 추가는 [`apps/daemon/agents.js`](apps/daemon/agents.js)에 항목 하나 추가하는 것입니다. 스트리밍 형식은 `claude-stream-json`(타입 이벤트) 또는 `plain`(원시 텍스트) 중 하나입니다. +새 CLI 추가는 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가하는 것입니다. 스트리밍 형식은 `claude-stream-json`(타입 이벤트) 또는 `plain`(원시 텍스트) 중 하나입니다. ## 참조 및 계보 @@ -509,7 +509,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. | [**`op7418/guizang-ppt-skill`**][guizang] | [`skills/guizang-ppt/`](skills/guizang-ppt/) 아래에 원본 그대로 번들된 Magazine-web-PPT skill, 원 LICENSE 보존. 덱 모드 기본. P0/P1/P2 체크리스트 문화는 다른 모든 skill에도 차용됩니다. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + 어댑터 아키텍처. PATH 스캔 에이전트 감지, 단일 특권 프로세스로서의 로컬 daemon, 에이전트-동료 세계관. 모델을 채용했지만 코드는 vendor하지 않습니다. | | [**`OpenCoworkAI/open-codesign`**][ocod] | 최초의 오픈소스 Claude-Design 대안이자 가장 가까운 동류. 채택된 UX 패턴: 스트리밍 아티팩트 루프, 샌드박스 iframe 미리보기(React 18 + Babel 내장), 실시간 에이전트 패널(todos + tool calls + 중단 가능), 5가지 내보내기 형식(HTML/PDF/PPTX/ZIP/Markdown), 로컬 우선 스토리지 허브, `SKILL.md` 취향 주입. 로드맵의 UX 패턴: 코멘트 모드 수술적 편집, AI 제안 트윅 패널. **[`pi-ai`][piai]는 의도적으로 vendor하지 않습니다** — open-codesign은 이를 에이전트 런타임으로 번들링하지만; 우리는 사용자가 이미 가진 CLI에 위임합니다. | -| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9섹션 `DESIGN.md` 스키마의 출처이자 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs)를 통해 가져온 69개 제품 시스템. | +| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9섹션 `DESIGN.md` 스키마의 출처이자 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 가져온 69개 제품 시스템. | | [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 여러 에이전트 CLI에 걸친 심링크 기반 skill 배포의 영감. | | [Claude Code skills][skill] | 원본 그대로 채택된 `SKILL.md` 규약 — 모든 Claude Code skill이 `skills/`에 드롭되면 daemon이 감지합니다. | @@ -547,7 +547,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. - **skill 추가** — [`SKILL.md`][skill] 규약을 따르는 폴더를 [`skills/`](skills/)에 드롭하세요. - **디자인 시스템 추가** — 9섹션 스키마를 사용하여 [`design-systems//`](design-systems/)에 `DESIGN.md`를 드롭하세요. -- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/agents.js`](apps/daemon/agents.js)에 항목 하나 추가. +- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가. 전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([简体中文](CONTRIBUTING.zh-CN.md)). diff --git a/README.md b/README.md index e915f9cfb..bb86866d0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Anthropic's [Claude Design][cd] (released 2026-04-17, Opus 4.7) showed what happens when an LLM stops writing prose and starts shipping design artifacts. It went viral — and stayed closed-source, paid-only, cloud-only, locked to Anthropic's model and Anthropic's skills. There is no checkout, no self-host, no Vercel deploy, no swap-in-your-own-agent. -**Open Design (OD) is the open-source alternative.** Same loop, same artifact-first mental model, none of the lock-in. We don't ship an agent — the strongest coding agents already live on your laptop. We wire them into a skill-driven design workflow that runs locally with `pnpm dev:all`, can deploy the web layer to Vercel, and stays BYOK at every layer. +**Open Design (OD) is the open-source alternative.** Same loop, same artifact-first mental model, none of the lock-in. We don't ship an agent — the strongest coding agents already live on your laptop. We wire them into a skill-driven design workflow that runs locally with `pnpm tools-dev`, can deploy the web layer to Vercel, and stays BYOK at every layer. Type `make me a magazine-style pitch deck for our seed round`. The interactive question form pops up before the model improvises a single pixel. The agent picks one of five curated visual directions. A live `TodoWrite` plan streams into the UI. The daemon builds a real on-disk project folder with a seed template, layout library, and self-check checklist. The agent reads them — pre-flight enforced — runs a five-dimensional critique against its own output, and emits a single `` that renders in a sandboxed iframe seconds later. @@ -45,7 +45,7 @@ OD stands on four open-source shoulders: | **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack | | **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across screens | | **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment | -| **Deployable to** | Local (`pnpm dev:all`) · Vercel web layer · Single-process prod (`pnpm start`) | +| **Deployable to** | Local (`pnpm tools-dev`) · Vercel web layer | | **License** | Apache-2.0 | [acd2]: https://github.com/VoltAgent/awesome-design-md @@ -262,8 +262,8 @@ cd open-design corepack enable corepack pnpm --version # should print 10.33.2 pnpm install -pnpm dev:all # daemon (:7456) + Next dev (:3000) -open http://localhost:3000 +pnpm tools-dev run web +# open the web URL printed by tools-dev ``` Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`. @@ -291,7 +291,7 @@ The daemon owns one hidden folder at the repo root. Everything in it is gitignor | Want to… | Do this | |---|---| | Inspect what's in there | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` | -| Reset to a clean slate | stop the daemon, `rm -rf .od`, run `pnpm dev:all` again | +| Reset to a clean slate | `pnpm tools-dev stop`, `rm -rf .od`, run `pnpm tools-dev run web` again | | Move it elsewhere | not supported yet — the path is hard-coded relative to the repo | Full file map, scripts, and troubleshooting → [`QUICKSTART.md`](QUICKSTART.md). @@ -373,7 +373,7 @@ open-design/ │ └── deck-framework.html ← deck baseline (nav / counter / print) │ ├── scripts/ -│ └── sync-design-systems.mjs ← re-import upstream awesome-design-md tarball +│ └── sync-design-systems.ts ← re-import upstream awesome-design-md tarball │ ├── docs/ │ ├── spec.md ← product spec, scenarios, differentiation @@ -423,7 +423,7 @@ open-design/ -The library is imported via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) from [`VoltAgent/awesome-design-md`][acd2]. Re-run to refresh. +The library is imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) from [`VoltAgent/awesome-design-md`][acd2]. Re-run to refresh. ## Visual directions @@ -495,7 +495,7 @@ Auto-detected from `PATH` on daemon boot. No config required. | [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json` (typed events) | `copilot -p --allow-all-tools --output-format json` | | Anthropic API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH | -Adding a new CLI is one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text). +Adding a new CLI is one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text). ## References & lineage @@ -508,7 +508,7 @@ Every external project this repo borrows from. Each link goes to the source so y | [**`op7418/guizang-ppt-skill`**][guizang] | Magazine-web-PPT skill bundled verbatim under [`skills/guizang-ppt/`](skills/guizang-ppt/) with original LICENSE preserved. Default for deck mode. P0/P1/P2 checklist culture borrowed for every other skill. | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | The daemon + adapter architecture. PATH-scan agent detection, local daemon as the only privileged process, agent-as-teammate worldview. We adopt the model; we do not vendor the code. | | [**`OpenCoworkAI/open-codesign`**][ocod] | The first open-source Claude-Design alternative and our closest peer. UX patterns adopted: streaming-artifact loop, sandboxed-iframe preview (vendored React 18 + Babel), live agent panel (todos + tool calls + interruptible), five-format export list (HTML/PDF/PPTX/ZIP/Markdown), local-first storage hub, `SKILL.md` taste-injection. UX patterns on our roadmap: comment-mode surgical edits, AI-emitted tweaks panel. **We deliberately do not vendor [`pi-ai`][piai]** — open-codesign bundles it as the agent runtime; we delegate to whichever CLI the user already has. | -| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source of the 9-section `DESIGN.md` schema and 69 product systems imported via [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs). | +| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | Source of the 9-section `DESIGN.md` schema and 69 product systems imported via [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts). | | [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | Inspiration for symlink-based skill distribution across multiple agent CLIs. | | [Claude Code skills][skill] | The `SKILL.md` convention adopted verbatim — any Claude Code skill drops into `skills/` and is picked up by the daemon. | @@ -546,7 +546,7 @@ Issues, PRs, new skills, and new design systems are all welcome. The highest-lev - **Add a skill** — drop a folder into [`skills/`](skills/) following the [`SKILL.md`][skill] convention. - **Add a design system** — drop a `DESIGN.md` into [`design-systems//`](design-systems/) using the 9-section schema. -- **Wire up a new coding-agent CLI** — one entry in [`apps/daemon/agents.js`](apps/daemon/agents.js). +- **Wire up a new coding-agent CLI** — one entry in [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts). Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([简体中文](CONTRIBUTING.zh-CN.md)). diff --git a/README.zh-CN.md b/README.zh-CN.md index fcae675fe..c59e215be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,7 +22,7 @@ Anthropic 的 [Claude Design][cd](2026-04-17 发布,基于 Opus 4.7)让大家第一次看到:当一个 LLM 不再写废话、开始直接交付设计成品,会是什么样子。它瞬间出圈 —— 然后保持**闭源**、付费、只跑在云上、绑定 Anthropic 的模型和 Anthropic 的内部 skill。没有 checkout,没有自托管,没有 Vercel 部署,也换不了自己的 agent。 -**Open Design(OD)就是它的开源替代品。** 同一套 loop、同一种「artifact-first」心智模型,但没有锁定。我们不做 agent —— 你笔记本上最强的 coding agent 已经装好了。我们要做的,是把它接进一个 skill 驱动的设计工作流:本地用 `pnpm dev:all` 跑完整 web + daemon,云端可单独部署 Web 层,每一层都 BYOK(自带 Key)。 +**Open Design(OD)就是它的开源替代品。** 同一套 loop、同一种「artifact-first」心智模型,但没有锁定。我们不做 agent —— 你笔记本上最强的 coding agent 已经装好了。我们要做的,是把它接进一个 skill 驱动的设计工作流:本地用 `pnpm tools-dev` 跑完整本地闭环,云端可单独部署 Web 层,每一层都 BYOK(自带 Key)。 输入「帮我做一份杂志风的种子轮 pitch deck」。在模型挥洒第一个像素之前,**初始化问题表单**已经先跳出来。Agent 从 5 套精挑的视觉方向里选一个。一张活的 `TodoWrite` 计划卡片实时流入 UI。Daemon 在磁盘上构建出一个真实的项目目录,里面有 seed 模板、布局库、自检 checklist。Agent **强制 pre-flight** 读取它们,对自己的输出跑一轮**五维评审**,几秒后吐出一个 ``,渲染在沙盒 iframe 里。 @@ -45,7 +45,7 @@ OD 站在四个开源项目的肩膀上: | **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm),每一套自带 OKLch 色板 + 字体栈 | | **设备外壳** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome —— 像素级精确,跨 skill 共享 | | **Agent 运行时** | 本地 daemon 在你的项目目录里 spawn CLI —— agent 拥有真实的 `Read` / `Write` / `Bash` / `WebFetch`,作用在真实磁盘上 | -| **部署目标** | 本地 `pnpm dev:all` · Vercel Web 层 · 单进程生产 (`pnpm start`) | +| **部署目标** | 本地 `pnpm tools-dev` · Vercel Web 层 | | **License** | Apache-2.0 | [acd2]: https://github.com/VoltAgent/awesome-design-md @@ -262,8 +262,8 @@ cd open-design corepack enable corepack pnpm --version # 应输出 10.33.2 pnpm install -pnpm dev:all # daemon (:7456) + Next dev (:3000) 一起起 -open http://localhost:3000 +pnpm tools-dev run web +# 打开 tools-dev 输出的 web URL ``` 环境要求:Node `~24`,pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24` 或 `fnm install 24 && fnm use 24`,再运行 `pnpm install`。 @@ -291,7 +291,7 @@ Daemon 在仓库根下维护一个隐藏目录,里面所有内容都已 gitign | 想做什么 | 怎么做 | |---|---| | 看一眼里面有啥 | `ls -la .od && sqlite3 .od/app.sqlite '.tables'` | -| 完全清空,从零再来 | 先停 daemon,再 `rm -rf .od`,然后重新 `pnpm dev:all` | +| 完全清空,从零再来 | `pnpm tools-dev stop`,再 `rm -rf .od`,然后重新 `pnpm tools-dev run web` | | 换到别的位置 | 暂不支持 —— 路径是相对仓库根写死的 | 完整文件地图、脚本、排错 → [`QUICKSTART.md`](QUICKSTART.md)。 @@ -370,7 +370,7 @@ open-design/ │ └── deck-framework.html ← deck 基线(nav / counter / print) │ ├── scripts/ -│ └── sync-design-systems.mjs ← 从上游 awesome-design-md tarball 重新导入 +│ └── sync-design-systems.ts ← 从上游 awesome-design-md tarball 重新导入 │ ├── docs/ │ ├── spec.md ← 产品定义、场景、差异化 @@ -420,7 +420,7 @@ open-design/ -整个库通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 从 [`VoltAgent/awesome-design-md`][acd2] 导入。重新执行即可刷新。 +整个库通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 从 [`VoltAgent/awesome-design-md`][acd2] 导入。重新执行即可刷新。 ## 视觉方向 @@ -492,7 +492,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。 | [GitHub Copilot CLI](https://github.com/features/copilot/cli) | `copilot` | `--output-format json`(类型化事件) | `copilot -p --allow-all-tools --output-format json` | | Anthropic API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 | -加一个新 CLI = 在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。 +加一个新 CLI = 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。 ## 引用与师承 @@ -505,7 +505,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。 | [**`op7418/guizang-ppt-skill`**(歸藏)][guizang] | Magazine-web-PPT skill 原样捆绑在 [`skills/guizang-ppt/`](skills/guizang-ppt/) 下,原 LICENSE 保留。Deck 模式默认。P0/P1/P2 checklist 文化也被借给了所有其他 skill。 | | [**`multica-ai/multica`**](https://github.com/multica-ai/multica) | Daemon + adapter 架构。PATH 扫描式 agent 检测、本地 daemon 作为唯一特权进程、agent-as-teammate 世界观。我们采纳模型,不 vendor 代码。 | | [**`OpenCoworkAI/open-codesign`**][ocod] | 第一个开源的 Claude-Design 替代品,也是我们最接近的同类。已采纳的 UX 模式:流式 artifact 循环、沙盒 iframe 预览(自带 React 18 + Babel)、实时 agent 面板(todos + tool calls + 可中断)、5 种导出格式列表(HTML/PDF/PPTX/ZIP/Markdown)、本地优先的 designs hub、`SKILL.md` 品味注入。路线图上的 UX 模式:评论模式手术刀编辑、AI 自吐 tweaks 面板。**我们刻意不 vendor [`pi-ai`][piai]** —— open-codesign 把它打包成 agent 运行时;我们则委托给用户已经装好的 CLI。 | -| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的来源,69 套产品系统通过 [`scripts/sync-design-systems.mjs`](scripts/sync-design-systems.mjs) 导入。 | +| [`VoltAgent/awesome-claude-design`][acd] / [`awesome-design-md`][acd2] | 9 段式 `DESIGN.md` schema 的来源,69 套产品系统通过 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts) 导入。 | | [`farion1231/cc-switch`](https://github.com/farion1231/cc-switch) | 跨多个 agent CLI 的 symlink 式 skill 分发灵感来源。 | | [Claude Code skills][skill] | `SKILL.md` 规范原样采纳 —— 任何 Claude Code skill 丢进 `skills/` 都能被 daemon 识别。 | @@ -543,7 +543,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。 - **加一个 skill** —— 往 [`skills/`](skills/) 丢一个文件夹,遵循 [`SKILL.md`][skill] 规范。 - **加一套 design system** —— 往 [`design-systems//`](design-systems/) 丢一份 `DESIGN.md`,用 9 段式 schema。 -- **接入一个新的 coding-agent CLI** —— 在 [`apps/daemon/agents.js`](apps/daemon/agents.js) 里加一项。 +- **接入一个新的 coding-agent CLI** —— 在 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) 里加一项。 完整流程、合并硬线、代码风格、我们不接收的 PR 类型 → [`CONTRIBUTING.zh-CN.md`](CONTRIBUTING.zh-CN.md)([English](CONTRIBUTING.md))。 diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 000000000..cd98bd760 --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,38 @@ +# apps/AGENTS.md + +Follow the root `AGENTS.md` first. This file only records module-level boundaries for `apps/`. + +## Active apps + +- `apps/web`: Next.js 16 App Router + React 18 web runtime. Entrypoints live in `apps/web/app/`; the main client shell is `apps/web/src/App.tsx`. During local `tools-dev` web runs, `apps/web/next.config.ts` rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to `OD_PORT`. +- `apps/daemon`: Express + SQLite local daemon and `od` bin. It owns REST/SSE APIs, agent CLI spawning, skills, design systems, artifact persistence, static serving, and local data under `.od/`. +- `apps/desktop`: Electron shell. Desktop does not guess the web port; it reads runtime status through sidecar IPC and opens the reported web URL. + +## Daemon layout + +- `apps/daemon/src/` contains only daemon app source. +- `apps/daemon/tests/` contains daemon tests. +- `apps/daemon/sidecar/` contains the daemon sidecar entry. +- CLI/agent argument changes or stdout parser changes belong in `apps/daemon/src/agents.ts` and the matching parser tests. + +## Sidecar awareness + +- App business layers must not import sidecar packages or branch on `runtime.mode`, `namespace`, `ipc`, or `source`. +- Keep sidecar awareness in `apps//sidecar` or the desktop sidecar entry wrapper. + +## Inactive app directories + +- `apps/nextjs` has been removed; do not restore it. +- `apps/packaged` is a minimal placeholder for future packaged app assembly. Do not add a package manifest, runtime code, or lifecycle script there in this round. + +## Common app commands + +```bash +pnpm --filter @open-design/web typecheck +pnpm --filter @open-design/web test +pnpm --filter @open-design/daemon typecheck +pnpm --filter @open-design/daemon test +pnpm --filter @open-design/daemon build +pnpm --filter @open-design/desktop typecheck +pnpm --filter @open-design/desktop build +``` diff --git a/apps/daemon/package.json b/apps/daemon/package.json index a4204fd8e..8a008bb87 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -7,15 +7,18 @@ "od": "./dist/cli.js" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && tsc -p tsconfig.sidecar.json", "daemon": "pnpm run build && node dist/cli.js --no-open", "dev": "pnpm run build && node dist/cli.js --no-open", "start": "pnpm run build && node dist/cli.js", "test": "vitest run -c vitest.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.sidecar.json --noEmit && tsc -p tsconfig.tests.json --noEmit" }, "dependencies": { - "@open-design/contracts": "workspace:*", + "@open-design/contracts": "workspace:0.1.0", + "@open-design/platform": "workspace:0.1.0", + "@open-design/sidecar": "workspace:0.1.0", + "@open-design/sidecar-proto": "workspace:0.1.0", "better-sqlite3": "^11.10.0", "express": "^4.19.2", "jszip": "^3.10.1", diff --git a/apps/daemon/sidecar/index.ts b/apps/daemon/sidecar/index.ts new file mode 100644 index 000000000..4bb916b13 --- /dev/null +++ b/apps/daemon/sidecar/index.ts @@ -0,0 +1,24 @@ +import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto"; +import { bootstrapSidecarRuntime } from "@open-design/sidecar"; +import { readProcessStamp } from "@open-design/platform"; + +import { startDaemonSidecar } from "./server.js"; + +async function main(): Promise { + const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); + if (stamp == null) throw new Error("sidecar stamp is required"); + + const runtime = bootstrapSidecarRuntime(stamp, process.env, { + app: APP_KEYS.DAEMON, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + }); + const server = await startDaemonSidecar(runtime); + + process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`); + await server.waitUntilStopped(); +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/apps/daemon/sidecar/server.ts b/apps/daemon/sidecar/server.ts new file mode 100644 index 000000000..aef683d7c --- /dev/null +++ b/apps/daemon/sidecar/server.ts @@ -0,0 +1,129 @@ +import type { Server } from "node:http"; + +import { + SIDECAR_ENV, + SIDECAR_MESSAGES, + normalizeDaemonSidecarMessage, + type DaemonStatusSnapshot, + type SidecarStamp, +} from "@open-design/sidecar-proto"; +import { + createJsonIpcServer, + type JsonIpcServerHandle, + type SidecarRuntimeContext, +} from "@open-design/sidecar"; + +import { startServer } from "../src/server.js"; + +const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT; +const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; + +export type DaemonSidecarHandle = { + status(): Promise; + stop(): Promise; + waitUntilStopped(): Promise; +}; + +function parsePort(value: string | undefined): number { + if (value == null || value.trim().length === 0) return 0; + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${DAEMON_PORT_ENV} must be an integer between 1 and 65535`); + } + return port; +} + +async function closeHttpServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function attachParentMonitor(stop: () => Promise): void { + const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); + if (!Number.isInteger(parentPid) || parentPid <= 0) return; + + const timer = setInterval(() => { + if (isProcessAlive(parentPid)) return; + clearInterval(timer); + void stop().finally(() => process.exit(0)); + }, 1000); + timer.unref(); +} + +export async function startDaemonSidecar(runtime: SidecarRuntimeContext): Promise { + const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as + | string + | { server: Server; url: string }; + if (typeof started === "string") { + throw new Error("daemon startServer did not return a server handle"); + } + const serverHandle = started; + + const state: DaemonStatusSnapshot = { + pid: process.pid, + state: "running", + updatedAt: new Date().toISOString(), + url: serverHandle.url, + }; + let ipcServer: JsonIpcServerHandle | null = null; + let stopped = false; + let resolveStopped!: () => void; + const stoppedPromise = new Promise((resolveStop) => { + resolveStopped = resolveStop; + }); + + async function stop(): Promise { + if (stopped) return; + stopped = true; + state.state = "stopped"; + state.updatedAt = new Date().toISOString(); + await ipcServer?.close().catch(() => undefined); + await closeHttpServer(serverHandle.server).catch(() => undefined); + resolveStopped(); + } + + attachParentMonitor(stop); + + ipcServer = await createJsonIpcServer({ + socketPath: runtime.ipc, + handler: async (message: unknown) => { + const request = normalizeDaemonSidecarMessage(message); + switch (request.type) { + case SIDECAR_MESSAGES.STATUS: + return { ...state }; + case SIDECAR_MESSAGES.SHUTDOWN: + setImmediate(() => { + void stop().finally(() => process.exit(0)); + }); + return { accepted: true }; + } + }, + }); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void stop().finally(() => process.exit(0)); + }); + } + + return { + async status() { + return { ...state }; + }, + stop, + waitUntilStopped() { + return stoppedPromise; + }, + }; +} diff --git a/apps/daemon/acp.ts b/apps/daemon/src/acp.ts similarity index 100% rename from apps/daemon/acp.ts rename to apps/daemon/src/acp.ts diff --git a/apps/daemon/agents.ts b/apps/daemon/src/agents.ts similarity index 100% rename from apps/daemon/agents.ts rename to apps/daemon/src/agents.ts diff --git a/apps/daemon/artifact-manifest.ts b/apps/daemon/src/artifact-manifest.ts similarity index 100% rename from apps/daemon/artifact-manifest.ts rename to apps/daemon/src/artifact-manifest.ts diff --git a/apps/daemon/claude-design-import.ts b/apps/daemon/src/claude-design-import.ts similarity index 100% rename from apps/daemon/claude-design-import.ts rename to apps/daemon/src/claude-design-import.ts diff --git a/apps/daemon/claude-stream.ts b/apps/daemon/src/claude-stream.ts similarity index 100% rename from apps/daemon/claude-stream.ts rename to apps/daemon/src/claude-stream.ts diff --git a/apps/daemon/cli.ts b/apps/daemon/src/cli.ts similarity index 100% rename from apps/daemon/cli.ts rename to apps/daemon/src/cli.ts diff --git a/apps/daemon/copilot-stream.ts b/apps/daemon/src/copilot-stream.ts similarity index 100% rename from apps/daemon/copilot-stream.ts rename to apps/daemon/src/copilot-stream.ts diff --git a/apps/daemon/db.ts b/apps/daemon/src/db.ts similarity index 100% rename from apps/daemon/db.ts rename to apps/daemon/src/db.ts diff --git a/apps/daemon/design-system-preview.ts b/apps/daemon/src/design-system-preview.ts similarity index 100% rename from apps/daemon/design-system-preview.ts rename to apps/daemon/src/design-system-preview.ts diff --git a/apps/daemon/design-system-showcase.ts b/apps/daemon/src/design-system-showcase.ts similarity index 100% rename from apps/daemon/design-system-showcase.ts rename to apps/daemon/src/design-system-showcase.ts diff --git a/apps/daemon/design-systems.ts b/apps/daemon/src/design-systems.ts similarity index 100% rename from apps/daemon/design-systems.ts rename to apps/daemon/src/design-systems.ts diff --git a/apps/daemon/document-preview.ts b/apps/daemon/src/document-preview.ts similarity index 100% rename from apps/daemon/document-preview.ts rename to apps/daemon/src/document-preview.ts diff --git a/apps/daemon/frontmatter.ts b/apps/daemon/src/frontmatter.ts similarity index 100% rename from apps/daemon/frontmatter.ts rename to apps/daemon/src/frontmatter.ts diff --git a/apps/daemon/json-event-stream.ts b/apps/daemon/src/json-event-stream.ts similarity index 100% rename from apps/daemon/json-event-stream.ts rename to apps/daemon/src/json-event-stream.ts diff --git a/apps/daemon/lint-artifact.ts b/apps/daemon/src/lint-artifact.ts similarity index 100% rename from apps/daemon/lint-artifact.ts rename to apps/daemon/src/lint-artifact.ts diff --git a/apps/daemon/projects.ts b/apps/daemon/src/projects.ts similarity index 100% rename from apps/daemon/projects.ts rename to apps/daemon/src/projects.ts diff --git a/apps/daemon/server.ts b/apps/daemon/src/server.ts similarity index 99% rename from apps/daemon/server.ts rename to apps/daemon/src/server.ts index 98d07e5e2..738b3d76c 100644 --- a/apps/daemon/server.ts +++ b/apps/daemon/src/server.ts @@ -845,7 +845,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) { // Project files. Each project owns a flat folder under .od/projects// // containing every file the user has uploaded, pasted, sketched, or that // the agent has generated. Names are sanitized; paths are confined to the - // project's own folder (see apps/daemon/projects.js). + // project's own folder (see apps/daemon/src/projects.ts). app.get('/api/projects/:id/files', async (req, res) => { try { const files = await listFiles(PROJECTS_DIR, req.params.id); diff --git a/apps/daemon/skills.ts b/apps/daemon/src/skills.ts similarity index 100% rename from apps/daemon/skills.ts rename to apps/daemon/src/skills.ts diff --git a/apps/daemon/artifact-manifest.test.ts b/apps/daemon/tests/artifact-manifest.test.ts similarity index 94% rename from apps/daemon/artifact-manifest.test.ts rename to apps/daemon/tests/artifact-manifest.test.ts index 21a49b50c..6f88e63aa 100644 --- a/apps/daemon/artifact-manifest.test.ts +++ b/apps/daemon/tests/artifact-manifest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { validateArtifactManifestInput } from './artifact-manifest.js'; +import { validateArtifactManifestInput } from '../src/artifact-manifest.js'; function validBase() { return { diff --git a/apps/daemon/json-event-stream.test.ts b/apps/daemon/tests/json-event-stream.test.ts similarity index 98% rename from apps/daemon/json-event-stream.test.ts rename to apps/daemon/tests/json-event-stream.test.ts index 9bbd84784..72d62d35f 100644 --- a/apps/daemon/json-event-stream.test.ts +++ b/apps/daemon/tests/json-event-stream.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { createJsonEventStreamHandler } from './json-event-stream.js'; +import { createJsonEventStreamHandler } from '../src/json-event-stream.js'; test('opencode json stream emits text and usage events', () => { const events = []; diff --git a/apps/daemon/server-paths.test.ts b/apps/daemon/tests/server-paths.test.ts similarity index 71% rename from apps/daemon/server-paths.test.ts rename to apps/daemon/tests/server-paths.test.ts index 1e20280be..9559ec981 100644 --- a/apps/daemon/server-paths.test.ts +++ b/apps/daemon/tests/server-paths.test.ts @@ -1,16 +1,16 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { resolveProjectRoot } from './server.js'; +import { resolveProjectRoot } from '../src/server.js'; describe('resolveProjectRoot', () => { it('resolves the repository root from the source daemon directory', () => { - const root = path.resolve(import.meta.dirname, '../..'); + const root = path.resolve(import.meta.dirname, '../../..'); expect(resolveProjectRoot(path.join(root, 'apps', 'daemon'))).toBe(root); }); it('resolves the repository root from the compiled daemon dist directory', () => { - const root = path.resolve(import.meta.dirname, '../..'); + const root = path.resolve(import.meta.dirname, '../../..'); expect(resolveProjectRoot(path.join(root, 'apps', 'daemon', 'dist'))).toBe(root); }); diff --git a/apps/daemon/sse-response.test.ts b/apps/daemon/tests/sse-response.test.ts similarity index 99% rename from apps/daemon/sse-response.test.ts rename to apps/daemon/tests/sse-response.test.ts index 047a2badf..6ccf340e5 100644 --- a/apps/daemon/sse-response.test.ts +++ b/apps/daemon/tests/sse-response.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createCompatApiErrorResponse, createSseResponse } from './server.js'; +import { createCompatApiErrorResponse, createSseResponse } from '../src/server.js'; afterEach(() => { vi.useRealTimers(); diff --git a/apps/daemon/tsconfig.json b/apps/daemon/tsconfig.json index aa263084d..627c67fae 100644 --- a/apps/daemon/tsconfig.json +++ b/apps/daemon/tsconfig.json @@ -10,6 +10,7 @@ "allowJs": false, "checkJs": false, "outDir": "dist", + "rootDir": "src", "declaration": true, "sourceMap": true, "isolatedModules": true, @@ -20,8 +21,8 @@ "types": ["node", "vitest"] }, "include": [ - "**/*.ts", - "**/*.tsx" + "src/**/*.ts", + "src/**/*.tsx" ], "exclude": ["node_modules", "dist"] } diff --git a/apps/daemon/tsconfig.sidecar.json b/apps/daemon/tsconfig.sidecar.json new file mode 100644 index 000000000..86ffb6b27 --- /dev/null +++ b/apps/daemon/tsconfig.sidecar.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": [ + "sidecar/**/*.ts", + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] +} diff --git a/apps/daemon/tsconfig.tests.json b/apps/daemon/tsconfig.tests.json new file mode 100644 index 000000000..8c0a76747 --- /dev/null +++ b/apps/daemon/tsconfig.tests.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": [ + "tests/**/*.ts", + "tests/**/*.tsx", + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/apps/daemon/vitest.config.ts b/apps/daemon/vitest.config.ts index bfe2db0e6..bc1733cdd 100644 --- a/apps/daemon/vitest.config.ts +++ b/apps/daemon/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['**/*.test.{ts,tsx,js,mjs,cjs}'], + include: ['tests/**/*.test.{ts,tsx,js,mjs,cjs}'], }, }); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 000000000..c19b1de45 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,33 @@ +{ + "name": "@open-design/desktop", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/main/index.js", + "files": [ + "dist" + ], + "exports": { + "./main": { + "types": "./dist/main/index.d.ts", + "default": "./dist/main/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@open-design/platform": "workspace:0.1.0", + "@open-design/sidecar": "workspace:0.1.0", + "@open-design/sidecar-proto": "workspace:0.1.0" + }, + "devDependencies": { + "@types/node": "24.12.2", + "electron": "41.3.0", + "typescript": "6.0.3" + }, + "engines": { + "node": "~24" + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 000000000..b822d3793 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,154 @@ +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { app } from "electron"; + +import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_ENV, + SIDECAR_MESSAGES, + normalizeDesktopSidecarMessage, + type DesktopClickInput, + type DesktopEvalInput, + type DesktopScreenshotInput, + type SidecarStamp, + type WebStatusSnapshot, +} from "@open-design/sidecar-proto"; +import { + bootstrapSidecarRuntime, + createJsonIpcServer, + requestJsonIpc, + resolveAppIpcPath, + type JsonIpcServerHandle, + type SidecarRuntimeContext, +} from "@open-design/sidecar"; +import { readProcessStamp } from "@open-design/platform"; + +import { createDesktopRuntime } from "./runtime.js"; + +const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; + +export type DesktopMainOptions = { + beforeShutdown?: () => Promise; + discoverWebUrl?: () => Promise; +}; + +function isDirectEntry(): boolean { + const entryPath = process.argv[1]; + if (entryPath == null || entryPath.length === 0 || entryPath.startsWith("--")) return false; + + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function attachParentMonitor(stop: () => Promise): void { + const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); + if (!Number.isInteger(parentPid) || parentPid <= 0) return; + + const timer = setInterval(() => { + if (isProcessAlive(parentPid)) return; + clearInterval(timer); + void stop().finally(() => process.exit(0)); + }, 1000); + timer.unref(); +} + +function createWebDiscovery(runtime: SidecarRuntimeContext): () => Promise { + return async () => { + const webIpc = resolveAppIpcPath({ + app: APP_KEYS.WEB, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + namespace: runtime.namespace, + }); + const web = await requestJsonIpc(webIpc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 600 }).catch(() => null); + return web?.url ?? null; + }; +} + +export async function runDesktopMain( + runtime: SidecarRuntimeContext, + options: DesktopMainOptions = {}, +): Promise { + await app.whenReady(); + + const desktop = await createDesktopRuntime({ + discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime), + }); + let ipcServer: JsonIpcServerHandle | null = null; + let shuttingDown = false; + + async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + await options.beforeShutdown?.().catch((error: unknown) => { + console.error("desktop beforeShutdown failed", error); + }); + await ipcServer?.close().catch(() => undefined); + await desktop.close().catch(() => undefined); + app.quit(); + } + + attachParentMonitor(shutdown); + + ipcServer = await createJsonIpcServer({ + socketPath: runtime.ipc, + handler: async (message: unknown) => { + const request = normalizeDesktopSidecarMessage(message); + switch (request.type) { + case SIDECAR_MESSAGES.STATUS: + return desktop.status(); + case SIDECAR_MESSAGES.EVAL: + return await desktop.eval(request.input as DesktopEvalInput); + case SIDECAR_MESSAGES.SCREENSHOT: + return await desktop.screenshot(request.input as DesktopScreenshotInput); + case SIDECAR_MESSAGES.CONSOLE: + return desktop.console(); + case SIDECAR_MESSAGES.CLICK: + return await desktop.click(request.input as DesktopClickInput); + case SIDECAR_MESSAGES.SHUTDOWN: + setImmediate(() => { + void shutdown().finally(() => process.exit(0)); + }); + return { accepted: true }; + } + }, + }); + + app.on("window-all-closed", () => { + void shutdown().finally(() => process.exit(0)); + }); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void shutdown().finally(() => process.exit(0)); + }); + } +} + +if (isDirectEntry()) { + const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); + if (stamp == null) throw new Error("sidecar stamp is required"); + + const runtime = bootstrapSidecarRuntime(stamp, process.env, { + app: APP_KEYS.DESKTOP, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + }); + + void runDesktopMain(runtime).catch((error: unknown) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); + }); +} diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts new file mode 100644 index 000000000..e894ea3b9 --- /dev/null +++ b/apps/desktop/src/main/runtime.ts @@ -0,0 +1,231 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; + +import { BrowserWindow } from "electron"; + +const PENDING_POLL_MS = 120; +const RUNNING_POLL_MS = 2000; +const MAX_CONSOLE_ENTRIES = 200; + +export type DesktopEvalInput = { + expression: string; +}; + +export type DesktopEvalResult = { + error?: string; + ok: boolean; + value?: unknown; +}; + +export type DesktopScreenshotInput = { + path: string; +}; + +export type DesktopScreenshotResult = { + path: string; +}; + +export type DesktopConsoleEntry = { + level: string; + text: string; + timestamp: string; +}; + +export type DesktopConsoleResult = { + entries: DesktopConsoleEntry[]; +}; + +export type DesktopClickInput = { + selector: string; +}; + +export type DesktopClickResult = { + clicked: boolean; + found: boolean; +}; + +export type DesktopStatusSnapshot = { + pid?: number; + state: "idle" | "running" | "unknown"; + title?: string | null; + updatedAt?: string; + url?: string | null; + windowVisible?: boolean; +}; + +export type DesktopRuntime = { + close(): Promise; + click(input: DesktopClickInput): Promise; + console(): DesktopConsoleResult; + eval(input: DesktopEvalInput): Promise; + screenshot(input: DesktopScreenshotInput): Promise; + status(): DesktopStatusSnapshot; +}; + +export type DesktopRuntimeOptions = { + discoverUrl(): Promise; +}; + +function createPendingHtml(): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(` + + + Open Design + + + +
+

Open Design

+

Waiting for the web runtime URL…

+
+ +`)}`; +} + +function normalizeScreenshotPath(filePath: string): string { + return isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath); +} + +function mapConsoleLevel(level: number): string { + switch (level) { + case 0: + return "debug"; + case 1: + return "info"; + case 2: + return "warn"; + case 3: + return "error"; + default: + return "log"; + } +} + +export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise { + const consoleEntries: DesktopConsoleEntry[] = []; + const window = new BrowserWindow({ + height: 900, + show: true, + title: "Open Design", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + width: 1280, + }); + let currentUrl: string | null = null; + let stopped = false; + let timer: NodeJS.Timeout | null = null; + + (window.webContents as any).on("console-message", (event: { level?: number | string; message?: string }) => { + const level = typeof event.level === "number" ? mapConsoleLevel(event.level) : (event.level ?? "log"); + consoleEntries.push({ + level, + text: event.message ?? "", + timestamp: new Date().toISOString(), + }); + if (consoleEntries.length > MAX_CONSOLE_ENTRIES) { + consoleEntries.splice(0, consoleEntries.length - MAX_CONSOLE_ENTRIES); + } + }); + + await window.loadURL(createPendingHtml()); + + const schedule = (delayMs: number) => { + if (stopped) return; + timer = setTimeout(() => { + void tick(); + }, delayMs); + }; + + const tick = async () => { + if (stopped || window.isDestroyed()) return; + + try { + const url = await options.discoverUrl(); + if (url != null && url !== currentUrl) { + currentUrl = url; + await window.loadURL(url); + } + schedule(url == null ? PENDING_POLL_MS : RUNNING_POLL_MS); + } catch (error) { + console.error("desktop web discovery failed", error); + schedule(PENDING_POLL_MS); + } + }; + + void tick(); + + return { + async click(input) { + if (window.isDestroyed()) return { clicked: false, found: false }; + const selector = JSON.stringify(input.selector); + return await window.webContents.executeJavaScript( + `(() => { + const element = document.querySelector(${selector}); + if (!element) return { found: false, clicked: false }; + if (typeof element.click === "function") element.click(); + return { found: true, clicked: true }; + })()`, + true, + ); + }, + async close() { + stopped = true; + if (timer != null) { + clearTimeout(timer); + timer = null; + } + if (!window.isDestroyed()) window.close(); + }, + console() { + return { entries: [...consoleEntries] }; + }, + async eval(input) { + if (window.isDestroyed()) return { error: "desktop window is destroyed", ok: false }; + try { + const value = await window.webContents.executeJavaScript(input.expression, true); + return { ok: true, value }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error), ok: false }; + } + }, + async screenshot(input) { + if (window.isDestroyed()) throw new Error("desktop window is destroyed"); + const outputPath = normalizeScreenshotPath(input.path); + const image = await window.webContents.capturePage(); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, image.toPNG()); + return { path: outputPath }; + }, + status() { + return { + pid: process.pid, + state: window.isDestroyed() ? "unknown" : "running", + title: window.isDestroyed() ? null : window.getTitle(), + updatedAt: new Date().toISOString(), + url: currentUrl, + windowVisible: !window.isDestroyed() && window.isVisible(), + }; + }, + }; +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 000000000..f6e399f44 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/packaged/README.md b/apps/packaged/README.md new file mode 100644 index 000000000..c6dfb0248 --- /dev/null +++ b/apps/packaged/README.md @@ -0,0 +1,5 @@ +# apps/packaged + +Minimal placeholder for the future packaged app assembly workstream. + +No active runtime code, package manifest, or lifecycle script lives here yet. diff --git a/apps/web/app/[[...slug]]/page.tsx b/apps/web/app/[[...slug]]/page.tsx index e18681db6..15e48ec47 100644 --- a/apps/web/app/[[...slug]]/page.tsx +++ b/apps/web/app/[[...slug]]/page.tsx @@ -7,7 +7,7 @@ import { ClientApp } from './client-app'; // // For `output: 'export'` we return a single empty `slug` so Next.js emits // one shell HTML at out/index.html; the daemon's SPA fallback (see -// apps/daemon/server.js) serves it for any unknown non-API path so deep links +// apps/daemon/src/server.ts) serves it for any unknown non-API path so deep links // still hydrate to the right view. In dev we leave `dynamicParams` at its // default (true) so `next dev` happily renders /projects/ directly. export function generateStaticParams() { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 376d6cf12..05ef0a793 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,8 @@ import type { NextConfig } from 'next'; +import { dirname, isAbsolute, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; -// Daemon port the local Express server binds to (see apps/daemon/cli.js). The +// Daemon port the local Express server binds to (see apps/daemon/src/cli.ts). The // dev-all launcher overrides OD_PORT after probing for a free port; we read // the same env so /api, /artifacts, and /frames always reach the right // daemon instance during `next dev`. @@ -16,12 +18,31 @@ const DAEMON_ORIGIN = `http://127.0.0.1:${DAEMON_PORT}`; // view. const isProd = process.env.NODE_ENV !== 'development'; +const WEB_ROOT = dirname(fileURLToPath(import.meta.url)); + +function resolveDevDistDir() { + const configured = process.env.OD_WEB_DIST_DIR; + if (!configured) return '.next'; + return isAbsolute(configured) ? relative(WEB_ROOT, configured) || '.' : configured; +} + +const DEV_DIST_DIR = resolveDevDistDir(); + +function resolveDevTsconfigPath() { + const configured = process.env.OD_WEB_TSCONFIG_PATH; + if (!configured) return undefined; + return isAbsolute(configured) ? relative(WEB_ROOT, configured) || 'tsconfig.json' : configured; +} + +const DEV_TSCONFIG_PATH = resolveDevTsconfigPath(); + const nextConfig: NextConfig = { allowedDevOrigins: ['127.0.0.1'], reactStrictMode: true, + ...(DEV_TSCONFIG_PATH ? { typescript: { tsconfigPath: DEV_TSCONFIG_PATH } } : {}), // Keep the bundle output predictable so the daemon's STATIC_DIR can point // at it without any glob trickery. - distDir: isProd ? 'out' : '.next', + distDir: isProd ? 'out' : DEV_DIST_DIR, ...(isProd ? { output: 'export' as const, diff --git a/apps/web/package.json b/apps/web/package.json index 48376421a..73a76efef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,10 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", - "@open-design/contracts": "workspace:*", + "@open-design/contracts": "workspace:0.1.0", + "@open-design/platform": "workspace:0.1.0", + "@open-design/sidecar": "workspace:0.1.0", + "@open-design/sidecar-proto": "workspace:0.1.0", "next": "^16.2.4", "openai": "^6.35.0", "react": "^18.3.1", diff --git a/apps/web/sidecar/index.ts b/apps/web/sidecar/index.ts new file mode 100644 index 000000000..c50dbe41f --- /dev/null +++ b/apps/web/sidecar/index.ts @@ -0,0 +1,24 @@ +import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT } from "@open-design/sidecar-proto"; +import { bootstrapSidecarRuntime } from "@open-design/sidecar"; +import { readProcessStamp } from "@open-design/platform"; + +import { startWebSidecar } from "./server.js"; + +async function main(): Promise { + const stamp = readProcessStamp(process.argv.slice(2), OPEN_DESIGN_SIDECAR_CONTRACT); + if (stamp == null) throw new Error("sidecar stamp is required"); + + const runtime = bootstrapSidecarRuntime(stamp, process.env, { + app: APP_KEYS.WEB, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + }); + const server = await startWebSidecar(runtime); + + process.stdout.write(`${JSON.stringify(await server.status(), null, 2)}\n`); + await server.waitUntilStopped(); +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/apps/web/sidecar/server.ts b/apps/web/sidecar/server.ts new file mode 100644 index 000000000..c2eb8e042 --- /dev/null +++ b/apps/web/sidecar/server.ts @@ -0,0 +1,192 @@ +import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { AddressInfo } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SIDECAR_ENV, + SIDECAR_MESSAGES, + normalizeWebSidecarMessage, + type SidecarStamp, + type WebStatusSnapshot, +} from "@open-design/sidecar-proto"; +import { + createJsonIpcServer, + type JsonIpcServerHandle, + type SidecarRuntimeContext, +} from "@open-design/sidecar"; + +const HOST = "127.0.0.1"; +const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT; +const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; +const require = createRequire(import.meta.url); +const createNextServer = require("next") as (options: { dev: boolean; dir: string }) => { + close?: () => Promise; + getRequestHandler(): (request: IncomingMessage, response: ServerResponse) => Promise; + prepare(): Promise; +}; + +export type WebSidecarHandle = { + status(): Promise; + stop(): Promise; + waitUntilStopped(): Promise; +}; + +function resolveWebRoot(): string { + let current = dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 8; depth += 1) { + try { + const packageJson = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as { name?: unknown }; + if (packageJson.name === "@open-design/web") return current; + } catch { + // Keep walking until the package root is found. This must work from both + // sidecar/*.ts under tsx and dist/sidecar/*.js in packaged installs. + } + + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + throw new Error("failed to resolve @open-design/web package root"); +} + +function parsePort(value: string | undefined): number { + if (value == null || value.trim().length === 0) return 0; + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${WEB_PORT_ENV} must be an integer between 1 and 65535`); + } + return port; +} + +async function prepareNextApp(app: { prepare(): Promise }, dir: string): Promise { + const nextEnvPath = join(dir, "next-env.d.ts"); + const previousNextEnv = await readFile(nextEnvPath, "utf8").catch(() => null); + await app.prepare(); + if (previousNextEnv == null) { + await rm(nextEnvPath, { force: true }).catch(() => undefined); + return; + } + await writeFile(nextEnvPath, previousNextEnv, "utf8").catch(() => undefined); +} + +async function listen(server: Server, port: number): Promise { + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen({ host: HOST, port }, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + + const address = server.address() as AddressInfo | string | null; + if (address == null || typeof address === "string") { + throw new Error("failed to resolve Next.js server address"); + } + return address.port; +} + +async function closeHttpServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function attachParentMonitor(stop: () => Promise): void { + const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]); + if (!Number.isInteger(parentPid) || parentPid <= 0) return; + + const timer = setInterval(() => { + if (isProcessAlive(parentPid)) return; + clearInterval(timer); + void stop().finally(() => process.exit(0)); + }, 1000); + timer.unref(); +} + +export async function startWebSidecar(runtime: SidecarRuntimeContext): Promise { + const dir = resolveWebRoot(); + const app = createNextServer({ dev: runtime.mode === "dev", dir }); + await prepareNextApp(app, dir); + + const handleRequest = app.getRequestHandler(); + const httpServer = createHttpServer((request, response) => { + void handleRequest(request, response).catch((error: unknown) => { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + }); + }); + const port = await listen(httpServer, parsePort(process.env[WEB_PORT_ENV])); + const state: WebStatusSnapshot = { + pid: process.pid, + state: "running", + updatedAt: new Date().toISOString(), + url: `http://${HOST}:${port}`, + }; + let ipcServer: JsonIpcServerHandle | null = null; + let stopped = false; + let resolveStopped!: () => void; + const stoppedPromise = new Promise((resolveStop) => { + resolveStopped = resolveStop; + }); + + async function stop(): Promise { + if (stopped) return; + stopped = true; + state.state = "stopped"; + state.updatedAt = new Date().toISOString(); + await ipcServer?.close().catch(() => undefined); + await closeHttpServer(httpServer).catch(() => undefined); + await (app as unknown as { close?: () => Promise }).close?.().catch(() => undefined); + resolveStopped(); + } + + attachParentMonitor(stop); + + ipcServer = await createJsonIpcServer({ + socketPath: runtime.ipc, + handler: async (message: unknown) => { + const request = normalizeWebSidecarMessage(message); + switch (request.type) { + case SIDECAR_MESSAGES.STATUS: + return { ...state }; + case SIDECAR_MESSAGES.SHUTDOWN: + setImmediate(() => { + void stop().finally(() => process.exit(0)); + }); + return { accepted: true }; + } + }, + }); + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void stop().finally(() => process.exit(0)); + }); + } + + return { + async status() { + return { ...state }; + }, + stop, + waitUntilStopped() { + return stoppedPromise; + }, + }; +} diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index b79f0713b..36fe1893b 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -168,7 +168,7 @@ export async function streamViaDaemon({ } } -// Translate a raw `agent` SSE payload (what apps/daemon/claude-stream.js emits) +// Translate a raw `agent` SSE payload (what apps/daemon/src/claude-stream.ts emits) // into the UI's AgentEvent union. Keep this liberal — unknown types just // return null so the UI ignores them instead of rendering garbage. function translateAgentEvent(data: DaemonAgentPayload): AgentEvent | null { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9a3182991..99538331f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -35,6 +35,7 @@ "next-env.d.ts", "next.config.ts", "app/**/*", + "sidecar/**/*", "src/**/*", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", diff --git a/design-systems/README.md b/design-systems/README.md index 85265fec2..b71e405da 100644 --- a/design-systems/README.md +++ b/design-systems/README.md @@ -61,7 +61,7 @@ to the latest hashes: ```bash curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz tar -xzf /tmp/getdesign.tgz -C /tmp -node scripts/sync-design-systems.mjs # planned helper — see roadmap +node --experimental-strip-types scripts/sync-design-systems.ts ``` For now, the original importer lives at the top of the diff --git a/docs/agent-adapters.md b/docs/agent-adapters.md index 8c024c89b..8ef5c8474 100644 --- a/docs/agent-adapters.md +++ b/docs/agent-adapters.md @@ -176,7 +176,7 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin ### 5.7 GitHub Copilot CLI - Invocation: `copilot -p "" --allow-all-tools --output-format json --add-dir --add-dir `. `--allow-all-tools` is mandatory in non-interactive mode — without it the CLI blocks waiting for human approval on every tool call. Unlike Codex (where `exec` is a dedicated headless subcommand with auto-approve baked in) or Claude Code (which inherits its permission policy from `~/.claude/settings.json`), Copilot's `-p` mode always prompts unless this flag is passed explicitly. `--add-dir` (repeatable) widens the path-level sandbox so Copilot can read skill seeds and design-system specs that live outside the project cwd. -- Streaming: `--output-format json` emits JSONL with the same expressive shape as Claude Code's stream-json (`assistant.reasoning_delta`, `assistant.message_delta`, `tool.execution_start/complete`, `result`). `apps/daemon/copilot-stream.js` maps these onto the same UI events as `claude-stream.js`. +- Streaming: `--output-format json` emits JSONL with the same expressive shape as Claude Code's stream-json (`assistant.reasoning_delta`, `assistant.message_delta`, `tool.execution_start/complete`, `result`). `apps/daemon/src/copilot-stream.ts` maps these onto the same UI events as `claude-stream.ts`. - Skill loading: prompt injection only. Github Copilot's tool catalog includes a `skill` tool — native format worth reverse-engineering later. - Surgical edits: dedicated `edit` tool. - Detection assumes Copilot is already authenticated, via one of: `copilot login` (subcommand, OAuth device flow), the interactive `/login` slash command inside `copilot` with no args. @@ -222,7 +222,7 @@ The user explicitly opts in to fallback — we don't silently switch, because a First run: ``` -$ pnpm dev:all +$ pnpm tools-dev run web [od] daemon starting on :7456 [od] detecting agents… [od] ✓ claude-code v0.6.3 (auth: ok, skills dir linked) diff --git a/docs/architecture.md b/docs/architecture.md index d3733b521..9a36d538a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ OD is a web app plus a local daemon. The split means the same UI can run in thre └────────────────────────────────────────────────────┘ ``` -One `pnpm dev:all` starts both the Next.js app and the daemon. Zero config. No accounts. +One `pnpm tools-dev run web` starts both the Next.js app and the daemon. `pnpm tools-dev` adds the desktop shell. Zero config. No accounts. ### Topology B — Web on Vercel + daemon on user's machine @@ -276,7 +276,7 @@ Full schema in [`schemas/protocol.md`](schemas/protocol.md) (TODO: write). ### Local ```sh pnpm install -pnpm dev:all # starts daemon on :7456, next on :3000 +pnpm tools-dev run web # starts daemon + web foreground loop ``` When a reverse proxy sits in front of the daemon, `/api/*` includes SSE streams and must stay unbuffered. The daemon sends `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no`, and also emits SSE comment keepalives, but nginx can still break chunked streams if gzip is enabled. For nginx, set `proxy_buffering off;`, `gzip off;`, and long `proxy_read_timeout` / `proxy_send_timeout` values on the API location. Otherwise browsers can report `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)` on long generations. diff --git a/docs/roadmap.md b/docs/roadmap.md index e5bb54526..ff7847b00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,7 +83,7 @@ Phased plan from "spec-only today" to "usable MVP" to "published v1." All estima ### MVP exit criteria -1. `corepack enable && pnpm install && pnpm dev:all` works on clean macOS and Linux with Node 24. +1. `corepack enable && pnpm install && pnpm tools-dev run web` works on clean macOS and Linux with Node 24. 2. With Claude Code installed: prototype + deck generation works end-to-end. 3. Without Claude Code installed: API-fallback produces prototypes (not decks — guizang-ppt-skill needs native skill loading). 4. A user can drop a DESIGN.md into the project root and subsequent generations respect it. diff --git a/docs/spec.md b/docs/spec.md index efa810216..476d183c1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,7 +29,7 @@ Other docs: | # | Bet | [Anthropic Claude Design][cd] | [Open CoDesign][ocod] | OD | |---|---|---|---|---| -| 1 | Where the product runs | claude.ai only | Local Electron app | **Next.js web app + local daemon** — `pnpm dev:all`, Vercel web deploy, or single-process daemon serving the built web app | +| 1 | Where the product runs | claude.ai only | Local Electron app | **Next.js web app + local daemon + desktop loop** — `pnpm tools-dev`, Vercel web deploy | | 2 | Who owns the agent loop | Anthropic, closed | [Open CoDesign][ocod] itself, via [`pi-ai`][piai] | **The user's existing code agent CLI** (Claude Code, Codex, Cursor Agent, Gemini CLI, OpenCode, OpenClaw); direct Anthropic API as fallback | | 3 | What "design skills" are | Proprietary internal tools | TypeScript modules baked into the app | **File-based skills** that follow Claude Code's `SKILL.md` spec — forkable, versionable, shareable, installable by symlink | | 4 | How design systems are authored | Implicit in prompt | N/A | **`DESIGN.md` files** following the [awesome-claude-design][acd] 9-section schema | @@ -126,7 +126,7 @@ In short: Claude Design is a product; OD is a **substrate**. ## 9. Success criteria for v1 -- One developer can `git clone && corepack enable && pnpm install && pnpm dev:all`, point at their Claude Code install, and produce a prototype in under 5 minutes. +- One developer can `git clone && corepack enable && pnpm install && pnpm tools-dev run web`, point at their Claude Code install, and produce a prototype in under 5 minutes. - A third party can author a skill in a separate git repo, publish it, and have a user install it by running `od skill add ` without touching OD's source. - A design system author can write a `DESIGN.md`, point OD at it, and have the style propagate across prototype / deck / template outputs. - Deploying to Vercel with a local daemon works end-to-end (the daemon is reachable via localhost tunnel or a user-provided URL). diff --git a/e2e/package.json b/e2e/package.json index 513b87447..2f641feb8 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "test": "vitest run -c vitest.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p ../scripts/tsconfig.json --noEmit", "test:ui:clean": "node --experimental-strip-types scripts/reset-artifacts.ts", "test:ui": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts", "test:ui:headed": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index af824387e..73d5e06c5 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,15 +1,8 @@ import { defineConfig, devices } from '@playwright/test'; -import { resolveDevPorts } from '../scripts/resolve-dev-ports.ts'; -const desiredDaemonPort = Number(process.env.OD_PORT) || 17_456; -const desiredNextPort = Number(process.env.NEXT_PORT) || 17_573; -const { daemonPort, appPort: nextPort } = await resolveDevPorts({ - daemonStart: desiredDaemonPort, - appStart: desiredNextPort, - appLabel: 'next', - searchRange: 200, -}); -const baseURL = `http://127.0.0.1:${nextPort}`; +const daemonPort = Number(process.env.OD_PORT) || 17_456; +const webPort = Number(process.env.OD_WEB_PORT) || 17_573; +const baseURL = `http://127.0.0.1:${webPort}`; export default defineConfig({ testDir: './specs', @@ -43,10 +36,9 @@ export default defineConfig({ webServer: { command: `OD_DATA_DIR=e2e/.od-data ` + - `OD_PORT=${daemonPort} OD_PORT_STRICT=1 ` + - `NEXT_PORT=${nextPort} NEXT_PORT_STRICT=1 corepack pnpm --dir .. run dev:all`, + `pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`, url: baseURL, - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 120_000, }, projects: [ diff --git a/e2e/tests/structured-streams.test.ts b/e2e/tests/structured-streams.test.ts index 35c2c291a..738a84a3c 100644 --- a/e2e/tests/structured-streams.test.ts +++ b/e2e/tests/structured-streams.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createClaudeStreamHandler } from '../../apps/daemon/claude-stream.js'; -import { createCopilotStreamHandler } from '../../apps/daemon/copilot-stream.js'; +import { createClaudeStreamHandler } from '../../apps/daemon/src/claude-stream.js'; +import { createCopilotStreamHandler } from '../../apps/daemon/src/copilot-stream.js'; describe('structured agent stream fixtures', () => { it('emits TodoWrite tool_use from Claude Code stream JSON', () => { diff --git a/package.json b/package.json index 58fc32ef6..0e85b0cef 100644 --- a/package.json +++ b/package.json @@ -10,20 +10,19 @@ "od": "./apps/daemon/dist/cli.js" }, "scripts": { - "daemon": "corepack pnpm --filter @open-design/daemon daemon", - "dev": "corepack pnpm --filter @open-design/web dev", - "dev:all": "node --experimental-strip-types scripts/dev-all.ts", - "build": "corepack pnpm --filter @open-design/web build", + "postinstall": "node ./scripts/postinstall.mjs", + "tools-dev": "pnpm exec tools-dev", + "build": "pnpm --filter @open-design/web build", "check:residual-js": "node --experimental-strip-types scripts/check-residual-js.ts", - "preview": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon daemon", - "test:e2e:live": "corepack pnpm --filter @open-design/e2e test:e2e:live", - "test": "corepack pnpm --filter @open-design/web test && corepack pnpm --filter @open-design/daemon test && corepack pnpm --filter @open-design/e2e test", - "test:ui:clean": "corepack pnpm --filter @open-design/e2e test:ui:clean", - "test:ui": "corepack pnpm --filter @open-design/e2e test:ui", - "test:ui:headed": "corepack pnpm --filter @open-design/e2e test:ui:headed", - "typecheck": "corepack pnpm --filter @open-design/contracts typecheck && corepack pnpm --filter @open-design/web typecheck && corepack pnpm --filter @open-design/daemon typecheck && corepack pnpm --filter @open-design/daemon build && corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit && corepack pnpm --filter @open-design/e2e typecheck && corepack pnpm run check:residual-js", - "start": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon start", - "test:run": "corepack pnpm run test" + "test:e2e:live": "pnpm --filter @open-design/e2e test:e2e:live", + "test": "pnpm -r --workspace-concurrency=1 --if-present run test", + "test:ui:clean": "pnpm --filter @open-design/e2e test:ui:clean", + "test:ui": "pnpm --filter @open-design/e2e test:ui", + "test:ui:headed": "pnpm --filter @open-design/e2e test:ui:headed", + "typecheck": "pnpm -r --workspace-concurrency=1 --if-present run typecheck && pnpm --filter @open-design/daemon build && pnpm check:residual-js" + }, + "devDependencies": { + "@open-design/tools-dev": "workspace:0.1.0" }, "engines": { "node": "~24", @@ -31,7 +30,9 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "better-sqlite3" + "better-sqlite3", + "electron", + "esbuild" ] } } diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 000000000..0dcad7251 --- /dev/null +++ b/packages/AGENTS.md @@ -0,0 +1,34 @@ +# packages/AGENTS.md + +Follow the root `AGENTS.md` first. This file only records module-level boundaries for `packages/`. + +## Package responsibilities + +- `packages/contracts`: web/daemon app contract layer. Keep it pure TypeScript; it must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol. +- `packages/sidecar-proto`: Open Design sidecar business protocol. Owns app/mode/source constants, namespace validation, stamp descriptor/fields/flags, IPC message schema, status shapes, error semantics, and default product path constants. +- `packages/sidecar`: generic sidecar runtime primitives. Includes bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime file helpers; it must not hard-code Open Design app keys or IPC business messages. +- `packages/platform`: generic OS process primitives. Includes stamp serialization, command parsing, and process matching/search; it must consume the `sidecar-proto` descriptor and must not hard-code `--od-stamp-*` details. + +## Removed directories + +- `packages/shared` has been removed; do not restore it. +- For new shared types, choose the boundary first: web/daemon app DTOs go in `contracts`; sidecar control-plane protocol goes in `sidecar-proto`; generic runtime code goes in `sidecar`; generic OS/process code goes in `platform`. + +## Boundary checklist + +- Do not move runtime validation/schema enforcement into `contracts` prematurely; current contracts define the typed target shape only. +- Do not let app packages depend directly on sidecar control-plane details. +- Do not hard-code Open Design app/source/mode constants in `sidecar` or `platform`. +- Keep stamp fields limited to five: `app`, `mode`, `namespace`, `ipc`, and `source`. + +## Common package commands + +```bash +pnpm --filter @open-design/contracts typecheck +pnpm --filter @open-design/sidecar-proto typecheck +pnpm --filter @open-design/sidecar-proto test +pnpm --filter @open-design/sidecar typecheck +pnpm --filter @open-design/sidecar test +pnpm --filter @open-design/platform typecheck +pnpm --filter @open-design/platform test +``` diff --git a/packages/platform/esbuild.config.mjs b/packages/platform/esbuild.config.mjs new file mode 100644 index 000000000..8998b1049 --- /dev/null +++ b/packages/platform/esbuild.config.mjs @@ -0,0 +1,11 @@ +import { build } from "esbuild"; + +await build({ + bundle: true, + entryPoints: ["./src/index.ts"], + format: "esm", + outfile: "./dist/index.mjs", + packages: "external", + platform: "node", + target: "node24", +}); diff --git a/packages/platform/package.json b/packages/platform/package.json new file mode 100644 index 000000000..433b3d421 --- /dev/null +++ b/packages/platform/package.json @@ -0,0 +1,31 @@ +{ + "name": "@open-design/platform", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.27.7", + "vitest": "^2.1.8", + "typescript": "6.0.3" + }, + "engines": { + "node": "~24" + } +} diff --git a/packages/platform/src/index.test.ts b/packages/platform/src/index.test.ts new file mode 100644 index 000000000..9ceb36c10 --- /dev/null +++ b/packages/platform/src/index.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { + createProcessStampArgs, + matchesStampedProcess, + readProcessStampFromCommand, + type ProcessStampContract, +} from "./index.js"; + +type FakeStamp = { + app: "api" | "ui"; + ipc: string; + mode: "dev" | "runtime"; + namespace: string; + source: "tool" | "pack"; +}; + +const fakeContract: ProcessStampContract = { + stampFields: ["app", "mode", "namespace", "ipc", "source"], + stampFlags: { + app: "--fake-app", + ipc: "--fake-ipc", + mode: "--fake-mode", + namespace: "--fake-namespace", + source: "--fake-source", + }, + normalizeStamp(input) { + const value = input as Partial; + if (value.app !== "api" && value.app !== "ui") throw new Error("invalid app"); + if (value.mode !== "dev" && value.mode !== "runtime") throw new Error("invalid mode"); + if (typeof value.namespace !== "string" || value.namespace.length === 0) throw new Error("invalid namespace"); + if (typeof value.ipc !== "string" || value.ipc.length === 0) throw new Error("invalid ipc"); + if (value.source !== "tool" && value.source !== "pack") throw new Error("invalid source"); + return { + app: value.app, + ipc: value.ipc, + mode: value.mode, + namespace: value.namespace, + source: value.source, + }; + }, + normalizeStampCriteria(input = {}) { + const value = input as Partial; + return { + ...(value.app == null ? {} : { app: value.app }), + ...(value.ipc == null ? {} : { ipc: value.ipc }), + ...(value.mode == null ? {} : { mode: value.mode }), + ...(value.namespace == null ? {} : { namespace: value.namespace }), + ...(value.source == null ? {} : { source: value.source }), + }; + }, +}; + +const stamp: FakeStamp = { + app: "ui", + ipc: "/tmp/fake-product/ipc/stamp-boundary-a/ui.sock", + mode: "dev", + namespace: "stamp-boundary-a", + source: "tool", +}; + +describe("generic process stamp primitives", () => { + it("serializes descriptor-defined stamp flags", () => { + const args = createProcessStampArgs(stamp, fakeContract); + + expect(args).toHaveLength(5); + expect(args.join(" ")).toContain("--fake-app=ui"); + expect(args.join(" ")).toContain("--fake-mode=dev"); + expect(args.join(" ")).toContain("--fake-namespace=stamp-boundary-a"); + expect(args.join(" ")).toContain("--fake-ipc=/tmp/fake-product/ipc/stamp-boundary-a/ui.sock"); + expect(args.join(" ")).toContain("--fake-source=tool"); + }); + + it("reads and matches stamped process commands using the descriptor", () => { + const command = ["node", "ui.js", ...createProcessStampArgs(stamp, fakeContract)].join(" "); + + expect(readProcessStampFromCommand(command, fakeContract)).toEqual(stamp); + expect(matchesStampedProcess({ command }, { app: "ui", namespace: stamp.namespace, source: "tool" }, fakeContract)).toBe(true); + expect(matchesStampedProcess({ command }, { namespace: "stamp-boundary-b" }, fakeContract)).toBe(false); + expect(matchesStampedProcess({ command }, { source: "pack" }, fakeContract)).toBe(false); + }); +}); diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts new file mode 100644 index 000000000..1ed92386a --- /dev/null +++ b/packages/platform/src/index.ts @@ -0,0 +1,372 @@ +import { execFile, spawn, type ChildProcess, type StdioOptions } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; + +export type CommandInvocation = { + args: string[]; + command: string; +}; + +export type ProcessStampShape = object; + +export type ProcessStampField = Extract; + +export type ProcessStampContract< + TStamp extends ProcessStampShape, + TCriteria extends Partial = Partial, +> = { + normalizeStamp(input: unknown): TStamp; + normalizeStampCriteria(input?: unknown): TCriteria; + stampFields: readonly ProcessStampField[]; + stampFlags: { readonly [K in ProcessStampField]: string }; +}; + +export type CommandInvocationRequest = { + args?: string[]; + command: string; + env?: NodeJS.ProcessEnv; +}; + +export type SpawnProcessRequest = CommandInvocationRequest & { + cwd?: string; + detached?: boolean; + logFd?: number | null; +}; + +export type ProcessSnapshot = { + command: string; + pid: number; + ppid: number; +}; + +export type StampedProcessMatchCriteria = Partial; + +export type StopProcessesResult = { + alreadyStopped: boolean; + forcedPids: number[]; + matchedPids: number[]; + remainingPids: number[]; + stoppedPids: number[]; +}; + +export type HttpWaitOptions = { + timeoutMs?: number; +}; + +type WindowsProcessRecord = { + CommandLine?: string | null; + ParentProcessId?: number | string | null; + ProcessId?: number | string | null; +}; + +export function createProcessStampArgs( + stamp: TStamp, + contract: ProcessStampContract, +): string[] { + const normalized = contract.normalizeStamp(stamp); + return contract.stampFields.map((field) => { + const value = normalized[field]; + if (typeof value !== "string") { + throw new Error(`process stamp field ${field} must normalize to a string`); + } + return `${contract.stampFlags[field]}=${value}`; + }); +} + +function commandArgs(command: string): string[] { + return command.trim().split(/\s+/).filter((part) => part.length > 0); +} + +export function readFlagValue(args: readonly string[], flagName: string): string | null { + const inlinePrefix = `${flagName}=`; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === flagName) return args[index + 1] ?? null; + if (typeof argument === "string" && argument.startsWith(inlinePrefix)) { + return argument.slice(inlinePrefix.length); + } + } + return null; +} + +export function readProcessStamp( + args: readonly string[], + contract: ProcessStampContract, +): TStamp | null { + try { + const input = Object.fromEntries( + contract.stampFields.map((field) => [field, readFlagValue(args, contract.stampFlags[field])]), + ); + return contract.normalizeStamp(input); + } catch { + return null; + } +} + +export function readProcessStampFromCommand( + command: string, + contract: ProcessStampContract, +): TStamp | null { + return readProcessStamp(commandArgs(command), contract); +} + +export function matchesProcessStamp = Partial>( + stamp: TStamp, + criteria: TCriteria | undefined, + contract: ProcessStampContract, +): boolean { + const normalizedStamp = contract.normalizeStamp(stamp); + const normalizedCriteria = contract.normalizeStampCriteria(criteria ?? {}); + return contract.stampFields.every((field) => { + const expected = normalizedCriteria[field as keyof TCriteria]; + return expected == null || normalizedStamp[field] === expected; + }); +} + +export function matchesStampedProcess = Partial>( + processInfo: Pick, + criteria: TCriteria | undefined, + contract: ProcessStampContract, +): boolean { + const stamp = readProcessStampFromCommand(processInfo.command, contract); + return stamp != null && matchesProcessStamp(stamp, criteria, contract); +} + +function errorCode(error: unknown): string | null { + if (typeof error !== "object" || error == null || !("code" in error)) return null; + const code = (error as { code?: unknown }).code; + return code == null ? null : String(code); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function quoteWindowsCommandArg(value: string): string { + if (!/[\s"&<>|^]/.test(value)) return value; + return `"${value.replace(/"/g, '""')}"`; +} + +export function createCommandInvocation({ args = [], command, env = process.env }: CommandInvocationRequest): CommandInvocation { + if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) { + return { + args: ["/d", "/s", "/c", [command, ...args].map(quoteWindowsCommandArg).join(" ")], + command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe", + }; + } + return { args, command }; +} + +export function createPackageManagerInvocation(args: string[], env: NodeJS.ProcessEnv = process.env): CommandInvocation { + const execPath = env.npm_execpath; + if (execPath) return { args: [execPath, ...args], command: process.execPath }; + if (process.platform === "win32") { + return { + args: ["/d", "/s", "/c", ["pnpm", ...args].map(quoteWindowsCommandArg).join(" ")], + command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe", + }; + } + return { args, command: "pnpm" }; +} + +function createLoggedStdio(logFd?: number | null): StdioOptions { + return logFd == null ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd]; +} + +async function waitForChildSpawn(child: ChildProcess): Promise { + await new Promise((resolveSpawn, rejectSpawn) => { + child.once("error", rejectSpawn); + child.once("spawn", resolveSpawn); + }); +} + +export async function spawnBackgroundProcess(request: SpawnProcessRequest): Promise<{ pid: number }> { + const invocation = createCommandInvocation(request); + const child = spawn(invocation.command, invocation.args, { + cwd: request.cwd, + detached: request.detached ?? true, + env: request.env, + stdio: createLoggedStdio(request.logFd), + windowsHide: process.platform === "win32", + }); + await waitForChildSpawn(child); + if (child.pid == null) throw new Error(`failed to spawn background process: ${invocation.command}`); + child.unref(); + return { pid: child.pid }; +} + +export async function spawnLoggedProcess(request: SpawnProcessRequest): Promise { + const invocation = createCommandInvocation(request); + const child = spawn(invocation.command, invocation.args, { + cwd: request.cwd, + detached: request.detached ?? false, + env: request.env, + stdio: createLoggedStdio(request.logFd), + windowsHide: process.platform === "win32", + }); + await waitForChildSpawn(child); + if (child.pid == null) throw new Error(`failed to spawn process: ${invocation.command}`); + return child; +} + +export function isProcessAlive(pid: number | null | undefined): boolean { + if (typeof pid !== "number") return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (errorCode(error) === "ESRCH") return false; + return true; + } +} + +export async function waitForProcessExit(pid: number | null | undefined, timeoutMs = 5000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (!isProcessAlive(pid)) return true; + await sleep(100); + } + return !isProcessAlive(pid); +} + +function parsePsOutput(stdout: string): ProcessSnapshot[] { + return stdout + .split(/\r?\n/) + .map((line) => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/); + if (!match) return null; + return { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }; + }) + .filter((snapshot): snapshot is ProcessSnapshot => snapshot != null); +} + +async function listPosixProcessSnapshots(): Promise { + const stdout = await new Promise((resolveList, rejectList) => { + execFile("ps", ["-axo", "pid=,ppid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => { + if (error) rejectList(error); + else resolveList(out); + }); + }); + return parsePsOutput(stdout); +} + +async function listWindowsProcessSnapshots(): Promise { + const command = [ + "$ErrorActionPreference = 'Stop'", + "Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress", + ].join("; "); + const stdout = await new Promise((resolveList, rejectList) => { + execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, out) => { + if (error) rejectList(error); + else resolveList(out); + }); + }); + const payload = stdout.trim(); + if (!payload) return []; + const records = JSON.parse(payload) as WindowsProcessRecord | WindowsProcessRecord[]; + return (Array.isArray(records) ? records : [records]) + .map((record) => { + const pid = Number(record.ProcessId); + const ppid = Number(record.ParentProcessId); + const commandLine = record.CommandLine?.trim(); + if (!commandLine || Number.isNaN(pid) || Number.isNaN(ppid)) return null; + return { command: commandLine, pid, ppid }; + }) + .filter((snapshot): snapshot is ProcessSnapshot => snapshot != null); +} + +export async function listProcessSnapshots(): Promise { + try { + return process.platform === "win32" + ? await listWindowsProcessSnapshots() + : await listPosixProcessSnapshots(); + } catch { + return []; + } +} + +export function collectProcessTreePids( + processes: ProcessSnapshot[], + rootPids: Array, +): number[] { + const queue = [...new Set(rootPids.filter((pid): pid is number => typeof pid === "number"))]; + const visited = new Set(); + const childrenByParent = new Map(); + for (const processInfo of processes) { + const children = childrenByParent.get(processInfo.ppid) ?? []; + children.push(processInfo.pid); + childrenByParent.set(processInfo.ppid, children); + } + while (queue.length > 0) { + const pid = queue.shift(); + if (pid == null || visited.has(pid)) continue; + visited.add(pid); + for (const childPid of childrenByParent.get(pid) ?? []) { + if (!visited.has(childPid)) queue.push(childPid); + } + } + return [...visited].sort((left, right) => right - left); +} + +function signalProcesses(pids: number[], signal: NodeJS.Signals): void { + for (const pid of pids) { + try { + process.kill(pid, signal); + } catch (error) { + if (errorCode(error) !== "ESRCH") throw error; + } + } +} + +async function waitForProcessesToExit(pids: number[], timeoutMs = 5000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const remaining = pids.filter(isProcessAlive); + if (remaining.length === 0) return []; + await sleep(100); + } + return pids.filter(isProcessAlive); +} + +export async function stopProcesses(pids: Array): Promise { + const uniquePids = [...new Set(pids)] + .filter((pid): pid is number => typeof pid === "number" && pid !== process.pid) + .sort((left, right) => right - left); + if (uniquePids.length === 0) { + return { alreadyStopped: true, forcedPids: [], matchedPids: [], remainingPids: [], stoppedPids: [] }; + } + signalProcesses(uniquePids, "SIGTERM"); + const remainingAfterTerm = await waitForProcessesToExit(uniquePids); + if (remainingAfterTerm.length === 0) { + return { alreadyStopped: false, forcedPids: [], matchedPids: uniquePids, remainingPids: [], stoppedPids: uniquePids }; + } + signalProcesses(remainingAfterTerm, "SIGKILL"); + const remainingAfterKill = await waitForProcessesToExit(remainingAfterTerm); + const stoppedPids = uniquePids.filter((pid) => !remainingAfterKill.includes(pid)); + return { alreadyStopped: false, forcedPids: remainingAfterTerm, matchedPids: uniquePids, remainingPids: remainingAfterKill, stoppedPids }; +} + +export async function waitForHttpOk(url: string, { timeoutMs = 20000 }: HttpWaitOptions = {}): Promise { + const startedAt = Date.now(); + let lastError: Error | null = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url, { cache: "no-store" }); + if (response.ok) return true; + lastError = new Error(`HTTP ${response.status} from ${url}`); + } catch (error) { + lastError = new Error(errorMessage(error)); + } + await sleep(150); + } + throw new Error(`timed out waiting for ${url}${lastError ? ` (${lastError.message})` : ""}`); +} + +export async function readLogTail(filePath: string, maxLines = 80): Promise { + try { + const payload = await readFile(filePath, "utf8"); + return payload.split(/\r?\n/).filter((line) => line.length > 0).slice(-maxLines); + } catch { + return []; + } +} diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json new file mode 100644 index 000000000..61134e989 --- /dev/null +++ b/packages/platform/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sidecar-proto/esbuild.config.mjs b/packages/sidecar-proto/esbuild.config.mjs new file mode 100644 index 000000000..3f4232506 --- /dev/null +++ b/packages/sidecar-proto/esbuild.config.mjs @@ -0,0 +1,13 @@ +import { build } from "esbuild"; + +await build({ + bundle: true, + entryPoints: ["./src/index.ts"], + format: "esm", + outbase: "./src", + outdir: "./dist", + outExtension: { ".js": ".mjs" }, + packages: "external", + platform: "node", + target: "node24", +}); diff --git a/packages/sidecar-proto/package.json b/packages/sidecar-proto/package.json new file mode 100644 index 000000000..cbf5e0c32 --- /dev/null +++ b/packages/sidecar-proto/package.json @@ -0,0 +1,31 @@ +{ + "name": "@open-design/sidecar-proto", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.27.7", + "typescript": "6.0.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": "~24" + } +} diff --git a/packages/sidecar-proto/src/index.test.ts b/packages/sidecar-proto/src/index.test.ts new file mode 100644 index 000000000..1d6a5e73f --- /dev/null +++ b/packages/sidecar-proto/src/index.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + APP_KEYS, + normalizeDaemonSidecarMessage, + normalizeDesktopSidecarMessage, + normalizeNamespace, + normalizeSidecarStamp, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_MESSAGES, + SIDECAR_SOURCES, + SIDECAR_STAMP_FIELDS, + STAMP_APP_FLAG, + STAMP_IPC_FLAG, + STAMP_MODE_FLAG, + STAMP_NAMESPACE_FLAG, + STAMP_SOURCE_FLAG, +} from "./index.js"; + +const validStamp = { + app: APP_KEYS.WEB, + ipc: "/tmp/open-design/ipc/contract-check/web.sock", + mode: "dev" as const, + namespace: "contract-check", + source: SIDECAR_SOURCES.TOOLS_DEV, +}; + +describe("open-design sidecar contract", () => { + it("exports the canonical five-field stamp descriptor", () => { + expect(SIDECAR_STAMP_FIELDS).toEqual(["app", "mode", "namespace", "ipc", "source"]); + expect(OPEN_DESIGN_SIDECAR_CONTRACT.stampFlags).toEqual({ + app: STAMP_APP_FLAG, + ipc: STAMP_IPC_FLAG, + mode: STAMP_MODE_FLAG, + namespace: STAMP_NAMESPACE_FLAG, + source: STAMP_SOURCE_FLAG, + }); + }); + + it("accepts the explicit namespace contract", () => { + expect(normalizeNamespace("contract-check_1.alpha")).toBe("contract-check_1.alpha"); + }); + + it("rejects path-like or whitespace namespaces", () => { + expect(() => normalizeNamespace("../other")).toThrow(); + expect(() => normalizeNamespace(" contract-check")).toThrow(); + expect(() => normalizeNamespace("contract check")).toThrow(); + }); + + it("accepts exactly app, mode, namespace, ipc, and source", () => { + expect(normalizeSidecarStamp(validStamp)).toEqual(validStamp); + }); + + it("rejects legacy or extra stamp fields", () => { + expect(() => normalizeSidecarStamp({ ...validStamp, runtimeToken: "legacy" })).toThrow(); + expect(() => normalizeSidecarStamp({ ...validStamp, role: "web-sidecar" })).toThrow(); + }); + + it("rejects non-contract sidecar sources", () => { + expect(() => normalizeSidecarStamp({ ...validStamp, source: "custom-script" })).toThrow(); + }); + + it("validates daemon IPC messages", () => { + expect(normalizeDaemonSidecarMessage({ type: SIDECAR_MESSAGES.STATUS })).toEqual({ type: "status" }); + expect(normalizeDaemonSidecarMessage({ type: SIDECAR_MESSAGES.SHUTDOWN })).toEqual({ type: "shutdown" }); + expect(() => normalizeDaemonSidecarMessage({ input: {}, type: SIDECAR_MESSAGES.EVAL })).toThrow(); + }); + + it("validates desktop IPC message inputs", () => { + expect(normalizeDesktopSidecarMessage({ input: { expression: "location.href" }, type: SIDECAR_MESSAGES.EVAL })).toEqual({ + input: { expression: "location.href" }, + type: "eval", + }); + expect(() => normalizeDesktopSidecarMessage({ input: { expression: 42 }, type: SIDECAR_MESSAGES.EVAL })).toThrow(); + expect(() => normalizeDesktopSidecarMessage({ input: { selector: "" }, type: SIDECAR_MESSAGES.CLICK })).toThrow(); + }); +}); diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts new file mode 100644 index 000000000..05f648155 --- /dev/null +++ b/packages/sidecar-proto/src/index.ts @@ -0,0 +1,403 @@ +export const APP_KEYS = Object.freeze({ + DAEMON: "daemon", + DESKTOP: "desktop", + WEB: "web", +} as const); + +export type AppKey = (typeof APP_KEYS)[keyof typeof APP_KEYS]; + +export const SIDECAR_MODES = Object.freeze({ + DEV: "dev", + RUNTIME: "runtime", +} as const); + +export type SidecarMode = (typeof SIDECAR_MODES)[keyof typeof SIDECAR_MODES]; + +export const SIDECAR_SOURCES = Object.freeze({ + PACKAGED: "packaged", + TOOLS_DEV: "tools-dev", + TOOLS_PACK: "tools-pack", +} as const); + +export type SidecarSource = (typeof SIDECAR_SOURCES)[keyof typeof SIDECAR_SOURCES]; + +export const SIDECAR_ENV = Object.freeze({ + BASE: "OD_SIDECAR_BASE", + DAEMON_PORT: "OD_PORT", + IPC_BASE: "OD_SIDECAR_IPC_BASE", + IPC_PATH: "OD_SIDECAR_IPC_PATH", + NAMESPACE: "OD_SIDECAR_NAMESPACE", + SOURCE: "OD_SIDECAR_SOURCE", + TOOLS_DEV_PARENT_PID: "OD_TOOLS_DEV_PARENT_PID", + WEB_DIST_DIR: "OD_WEB_DIST_DIR", + WEB_PORT: "OD_WEB_PORT", + WEB_TSCONFIG_PATH: "OD_WEB_TSCONFIG_PATH", +} as const); + +export const SIDECAR_RUNTIME_ENV = Object.freeze({ + base: SIDECAR_ENV.BASE, + ipcBase: SIDECAR_ENV.IPC_BASE, + ipcPath: SIDECAR_ENV.IPC_PATH, + namespace: SIDECAR_ENV.NAMESPACE, + source: SIDECAR_ENV.SOURCE, +} as const); + +export const SIDECAR_STAMP_FLAGS = Object.freeze({ + app: "--od-stamp-app", + ipc: "--od-stamp-ipc", + mode: "--od-stamp-mode", + namespace: "--od-stamp-namespace", + source: "--od-stamp-source", +} as const); + +export const STAMP_APP_FLAG = SIDECAR_STAMP_FLAGS.app; +export const STAMP_IPC_FLAG = SIDECAR_STAMP_FLAGS.ipc; +export const STAMP_MODE_FLAG = SIDECAR_STAMP_FLAGS.mode; +export const STAMP_NAMESPACE_FLAG = SIDECAR_STAMP_FLAGS.namespace; +export const STAMP_SOURCE_FLAG = SIDECAR_STAMP_FLAGS.source; + +export const SIDECAR_STAMP_FIELDS = ["app", "mode", "namespace", "ipc", "source"] as const; + +export const SIDECAR_DEFAULTS = Object.freeze({ + host: "127.0.0.1", + ipcBase: "/tmp/open-design/ipc", + namespace: "default", + projectTmpDirName: ".tmp", + windowsPipePrefix: "open-design", +} as const); + +export const SIDECAR_MESSAGES = Object.freeze({ + CLICK: "click", + CONSOLE: "console", + EVAL: "eval", + SCREENSHOT: "screenshot", + SHUTDOWN: "shutdown", + STATUS: "status", +} as const); + +export const SIDECAR_ERROR_CODES = Object.freeze({ + INVALID_MESSAGE: "SIDECAR_INVALID_MESSAGE", + UNKNOWN_MESSAGE: "SIDECAR_UNKNOWN_MESSAGE", +} as const); + +export type SidecarErrorCode = (typeof SIDECAR_ERROR_CODES)[keyof typeof SIDECAR_ERROR_CODES]; + +export class SidecarContractError extends Error { + readonly code: SidecarErrorCode; + + constructor(code: SidecarErrorCode, message: string) { + super(message); + this.name = "SidecarContractError"; + this.code = code; + } +} + +export type ServiceRuntimeState = "idle" | "running" | "starting" | "stopped" | "unknown"; + +export type DaemonStatusSnapshot = { + pid?: number | null; + state: ServiceRuntimeState; + updatedAt?: string; + url: string | null; +}; + +export type WebStatusSnapshot = { + pid?: number | null; + state: ServiceRuntimeState; + updatedAt?: string; + url: string | null; +}; + +export type DesktopRuntimeState = "idle" | "running" | "unknown"; + +export type DesktopStatusSnapshot = { + pid?: number | null; + state: DesktopRuntimeState; + title?: string | null; + updatedAt?: string; + url?: string | null; + windowVisible?: boolean; +}; + +export type DesktopEvalInput = { + expression: string; +}; + +export type DesktopEvalResult = { + error?: string; + ok: boolean; + value?: unknown; +}; + +export type DesktopScreenshotInput = { + path: string; +}; + +export type DesktopScreenshotResult = { + path: string; +}; + +export type DesktopConsoleEntry = { + level: string; + text: string; + timestamp: string; +}; + +export type DesktopConsoleResult = { + entries: DesktopConsoleEntry[]; +}; + +export type DesktopClickInput = { + selector: string; +}; + +export type DesktopClickResult = { + clicked: boolean; + found: boolean; +}; + +export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS }; +export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN }; +export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL }; +export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: typeof SIDECAR_MESSAGES.SCREENSHOT }; +export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE }; +export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK }; + +export type DaemonSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage; +export type WebSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage; +export type DesktopSidecarMessage = + | SidecarStatusMessage + | SidecarShutdownMessage + | DesktopEvalMessage + | DesktopScreenshotMessage + | DesktopConsoleMessage + | DesktopClickMessage; + +export type ShutdownResult = { + accepted: true; +}; + +export type SidecarStamp = { + app: AppKey; + ipc: string; + mode: SidecarMode; + namespace: string; + source: SidecarSource; +}; + +export type SidecarStampInput = Partial>; +export type SidecarStampCriteria = Partial; + +export type OpenDesignSidecarContract = { + appKeys: typeof APP_KEYS; + defaults: typeof SIDECAR_DEFAULTS; + env: typeof SIDECAR_RUNTIME_ENV; + errorCodes: typeof SIDECAR_ERROR_CODES; + messages: typeof SIDECAR_MESSAGES; + modes: typeof SIDECAR_MODES; + normalizeApp: typeof normalizeAppKey; + normalizeNamespace: typeof normalizeNamespace; + normalizeSource: typeof normalizeSidecarSource; + normalizeStamp: typeof normalizeSidecarStamp; + normalizeStampCriteria: typeof normalizeSidecarStampCriteria; + sources: typeof SIDECAR_SOURCES; + stampFields: typeof SIDECAR_STAMP_FIELDS; + stampFlags: typeof SIDECAR_STAMP_FLAGS; +}; + +function assertObject(value: unknown, label: string): Record { + if (typeof value !== "object" || value == null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function assertKnownKeys(value: Record, allowed: readonly string[], label: string): void { + const allowedSet = new Set(allowed); + const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key)); + if (unexpected.length > 0) { + throw new Error(`${label} contains unsupported fields: ${unexpected.join(", ")}`); + } +} + +function normalizeNonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + if (value.length === 0) throw new Error(`${label} must not be empty`); + return value; +} + +export function normalizeNamespace(namespace: unknown): string { + if (typeof namespace !== "string") throw new Error("namespace must be a string"); + const value = namespace.trim(); + if (value.length === 0) throw new Error("namespace must not be empty"); + if (value !== namespace) throw new Error("namespace must not contain leading or trailing whitespace"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) { + throw new Error(`namespace contains unsupported characters: ${value}`); + } + if (/[\\/]/.test(value)) throw new Error(`namespace must not contain path separators: ${value}`); + return value; +} + +export function isSidecarMode(value: unknown): value is SidecarMode { + return Object.values(SIDECAR_MODES).includes(value as SidecarMode); +} + +export function normalizeSidecarMode(mode: unknown): SidecarMode { + if (!isSidecarMode(mode)) { + throw new Error("sidecar mode must be dev or runtime"); + } + return mode; +} + +export function isAppKey(value: unknown): value is AppKey { + return Object.values(APP_KEYS).includes(value as AppKey); +} + +export function normalizeAppKey(app: unknown): AppKey { + if (!isAppKey(app)) throw new Error(`unsupported sidecar app: ${String(app)}`); + return app; +} + +export function isSidecarSource(value: unknown): value is SidecarSource { + return Object.values(SIDECAR_SOURCES).includes(value as SidecarSource); +} + +export function normalizeSidecarSource(source: unknown): SidecarSource { + if (!isSidecarSource(source)) { + throw new Error(`unsupported sidecar source: ${String(source)}`); + } + return source; +} + +export function isWindowsNamedPipePath(value: unknown): boolean { + return typeof value === "string" && value.startsWith("\\\\.\\pipe\\"); +} + +export function normalizeIpcPath(ipc: unknown): string { + if (typeof ipc !== "string") throw new Error("sidecar ipc path must be a string"); + if (ipc.length === 0) throw new Error("sidecar ipc path must not be empty"); + if (ipc.trim() !== ipc) throw new Error("sidecar ipc path must not contain leading or trailing whitespace"); + if (ipc.includes("\0")) throw new Error("sidecar ipc path must not contain null bytes"); + if (isWindowsNamedPipePath(ipc)) return ipc; + if (!ipc.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(ipc)) { + throw new Error(`sidecar ipc path must be absolute: ${ipc}`); + } + return ipc; +} + +function assertKnownStampKeys(value: Record, label: string): void { + assertKnownKeys(value, SIDECAR_STAMP_FIELDS, label); +} + +export function normalizeSidecarStamp(input: unknown): SidecarStamp { + const value = assertObject(input, "sidecar stamp"); + assertKnownStampKeys(value, "sidecar stamp"); + return { + app: normalizeAppKey(value.app), + ipc: normalizeIpcPath(value.ipc), + mode: normalizeSidecarMode(value.mode), + namespace: normalizeNamespace(value.namespace), + source: normalizeSidecarSource(value.source), + }; +} + +export function normalizeSidecarStampCriteria(input: unknown = {}): SidecarStampCriteria { + const value = assertObject(input, "sidecar stamp criteria"); + assertKnownStampKeys(value, "sidecar stamp criteria"); + return { + ...(value.app == null ? {} : { app: normalizeAppKey(value.app) }), + ...(value.ipc == null ? {} : { ipc: normalizeIpcPath(value.ipc) }), + ...(value.mode == null ? {} : { mode: normalizeSidecarMode(value.mode) }), + ...(value.namespace == null ? {} : { namespace: normalizeNamespace(value.namespace) }), + ...(value.source == null ? {} : { source: normalizeSidecarSource(value.source) }), + }; +} + +export function assertSidecarStamp(input: unknown): asserts input is SidecarStamp { + normalizeSidecarStamp(input); +} + +function normalizeDesktopEvalInput(input: unknown): DesktopEvalInput { + const value = assertObject(input, "desktop eval input"); + assertKnownKeys(value, ["expression"], "desktop eval input"); + return { expression: normalizeNonEmptyString(value.expression, "desktop eval expression") }; +} + +function normalizeDesktopScreenshotInput(input: unknown): DesktopScreenshotInput { + const value = assertObject(input, "desktop screenshot input"); + assertKnownKeys(value, ["path"], "desktop screenshot input"); + return { path: normalizeNonEmptyString(value.path, "desktop screenshot path") }; +} + +function normalizeDesktopClickInput(input: unknown): DesktopClickInput { + const value = assertObject(input, "desktop click input"); + assertKnownKeys(value, ["selector"], "desktop click input"); + return { selector: normalizeNonEmptyString(value.selector, "desktop click selector") }; +} + +function normalizeMessageType(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`); + } + return value; +} + +export function normalizeDaemonSidecarMessage(input: unknown): DaemonSidecarMessage { + const value = assertObject(input, "daemon sidecar message"); + const type = normalizeMessageType(value.type, "daemon sidecar message"); + if (type === SIDECAR_MESSAGES.STATUS || type === SIDECAR_MESSAGES.SHUTDOWN) { + assertKnownKeys(value, ["type"], "daemon sidecar message"); + return { type }; + } + throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown daemon sidecar message: ${type}`); +} + +export function normalizeWebSidecarMessage(input: unknown): WebSidecarMessage { + const value = assertObject(input, "web sidecar message"); + const type = normalizeMessageType(value.type, "web sidecar message"); + if (type === SIDECAR_MESSAGES.STATUS || type === SIDECAR_MESSAGES.SHUTDOWN) { + assertKnownKeys(value, ["type"], "web sidecar message"); + return { type }; + } + throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown web sidecar message: ${type}`); +} + +export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMessage { + const value = assertObject(input, "desktop sidecar message"); + const type = normalizeMessageType(value.type, "desktop sidecar message"); + switch (type) { + case SIDECAR_MESSAGES.STATUS: + case SIDECAR_MESSAGES.SHUTDOWN: + case SIDECAR_MESSAGES.CONSOLE: + assertKnownKeys(value, ["type"], "desktop sidecar message"); + return { type }; + case SIDECAR_MESSAGES.EVAL: + assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); + return { input: normalizeDesktopEvalInput(value.input), type }; + case SIDECAR_MESSAGES.SCREENSHOT: + assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); + return { input: normalizeDesktopScreenshotInput(value.input), type }; + case SIDECAR_MESSAGES.CLICK: + assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); + return { input: normalizeDesktopClickInput(value.input), type }; + default: + throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`); + } +} + +export const OPEN_DESIGN_SIDECAR_CONTRACT = Object.freeze({ + appKeys: APP_KEYS, + defaults: SIDECAR_DEFAULTS, + env: SIDECAR_RUNTIME_ENV, + errorCodes: SIDECAR_ERROR_CODES, + messages: SIDECAR_MESSAGES, + modes: SIDECAR_MODES, + normalizeApp: normalizeAppKey, + normalizeNamespace, + normalizeSource: normalizeSidecarSource, + normalizeStamp: normalizeSidecarStamp, + normalizeStampCriteria: normalizeSidecarStampCriteria, + sources: SIDECAR_SOURCES, + stampFields: SIDECAR_STAMP_FIELDS, + stampFlags: SIDECAR_STAMP_FLAGS, +} as const satisfies OpenDesignSidecarContract); diff --git a/packages/sidecar-proto/tsconfig.json b/packages/sidecar-proto/tsconfig.json new file mode 100644 index 000000000..61134e989 --- /dev/null +++ b/packages/sidecar-proto/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sidecar/esbuild.config.mjs b/packages/sidecar/esbuild.config.mjs new file mode 100644 index 000000000..8998b1049 --- /dev/null +++ b/packages/sidecar/esbuild.config.mjs @@ -0,0 +1,11 @@ +import { build } from "esbuild"; + +await build({ + bundle: true, + entryPoints: ["./src/index.ts"], + format: "esm", + outfile: "./dist/index.mjs", + packages: "external", + platform: "node", + target: "node24", +}); diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json new file mode 100644 index 000000000..d7c437aa8 --- /dev/null +++ b/packages/sidecar/package.json @@ -0,0 +1,31 @@ +{ + "name": "@open-design/sidecar", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.27.7", + "vitest": "^2.1.8", + "typescript": "6.0.3" + }, + "engines": { + "node": "~24" + } +} diff --git a/packages/sidecar/src/index.test.ts b/packages/sidecar/src/index.test.ts new file mode 100644 index 000000000..e460a258f --- /dev/null +++ b/packages/sidecar/src/index.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { + bootstrapSidecarRuntime, + createSidecarLaunchEnv, + resolveAppIpcPath, + resolveAppRuntimePath, + resolveNamespace, + resolveNamespaceRoot, + resolveSidecarBase, + resolveSourceRuntimeRoot, + type SidecarContractDescriptor, + type SidecarStampShape, +} from "./index.js"; + +type FakeStamp = SidecarStampShape & { + app: "api" | "ui"; + mode: "dev" | "prod"; + source: "tool" | "pack"; +}; + +const fakeContract: SidecarContractDescriptor = { + defaults: { + host: "127.0.0.1", + ipcBase: "/tmp/fake-product/ipc", + namespace: "default", + projectTmpDirName: ".fake-tmp", + windowsPipePrefix: "fake-product", + }, + env: { + base: "FAKE_BASE", + ipcBase: "FAKE_IPC_BASE", + ipcPath: "FAKE_IPC_PATH", + namespace: "FAKE_NAMESPACE", + source: "FAKE_SOURCE", + }, + normalizeApp(value) { + if (value === "api" || value === "ui") return value; + throw new Error(`unsupported fake app: ${String(value)}`); + }, + normalizeNamespace(value) { + if (typeof value !== "string" || !/^[a-z0-9-]+$/.test(value)) { + throw new Error("invalid fake namespace"); + } + return value; + }, + normalizeSource(value) { + if (value === "tool" || value === "pack") return value; + throw new Error(`unsupported fake source: ${String(value)}`); + }, + normalizeStamp(value) { + const stamp = value as Partial; + return { + app: this.normalizeApp(stamp.app), + ipc: String(stamp.ipc), + mode: stamp.mode === "prod" ? "prod" : "dev", + namespace: this.normalizeNamespace(stamp.namespace), + source: this.normalizeSource(stamp.source), + }; + }, +}; + +describe("generic sidecar path boundary", () => { + it("uses descriptor defaults instead of Open Design constants", () => { + const sourceRoot = resolveSourceRuntimeRoot({ + contract: fakeContract, + projectRoot: "/repo/product", + source: "tool", + }); + + expect(sourceRoot).toBe("/repo/product/.fake-tmp/tool"); + expect(resolveNamespaceRoot({ base: sourceRoot, contract: fakeContract, namespace: "alpha" })).toBe( + "/repo/product/.fake-tmp/tool/alpha", + ); + expect( + resolveAppRuntimePath({ + app: "ui", + contract: fakeContract, + fileName: "cache", + namespaceRoot: "/repo/product/.fake-tmp/tool/alpha", + }), + ).toBe("/repo/product/.fake-tmp/tool/alpha/ui/cache"); + }); + + it("resolves descriptor-specific IPC paths", () => { + expect(resolveAppIpcPath({ app: "ui", contract: fakeContract, namespace: "alpha" })).toBe( + process.platform === "win32" ? "\\\\.\\pipe\\fake-product-alpha-ui" : "/tmp/fake-product/ipc/alpha/ui.sock", + ); + }); + + it("resolves namespace and base from descriptor env names", () => { + const env = { + FAKE_BASE: "/runtime/base", + FAKE_NAMESPACE: "selected", + }; + + expect(resolveNamespace({ contract: fakeContract, env })).toBe("selected"); + expect(resolveSidecarBase({ contract: fakeContract, env, projectRoot: "/repo/product", source: "tool" })).toBe( + "/runtime/base", + ); + }); +}); + +describe("generic sidecar bootstrap", () => { + it("creates and validates launch env from descriptor env names", () => { + const stamp: FakeStamp = { + app: "api", + ipc: "/tmp/fake-product/ipc/alpha/api.sock", + mode: "dev", + namespace: "alpha", + source: "tool", + }; + + expect(createSidecarLaunchEnv({ base: "/runtime/base", contract: fakeContract, extraEnv: {}, stamp })).toEqual({ + FAKE_BASE: "/runtime/base", + FAKE_IPC_PATH: stamp.ipc, + FAKE_NAMESPACE: stamp.namespace, + FAKE_SOURCE: stamp.source, + }); + + expect( + bootstrapSidecarRuntime(stamp, { FAKE_BASE: "/runtime/base" }, { app: "api", contract: fakeContract }), + ).toEqual({ + app: "api", + base: "/runtime/base", + ipc: stamp.ipc, + mode: "dev", + namespace: "alpha", + source: "tool", + }); + }); +}); diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts new file mode 100644 index 000000000..5316350e0 --- /dev/null +++ b/packages/sidecar/src/index.ts @@ -0,0 +1,565 @@ +import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { createConnection, createServer as createNetServer, type Server } from "node:net"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export type SidecarStampShape = { + app: string; + ipc: string; + mode: string; + namespace: string; + source: string; +}; + +export type SidecarContractDescriptor = { + defaults: { + host: string; + ipcBase: string; + namespace: string; + projectTmpDirName: string; + windowsPipePrefix: string; + }; + env: { + base: string; + ipcBase: string; + ipcPath: string; + namespace: string; + source: string; + }; + normalizeApp(app: unknown): TStamp["app"]; + normalizeNamespace(namespace: unknown): string; + normalizeSource(source: unknown): TStamp["source"]; + normalizeStamp(input: unknown): TStamp; +}; + +export type NamespaceResolutionOptions = { + contract: SidecarContractDescriptor; + env?: NodeJS.ProcessEnv; + namespace?: string | null; +}; + +export type ProjectRuntimePathRequest = { + contract: SidecarContractDescriptor; + projectRoot: string; + source: TStamp["source"] | string; +}; + +export type BaseResolutionOptions = { + base?: string | null; + contract: SidecarContractDescriptor; + env?: NodeJS.ProcessEnv; + projectRoot?: string; + source: TStamp["source"] | string; +}; + +export type RuntimePathRequest = { + base: string; + contract: SidecarContractDescriptor; + namespace: string; +}; + +export type RuntimeRootRequest = RuntimePathRequest & { + runId: string; +}; + +export type AppIpcPathRequest = { + app: TStamp["app"] | string; + contract: SidecarContractDescriptor; + env?: NodeJS.ProcessEnv; + namespace: string; +}; + +export type AppRuntimePathRequest = { + app: TStamp["app"] | string; + contract: SidecarContractDescriptor; + namespaceRoot: string; +}; + +export type SidecarRuntimeContext = { + app: TStamp["app"]; + base: string; + ipc: string; + mode: TStamp["mode"]; + namespace: string; + source: TStamp["source"]; +}; + +export type SidecarLaunchEnvRequest = { + base: string; + contract: SidecarContractDescriptor; + extraEnv?: NodeJS.ProcessEnv; + stamp: TStamp; +}; + +export type BootstrapSidecarRuntimeOptions = { + app: TStamp["app"] | string; + base?: string | null; + contract: SidecarContractDescriptor; + projectRoot?: string; +}; + +export type PortAllocation = { + port: number; + source: "dynamic" | "forced"; +}; + +export type PortRequest = { + host?: string; + label?: string; + port?: number | string | null; + reserved?: Set; +}; + +export type JsonIpcHandler = (message: any) => unknown | Promise; + +export type JsonIpcServerHandle = { + close(): Promise; +}; + +export function isWindowsNamedPipePath(value: unknown): boolean { + return typeof value === "string" && value.startsWith("\\\\.\\pipe\\"); +} + +export function normalizeIpcPath(ipc: unknown): string { + if (typeof ipc !== "string") throw new Error("sidecar ipc path must be a string"); + if (ipc.length === 0) throw new Error("sidecar ipc path must not be empty"); + if (ipc.trim() !== ipc) throw new Error("sidecar ipc path must not contain leading or trailing whitespace"); + if (ipc.includes("\0")) throw new Error("sidecar ipc path must not contain null bytes"); + if (isWindowsNamedPipePath(ipc)) return ipc; + if (!isAbsolute(ipc)) throw new Error(`sidecar ipc path must be absolute: ${ipc}`); + return ipc; +} + +export function resolveNamespace(options: NamespaceResolutionOptions): string { + return options.contract.normalizeNamespace( + options.namespace ?? + options.env?.[options.contract.env.namespace] ?? + options.contract.defaults.namespace, + ); +} + +export function resolveProjectRoot(projectRoot: string): string { + if (typeof projectRoot !== "string" || projectRoot.trim().length === 0) { + throw new Error("projectRoot must be a non-empty string"); + } + return resolve(projectRoot); +} + +export function resolveProjectTmpRoot({ + contract, + projectRoot, +}: { + contract: SidecarContractDescriptor; + projectRoot: string; +}): string { + return join(resolveProjectRoot(projectRoot), contract.defaults.projectTmpDirName); +} + +export function resolveSourceRuntimeRoot({ + contract, + projectRoot, + source, +}: ProjectRuntimePathRequest): string { + return join(resolveProjectTmpRoot({ contract, projectRoot }), contract.normalizeSource(source)); +} + +export function resolveSidecarBase({ + base, + contract, + env = process.env, + projectRoot = process.cwd(), + source, +}: BaseResolutionOptions): string { + return resolve(base ?? env[contract.env.base] ?? resolveSourceRuntimeRoot({ contract, projectRoot, source })); +} + +export function resolveNamespaceRoot({ + base, + contract, + namespace, +}: RuntimePathRequest): string { + return join(resolve(base), contract.normalizeNamespace(namespace)); +} + +export function resolveRuntimeRoot({ + base, + contract, + namespace, + runId, +}: RuntimeRootRequest): string { + return join(resolveNamespaceRoot({ base, contract, namespace }), "runs", runId); +} + +export function resolvePointerPath({ base, contract, namespace }: RuntimePathRequest): string { + return join(resolveNamespaceRoot({ base, contract, namespace }), "current.json"); +} + +export function resolveManifestPath({ runtimeRoot }: { runtimeRoot: string }): string { + return join(runtimeRoot, "manifest.json"); +} + +export function resolveLogsDir({ + app, + contract, + runtimeRoot, +}: { + app: TStamp["app"] | string; + contract: SidecarContractDescriptor; + runtimeRoot: string; +}): string { + return join(runtimeRoot, "logs", contract.normalizeApp(app)); +} + +export function resolveLogFilePath({ + app, + contract, + fileName = "latest.log", + runtimeRoot, +}: { + app: TStamp["app"] | string; + contract: SidecarContractDescriptor; + fileName?: string; + runtimeRoot: string; +}): string { + return join(resolveLogsDir({ app, contract, runtimeRoot }), fileName); +} + +export function resolveAppRuntimeDir({ + app, + contract, + namespaceRoot, +}: AppRuntimePathRequest): string { + return join(namespaceRoot, contract.normalizeApp(app)); +} + +export function resolveAppRuntimePath({ + app, + contract, + fileName, + namespaceRoot, +}: AppRuntimePathRequest & { fileName: string }): string { + if (fileName.length === 0 || fileName.includes("\0") || /[\\/]/.test(fileName)) { + throw new Error(`app runtime fileName must be a simple path segment: ${fileName}`); + } + return join(resolveAppRuntimeDir({ app, contract, namespaceRoot }), fileName); +} + +export function resolveAppIpcPath({ + app, + contract, + env = process.env, + namespace, +}: AppIpcPathRequest): string { + const normalizedApp = contract.normalizeApp(app); + const normalizedNamespace = contract.normalizeNamespace(namespace); + + if (process.platform === "win32") { + return `\\\\.\\pipe\\${contract.defaults.windowsPipePrefix}-${normalizedNamespace}-${normalizedApp}`; + } + + const ipcBase = resolve(env[contract.env.ipcBase] ?? contract.defaults.ipcBase); + return join(ipcBase, normalizedNamespace, `${normalizedApp}.sock`); +} + +export function createSidecarLaunchEnv({ + base, + contract, + extraEnv = process.env, + stamp, +}: SidecarLaunchEnvRequest): NodeJS.ProcessEnv { + const normalizedStamp = contract.normalizeStamp(stamp); + return { + ...extraEnv, + [contract.env.base]: resolveSidecarBase({ base, contract, env: extraEnv, source: normalizedStamp.source }), + [contract.env.ipcPath]: normalizedStamp.ipc, + [contract.env.namespace]: normalizedStamp.namespace, + [contract.env.source]: normalizedStamp.source, + }; +} + +function assertMatchingEnv(env: NodeJS.ProcessEnv, key: string, expected: string): void { + const current = env[key]; + if (current != null && current !== expected) { + throw new Error(`sidecar env mismatch for ${key}: expected ${expected}, received ${current}`); + } +} + +export function bootstrapSidecarRuntime( + stampInput: unknown, + env: NodeJS.ProcessEnv, + options: BootstrapSidecarRuntimeOptions, +): SidecarRuntimeContext { + const stamp = options.contract.normalizeStamp(stampInput); + const expectedApp = options.contract.normalizeApp(options.app); + if (stamp.app !== expectedApp) { + throw new Error(`sidecar stamp app mismatch: expected ${expectedApp}, received ${stamp.app}`); + } + + const base = resolveSidecarBase({ + base: options.base, + contract: options.contract, + env, + projectRoot: options.projectRoot, + source: stamp.source, + }); + const ipc = resolveAppIpcPath({ app: stamp.app, contract: options.contract, env, namespace: stamp.namespace }); + if (stamp.ipc !== ipc) { + throw new Error(`sidecar ipc path mismatch: expected ${ipc}, received ${stamp.ipc}`); + } + + assertMatchingEnv(env, options.contract.env.ipcPath, stamp.ipc); + assertMatchingEnv(env, options.contract.env.namespace, stamp.namespace); + assertMatchingEnv(env, options.contract.env.source, stamp.source); + + env[options.contract.env.ipcPath] = ipc; + env[options.contract.env.namespace] = stamp.namespace; + env[options.contract.env.source] = stamp.source; + + return { + app: stamp.app, + base, + ipc, + mode: stamp.mode, + namespace: stamp.namespace, + source: stamp.source, + }; +} + +async function closeServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); +} + +async function listenOnPort(port: number, host: string): Promise { + const server = createNetServer(); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen({ port, host, exclusive: true }, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + return server; +} + +function parsePort(value: number | string | null | undefined, label: string): number | null { + if (value == null || value === "") return null; + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${label} port must be an integer between 1 and 65535`); + } + return port; +} + +function errorCode(error: unknown): string | null { + if (typeof error !== "object" || error == null || !("code" in error)) return null; + const code = (error as { code?: unknown }).code; + return code == null ? null : String(code); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function jsonIpcError(error: unknown): { code?: string; message: string } { + return { + ...(errorCode(error) == null ? {} : { code: errorCode(error) as string }), + message: errorMessage(error), + }; +} + +async function allocateForcedPort(port: number, label: string, host: string, reserved: Set): Promise { + if (reserved.has(port)) { + throw new Error(`forced ${label} port ${port} conflicts with another managed port`); + } + let server: Server | null = null; + try { + server = await listenOnPort(port, host); + } catch (error) { + throw new Error(`forced ${label} port ${port} is not available (${errorCode(error) ?? errorMessage(error)})`); + } finally { + if (server) await closeServer(server); + } + reserved.add(port); + return { port, source: "forced" }; +} + +async function allocateDynamicPort(label: string, host: string, reserved: Set): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const server = await listenOnPort(0, host); + const address = server.address(); + await closeServer(server); + if (address == null || typeof address === "string") { + throw new Error(`failed to allocate dynamic ${label} port`); + } + if (!reserved.has(address.port)) { + reserved.add(address.port); + return { port: address.port, source: "dynamic" }; + } + } + throw new Error(`failed to allocate dynamic ${label} port without conflict`); +} + +export async function allocatePort({ + host = "127.0.0.1", + label = "runtime", + port, + reserved = new Set(), +}: PortRequest = {}): Promise { + const forcedPort = parsePort(port, label); + return forcedPort == null + ? await allocateDynamicPort(label, host, reserved) + : await allocateForcedPort(forcedPort, label, host, reserved); +} + +export async function readJsonFile(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as T; + } catch { + return null; + } +} + +export async function writeJsonFile(filePath: string, payload: unknown): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await rename(tmpPath, filePath); +} + +export async function removeFile(filePath: string): Promise { + await rm(filePath, { force: true }); +} + +export async function removePointerIfCurrent(pointerPath: string, runId: string): Promise { + const pointer = await readJsonFile<{ runId?: string }>(pointerPath); + if (pointer?.runId === runId) await removeFile(pointerPath); +} + +async function staleUnixSocketExists(socketPath: string): Promise { + try { + const stat = await lstat(socketPath); + if (!stat.isSocket()) return false; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } + + return await new Promise((resolveStale, rejectStale) => { + const socket = createConnection(socketPath); + let settled = false; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + callback(); + }; + + socket.once("connect", () => settle(() => resolveStale(false))); + socket.once("error", (error) => { + const code = errorCode(error); + if (code === "ENOENT" || code === "ECONNREFUSED") { + settle(() => resolveStale(true)); + return; + } + settle(() => rejectStale(error)); + }); + }); +} + +async function prepareIpcPath(socketPath: string): Promise { + if (isWindowsNamedPipePath(socketPath)) return; + await mkdir(dirname(socketPath), { recursive: true }); + if (await staleUnixSocketExists(socketPath)) await rm(socketPath, { force: true }); +} + +export async function createJsonIpcServer({ + handler, + socketPath, +}: { + handler: JsonIpcHandler; + socketPath: string; +}): Promise { + await prepareIpcPath(socketPath); + const server = createNetServer((socket) => { + let buffer = ""; + socket.on("data", async (chunk) => { + buffer += chunk.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex < 0) return; + const frame = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + try { + const result = await handler(JSON.parse(frame)); + socket.end(`${JSON.stringify({ ok: true, result })}\n`); + } catch (error) { + socket.end( + `${JSON.stringify({ + ok: false, + error: jsonIpcError(error), + })}\n`, + ); + } + }); + }); + + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(socketPath, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + + return { + async close() { + await closeServer(server); + if (!isWindowsNamedPipePath(socketPath)) await rm(socketPath, { force: true }); + }, + }; +} + +export async function requestJsonIpc( + socketPath: string, + payload: unknown, + { timeoutMs = 1500 }: { timeoutMs?: number } = {}, +): Promise { + return await new Promise((resolveRequest, rejectRequest) => { + const socket = createConnection(socketPath); + let settled = false; + let buffer = ""; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(); + }; + const timeout = setTimeout(() => { + socket.destroy(); + settle(() => rejectRequest(new Error(`IPC request timed out: ${socketPath}`))); + }, timeoutMs); + + socket.on("connect", () => { + socket.write(`${JSON.stringify(payload)}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex < 0) return; + socket.end(); + settle(() => { + const response = JSON.parse(buffer.slice(0, newlineIndex)) as { error?: { message?: string }; ok: boolean; result?: T }; + if (!response.ok) { + rejectRequest(new Error(response.error?.message ?? "IPC request failed")); + return; + } + resolveRequest(response.result as T); + }); + }); + socket.on("error", (error) => { + settle(() => rejectRequest(error)); + }); + }); +} diff --git a/packages/sidecar/tsconfig.json b/packages/sidecar/tsconfig.json new file mode 100644 index 000000000..61134e989 --- /dev/null +++ b/packages/sidecar/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5befa34ff..cf6ef7d01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,13 +6,26 @@ settings: importers: - .: {} + .: + devDependencies: + '@open-design/tools-dev': + specifier: workspace:0.1.0 + version: link:tools/dev apps/daemon: dependencies: '@open-design/contracts': - specifier: workspace:* + specifier: workspace:0.1.0 version: link:../../packages/contracts + '@open-design/platform': + specifier: workspace:0.1.0 + version: link:../../packages/platform + '@open-design/sidecar': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar + '@open-design/sidecar-proto': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar-proto better-sqlite3: specifier: ^11.10.0 version: 11.10.0 @@ -45,14 +58,45 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.0) + apps/desktop: + dependencies: + '@open-design/platform': + specifier: workspace:0.1.0 + version: link:../../packages/platform + '@open-design/sidecar': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar + '@open-design/sidecar-proto': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar-proto + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + electron: + specifier: 41.3.0 + version: 41.3.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + apps/web: dependencies: '@anthropic-ai/sdk': specifier: ^0.32.1 version: 0.32.1 '@open-design/contracts': - specifier: workspace:* + specifier: workspace:0.1.0 version: link:../../packages/contracts + '@open-design/platform': + specifier: workspace:0.1.0 + version: link:../../packages/platform + '@open-design/sidecar': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar + '@open-design/sidecar-proto': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar-proto next: specifier: ^16.2.4 version: 16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -115,6 +159,79 @@ importers: specifier: ^5.6.3 version: 5.9.3 + packages/platform: + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.27.7 + version: 0.27.7 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.0) + + packages/sidecar: + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.27.7 + version: 0.27.7 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.0) + + packages/sidecar-proto: + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.27.7 + version: 0.27.7 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.0) + + tools/dev: + dependencies: + '@open-design/platform': + specifier: workspace:0.1.0 + version: link:../../packages/platform + '@open-design/sidecar': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar + '@open-design/sidecar-proto': + specifier: workspace:0.1.0 + version: link:../../packages/sidecar-proto + cac: + specifier: 6.7.14 + version: 6.7.14 + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + esbuild: + specifier: 0.27.7 + version: 0.27.7 + tsx: + specifier: 4.21.0 + version: 4.21.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + packages: '@anthropic-ai/sdk@0.32.1': @@ -187,6 +304,10 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -196,138 +317,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -691,9 +968,17 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -722,6 +1007,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -734,9 +1022,15 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -752,6 +1046,9 @@ packages: '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -769,6 +1066,9 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -778,6 +1078,9 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -867,6 +1170,13 @@ packages: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -885,6 +1195,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -910,6 +1228,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -979,6 +1300,18 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -999,6 +1332,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1009,6 +1345,11 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron@41.3.0: + resolution: {integrity: sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==} + engines: {node: '>= 12.20.55'} + hasBin: true + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1020,6 +1361,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1039,14 +1384,26 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1070,6 +1427,14 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1099,6 +1464,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1120,13 +1489,38 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1143,10 +1537,17 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -1188,9 +1589,21 @@ packages: canvas: optional: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1201,6 +1614,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -1212,6 +1629,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1243,6 +1664,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -1319,6 +1744,10 @@ packages: encoding: optional: true + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1327,6 +1756,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1346,6 +1779,10 @@ packages: zod: optional: true + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -1366,6 +1803,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1400,6 +1840,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1419,6 +1863,10 @@ packages: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1454,6 +1902,19 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rollup@4.60.2: resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1475,6 +1936,13 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1484,6 +1952,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -1527,6 +1999,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1564,6 +2039,10 @@ packages: babel-plugin-macros: optional: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -1617,9 +2096,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -1632,16 +2120,28 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1763,6 +2263,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + snapshots: '@anthropic-ai/sdk@0.32.1': @@ -1835,6 +2338,20 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -1843,72 +2360,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@exodus/bytes@1.15.0': {} '@img/colour@1.1.0': @@ -2115,10 +2710,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@sindresorhus/is@4.6.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -2151,6 +2752,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 20.19.39 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 20.19.39 + '@types/responselike': 1.0.3 + '@types/connect@3.4.38': dependencies: '@types/node': 20.19.39 @@ -2171,8 +2779,14 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 1.15.10 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 20.19.39 + '@types/mime@1.3.5': {} '@types/multer@1.4.13': @@ -2192,6 +2806,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + '@types/prop-types@15.7.15': {} '@types/qs@6.15.0': {} @@ -2207,6 +2825,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 20.19.39 + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -2222,6 +2844,11 @@ snapshots: '@types/node': 20.19.39 '@types/send': 0.17.6 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.19.39 + optional: true + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -2237,6 +2864,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.39) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.12.2) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -2331,6 +2966,11 @@ snapshots: transitivePeerDependencies: - supports-color + boolean@3.2.0: + optional: true + + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -2346,6 +2986,18 @@ snapshots: cac@6.7.14: {} + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2372,6 +3024,10 @@ snapshots: client-only@0.0.1: {} + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -2427,6 +3083,22 @@ snapshots: deep-extend@0.6.0: {} + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -2437,6 +3109,9 @@ snapshots: detect-libc@2.1.2: {} + detect-node@2.1.0: + optional: true + dom-accessibility-api@0.5.16: {} dunder-proto@1.0.1: @@ -2447,6 +3122,14 @@ snapshots: ee-first@1.1.1: {} + electron@41.3.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.12.2 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -2455,6 +3138,8 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2472,6 +3157,9 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es6-error@4.1.1: + optional: true + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2498,8 +3186,40 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: + optional: true + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -2548,6 +3268,20 @@ snapshots: transitivePeerDependencies: - supports-color + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + file-uri-to-path@1.0.0: {} finalhandler@1.3.2: @@ -2583,6 +3317,12 @@ snapshots: fs-constants@1.0.0: {} + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fsevents@2.3.2: optional: true @@ -2609,10 +3349,55 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -2629,6 +3414,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2637,6 +3424,11 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -2687,6 +3479,15 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + json-buffer@3.0.1: {} + + json-stringify-safe@5.0.1: + optional: true + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -2694,6 +3495,10 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -2704,6 +3509,8 @@ snapshots: loupe@3.2.1: {} + lowercase-keys@2.0.0: {} + lru-cache@11.3.5: {} lz-string@1.5.0: {} @@ -2712,6 +3519,11 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdn-data@2.27.1: {} @@ -2730,6 +3542,8 @@ snapshots: mime@1.6.0: {} + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} minimist@1.2.8: {} @@ -2795,10 +3609,15 @@ snapshots: dependencies: whatwg-url: 5.0.0 + normalize-url@6.1.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} + object-keys@1.1.1: + optional: true + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2809,6 +3628,8 @@ snapshots: openai@6.35.0: {} + p-cancelable@2.1.1: {} + pako@1.0.11: {} parse5@8.0.1: @@ -2823,6 +3644,8 @@ snapshots: pathval@2.0.1: {} + pend@1.2.0: {} + picocolors@1.1.1: {} playwright-core@1.59.1: {} @@ -2868,6 +3691,8 @@ snapshots: process-nextick-args@2.0.1: {} + progress@2.0.3: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2888,6 +3713,8 @@ snapshots: dependencies: side-channel: 1.1.0 + quick-lru@5.1.1: {} + range-parser@1.2.1: {} raw-body@2.5.3: @@ -2934,6 +3761,24 @@ snapshots: require-from-string@2.0.2: {} + resolve-alpn@1.2.1: {} + + resolve-pkg-maps@1.0.0: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rollup@4.60.2: dependencies: '@types/estree': 1.0.8 @@ -2979,6 +3824,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + semver@7.7.4: {} send@0.19.2: @@ -2999,6 +3849,11 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -3084,6 +3939,9 @@ snapshots: source-map-js@1.2.1: {} + sprintf-js@1.1.3: + optional: true + stackback@0.0.2: {} statuses@2.0.2: {} @@ -3107,6 +3965,12 @@ snapshots: client-only: 0.0.1 react: 18.3.1 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + symbol-tree@3.2.4: {} tar-fs@2.1.4: @@ -3154,10 +4018,20 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + type-fest@0.13.1: + optional: true + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -3167,12 +4041,18 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + undici-types@5.26.5: {} undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici@7.25.0: {} + universalify@0.1.2: {} + unpipe@1.0.0: {} util-deprecate@1.0.2: {} @@ -3199,6 +4079,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@24.12.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.12.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite@5.4.21(@types/node@20.19.39): dependencies: esbuild: 0.21.5 @@ -3208,6 +4106,15 @@ snapshots: '@types/node': 20.19.39 fsevents: 2.3.3 + vite@5.4.21(@types/node@24.12.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.12 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 24.12.2 + fsevents: 2.3.3 + vitest@2.1.9(@types/node@20.19.39)(jsdom@29.1.0): dependencies: '@vitest/expect': 2.1.9 @@ -3244,6 +4151,42 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@24.12.2)(jsdom@29.1.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.12.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.12.2) + vite-node: 2.1.9(@types/node@24.12.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.2 + jsdom: 29.1.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -3281,3 +4224,8 @@ snapshots: xmlchars@2.2.0: {} xtend@4.0.2: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f5061ed6..af4537778 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: - packages/* - apps/* + - tools/* - e2e diff --git a/scripts/check-residual-js.ts b/scripts/check-residual-js.ts index 514782a9b..f0d398614 100644 --- a/scripts/check-residual-js.ts +++ b/scripts/check-residual-js.ts @@ -15,8 +15,20 @@ const skippedDirectories = new Set([ ".od-e2e", ".opencode", ".task", + ".tmp", ".vite", + "dist", "node_modules", + "out", +]); + +const allowedExactPaths = new Set([ + "packages/platform/esbuild.config.mjs", + "packages/sidecar/esbuild.config.mjs", + "packages/sidecar-proto/esbuild.config.mjs", + "scripts/postinstall.mjs", + "tools/dev/bin/tools-dev.mjs", + "tools/dev/esbuild.config.mjs", ]); const allowedPathPrefixes = [ @@ -37,9 +49,14 @@ function toRepositoryPath(filePath: string): string { } function isAllowedOutputPath(repositoryPath: string): boolean { + if (allowedExactPaths.has(repositoryPath)) return true; return allowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix)); } +function isSkippedDirectoryName(directoryName: string): boolean { + return skippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-"); +} + async function collectResidualJavaScript(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const residualFiles: string[] = []; @@ -49,7 +66,7 @@ async function collectResidualJavaScript(directory: string): Promise { const repositoryPath = toRepositoryPath(fullPath); if (entry.isDirectory()) { - if (skippedDirectories.has(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) { + if (isSkippedDirectoryName(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) { continue; } diff --git a/scripts/dev-all.ts b/scripts/dev-all.ts deleted file mode 100644 index 799564716..000000000 --- a/scripts/dev-all.ts +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -// Launcher for `pnpm run dev:all`. -// -// Probes for free ports for the daemon (OD_PORT, default 7456) and the -// Next.js dev server (NEXT_PORT, default 3000) before spawning the workspace -// apps, so a stray process holding either port doesn't kill the -// whole boot. The resolved ports are exported into the child env, which -// means: -// * the daemon's cli.ts sees the new OD_PORT and binds to it -// * apps/web/next.config.ts reads the same OD_PORT and proxies /api, /artifacts, -// /frames to the daemon's actual port -// * Next.js binds to NEXT_PORT (we pass `-p $NEXT_PORT` to the web package -// dev script so it can stay parameter-free for the common single-process -// case where the user runs just `pnpm dev`) -// -// If a port is busy we walk forward up to PORT_SEARCH_RANGE steps and log -// the switch so the user notices. - -import { spawn } from 'node:child_process'; -import { findFreePort } from './resolve-dev-ports.ts'; - -const desiredDaemon = Number(process.env.OD_PORT) || 7456; -const desiredNext = Number(process.env.NEXT_PORT) || 3000; -const strictDaemonPort = process.env.OD_PORT_STRICT === '1'; -const strictNextPort = process.env.NEXT_PORT_STRICT === '1'; - -const daemonPort = strictDaemonPort - ? desiredDaemon - : await findFreePort(desiredDaemon, 'daemon'); -const nextPort = strictNextPort - ? desiredNext - : await findFreePort(desiredNext, 'next'); - -if (daemonPort !== desiredDaemon) { - console.log( - `[dev:all] daemon port ${desiredDaemon} is busy, switching to ${daemonPort}`, - ); -} -if (nextPort !== desiredNext) { - console.log( - `[dev:all] next port ${desiredNext} is busy, switching to ${nextPort}`, - ); -} - -const env = { - ...process.env, - OD_PORT: String(daemonPort), - NEXT_PORT: String(nextPort), - PORT: String(nextPort), -}; - -const packageManager = process.platform === 'win32' ? 'corepack.cmd' : 'corepack'; - -const children = [ - spawn(packageManager, ['pnpm', '--filter', '@open-design/daemon', 'daemon'], { - env, - stdio: 'inherit', - }), - spawn(packageManager, ['pnpm', '--filter', '@open-design/web', 'dev', '-p', String(nextPort)], { - env, - stdio: 'inherit', - }), -]; - -let shuttingDown = false; - -function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { - for (const child of children) { - if (!child.killed) child.kill(signal); - } -} - -for (const child of children) { - child.on('exit', (code, signal) => { - if (shuttingDown) return; - shuttingDown = true; - stopChildren(signal || 'SIGTERM'); - if (signal) process.kill(process.pid, signal); - else process.exit(code ?? 0); - }); -} - -for (const sig of ['SIGINT', 'SIGTERM'] as const) { - process.on(sig, () => { - shuttingDown = true; - stopChildren(sig); - }); -} diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 000000000..d9f387dcc --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); + +const buildTargets = [ + "packages/sidecar-proto", + "packages/sidecar", + "packages/platform", + "tools/dev", +]; + +function resolvePackageManagerInvocation() { + const pnpmExecPath = process.env.npm_execpath; + if (pnpmExecPath != null && pnpmExecPath.length > 0) { + return { argsPrefix: [pnpmExecPath], command: process.execPath }; + } + + return { argsPrefix: [], command: process.platform === "win32" ? "pnpm.cmd" : "pnpm" }; +} + +const packageManager = resolvePackageManagerInvocation(); + +for (const target of buildTargets) { + const result = spawnSync( + packageManager.command, + [...packageManager.argsPrefix, "-C", target, "run", "build"], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + if (result.error != null) { + throw result.error; + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/scripts/resolve-dev-ports.ts b/scripts/resolve-dev-ports.ts deleted file mode 100644 index bc7ddad77..000000000 --- a/scripts/resolve-dev-ports.ts +++ /dev/null @@ -1,63 +0,0 @@ -import net from 'node:net'; - -const HOST = '127.0.0.1'; -const DEFAULT_PORT_SEARCH_RANGE = 50; - -export interface PortSearchOptions { - host?: string; - searchRange?: number; -} - -export interface ResolveDevPortsOptions extends PortSearchOptions { - daemonStart?: number; - appStart?: number; - appLabel?: string; -} - -export interface ResolvedDevPorts { - daemonPort: number; - appPort: number; -} - -export function isPortFree(port: number, host = HOST): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - server.unref(); - server.once('error', () => resolve(false)); - server.listen({ port, host, exclusive: true }, () => { - server.close(() => resolve(true)); - }); - }); -} - -export async function findFreePort( - start: number, - label: string, - { host = HOST, searchRange = DEFAULT_PORT_SEARCH_RANGE }: PortSearchOptions = {}, -): Promise { - for (let port = start; port < start + searchRange; port += 1) { - if (await isPortFree(port, host)) return port; - } - throw new Error( - `[dev:all] could not find a free ${label} port near ${start} (tried ${searchRange})`, - ); -} - -export async function resolveDevPorts({ - daemonStart = 7456, - appStart = 5173, - appLabel = 'app', - host = HOST, - searchRange = DEFAULT_PORT_SEARCH_RANGE, -}: ResolveDevPortsOptions = {}): Promise { - const daemonPort = await findFreePort(daemonStart, 'daemon', { - host, - searchRange, - }); - const appPort = await findFreePort(appStart, appLabel, { - host, - searchRange, - }); - - return { daemonPort, appPort }; -} diff --git a/specs/change/20260430-implement-maintainability-w2-w3/spec.md b/specs/change/20260430-implement-maintainability-w2-w3/spec.md index 771dcd650..ad878d03d 100644 --- a/specs/change/20260430-implement-maintainability-w2-w3/spec.md +++ b/specs/change/20260430-implement-maintainability-w2-w3/spec.md @@ -50,17 +50,17 @@ created: '2026-04-30' - Dev-mode web rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the local daemon origin; the config notes that `/api/chat` SSE streams through the rewrite. Source: `apps/web/next.config.ts:35-44` - Web-side daemon chat types live in `apps/web/src/providers/daemon.ts`: `DaemonStreamOptions` sends `agentId`, `history`, `systemPrompt`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:19-38` - The web chat client posts `/api/chat` with JSON fields `agentId`, `systemPrompt`, `message`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:57-77` -- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/server.js:868-884` +- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/src/server.ts:868-884` - Web-side `AgentEvent` currently models UI events as `status`, `text`, `thinking`, `tool_use`, `tool_result`, `usage`, and `raw`. Source: `apps/web/src/types.ts:32-39` -- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: ` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1095`, `apps/daemon/server.js:1136-1180` +- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: ` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1095`, `apps/daemon/src/server.ts:1136-1180` - The web SSE parser consumes frame separators, parses event/data fields, maps `stdout` to text, buffers `stderr`, translates `agent` payloads, handles `start`, treats `error` as terminal, and reads `end` exit code. Source: `apps/web/src/providers/daemon.ts:85-151` - Web translation accepts daemon `agent` payload types `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; unknown payloads are ignored. Source: `apps/web/src/providers/daemon.ts:178-228` -- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/json-event-stream.js:35-91` -- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/server.js:1188-1192`, `apps/daemon/server.js:1241-1250`, `apps/daemon/server.js:1262-1275`, `apps/daemon/server.js:1291-1303` -- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/server.js:200-205`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:755-763` -- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424` -- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/server.js:725-752`, `apps/daemon/server.js:776-833`, `apps/daemon/server.js:840-864` -- `apps/daemon/projects.js` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/projects.js:30-70` +- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/src/json-event-stream.ts:35-91` +- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/src/server.ts:1188-1192`, `apps/daemon/src/server.ts:1241-1250`, `apps/daemon/src/server.ts:1262-1275`, `apps/daemon/src/server.ts:1291-1303` +- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:755-763` +- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424` +- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/src/server.ts:725-752`, `apps/daemon/src/server.ts:776-833`, `apps/daemon/src/server.ts:840-864` +- `apps/daemon/src/projects.ts` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/src/projects.ts:30-70` - Web application types already include daemon-adjacent DTOs such as `AgentInfo`, `ProjectFileKind`, `ProjectFile`, `Project`, and chat attachment/message/event types in `apps/web/src/types.ts`. Source: `apps/web/src/types.ts:41-101`, `apps/web/src/types.ts:150-160` - `apps/web` has TypeScript configured with `strict`, `noUncheckedIndexedAccess`, `allowJs`, `noEmit`, and a `typecheck` script using `tsc -b --noEmit`. Source: `apps/web/tsconfig.json:2-23`, `apps/web/package.json:6-10` - `apps/daemon` is ESM, starts with `node cli.js`, tests with `vitest run -c vitest.config.ts`, and currently has no `typecheck` script. Source: `apps/daemon/package.json:1-23` @@ -84,9 +84,9 @@ created: '2026-04-30' - Runtime validation for HTTP inputs, paths, agents, models, uploads, task IDs, and command args is W4 scope, so research for W2/W3 should capture type boundaries without implementing full validation policy yet. Source: `specs/current/maintainability-roadmap.md:59` - Shared code must stay free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56` - API DTOs should prefer workspace-scoped logical or relative paths; machine absolute paths should remain daemon-internal. Source: `specs/current/architecture-boundaries.md:58-64` -- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/server.js:1087-1095` -- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180` -- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180` +- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/src/server.ts:1087-1095` +- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180` +- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180` - Daemon package devDependencies currently include `vitest` only; TypeScript and Node/Express type packages are available in web but not daemon. Source: `apps/daemon/package.json:21-23`, `apps/web/package.json:19-24` - Full-project TypeScript migration includes CommonJS/MJS operational edges such as Playwright reporter loading and Node test/script execution. Source: `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15` @@ -99,11 +99,11 @@ created: '2026-04-30' - `apps/web/src/providers/daemon.ts:85-151` - web-side SSE frame handling. - `apps/web/src/providers/daemon.ts:178-228` - web-side daemon agent event translation. - `apps/web/src/types.ts:32-39` - current UI `AgentEvent` union. -- `apps/daemon/server.js:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors. -- `apps/daemon/server.js:1035-1044` - daemon SSE frame writer. -- `apps/daemon/server.js:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle. -- `apps/daemon/server.js:1188-1303` - daemon API proxy stream request and SSE events. -- `apps/daemon/json-event-stream.js:35-91` - normalized agent JSON event output. +- `apps/daemon/src/server.ts:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors. +- `apps/daemon/src/server.ts:1035-1044` - daemon SSE frame writer. +- `apps/daemon/src/server.ts:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle. +- `apps/daemon/src/server.ts:1188-1303` - daemon API proxy stream request and SSE events. +- `apps/daemon/src/json-event-stream.ts:35-91` - normalized agent JSON event output. - `apps/daemon/package.json:9-23` - daemon scripts and dependencies. - `apps/web/tsconfig.json:2-23` - web TypeScript baseline. - `e2e/package.json:6-12` - e2e test scripts that currently execute TS config plus MJS runtime support. @@ -130,14 +130,14 @@ flowchart LR - Decision: Add a new `packages/contracts` workspace package for W2. The package exports pure TypeScript types for daemon HTTP DTOs, SSE event unions, task states, and error codes; this aligns with the shared-boundary allowed contents and the roadmap's shared-contract output. Source: `specs/current/architecture-boundaries.md:41-56`, `specs/current/maintainability-roadmap.md:57-58`, `pnpm-workspace.yaml:1-3` - Decision: Keep `apps/web/src/types.ts` as the UI/application type layer and move only daemon-facing DTOs/events/errors into `packages/contracts`. Web owns UI state and communicates with daemon through API DTOs and streaming events; current UI `AgentEvent` is a presentation union. Source: `specs/current/architecture-boundaries.md:13-27`, `apps/web/src/types.ts:32-39`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252` -- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/server.js:200-269`, `apps/daemon/server.js:725-864`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1188-1303` -- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91` -- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180` -- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180` -- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/server.js:1087-1095` +- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:725-864`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1188-1303` +- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91` +- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180` +- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180` +- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/src/server.ts:1087-1095` - Decision: W3's end state is a compiled TypeScript daemon runtime with a transitional `allowJs` phase. The daemon currently runs `node cli.js` and exposes `./cli.js` as its bin, so TypeScript entrypoint migration needs a deliberate build output and script/bin update. Source: `apps/daemon/package.json:6-13`, `package.json:9-24` - Decision: Broaden typechecking from web-only to contracts, daemon, scripts, and e2e support. Root `typecheck` currently filters only `@open-design/web`; daemon has tests but no typecheck script; e2e already uses TypeScript configs and MJS/CJS operational files. Source: `package.json:19-25`, `apps/daemon/package.json:9-23`, `apps/web/tsconfig.json:2-23`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58` -- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/json-event-stream.js:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15` +- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/src/json-event-stream.ts:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15` ### Why this design @@ -162,8 +162,8 @@ flowchart LR - Contracts: run `pnpm --filter @open-design/contracts typecheck`; add lightweight type-level coverage via exported example payloads or `tsc`-checked fixture files. Source: `specs/current/maintainability-roadmap.md:57-58` - Web adoption: run `pnpm --filter @open-design/web typecheck` and existing web tests after importing shared DTO/SSE/error types. Source: `apps/web/package.json:6-10`, `apps/web/tsconfig.json:2-23` - Daemon adoption: add and run `pnpm --filter @open-design/daemon typecheck`, then `pnpm --filter @open-design/daemon test`; daemon already uses Vitest with TypeScript config. Source: `apps/daemon/package.json:9-23`, `apps/daemon/vitest.config.ts:1-8` -- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/json-event-stream.js:35-91` -- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884` +- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/src/json-event-stream.ts:35-91` +- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884` - Runtime migration: after each TypeScript conversion batch, run daemon tests and root typecheck; after script/e2e migration, run `pnpm --filter @open-design/e2e test` and a Playwright reporter smoke run when feasible. Source: `package.json:19-25`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:22-37` ### Pseudocode @@ -213,18 +213,18 @@ Flow: ### Interfaces / APIs -- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/server.js:868-884` -- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180` -- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91` -- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/server.js:1188-1303` -- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205` -- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424`, `apps/daemon/server.js:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252` +- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/src/server.ts:868-884` +- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180` +- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91` +- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1188-1303` +- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205` +- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424`, `apps/daemon/src/server.ts:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252` ### Edge Cases - Existing SSE consumers should continue ignoring unknown `agent` payloads, so new union members can be added safely. Source: `apps/web/src/providers/daemon.ts:178-228` - Malformed or partial SSE frames should preserve current parser tolerance until W4 validation defines stricter behavior. Source: `apps/web/src/providers/daemon.ts:163-176` -- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180` +- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180` - Playwright reporter loading currently points to a `.cjs` reporter path; migration needs a compiled JS reporter path or supported TS execution path. Source: `e2e/playwright.config.ts:22-37` - The root `od` bin and daemon package bin currently point at JavaScript entrypoints; conversion to `.ts` requires a compiled output target before script/bin paths change. Source: `package.json:9-10`, `apps/daemon/package.json:6-13` - Shared contracts should stay pure and free of Next, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56` @@ -283,8 +283,8 @@ Flow: - `apps/web/package.json` and `apps/daemon/package.json` - added workspace dependencies on `@open-design/contracts` for boundary type adoption. - `apps/web/src/types.ts` - re-exported shared chat, registry, project, file, and conversation DTOs while keeping UI/config-only types local. - `apps/web/src/providers/daemon.ts` - typed `/api/chat` request construction, chat SSE frame handling, daemon agent payload translation, and unified SSE error payload reading with shared contracts. -- `apps/daemon/server.js` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers. -- `apps/daemon/server.js` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior. +- `apps/daemon/src/server.ts` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers. +- `apps/daemon/src/server.ts` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior. - `apps/web/src/providers/sse.test.ts` - added coverage for unified daemon SSE error payload handling. - `apps/daemon/sse-response.test.mjs` - added coverage for compatibility `ApiErrorResponse` construction. - `apps/daemon/tsconfig.json` - added a strict daemon TypeScript foundation with `allowJs` for the current JavaScript/MJS transition and bundler resolution for workspace contract source imports. diff --git a/specs/current/maintainability-roadmap.md b/specs/current/maintainability-roadmap.md index e97cf7031..53ee11564 100644 --- a/specs/current/maintainability-roadmap.md +++ b/specs/current/maintainability-roadmap.md @@ -35,10 +35,10 @@ The first-principles maintainability goals are: | R3 | P0 | Runtime validation is incomplete at the daemon boundary. | Daemon requests can trigger local filesystem access, SQLite writes, and `child_process.spawn()`. | Type correctness alone cannot protect against malformed runtime input, path traversal, invalid agent IDs, or unsafe args. | Add schema validation at HTTP boundaries with Zod/TypeBox; centralize validation for workspace paths, task IDs, agent IDs, models, reasoning options, uploaded files, and command arguments. | | R4 | P0 | Local capability security boundary needs explicit rules. | Daemon owns high-permission capabilities: local files, `.od`, project workspaces, agent CLIs, and logs. | Unsafe path handling, broad command execution, token leakage, and unintended workspace access become possible failure modes. | Treat daemon as a capability server: bind to localhost, use workspace/path allowlists, normalize and jail paths, allowlist agent commands, and redact sensitive output. | | R5 | P0 | Agent process lifecycle needs a first-class manager. | `/api/chat` spawns multiple agent runtimes and streams output to the frontend. | Zombie processes, cancellation gaps, orphaned tasks, inconsistent exit handling, and concurrent process conflicts. | Introduce a process/task manager with task state machine, cancellation, timeout, cleanup, exit code capture, signal handling, and concurrency limits. | -| R6 | P1 | `server.js` is too monolithic. | `apps/daemon/server.js` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. | +| R6 | P1 | `server.ts` is too monolithic. | `apps/daemon/src/server.ts` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. | | R7 | P1 | Error handling is inconsistent. | Handlers commonly use local `try/catch` and return ad hoc JSON errors. | UI receives inconsistent failures; logs lose context; task state can stall after partial failures. | Define a unified error model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`; add centralized Express error middleware and adapter-level error mapping. | | R8 | P1 | SSE protocol is under-specified. | Daemon manually writes `text/event-stream` events for agent output and status. | Frontend parsing is fragile; disconnect, heartbeat, terminal events, and error semantics can drift. | Version the SSE event contract and define canonical events such as `task.started`, `task.output`, `task.error`, `task.completed`, `task.cancelled`, and `heartbeat`. | -| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/db.js` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. | +| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/src/db.ts` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. | | R10 | P1 | Test coverage is thin around daemon behavior. | Existing daemon tests focus on stream parsing and artifact manifest behavior; HTTP/DB/spawn flows have limited coverage. | Changes are validated by manual testing; regressions in filesystem, SQLite, SSE, or agent mocks can ship. | Build layered tests: shared contract tests, route integration tests, service unit tests, SQLite migration tests, SSE parser tests, and agent mock integration tests. | | R11 | P1 | Logging and observability are insufficient for local runtime debugging. | Agent execution involves long-lived tasks, subprocess output, filesystem state, and frontend SSE consumption. | User issues are hard to reproduce; failures lack correlated context. | Add structured logs with `requestId`, `taskId`, `agentId`, `workspace`, exit code, and duration; separate app logs from agent output; redact secrets. | | R12 | P2 | Configuration, port, and health behavior can become fragile. | Web proxies `/api/*` to daemon; dev startup coordinates Next.js and daemon ports. | Port conflicts, daemon-not-ready states, and mismatched environment variables can break startup or distribution. | Centralize config resolution; expose `/health`; add daemon readiness checks; make port selection and UI fallback deterministic. | diff --git a/specs/current/runtime-adapter.md b/specs/current/runtime-adapter.md index 9aaf1450c..78d5e14c1 100644 --- a/specs/current/runtime-adapter.md +++ b/specs/current/runtime-adapter.md @@ -6,15 +6,15 @@ Runtime Adapter is the daemon layer responsible for adapting local AI agent CLIs The current implementation is concentrated in: -- `apps/daemon/agents.js`: agent definitions, detection, model lists, argument construction, model validation. -- `apps/daemon/server.js`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding. -- `apps/daemon/claude-stream.js`: parsing Claude Code structured JSONL output. -- `apps/daemon/json-event-stream.js`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent. -- `apps/daemon/acp.js`: model detection and streaming session orchestration for the ACP JSON-RPC runtime. +- `apps/daemon/src/agents.ts`: agent definitions, detection, model lists, argument construction, model validation. +- `apps/daemon/src/server.ts`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding. +- `apps/daemon/src/claude-stream.ts`: parsing Claude Code structured JSONL output. +- `apps/daemon/src/json-event-stream.ts`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent. +- `apps/daemon/src/acp.ts`: model detection and streaming session orchestration for the ACP JSON-RPC runtime. ## Currently Supported Runtimes -`AGENT_DEFS` in `apps/daemon/agents.js` defines 8 local runtimes: +`AGENT_DEFS` in `apps/daemon/src/agents.ts` defines 8 local runtimes: | id | Name | CLI | Output format | Model list source | |---|---|---|---|---| @@ -61,7 +61,7 @@ The detection result includes: ## Runtime Flow -Actual execution happens in `POST /api/chat` in `apps/daemon/server.js`. +Actual execution happens in `POST /api/chat` in `apps/daemon/src/server.ts`. Flow: @@ -102,7 +102,7 @@ These events are sent to the frontend through the SSE `agent` event. ### Codex / Gemini / OpenCode / Cursor Agent: Structured JSON Event Stream -These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `apps/daemon/json-event-stream.js`. +These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `apps/daemon/src/json-event-stream.ts`. #### Codex @@ -198,7 +198,7 @@ Kimi uses: kimi acp ``` -The daemon starts an ACP session over stdio through `apps/daemon/acp.js`: +The daemon starts an ACP session over stdio through `apps/daemon/src/acp.ts`: 1. `initialize` 2. `session/new` diff --git a/tools/AGENTS.md b/tools/AGENTS.md new file mode 100644 index 000000000..e2238b83c --- /dev/null +++ b/tools/AGENTS.md @@ -0,0 +1,32 @@ +# tools/AGENTS.md + +Follow the root `AGENTS.md` first. This file only records module-level boundaries for `tools/`. + +## Active tools + +- `tools/dev` provides `@open-design/tools-dev` and the `tools-dev` bin. It is the only currently active local development lifecycle control plane. +- `pnpm tools-dev` manages daemon -> web -> desktop. +- `pnpm tools-dev run web` runs foreground daemon + web for the Playwright webServer flow. +- `pnpm tools-dev inspect desktop ...` inspects the desktop runtime through sidecar IPC. + +## Placeholder tools + +- `tools/pack` is the minimal placeholder for the future `tools-pack` workstream. +- Do not add a package manifest, root script, packaging command, or release/signing logic under `tools/pack` in this round. +- The package/build boundary of root `pnpm build` is intentionally unchanged in this round and should be handled by the future `tools-pack` task. + +## Orchestration boundary + +- Orchestration layers must consume primitives from `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform`. +- Do not hand-build `--od-stamp-*` args, process-scan regexes, runtime tokens, process roles, or duplicate namespace/source args in `tools/dev`, future `tools/pack`, or packaged launchers. +- Port flags are authoritative inputs: `--daemon-port` and `--web-port`. Internal env vars are `OD_PORT` and `OD_WEB_PORT`; do not introduce `NEXT_PORT`. + +## Common tools commands + +```bash +pnpm --filter @open-design/tools-dev typecheck +pnpm --filter @open-design/tools-dev build +pnpm tools-dev status --json +pnpm tools-dev logs --json +pnpm tools-dev check +``` diff --git a/tools/dev/bin/tools-dev.mjs b/tools/dev/bin/tools-dev.mjs new file mode 100755 index 000000000..d79fa738c --- /dev/null +++ b/tools/dev/bin/tools-dev.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const entryDir = dirname(fileURLToPath(import.meta.url)); +const distEntry = resolve(entryDir, '../dist/index.mjs'); +const requiredDistEntries = [distEntry]; +const missingDistEntries = requiredDistEntries.filter((entry) => !existsSync(entry)); + +if (missingDistEntries.length > 0) { + throw new Error( + `tools-dev dist entries not found: ${missingDistEntries.join(', ')}. Run "pnpm --filter @open-design/tools-dev build" first.`, + ); +} + +await import(pathToFileURL(distEntry).href); diff --git a/tools/dev/esbuild.config.mjs b/tools/dev/esbuild.config.mjs new file mode 100644 index 000000000..8992ac9a5 --- /dev/null +++ b/tools/dev/esbuild.config.mjs @@ -0,0 +1,18 @@ +import { build } from "esbuild"; + +await build({ + banner: { + js: "#!/usr/bin/env node", + }, + bundle: true, + entryNames: "[name]", + entryPoints: ["./src/index.ts"], + format: "esm", + outdir: "./dist", + outExtension: { + ".js": ".mjs", + }, + packages: "external", + platform: "node", + target: "node24", +}); diff --git a/tools/dev/package.json b/tools/dev/package.json new file mode 100644 index 000000000..7e1a4c149 --- /dev/null +++ b/tools/dev/package.json @@ -0,0 +1,29 @@ +{ + "name": "@open-design/tools-dev", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "tools-dev": "./bin/tools-dev.mjs" + }, + "scripts": { + "build": "node ./esbuild.config.mjs", + "dev": "tsx ./src/index.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@open-design/platform": "workspace:0.1.0", + "@open-design/sidecar": "workspace:0.1.0", + "@open-design/sidecar-proto": "workspace:0.1.0", + "cac": "6.7.14" + }, + "devDependencies": { + "@types/node": "24.12.2", + "esbuild": "0.27.7", + "tsx": "4.21.0", + "typescript": "6.0.3" + }, + "engines": { + "node": "~24" + } +} diff --git a/tools/dev/src/config.ts b/tools/dev/src/config.ts new file mode 100644 index 000000000..59f53bc60 --- /dev/null +++ b/tools/dev/src/config.ts @@ -0,0 +1,186 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_ENV, + SIDECAR_SOURCES, +} from "@open-design/sidecar-proto"; +import { + resolveAppIpcPath, + resolveAppRuntimePath, + resolveLogFilePath, + resolveNamespace, + resolveNamespaceRoot, + resolveSidecarBase, + resolveSourceRuntimeRoot, +} from "@open-design/sidecar"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ENTRY_DIR_NAME = path.basename(__dirname); + +export const WORKSPACE_ROOT = path.resolve(__dirname, ENTRY_DIR_NAME === "dist" ? "../../.." : "../../.."); + +export const ALL_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const; +export const DEFAULT_START_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const; +export const DEFAULT_RUN_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB] as const; +export const DEFAULT_STOP_APPS = [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON] as const; + +export type ToolDevAppName = (typeof ALL_APPS)[number]; + +export type ToolDevOptions = { + daemonPort?: number | string | null; + json?: boolean; + namespace?: string; + toolsDevRoot?: string; + webPort?: number | string | null; +}; + +export type ToolDevAppConfig = { + app: ToolDevAppName; + ipcPath: string; + latestLogPath: string; + logDir: string; +}; + +export type ToolDevConfig = { + apps: { + daemon: ToolDevAppConfig & { + sidecarEntryPath: string; + }; + desktop: ToolDevAppConfig & { + electronBinaryPath: string; + mainEntryPath: string; + packageJsonPath: string; + }; + web: ToolDevAppConfig & { + nextDistDir: string; + nextTsconfigPath: string; + sidecarEntryPath: string; + }; + }; + namespace: string; + namespaceRoot: string; + toolsDevRoot: string; + tsxCliPath: string; + workspaceRoot: string; +}; + +function resolveTsxCliPath(): string { + const require = createRequire(import.meta.url); + return require.resolve("tsx/cli"); +} + +function resolveElectronBinaryPath(workspaceRoot: string): string { + const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); + const require = createRequire(packageJsonPath); + const electron = require("electron") as unknown; + if (typeof electron === "string" && electron.length > 0) return electron; + return require.resolve("electron/cli.js"); +} + +function resolveAppConfig(options: { + app: ToolDevAppName; + namespace: string; + namespaceRoot: string; + toolsDevRoot: string; +}): ToolDevAppConfig { + return { + app: options.app, + ipcPath: resolveAppIpcPath({ + app: options.app, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + namespace: options.namespace, + }), + latestLogPath: resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT }), + logDir: path.dirname(resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT })), + }; +} + +export function isToolDevAppName(value: string): value is ToolDevAppName { + return ALL_APPS.includes(value as ToolDevAppName); +} + +export function resolveTargetApps(appName: string | undefined, defaults: readonly ToolDevAppName[]): ToolDevAppName[] { + if (appName == null) return [...defaults]; + if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); + return [appName]; +} + +export function resolveStartApps(appName: string | undefined): ToolDevAppName[] { + if (appName == null) return [...DEFAULT_START_APPS]; + if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); + if (appName === APP_KEYS.WEB) return [APP_KEYS.DAEMON, APP_KEYS.WEB]; + if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP]; + return [APP_KEYS.DAEMON]; +} + +export function resolveRunApps(appName: string | undefined): ToolDevAppName[] { + if (appName == null) return [...DEFAULT_RUN_APPS]; + return resolveStartApps(appName); +} + +export function resolveStopApps(appName: string | undefined): ToolDevAppName[] { + if (appName == null) return [...DEFAULT_STOP_APPS]; + if (!isToolDevAppName(appName)) throw new Error(`unsupported tools-dev app: ${appName}`); + if (appName === APP_KEYS.WEB) return [APP_KEYS.WEB, APP_KEYS.DAEMON]; + if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DESKTOP]; + return [APP_KEYS.DAEMON]; +} + +export function parsePortOption(value: number | string | null | undefined, optionName: string): number | null { + if (value == null || value === "") return null; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`${optionName} must be an integer between 1 and 65535`); + } + return parsed; +} + +export function resolveToolDevConfig(options: ToolDevOptions = {}): ToolDevConfig { + const namespace = resolveNamespace({ namespace: options.namespace, env: process.env, contract: OPEN_DESIGN_SIDECAR_CONTRACT }); + const toolsDevRoot = resolveSidecarBase({ + base: options.toolsDevRoot ?? process.env[SIDECAR_ENV.BASE] ?? resolveSourceRuntimeRoot({ + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + projectRoot: WORKSPACE_ROOT, + source: SIDECAR_SOURCES.TOOLS_DEV, + }), + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + env: process.env, + projectRoot: WORKSPACE_ROOT, + source: SIDECAR_SOURCES.TOOLS_DEV, + }); + const namespaceRoot = resolveNamespaceRoot({ base: toolsDevRoot, namespace, contract: OPEN_DESIGN_SIDECAR_CONTRACT }); + const daemon = resolveAppConfig({ app: APP_KEYS.DAEMON, namespace, namespaceRoot, toolsDevRoot }); + const desktop = resolveAppConfig({ app: APP_KEYS.DESKTOP, namespace, namespaceRoot, toolsDevRoot }); + const web = resolveAppConfig({ app: APP_KEYS.WEB, namespace, namespaceRoot, toolsDevRoot }); + const desktopPackageJsonPath = path.join(WORKSPACE_ROOT, "apps/desktop/package.json"); + + return { + apps: { + daemon: { + ...daemon, + sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/daemon/sidecar/index.ts"), + }, + desktop: { + ...desktop, + electronBinaryPath: resolveElectronBinaryPath(WORKSPACE_ROOT), + mainEntryPath: path.join(WORKSPACE_ROOT, "apps/desktop/dist/main/index.js"), + packageJsonPath: desktopPackageJsonPath, + }, + web: { + ...web, + nextDistDir: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "next", contract: OPEN_DESIGN_SIDECAR_CONTRACT }), + nextTsconfigPath: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "tsconfig.json", contract: OPEN_DESIGN_SIDECAR_CONTRACT }), + sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/web/sidecar/index.ts"), + }, + }, + namespace, + namespaceRoot, + toolsDevRoot, + tsxCliPath: resolveTsxCliPath(), + workspaceRoot: WORKSPACE_ROOT, + }; +} diff --git a/tools/dev/src/index.ts b/tools/dev/src/index.ts new file mode 100644 index 000000000..6b94e3061 --- /dev/null +++ b/tools/dev/src/index.ts @@ -0,0 +1,711 @@ +import { spawn } from "node:child_process"; +import { mkdir, open, writeFile, type FileHandle } from "node:fs/promises"; +import path from "node:path"; + +import { cac } from "cac"; + +import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_ENV, + SIDECAR_MESSAGES, + SIDECAR_SOURCES, + type DaemonStatusSnapshot, + type DesktopClickResult, + type DesktopConsoleResult, + type DesktopEvalResult, + type DesktopScreenshotResult, + type DesktopStatusSnapshot, + type WebStatusSnapshot, +} from "@open-design/sidecar-proto"; +import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar"; +import { + collectProcessTreePids, + createPackageManagerInvocation, + createProcessStampArgs, + listProcessSnapshots, + matchesStampedProcess, + readLogTail, + spawnBackgroundProcess, + stopProcesses, + type StopProcessesResult, +} from "@open-design/platform"; + +import { + DEFAULT_START_APPS, + DEFAULT_STOP_APPS, + parsePortOption, + resolveRunApps, + resolveStartApps, + resolveStopApps, + resolveTargetApps, + resolveToolDevConfig, + type ToolDevAppName, + type ToolDevConfig, + type ToolDevOptions, +} from "./config.js"; +import { + inspectDaemonRuntime, + inspectDesktopRuntime, + inspectWebRuntime, + waitForDaemonRuntime, + waitForDesktopRuntime, + waitForWebRuntime, +} from "./sidecar-client.js"; + +type CliOptions = ToolDevOptions & { + expr?: string; + parentPid?: number; + path?: string; + selector?: string; + timeout?: string; +}; + +const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function exitWithError(error: unknown): never { + process.stderr.write(`${formatError(error)}\n`); + process.exit(1); +} + +process.on("uncaughtException", exitWithError); +process.on("unhandledRejection", exitWithError); + +function printJson(payload: unknown): void { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +function output(payload: unknown, options: CliOptions = {}): void { + if (typeof payload === "string" && options.json !== true) { + process.stdout.write(`${payload}\n`); + return; + } + printJson(payload); +} + +function runtimeLookup(config: ToolDevConfig) { + return { base: config.toolsDevRoot, namespace: config.namespace }; +} + +function appConfig(config: ToolDevConfig, appName: ToolDevAppName) { + return config.apps[appName]; +} + +function urlPort(url: string): string { + const parsed = new URL(url); + if (parsed.port) return parsed.port; + return parsed.protocol === "https:" ? "443" : "80"; +} + +function statusMatchesForcedPort(url: string | null | undefined, forcedPort: number | null): boolean { + return forcedPort == null || (url != null && urlPort(url) === String(forcedPort)); +} + +async function openAppLog(config: ToolDevConfig, appName: ToolDevAppName): Promise { + const logPath = appConfig(config, appName).latestLogPath; + await mkdir(path.dirname(logPath), { recursive: true }); + return await open(logPath, "a"); +} + +async function runLoggedCommand(request: { + args: string[]; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; + logFd: number; +}): Promise { + const child = spawn(request.command, request.args, { + cwd: request.cwd, + env: request.env, + stdio: ["ignore", request.logFd, request.logFd], + windowsHide: process.platform === "win32", + }); + + await new Promise((resolveRun, rejectRun) => { + child.once("error", rejectRun); + child.once("exit", (code, signal) => { + if (code === 0) { + resolveRun(); + return; + } + rejectRun(new Error(`command failed: ${request.command} ${request.args.join(" ")} (${signal ?? code})`)); + }); + }); +} + +function createAppStamp(config: ToolDevConfig, appName: ToolDevAppName) { + const currentAppConfig = appConfig(config, appName); + const stamp = { + app: appName, + ipc: currentAppConfig.ipcPath, + mode: "dev" as const, + namespace: config.namespace, + source: SIDECAR_SOURCES.TOOLS_DEV, + }; + + return { + args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), + env: createSidecarLaunchEnv({ + base: config.toolsDevRoot, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + stamp, + }), + stamp, + }; +} + +async function findAppProcessTree(config: ToolDevConfig, appName: ToolDevAppName) { + const processes = await listProcessSnapshots(); + const rootPids = processes + .filter((processInfo) => + matchesStampedProcess(processInfo, { + app: appName, + mode: "dev", + namespace: config.namespace, + source: SIDECAR_SOURCES.TOOLS_DEV, + }, OPEN_DESIGN_SIDECAR_CONTRACT), + ) + .map((processInfo) => processInfo.pid); + const pids = collectProcessTreePids(processes, rootPids); + + return { pids, rootPids }; +} + +async function waitForAppProcessExit(config: ToolDevConfig, appName: ToolDevAppName, timeoutMs = 5000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const current = await findAppProcessTree(config, appName); + if (current.pids.length === 0) return []; + await new Promise((resolveWait) => setTimeout(resolveWait, 120)); + } + return (await findAppProcessTree(config, appName)).pids; +} + +async function assertNoStaleActiveProcess(config: ToolDevConfig, appName: ToolDevAppName): Promise { + const active = await findAppProcessTree(config, appName); + if (active.pids.length > 0) { + throw new Error(`${appName} has active stamped processes but no reachable IPC status; run tools-dev stop ${appName} first`); + } +} + +async function spawnSidecarRuntime(request: { + appName: typeof APP_KEYS.DAEMON | typeof APP_KEYS.WEB; + config: ToolDevConfig; + env: NodeJS.ProcessEnv; + logHandle: FileHandle; +}): Promise<{ pid: number }> { + const { args: stampArgs, env } = createAppStamp(request.config, request.appName); + const sidecarConfig = request.config.apps[request.appName]; + const spawned = await spawnBackgroundProcess({ + args: [request.config.tsxCliPath, sidecarConfig.sidecarEntryPath, ...stampArgs], + command: process.execPath, + cwd: request.config.workspaceRoot, + detached: true, + env: { + ...process.env, + ...env, + ...request.env, + }, + logFd: request.logHandle.fd, + }); + return { pid: spawned.pid }; +} + +async function spawnDaemonRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> { + const daemonPort = parsePortOption(options.daemonPort, "--daemon-port"); + const logHandle = await openAppLog(config, APP_KEYS.DAEMON); + + try { + await logHandle.write(`\n[tools-dev] launching daemon at ${new Date().toISOString()}\n`); + return await spawnSidecarRuntime({ + appName: APP_KEYS.DAEMON, + config, + env: { + [SIDECAR_ENV.DAEMON_PORT]: String(daemonPort ?? 0), + ...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }), + }, + logHandle, + }); + } finally { + await logHandle.close(); + } +} + +async function spawnWebRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> { + const daemonStatus = await waitForDaemonRuntime(runtimeLookup(config)); + if (daemonStatus.url == null) throw new Error("daemon must be running before web starts"); + + const webPort = parsePortOption(options.webPort, "--web-port"); + const daemonPort = urlPort(daemonStatus.url); + const logHandle = await openAppLog(config, APP_KEYS.WEB); + + try { + await writeWebDevTsconfig(config); + await logHandle.write(`\n[tools-dev] launching web at ${new Date().toISOString()}\n`); + await logHandle.write(`[tools-dev] proxying web API requests to daemon port ${daemonPort}\n`); + return await spawnSidecarRuntime({ + appName: APP_KEYS.WEB, + config, + env: { + [SIDECAR_ENV.DAEMON_PORT]: daemonPort, + [SIDECAR_ENV.WEB_DIST_DIR]: config.apps.web.nextDistDir, + [SIDECAR_ENV.WEB_TSCONFIG_PATH]: config.apps.web.nextTsconfigPath, + [SIDECAR_ENV.WEB_PORT]: String(webPort ?? 0), + PORT: String(webPort ?? 0), + ...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }), + }, + logHandle, + }); + } finally { + await logHandle.close(); + } +} + +async function buildDesktop(config: ToolDevConfig, logHandle: FileHandle): Promise { + await logHandle.write(`\n[tools-dev] building @open-design/desktop at ${new Date().toISOString()}\n`); + const invocation = createPackageManagerInvocation(["--filter", "@open-design/desktop", "build"], process.env); + await runLoggedCommand({ + args: invocation.args, + command: invocation.command, + cwd: config.workspaceRoot, + env: process.env, + logFd: logHandle.fd, + }); +} + +async function writeWebDevTsconfig(config: ToolDevConfig): Promise { + const webRoot = path.join(config.workspaceRoot, "apps/web"); + const tsconfigPath = config.apps.web.nextTsconfigPath; + const tsconfigDir = path.dirname(tsconfigPath); + const sourceTsconfig = path.join(webRoot, "tsconfig.json"); + const relativeSourceTsconfig = path.relative(tsconfigDir, sourceTsconfig) || "./tsconfig.json"; + + await mkdir(tsconfigDir, { recursive: true }); + await writeFile( + tsconfigPath, + `${JSON.stringify({ + extends: relativeSourceTsconfig, + compilerOptions: { + plugins: [{ name: "next" }], + }, + }, null, 2)}\n`, + "utf8", + ); +} + +async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> { + const { args: stampArgs, env } = createAppStamp(config, APP_KEYS.DESKTOP); + const logHandle = await openAppLog(config, APP_KEYS.DESKTOP); + + try { + await buildDesktop(config, logHandle); + await logHandle.write(`[tools-dev] launching desktop at ${new Date().toISOString()}\n`); + const spawned = await spawnBackgroundProcess({ + args: [config.apps.desktop.mainEntryPath, ...stampArgs], + command: config.apps.desktop.electronBinaryPath, + cwd: config.workspaceRoot, + detached: true, + env: { + ...process.env, + ...env, + ...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }), + }, + logFd: logHandle.fd, + }); + return { pid: spawned.pid }; + } finally { + await logHandle.close(); + } +} + +async function startDaemon(config: ToolDevConfig, options: CliOptions) { + const daemonPort = parsePortOption(options.daemonPort, "--daemon-port"); + const existing = await inspectDaemonRuntime(runtimeLookup(config)); + if (existing?.url != null && statusMatchesForcedPort(existing.url, daemonPort)) { + return { app: APP_KEYS.DAEMON, created: false, logPath: config.apps.daemon.latestLogPath, status: existing }; + } + if (existing?.url != null) { + throw new Error(`${APP_KEYS.DAEMON} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`); + } + await assertNoStaleActiveProcess(config, APP_KEYS.DAEMON); + + const spawned = await spawnDaemonRuntime(config, options); + try { + const status = await waitForDaemonRuntime(runtimeLookup(config)); + return { + app: APP_KEYS.DAEMON, + created: true, + logPath: config.apps.daemon.latestLogPath, + pid: spawned.pid, + status, + }; + } catch (error) { + await stopApp(config, APP_KEYS.DAEMON).catch(() => undefined); + throw error; + } +} + +async function startWeb(config: ToolDevConfig, options: CliOptions) { + const webPort = parsePortOption(options.webPort, "--web-port"); + const existing = await inspectWebRuntime(runtimeLookup(config)); + if (existing?.url != null && statusMatchesForcedPort(existing.url, webPort)) { + return { app: APP_KEYS.WEB, created: false, logPath: config.apps.web.latestLogPath, status: existing }; + } + if (existing?.url != null) { + throw new Error(`${APP_KEYS.WEB} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`); + } + await assertNoStaleActiveProcess(config, APP_KEYS.WEB); + + const spawned = await spawnWebRuntime(config, options); + try { + const status = await waitForWebRuntime(runtimeLookup(config)); + return { + app: APP_KEYS.WEB, + created: true, + logPath: config.apps.web.latestLogPath, + pid: spawned.pid, + status, + }; + } catch (error) { + await stopApp(config, APP_KEYS.WEB).catch(() => undefined); + throw error; + } +} + +async function startDesktop(config: ToolDevConfig, options: CliOptions) { + const existing = await inspectDesktopRuntime(runtimeLookup(config)); + if (existing != null) { + return { app: APP_KEYS.DESKTOP, created: false, logPath: config.apps.desktop.latestLogPath, status: existing }; + } + await assertNoStaleActiveProcess(config, APP_KEYS.DESKTOP); + + const spawned = await spawnDesktopRuntime(config, options); + try { + const status = await waitForDesktopRuntime(runtimeLookup(config)); + return { + app: APP_KEYS.DESKTOP, + created: true, + logPath: config.apps.desktop.latestLogPath, + pid: spawned.pid, + status, + }; + } catch (error) { + await stopApp(config, APP_KEYS.DESKTOP).catch(() => undefined); + throw error; + } +} + +async function startApp(config: ToolDevConfig, appName: ToolDevAppName, options: CliOptions) { + switch (appName) { + case APP_KEYS.DAEMON: + return await startDaemon(config, options); + case APP_KEYS.WEB: + return await startWeb(config, options); + case APP_KEYS.DESKTOP: + return await startDesktop(config, options); + } +} + +async function requestAppShutdown(config: ToolDevConfig, appName: ToolDevAppName): Promise { + try { + await requestJsonIpc(appConfig(config, appName).ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); + return true; + } catch { + return false; + } +} + +function stoppedByGracefulResult(matchedPids: number[]): StopProcessesResult { + return { + alreadyStopped: matchedPids.length === 0, + forcedPids: [], + matchedPids, + remainingPids: [], + stoppedPids: matchedPids, + }; +} + +async function stopApp(config: ToolDevConfig, appName: ToolDevAppName) { + const before = await findAppProcessTree(config, appName); + const gracefulRequested = await requestAppShutdown(config, appName); + const remainingAfterGraceful = gracefulRequested + ? await waitForAppProcessExit(config, appName) + : before.pids; + + if (remainingAfterGraceful.length === 0) { + return { + app: appName, + status: before.pids.length === 0 ? "not-running" : "stopped", + stop: stoppedByGracefulResult(before.pids), + via: gracefulRequested ? "ipc" : "process-scan", + }; + } + + const stop = await stopProcesses(remainingAfterGraceful); + return { + app: appName, + status: stop.remainingPids.length === 0 ? "stopped" : "partial", + stop, + via: gracefulRequested ? "ipc+fallback" : "fallback", + }; +} + +async function inspectAppStatus(config: ToolDevConfig, appName: ToolDevAppName) { + if (appName === APP_KEYS.DAEMON) { + const status = await inspectDaemonRuntime(runtimeLookup(config)); + if (status != null) return status; + const active = await findAppProcessTree(config, appName); + return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies DaemonStatusSnapshot; + } + if (appName === APP_KEYS.WEB) { + const status = await inspectWebRuntime(runtimeLookup(config)); + if (status != null) return status; + const active = await findAppProcessTree(config, appName); + return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies WebStatusSnapshot; + } + + const status = await inspectDesktopRuntime(runtimeLookup(config)); + if (status != null) return status; + const active = await findAppProcessTree(config, appName); + return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "unknown" : "idle", url: null }; +} + +function summarizeStatus(apps: Record): string { + const states = Object.values(apps).map((entry) => entry?.state); + if (states.every((state) => state === "idle")) return "not-running"; + if (states.every((state) => state === "running")) return "running"; + return "partial"; +} + +async function status(config: ToolDevConfig, appName: string | undefined) { + const targets = resolveTargetApps(appName, DEFAULT_START_APPS); + if (targets.length === 1) return await inspectAppStatus(config, targets[0]); + + const apps = Object.fromEntries( + await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)), + ) as Record; + return { apps, namespace: config.namespace, status: summarizeStatus(apps) }; +} + +async function restartTargets(config: ToolDevConfig, appName: string | undefined, options: CliOptions) { + const stopTargets = resolveStopApps(appName); + const startTargets = resolveStartApps(appName); + return { + stop: await runSequential(stopTargets, (target) => stopApp(config, target)), + start: await runSequential(startTargets, (target) => startApp(config, target, options)), + }; +} + +async function readLogs(config: ToolDevConfig, appName: ToolDevAppName) { + const logPath = appConfig(config, appName).latestLogPath; + return { app: appName, lines: await readLogTail(logPath, 200), logPath }; +} + +type LogResult = Awaited>; + +function isLogResult(value: LogResult | Record): value is LogResult { + return Array.isArray((value as LogResult).lines); +} + +function printLogs(result: LogResult | Record, options: CliOptions) { + if (options.json === true) { + printJson(result); + return; + } + + const entries: Array<[string, LogResult]> = isLogResult(result) ? [[result.app, result]] : Object.entries(result); + for (const [appName, entry] of entries) { + process.stdout.write(`[${appName}] ${entry.logPath}\n`); + process.stdout.write(entry.lines.length > 0 ? `${entry.lines.join("\n")}\n` : "(no log lines)\n"); + } +} + +function parseTimeoutMs(value: string | undefined): number | undefined { + if (value == null) return undefined; + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--timeout must be a positive number of seconds"); + return seconds * 1000; +} + +async function inspectDesktop(config: ToolDevConfig, target: string | undefined, options: CliOptions) { + const operation = target ?? "status"; + const timeoutMs = parseTimeoutMs(options.timeout) ?? 30000; + + switch (operation) { + case "status": + return (await inspectDesktopRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle" } satisfies DesktopStatusSnapshot); + case "eval": + if (options.expr == null) throw new Error("--expr is required for desktop eval"); + return await requestJsonIpc( + config.apps.desktop.ipcPath, + { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, + { timeoutMs }, + ); + case "screenshot": + if (options.path == null) throw new Error("--path is required for desktop screenshot"); + return await requestJsonIpc( + config.apps.desktop.ipcPath, + { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, + { timeoutMs }, + ); + case "console": + return await requestJsonIpc(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs }); + case "click": + if (options.selector == null) throw new Error("--selector is required for desktop click"); + return await requestJsonIpc( + config.apps.desktop.ipcPath, + { input: { selector: options.selector }, type: SIDECAR_MESSAGES.CLICK }, + { timeoutMs }, + ); + default: + throw new Error(`unsupported desktop inspect target: ${operation}`); + } +} + +async function inspect(config: ToolDevConfig, appName: string, target: string | undefined, options: CliOptions) { + if (appName === APP_KEYS.DAEMON) { + if (target != null && target !== "status") throw new Error(`unsupported daemon inspect target: ${target}`); + return (await inspectDaemonRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies DaemonStatusSnapshot); + } + if (appName === APP_KEYS.WEB) { + if (target != null && target !== "status") throw new Error(`unsupported web inspect target: ${target}`); + return (await inspectWebRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies WebStatusSnapshot); + } + if (appName !== APP_KEYS.DESKTOP) throw new Error(`unsupported tools-dev app: ${appName}`); + return await inspectDesktop(config, target, options); +} + +async function runSequential(targets: readonly ToolDevAppName[], operation: (target: ToolDevAppName) => Promise) { + const result: Partial> = {}; + for (const target of targets) result[target] = await operation(target); + return result; +} + +function stopOrderFor(targets: readonly ToolDevAppName[]): ToolDevAppName[] { + const selected = new Set(targets); + return DEFAULT_STOP_APPS.filter((target) => selected.has(target)); +} + +async function runForeground(config: ToolDevConfig, appName: string | undefined, options: CliOptions) { + const targets = resolveRunApps(appName); + const foregroundOptions = { ...options, parentPid: process.pid }; + const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions)); + output({ mode: "foreground", started }, options); + + let shuttingDown = false; + const keepAlive = setInterval(() => undefined, 60_000); + await new Promise((resolveDone) => { + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + clearInterval(keepAlive); + void runSequential(stopOrderFor(targets), (target) => stopApp(config, target)).finally(resolveDone); + }; + for (const sig of ["SIGINT", "SIGTERM"] as const) { + process.on(sig, shutdown); + } + }); +} + +const cli = cac("tools-dev"); + +function addSharedOptions(command: ReturnType) { + return command + .option("--namespace ", "runtime namespace (default: default)") + .option("--tools-dev-root ", "tools-dev runtime root") + .option("--json", "print JSON"); +} + +function addPortOptions(command: ReturnType) { + return command + .option("--daemon-port ", "force daemon port; conflict quick-fails") + .option("--web-port ", "force web port; conflict quick-fails"); +} + +addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, desktop, or all when app is omitted"))).action( + async (appName: string | undefined, options: CliOptions) => { + const config = resolveToolDevConfig(options); + const targets = resolveStartApps(appName); + const result = await runSequential(targets, (target) => startApp(config, target, options)); + output(result, options); + }, +); + +addPortOptions(addSharedOptions(cli.command("run [app]", "Start apps and keep this command alive until interrupted"))).action( + async (appName: string | undefined, options: CliOptions) => { + await runForeground(resolveToolDevConfig(options), appName, options); + }, +); + +addSharedOptions(cli.command("status [app]", "Show app status for daemon, web, desktop, or all")).action( + async (appName: string | undefined, options: CliOptions) => { + output(await status(resolveToolDevConfig(options), appName), options); + }, +); + +addSharedOptions(cli.command("stop [app]", "Stop daemon, web, desktop, or all when app is omitted")).action( + async (appName: string | undefined, options: CliOptions) => { + const config = resolveToolDevConfig(options); + const targets = resolveStopApps(appName); + const result = await runSequential(targets, (target) => stopApp(config, target)); + output(result, options); + }, +); + +addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action( + async (appName: string | undefined, options: CliOptions) => { + output(await restartTargets(resolveToolDevConfig(options), appName, options), options); + }, +); + +addSharedOptions(cli.command("logs [app]", "Show log tail for daemon, web, desktop, or all")).action( + async (appName: string | undefined, options: CliOptions) => { + const config = resolveToolDevConfig(options); + const targets = resolveTargetApps(appName, DEFAULT_START_APPS); + const result = targets.length === 1 + ? await readLogs(config, targets[0]) + : Object.fromEntries(await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const))); + printLogs(result, options); + }, +); + +addSharedOptions( + cli.command("inspect [target]", "Inspect daemon/web status or desktop status/eval/screenshot/console/click"), +) + .option("--expr ", "JavaScript expression for desktop eval") + .option("--path ", "Output path for desktop screenshot") + .option("--selector ", "CSS selector for desktop click") + .option("--timeout ", "Desktop inspect timeout in seconds") + .action(async (appName: string, target: string | undefined, options: CliOptions) => { + output(await inspect(resolveToolDevConfig(options), appName, target, options), options); + }); + +addSharedOptions(cli.command("check [app]", "Print status and recent logs for quick diagnostics")).action( + async (appName: string | undefined, options: CliOptions) => { + const config = resolveToolDevConfig(options); + const targets = resolveTargetApps(appName, DEFAULT_START_APPS); + const apps = Object.fromEntries( + await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)), + ); + const logs = Object.fromEntries( + await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)), + ); + output({ apps, logs, namespace: config.namespace }, options); + }, +); + +cli.help(); + +const rawCliArgs = process.argv.slice(2); +const cliArgs = rawCliArgs[0] === "--" ? rawCliArgs.slice(1) : rawCliArgs; +process.argv.splice(2, process.argv.length - 2, ...cliArgs); + +if (cliArgs.length === 0 || (cliArgs[0]?.startsWith("-") && cliArgs[0] !== "--help" && cliArgs[0] !== "-h")) { + process.argv.splice(2, 0, "start"); +} + +cli.parse(); diff --git a/tools/dev/src/sidecar-client.ts b/tools/dev/src/sidecar-client.ts new file mode 100644 index 000000000..cc2ee718e --- /dev/null +++ b/tools/dev/src/sidecar-client.ts @@ -0,0 +1,80 @@ +import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_MESSAGES, + type DaemonStatusSnapshot, + type DesktopStatusSnapshot, + type WebStatusSnapshot, +} from "@open-design/sidecar-proto"; +import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; + +export type AppRuntimeLookup = { + base: string; + namespace: string; +}; + +export function resolveDaemonIpcPath(runtime: AppRuntimeLookup): string { + return resolveAppIpcPath({ app: APP_KEYS.DAEMON, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); +} + +export function resolveWebIpcPath(runtime: AppRuntimeLookup): string { + return resolveAppIpcPath({ app: APP_KEYS.WEB, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); +} + +export function resolveDesktopIpcPath(runtime: AppRuntimeLookup): string { + return resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace }); +} + +export async function inspectDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { + try { + return await requestJsonIpc(resolveDaemonIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + } catch { + return null; + } +} + +export async function waitForDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const snapshot = await inspectDaemonRuntime(runtime, 800); + if (snapshot?.url != null) return snapshot; + await new Promise((resolveWait) => setTimeout(resolveWait, 150)); + } + throw new Error("daemon did not expose status in time"); +} + +export async function inspectWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { + try { + return await requestJsonIpc(resolveWebIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + } catch { + return null; + } +} + +export async function waitForWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const snapshot = await inspectWebRuntime(runtime, 800); + if (snapshot?.url != null) return snapshot; + await new Promise((resolveWait) => setTimeout(resolveWait, 150)); + } + throw new Error("web did not expose status in time"); +} + +export async function inspectDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise { + try { + return await requestJsonIpc(resolveDesktopIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs }); + } catch { + return null; + } +} + +export async function waitForDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 15000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const snapshot = await inspectDesktopRuntime(runtime, 800); + if (snapshot != null) return snapshot; + await new Promise((resolveWait) => setTimeout(resolveWait, 150)); + } + throw new Error("desktop did not expose status in time"); +} diff --git a/tools/dev/tsconfig.json b/tools/dev/tsconfig.json new file mode 100644 index 000000000..621b9afff --- /dev/null +++ b/tools/dev/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2024", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": false, + "target": "ES2024", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "esbuild.config.mjs"] +} diff --git a/tools/pack/README.md b/tools/pack/README.md new file mode 100644 index 000000000..5b0bcfc40 --- /dev/null +++ b/tools/pack/README.md @@ -0,0 +1,5 @@ +# tools/pack + +Minimal placeholder for the future `tools-pack` workstream. + +No active packaging command, release logic, package manifest, or root script lives here yet.