mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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.
This commit is contained in:
parent
56d08b8c5f
commit
c6d11018a0
93 changed files with 5160 additions and 363 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,6 +2,8 @@ node_modules/
|
|||
dist/
|
||||
out/
|
||||
.next/
|
||||
.next-*/
|
||||
.tmp/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
|
|
|
|||
150
AGENTS.md
150
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/<id>/`, 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/<id>/`.
|
||||
- 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/<app>/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 `<project-root>/.tmp/<source>/<namespace>/...`; POSIX IPC sockets are fixed at `/tmp/open-design/ipc/<namespace>/<app>.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 <port> --web-port <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 <name> --json` and confirm log paths are under `.tmp/tools-dev/<namespace>/...`.
|
||||
|
||||
# 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/<id>/`, 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.
|
||||
|
|
|
|||
|
|
@ -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/<your-skill>/`](skills/) | one folder, ~2 files |
|
||||
| Make OD speak a new brand's visual language | a **Design System** | [`design-systems/<brand>/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.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|---|---|---|---|
|
||||
| 让 OD 渲染一种新的 artifact(一份发票、一个 iOS 设置页、一张 one-pager……) | 一个 **Skill** | [`skills/<your-skill>/`](skills/) | 一个文件夹,约 2 个文件 |
|
||||
| 让 OD 说一种新品牌的视觉语言 | 一套 **Design System** | [`design-systems/<brand>/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 秒就能定位。
|
||||
|
|
|
|||
|
|
@ -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 `<artifact>` 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/<timestamp>-<slug>/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 <artifact> 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/<id>/ # 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 `<artifact>`. 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`.
|
||||
|
|
|
|||
20
README.ko.md
20
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에 렌더링되는 단일 `<artifact>`를 내보냅니다.
|
||||
|
||||
|
|
@ -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/
|
|||
|
||||
</details>
|
||||
|
||||
라이브러리는 [`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 <prompt> --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/<brand>/`](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)).
|
||||
|
||||
|
|
|
|||
20
README.md
20
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 `<artifact>` 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/
|
|||
|
||||
</details>
|
||||
|
||||
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 <prompt> --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/<brand>/`](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)).
|
||||
|
||||
|
|
|
|||
|
|
@ -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** 读取它们,对自己的输出跑一轮**五维评审**,几秒后吐出一个 `<artifact>`,渲染在沙盒 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/
|
|||
|
||||
</details>
|
||||
|
||||
整个库通过 [`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 <prompt> --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/<brand>/`](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))。
|
||||
|
||||
|
|
|
|||
38
apps/AGENTS.md
Normal file
38
apps/AGENTS.md
Normal file
|
|
@ -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/<app>/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
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
24
apps/daemon/sidecar/index.ts
Normal file
24
apps/daemon/sidecar/index.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
129
apps/daemon/sidecar/server.ts
Normal file
129
apps/daemon/sidecar/server.ts
Normal file
|
|
@ -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<DaemonStatusSnapshot>;
|
||||
stop(): Promise<void>;
|
||||
waitUntilStopped(): Promise<void>;
|
||||
};
|
||||
|
||||
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<void> {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((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>): 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<SidecarStamp>): Promise<DaemonSidecarHandle> {
|
||||
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<void>((resolveStop) => {
|
||||
resolveStopped = resolveStop;
|
||||
});
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -845,7 +845,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
|
|||
// Project files. Each project owns a flat folder under .od/projects/<id>/
|
||||
// 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);
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 = [];
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
15
apps/daemon/tsconfig.sidecar.json
Normal file
15
apps/daemon/tsconfig.sidecar.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"sidecar/**/*.ts",
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
16
apps/daemon/tsconfig.tests.json
Normal file
16
apps/daemon/tsconfig.tests.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx",
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
|
@ -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}'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
33
apps/desktop/package.json
Normal file
33
apps/desktop/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
154
apps/desktop/src/main/index.ts
Normal file
154
apps/desktop/src/main/index.ts
Normal file
|
|
@ -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<void>;
|
||||
discoverWebUrl?: () => Promise<string | null>;
|
||||
};
|
||||
|
||||
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>): 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<SidecarStamp>): () => Promise<string | null> {
|
||||
return async () => {
|
||||
const webIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.WEB,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: runtime.namespace,
|
||||
});
|
||||
const web = await requestJsonIpc<WebStatusSnapshot>(webIpc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 600 }).catch(() => null);
|
||||
return web?.url ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDesktopMain(
|
||||
runtime: SidecarRuntimeContext<SidecarStamp>,
|
||||
options: DesktopMainOptions = {},
|
||||
): Promise<void> {
|
||||
await app.whenReady();
|
||||
|
||||
const desktop = await createDesktopRuntime({
|
||||
discoverUrl: options.discoverWebUrl ?? createWebDiscovery(runtime),
|
||||
});
|
||||
let ipcServer: JsonIpcServerHandle | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
231
apps/desktop/src/main/runtime.ts
Normal file
231
apps/desktop/src/main/runtime.ts
Normal file
|
|
@ -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<void>;
|
||||
click(input: DesktopClickInput): Promise<DesktopClickResult>;
|
||||
console(): DesktopConsoleResult;
|
||||
eval(input: DesktopEvalInput): Promise<DesktopEvalResult>;
|
||||
screenshot(input: DesktopScreenshotInput): Promise<DesktopScreenshotResult>;
|
||||
status(): DesktopStatusSnapshot;
|
||||
};
|
||||
|
||||
export type DesktopRuntimeOptions = {
|
||||
discoverUrl(): Promise<string | null>;
|
||||
};
|
||||
|
||||
function createPendingHtml(): string {
|
||||
return `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Open Design</title>
|
||||
<style>
|
||||
body {
|
||||
align-items: center;
|
||||
background: #05070d;
|
||||
color: #f7f7fb;
|
||||
display: flex;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
}
|
||||
p { color: #aeb7d5; margin: 12px 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Open Design</h1>
|
||||
<p>Waiting for the web runtime URL…</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`)}`;
|
||||
}
|
||||
|
||||
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<DesktopRuntime> {
|
||||
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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
21
apps/desktop/tsconfig.json
Normal file
21
apps/desktop/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
5
apps/packaged/README.md
Normal file
5
apps/packaged/README.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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/<id> directly.
|
||||
export function generateStaticParams() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
apps/web/sidecar/index.ts
Normal file
24
apps/web/sidecar/index.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
192
apps/web/sidecar/server.ts
Normal file
192
apps/web/sidecar/server.ts
Normal file
|
|
@ -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<void>;
|
||||
getRequestHandler(): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
||||
prepare(): Promise<void>;
|
||||
};
|
||||
|
||||
export type WebSidecarHandle = {
|
||||
status(): Promise<WebStatusSnapshot>;
|
||||
stop(): Promise<void>;
|
||||
waitUntilStopped(): Promise<void>;
|
||||
};
|
||||
|
||||
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<void> }, dir: string): Promise<void> {
|
||||
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<number> {
|
||||
await new Promise<void>((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<void> {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((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>): 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<SidecarStamp>): Promise<WebSidecarHandle> {
|
||||
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<void>((resolveStop) => {
|
||||
resolveStopped = resolveStop;
|
||||
});
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
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<void> }).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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"app/**/*",
|
||||
"sidecar/**/*",
|
||||
"src/**/*",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ The adapter declares which strategy to use via `capabilities().nativeSkillLoadin
|
|||
### 5.7 GitHub Copilot CLI
|
||||
|
||||
- Invocation: `copilot -p "<prompt>" --allow-all-tools --output-format json --add-dir <skills> --add-dir <design-systems>`. `--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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <git-url>` 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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
29
package.json
29
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
packages/AGENTS.md
Normal file
34
packages/AGENTS.md
Normal file
|
|
@ -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
|
||||
```
|
||||
11
packages/platform/esbuild.config.mjs
Normal file
11
packages/platform/esbuild.config.mjs
Normal file
|
|
@ -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",
|
||||
});
|
||||
31
packages/platform/package.json
Normal file
31
packages/platform/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
82
packages/platform/src/index.test.ts
Normal file
82
packages/platform/src/index.test.ts
Normal file
|
|
@ -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<FakeStamp> = {
|
||||
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<FakeStamp>;
|
||||
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<FakeStamp>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
372
packages/platform/src/index.ts
Normal file
372
packages/platform/src/index.ts
Normal file
|
|
@ -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<TStamp extends ProcessStampShape> = Extract<keyof TStamp, string>;
|
||||
|
||||
export type ProcessStampContract<
|
||||
TStamp extends ProcessStampShape,
|
||||
TCriteria extends Partial<TStamp> = Partial<TStamp>,
|
||||
> = {
|
||||
normalizeStamp(input: unknown): TStamp;
|
||||
normalizeStampCriteria(input?: unknown): TCriteria;
|
||||
stampFields: readonly ProcessStampField<TStamp>[];
|
||||
stampFlags: { readonly [K in ProcessStampField<TStamp>]: 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<TStamp extends ProcessStampShape> = Partial<TStamp>;
|
||||
|
||||
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<TStamp extends ProcessStampShape>(
|
||||
stamp: TStamp,
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): 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<TStamp extends ProcessStampShape>(
|
||||
args: readonly string[],
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): 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<TStamp extends ProcessStampShape>(
|
||||
command: string,
|
||||
contract: ProcessStampContract<TStamp>,
|
||||
): TStamp | null {
|
||||
return readProcessStamp(commandArgs(command), contract);
|
||||
}
|
||||
|
||||
export function matchesProcessStamp<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
|
||||
stamp: TStamp,
|
||||
criteria: TCriteria | undefined,
|
||||
contract: ProcessStampContract<TStamp, TCriteria>,
|
||||
): 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<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>>(
|
||||
processInfo: Pick<ProcessSnapshot, "command">,
|
||||
criteria: TCriteria | undefined,
|
||||
contract: ProcessStampContract<TStamp, TCriteria>,
|
||||
): 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<void> {
|
||||
await new Promise<void>((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<ChildProcess> {
|
||||
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<boolean> {
|
||||
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<ProcessSnapshot[]> {
|
||||
const stdout = await new Promise<string>((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<ProcessSnapshot[]> {
|
||||
const command = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine | ConvertTo-Json -Compress",
|
||||
].join("; ");
|
||||
const stdout = await new Promise<string>((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<ProcessSnapshot[]> {
|
||||
try {
|
||||
return process.platform === "win32"
|
||||
? await listWindowsProcessSnapshots()
|
||||
: await listPosixProcessSnapshots();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function collectProcessTreePids(
|
||||
processes: ProcessSnapshot[],
|
||||
rootPids: Array<number | null | undefined>,
|
||||
): number[] {
|
||||
const queue = [...new Set(rootPids.filter((pid): pid is number => typeof pid === "number"))];
|
||||
const visited = new Set<number>();
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
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<number[]> {
|
||||
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<number | null | undefined>): Promise<StopProcessesResult> {
|
||||
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<true> {
|
||||
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<string[]> {
|
||||
try {
|
||||
const payload = await readFile(filePath, "utf8");
|
||||
return payload.split(/\r?\n/).filter((line) => line.length > 0).slice(-maxLines);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
21
packages/platform/tsconfig.json
Normal file
21
packages/platform/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
13
packages/sidecar-proto/esbuild.config.mjs
Normal file
13
packages/sidecar-proto/esbuild.config.mjs
Normal file
|
|
@ -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",
|
||||
});
|
||||
31
packages/sidecar-proto/package.json
Normal file
31
packages/sidecar-proto/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
77
packages/sidecar-proto/src/index.test.ts
Normal file
77
packages/sidecar-proto/src/index.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
403
packages/sidecar-proto/src/index.ts
Normal file
403
packages/sidecar-proto/src/index.ts
Normal file
|
|
@ -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<Record<(typeof SIDECAR_STAMP_FIELDS)[number], unknown>>;
|
||||
export type SidecarStampCriteria = Partial<SidecarStamp>;
|
||||
|
||||
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<string, unknown> {
|
||||
if (typeof value !== "object" || value == null || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertKnownKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
|
||||
const allowedSet = new Set<string>(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<string, unknown>, 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);
|
||||
21
packages/sidecar-proto/tsconfig.json
Normal file
21
packages/sidecar-proto/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
11
packages/sidecar/esbuild.config.mjs
Normal file
11
packages/sidecar/esbuild.config.mjs
Normal file
|
|
@ -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",
|
||||
});
|
||||
31
packages/sidecar/package.json
Normal file
31
packages/sidecar/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
132
packages/sidecar/src/index.test.ts
Normal file
132
packages/sidecar/src/index.test.ts
Normal file
|
|
@ -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<FakeStamp> = {
|
||||
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<FakeStamp>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
565
packages/sidecar/src/index.ts
Normal file
565
packages/sidecar/src/index.ts
Normal file
|
|
@ -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<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
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<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
namespace?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectRuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
projectRoot: string;
|
||||
source: TStamp["source"] | string;
|
||||
};
|
||||
|
||||
export type BaseResolutionOptions<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
base?: string | null;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
projectRoot?: string;
|
||||
source: TStamp["source"] | string;
|
||||
};
|
||||
|
||||
export type RuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
base: string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
export type RuntimeRootRequest<TStamp extends SidecarStampShape = SidecarStampShape> = RuntimePathRequest<TStamp> & {
|
||||
runId: string;
|
||||
};
|
||||
|
||||
export type AppIpcPathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
app: TStamp["app"] | string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
export type AppRuntimePathRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
app: TStamp["app"] | string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
namespaceRoot: string;
|
||||
};
|
||||
|
||||
export type SidecarRuntimeContext<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
app: TStamp["app"];
|
||||
base: string;
|
||||
ipc: string;
|
||||
mode: TStamp["mode"];
|
||||
namespace: string;
|
||||
source: TStamp["source"];
|
||||
};
|
||||
|
||||
export type SidecarLaunchEnvRequest<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
base: string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
extraEnv?: NodeJS.ProcessEnv;
|
||||
stamp: TStamp;
|
||||
};
|
||||
|
||||
export type BootstrapSidecarRuntimeOptions<TStamp extends SidecarStampShape = SidecarStampShape> = {
|
||||
app: TStamp["app"] | string;
|
||||
base?: string | null;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
projectRoot?: string;
|
||||
};
|
||||
|
||||
export type PortAllocation = {
|
||||
port: number;
|
||||
source: "dynamic" | "forced";
|
||||
};
|
||||
|
||||
export type PortRequest = {
|
||||
host?: string;
|
||||
label?: string;
|
||||
port?: number | string | null;
|
||||
reserved?: Set<number>;
|
||||
};
|
||||
|
||||
export type JsonIpcHandler = (message: any) => unknown | Promise<unknown>;
|
||||
|
||||
export type JsonIpcServerHandle = {
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
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<TStamp extends SidecarStampShape>(options: NamespaceResolutionOptions<TStamp>): 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<TStamp extends SidecarStampShape>({
|
||||
contract,
|
||||
projectRoot,
|
||||
}: {
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
projectRoot: string;
|
||||
}): string {
|
||||
return join(resolveProjectRoot(projectRoot), contract.defaults.projectTmpDirName);
|
||||
}
|
||||
|
||||
export function resolveSourceRuntimeRoot<TStamp extends SidecarStampShape>({
|
||||
contract,
|
||||
projectRoot,
|
||||
source,
|
||||
}: ProjectRuntimePathRequest<TStamp>): string {
|
||||
return join(resolveProjectTmpRoot({ contract, projectRoot }), contract.normalizeSource(source));
|
||||
}
|
||||
|
||||
export function resolveSidecarBase<TStamp extends SidecarStampShape>({
|
||||
base,
|
||||
contract,
|
||||
env = process.env,
|
||||
projectRoot = process.cwd(),
|
||||
source,
|
||||
}: BaseResolutionOptions<TStamp>): string {
|
||||
return resolve(base ?? env[contract.env.base] ?? resolveSourceRuntimeRoot({ contract, projectRoot, source }));
|
||||
}
|
||||
|
||||
export function resolveNamespaceRoot<TStamp extends SidecarStampShape>({
|
||||
base,
|
||||
contract,
|
||||
namespace,
|
||||
}: RuntimePathRequest<TStamp>): string {
|
||||
return join(resolve(base), contract.normalizeNamespace(namespace));
|
||||
}
|
||||
|
||||
export function resolveRuntimeRoot<TStamp extends SidecarStampShape>({
|
||||
base,
|
||||
contract,
|
||||
namespace,
|
||||
runId,
|
||||
}: RuntimeRootRequest<TStamp>): string {
|
||||
return join(resolveNamespaceRoot({ base, contract, namespace }), "runs", runId);
|
||||
}
|
||||
|
||||
export function resolvePointerPath<TStamp extends SidecarStampShape>({ base, contract, namespace }: RuntimePathRequest<TStamp>): string {
|
||||
return join(resolveNamespaceRoot({ base, contract, namespace }), "current.json");
|
||||
}
|
||||
|
||||
export function resolveManifestPath({ runtimeRoot }: { runtimeRoot: string }): string {
|
||||
return join(runtimeRoot, "manifest.json");
|
||||
}
|
||||
|
||||
export function resolveLogsDir<TStamp extends SidecarStampShape>({
|
||||
app,
|
||||
contract,
|
||||
runtimeRoot,
|
||||
}: {
|
||||
app: TStamp["app"] | string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
runtimeRoot: string;
|
||||
}): string {
|
||||
return join(runtimeRoot, "logs", contract.normalizeApp(app));
|
||||
}
|
||||
|
||||
export function resolveLogFilePath<TStamp extends SidecarStampShape>({
|
||||
app,
|
||||
contract,
|
||||
fileName = "latest.log",
|
||||
runtimeRoot,
|
||||
}: {
|
||||
app: TStamp["app"] | string;
|
||||
contract: SidecarContractDescriptor<TStamp>;
|
||||
fileName?: string;
|
||||
runtimeRoot: string;
|
||||
}): string {
|
||||
return join(resolveLogsDir({ app, contract, runtimeRoot }), fileName);
|
||||
}
|
||||
|
||||
export function resolveAppRuntimeDir<TStamp extends SidecarStampShape>({
|
||||
app,
|
||||
contract,
|
||||
namespaceRoot,
|
||||
}: AppRuntimePathRequest<TStamp>): string {
|
||||
return join(namespaceRoot, contract.normalizeApp(app));
|
||||
}
|
||||
|
||||
export function resolveAppRuntimePath<TStamp extends SidecarStampShape>({
|
||||
app,
|
||||
contract,
|
||||
fileName,
|
||||
namespaceRoot,
|
||||
}: AppRuntimePathRequest<TStamp> & { 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<TStamp extends SidecarStampShape>({
|
||||
app,
|
||||
contract,
|
||||
env = process.env,
|
||||
namespace,
|
||||
}: AppIpcPathRequest<TStamp>): 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<TStamp extends SidecarStampShape>({
|
||||
base,
|
||||
contract,
|
||||
extraEnv = process.env,
|
||||
stamp,
|
||||
}: SidecarLaunchEnvRequest<TStamp>): 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<TStamp extends SidecarStampShape>(
|
||||
stampInput: unknown,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: BootstrapSidecarRuntimeOptions<TStamp>,
|
||||
): SidecarRuntimeContext<TStamp> {
|
||||
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<void> {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
|
||||
});
|
||||
}
|
||||
|
||||
async function listenOnPort(port: number, host: string): Promise<Server> {
|
||||
const server = createNetServer();
|
||||
await new Promise<void>((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<number>): Promise<PortAllocation> {
|
||||
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<number>): Promise<PortAllocation> {
|
||||
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<number>(),
|
||||
}: PortRequest = {}): Promise<PortAllocation> {
|
||||
const forcedPort = parsePort(port, label);
|
||||
return forcedPort == null
|
||||
? await allocateDynamicPort(label, host, reserved)
|
||||
: await allocateForcedPort(forcedPort, label, host, reserved);
|
||||
}
|
||||
|
||||
export async function readJsonFile<T = any>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
|
||||
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<void> {
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
|
||||
export async function removePointerIfCurrent(pointerPath: string, runId: string): Promise<void> {
|
||||
const pointer = await readJsonFile<{ runId?: string }>(pointerPath);
|
||||
if (pointer?.runId === runId) await removeFile(pointerPath);
|
||||
}
|
||||
|
||||
async function staleUnixSocketExists(socketPath: string): Promise<boolean> {
|
||||
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<boolean>((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<void> {
|
||||
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<JsonIpcServerHandle> {
|
||||
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<void>((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<T = any>(
|
||||
socketPath: string,
|
||||
payload: unknown,
|
||||
{ timeoutMs = 1500 }: { timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
return await new Promise<T>((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));
|
||||
});
|
||||
});
|
||||
}
|
||||
21
packages/sidecar/tsconfig.json
Normal file
21
packages/sidecar/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
954
pnpm-lock.yaml
954
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
packages:
|
||||
- packages/*
|
||||
- apps/*
|
||||
- tools/*
|
||||
- e2e
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const residualFiles: string[] = [];
|
||||
|
|
@ -49,7 +66,7 @@ async function collectResidualJavaScript(directory: string): Promise<string[]> {
|
|||
const repositoryPath = toRepositoryPath(fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (skippedDirectories.has(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
|
||||
if (isSkippedDirectoryName(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
43
scripts/postinstall.mjs
Normal file
43
scripts/postinstall.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean> {
|
||||
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<number> {
|
||||
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<ResolvedDevPorts> {
|
||||
const daemonPort = await findFreePort(daemonStart, 'daemon', {
|
||||
host,
|
||||
searchRange,
|
||||
});
|
||||
const appPort = await findFreePort(appStart, appLabel, {
|
||||
host,
|
||||
searchRange,
|
||||
});
|
||||
|
||||
return { daemonPort, appPort };
|
||||
}
|
||||
|
|
@ -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: <name>` 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: <name>` 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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
32
tools/AGENTS.md
Normal file
32
tools/AGENTS.md
Normal file
|
|
@ -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
|
||||
```
|
||||
18
tools/dev/bin/tools-dev.mjs
Executable file
18
tools/dev/bin/tools-dev.mjs
Executable file
|
|
@ -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);
|
||||
18
tools/dev/esbuild.config.mjs
Normal file
18
tools/dev/esbuild.config.mjs
Normal file
|
|
@ -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",
|
||||
});
|
||||
29
tools/dev/package.json
Normal file
29
tools/dev/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
186
tools/dev/src/config.ts
Normal file
186
tools/dev/src/config.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
711
tools/dev/src/index.ts
Normal file
711
tools/dev/src/index.ts
Normal file
|
|
@ -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<FileHandle> {
|
||||
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<void> {
|
||||
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<void>((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<number[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<ToolDevAppName, any>): 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<ToolDevAppName, unknown>;
|
||||
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<ReturnType<typeof readLogs>>;
|
||||
|
||||
function isLogResult(value: LogResult | Record<string, LogResult>): value is LogResult {
|
||||
return Array.isArray((value as LogResult).lines);
|
||||
}
|
||||
|
||||
function printLogs(result: LogResult | Record<string, LogResult>, 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<DesktopEvalResult>(
|
||||
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<DesktopScreenshotResult>(
|
||||
config.apps.desktop.ipcPath,
|
||||
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
|
||||
{ timeoutMs },
|
||||
);
|
||||
case "console":
|
||||
return await requestJsonIpc<DesktopConsoleResult>(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<DesktopClickResult>(
|
||||
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<T>(targets: readonly ToolDevAppName[], operation: (target: ToolDevAppName) => Promise<T>) {
|
||||
const result: Partial<Record<ToolDevAppName, T>> = {};
|
||||
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<void>((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<typeof cli.command>) {
|
||||
return command
|
||||
.option("--namespace <name>", "runtime namespace (default: default)")
|
||||
.option("--tools-dev-root <path>", "tools-dev runtime root")
|
||||
.option("--json", "print JSON");
|
||||
}
|
||||
|
||||
function addPortOptions(command: ReturnType<typeof cli.command>) {
|
||||
return command
|
||||
.option("--daemon-port <port>", "force daemon port; conflict quick-fails")
|
||||
.option("--web-port <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 <app> [target]", "Inspect daemon/web status or desktop status/eval/screenshot/console/click"),
|
||||
)
|
||||
.option("--expr <js>", "JavaScript expression for desktop eval")
|
||||
.option("--path <file>", "Output path for desktop screenshot")
|
||||
.option("--selector <css>", "CSS selector for desktop click")
|
||||
.option("--timeout <seconds>", "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();
|
||||
80
tools/dev/src/sidecar-client.ts
Normal file
80
tools/dev/src/sidecar-client.ts
Normal file
|
|
@ -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<DaemonStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<DaemonStatusSnapshot>(resolveDaemonIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<DaemonStatusSnapshot> {
|
||||
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<WebStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<WebStatusSnapshot>(resolveWebIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<WebStatusSnapshot> {
|
||||
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<DesktopStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<DesktopStatusSnapshot>(resolveDesktopIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 15000): Promise<DesktopStatusSnapshot> {
|
||||
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");
|
||||
}
|
||||
18
tools/dev/tsconfig.json
Normal file
18
tools/dev/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
5
tools/pack/README.md
Normal file
5
tools/pack/README.md
Normal file
|
|
@ -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.
|
||||
Loading…
Reference in a new issue