chore: align namespace lifecycle packaging

This commit is contained in:
PerishCode 2026-05-14 16:35:46 +08:00
parent d83b228c81
commit cba8bf151d
39 changed files with 572 additions and 276 deletions

View file

@ -375,7 +375,7 @@ jobs:
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }}
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"

View file

@ -439,7 +439,7 @@ jobs:
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }}
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"

View file

@ -234,7 +234,7 @@ A memory-plus-UI release: **auto-memory store** carries agent context across run
- **`docs/atoms.md`.** New canonical reference for the first-party atom catalog: implemented vs planned ids, task-kind mapping, how the daemon resolves an atom at run time, the closed `until`-signal vocabulary, and the §22.5 community-plugin → first-party-atom promotion path.
- **Plugin & marketplace system — Phase 2A finished + Phase 1 follow-up + Phase 1.5 + Phase 2B (composer mount) + Phase 2C entry slice + Phase 3 entry slice + early Phase 5 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **CLI canonical agent-facing API (spec §11.7).** Every UI action now has a CLI equivalent: `od project create/list/info/delete`, `od run start/watch/cancel/list/info` (with `--plugin`, `--inputs`, `--grant-caps`, `--snapshot-id`, `--follow`), `od files list/read/write/upload/delete`, `od conversation list/info`. Plugin runs route through the §3.A1 snapshot resolver.
- **Phase 1.5 headless lifecycle.** New `od daemon start [--headless] [--serve-web] [--port] [--host] [--namespace]`, `od daemon status [--json]`, `od daemon stop`. Backed by new HTTP routes `GET /api/daemon/status` and `POST /api/daemon/shutdown` (loopback-only). The default `od` (no subcommand) keeps its desktop behaviour for back-compat. e2e-3's headless install→project→run loop is now anchored in a daemon supertest (`apps/daemon/tests/plugins-headless-run.test.ts`).
- **Phase 1.5 headless lifecycle.** New `od daemon start [--headless] [--serve-web] [--port] [--host]`, `od daemon status [--json]`, `od daemon stop`. Backed by new HTTP routes `GET /api/daemon/status` and `POST /api/daemon/shutdown` (loopback-only). The default `od` (no subcommand) keeps its desktop behaviour for back-compat. e2e-3's headless install→project→run loop is now anchored in a daemon supertest (`apps/daemon/tests/plugins-headless-run.test.ts`).
- **Phase 3 marketplace plugin install resolution.** `POST /api/plugins/install` with a bare plugin name walks every configured `plugin_marketplaces` row in registration order and re-routes to the canonical `github:…` / `https://…` source recorded in the matched manifest. CLI: `od plugin install <name>` works against any catalog the operator added via `od marketplace add <url>`.
- **Web composer mount.** New `PluginsSection` widget combines `InlinePluginsRail`, `ContextChipStrip`, `PluginInputsForm`, plus `renderPluginBriefTemplate`'s `{{var}}` substitution. Mounted in `NewProjectPanel` below the project-name input as the Phase 2A discovery surface. Purely additive: the existing Send button rules are unchanged.
- The earlier work in this changelog block remains in place (snapshot resolver, trust mutation, connector tool-token gate, fallback rejection, GitHub tarball + HTTPS install sources, pipeline runner with cross-conversation cache, marketplace registry, snapshot GC worker, web component primitives, definition-of-done suite, …).

View file

@ -6,6 +6,7 @@ import { runConnectorsToolCli } from './tools-connectors-cli.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js';
import { openBrowser } from './browser-open.js';
import { resolveDaemonUrl } from './daemon-url.js';
const argv = process.argv.slice(2);
@ -128,7 +129,7 @@ const UI_BOOLEAN_FLAGS = new Set([
// to their handlers); we just export the bindings up here so the
// dispatch path always sees an initialized value.
const DAEMON_STRING_FLAGS = new Set([
'daemon-url', 'port', 'host', 'namespace',
'daemon-url', 'port', 'host',
]);
const DAEMON_BOOLEAN_FLAGS = new Set([
'help', 'h', 'json', 'headless', 'serve-web', 'no-open',
@ -376,8 +377,7 @@ async function runResearchSearch(rawArgs) {
console.error('--query required');
process.exit(2);
}
const daemonUrl =
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const daemonUrl = await cliDaemonUrl(flags);
const maxSources =
flags['max-sources'] == null ? undefined : Number(flags['max-sources']);
const url = `${daemonUrl.replace(/\/$/, '')}/api/research/search`;
@ -414,7 +414,7 @@ Output is JSON only on stdout:
Flags:
--query Required search query.
--max-sources Optional source cap. Defaults to 5, clamped to Tavily's max.
--daemon-url Local daemon URL. Defaults to OD_DAEMON_URL or http://127.0.0.1:7456.`);
--daemon-url Local daemon URL. Defaults to OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456.`);
}
// ---------------------------------------------------------------------------
@ -452,7 +452,7 @@ async function runMediaGenerate(rawArgs) {
process.exit(2);
}
const daemonUrl = flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const daemonUrl = await cliDaemonUrl(flags);
const projectId = flags.project || process.env.OD_PROJECT_ID;
if (!projectId) {
console.error(
@ -533,8 +533,7 @@ async function runMediaWait(rawArgs) {
printMediaHelp();
process.exit(2);
}
const daemonUrl =
flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
const daemonUrl = await cliDaemonUrl(flags);
const since = Number.isFinite(Number(flags.since))
? Number(flags.since)
: 0;
@ -716,6 +715,14 @@ function parseFlags(argv, opts = {}) {
return out;
}
async function cliDaemonUrl(flags) {
return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] });
}
async function cliDaemonBaseUrl(flags) {
return (await cliDaemonUrl(flags)).replace(/\/$/, '');
}
function printMediaHelp() {
console.log(`Usage: od media generate --surface <image|video|audio> --model <id> [opts]
"$OD_NODE_BIN" "$OD_BIN" media generate --surface <image|video|audio> --model <id> [opts]
@ -745,7 +752,7 @@ Common options:
future image-edit endpoints). Daemon reads
the file from the project, base64-encodes
it, and forwards it to the upstream API.
--daemon-url http://127.0.0.1:7456
--daemon-url <url>
Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}.
@ -775,8 +782,7 @@ async function runMcp(args) {
return;
}
const { resolveMcpDaemonUrl } = await import('./mcp-daemon-url.js');
const daemonUrl = await resolveMcpDaemonUrl({ flagUrl: flags['daemon-url'] });
const daemonUrl = await cliDaemonUrl(flags);
const { runMcpStdio } = await import('./mcp.js');
await runMcpStdio({ daemonUrl });
@ -792,9 +798,7 @@ project without exporting a zip every iteration.
Options:
--daemon-url <url> Open Design daemon HTTP base URL. Resolution
order: this flag, OD_DAEMON_URL, the running
daemon's sidecar IPC status socket
(/tmp/open-design/ipc/<namespace>/daemon.sock),
order: this flag, OD_DAEMON_URL, OD_SIDECAR_IPC_PATH,
then http://127.0.0.1:7456. Each new MCP spawn
discovers the live daemon URL at startup, so
MCP client configs stay valid across daemon
@ -992,7 +996,7 @@ Exit codes:
// can't resolve.
let registry;
if (!flags['no-daemon']) {
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
try {
const [skillsResp, dsResp, atomsResp] = await Promise.all([
fetch(`${base}/api/skills`).catch(() => null),
@ -1162,7 +1166,7 @@ view is the single source of truth.`);
const out = typeof flags.out === 'string' && flags.out.length > 0
? flags.out
: process.cwd();
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const resp = await fetch(`${base}/api/applied-plugins/export`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -1197,14 +1201,14 @@ async function runMarketplace(args) {
Update the marketplace trust tier.
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456).
--json Emit raw JSON (suitable for scripts).`);
process.exit(args.length === 0 ? 2 : 0);
}
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const resp = await fetch(`${base}/api/marketplaces`);
@ -1349,7 +1353,7 @@ async function runPluginSnapshots(args) {
process.exit(args.length === 0 ? 2 : 0);
}
const flags = parseFlags(args.slice(1), { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
if (sub === 'show') {
const positional = args.slice(1).filter((a) => !a.startsWith('-'));
const id = positional[0];
@ -1486,7 +1490,7 @@ async function runPluginRun(rest) {
const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0
? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean)
: [];
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
// 1. Apply (returns ApplyResult + manifestSourceDigest).
const applyResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/apply`, {
method: 'POST',
@ -1537,8 +1541,8 @@ async function runPluginRun(rest) {
}
}
function pluginDaemonUrl(flags) {
return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
async function pluginDaemonUrl(flags) {
return cliDaemonUrl(flags);
}
// Plan §3.Y1 — filter knobs on `od plugin list` (and feeds
@ -1623,7 +1627,7 @@ Prints an at-a-glance plugin + snapshot inventory:
- Oldest / newest applied snapshot timestamps.`);
process.exit(0);
}
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const url = `${base}/api/plugins/stats`;
const resp = await fetch(url);
if (!resp.ok) {
@ -1674,7 +1678,7 @@ function formatTimestamp(ts) {
}
async function fetchPluginList(flags) {
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins`;
const resp = await fetch(url);
if (!resp.ok) {
console.error(`GET /api/plugins failed: ${resp.status} ${await resp.text()}`);
@ -1737,7 +1741,7 @@ async function runPluginInfo(rest) {
console.error('Usage: od plugin info <id>');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const resp = await fetch(url);
if (!resp.ok) {
console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`);
@ -1759,7 +1763,7 @@ async function runPluginManifest(rest) {
console.error('Usage: od plugin manifest <id>');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const resp = await fetch(url);
if (resp.status === 404) {
console.error(`plugin ${id} not found`);
@ -1784,7 +1788,7 @@ async function runPluginManifest(rest) {
// authors comparing their fork to its upstream installs.
async function runPluginSources(rest) {
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins`;
const resp = await fetch(url);
if (!resp.ok) {
console.error(`GET /api/plugins failed: ${resp.status} ${await resp.text()}`);
@ -1833,7 +1837,7 @@ async function runPluginInstall(rest) {
' od plugin install <name> # resolves through configured marketplaces');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/install`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/install`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
@ -1911,7 +1915,7 @@ Lifecycle vocabulary:
string: new Set([...PLUGIN_STRING_FLAGS, 'since', 'kind', 'plugin-id']),
boolean: new Set([...PLUGIN_BOOLEAN_FLAGS, 'f', 'follow']),
});
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const since = typeof flags.since === 'string' ? Number(flags.since) : 0;
const kindFilter = typeof flags.kind === 'string' && flags.kind.length > 0 ? flags.kind : null;
const pluginIdFilter = typeof flags['plugin-id'] === 'string' && flags['plugin-id'].length > 0
@ -2132,7 +2136,7 @@ Exit codes:
2 CLI usage error / plugin not found / config malformed`);
process.exit(id ? 0 : 2);
}
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
// 1. Resolve the plugin record (fsPath + manifest).
const pluginResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`);
@ -2304,7 +2308,7 @@ Closed signal vocabulary:
}
// Fetch the plugin from the daemon so we get the resolved
// manifest (including pipeline).
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const resp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}`);
if (resp.status === 404) {
console.error(`plugin ${id} not found`);
@ -2386,7 +2390,7 @@ exits 4 on mismatch. Useful for committing renderPluginBlock()
fixtures into a plugin's own tests/.`);
process.exit(id ? 0 : 2);
}
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const url = `${base}/api/applied-plugins/${encodeURIComponent(id)}/canon`;
const checkPath = typeof flags.check === 'string' ? flags.check : null;
// --check always wants the raw text output; force text/plain.
@ -2458,7 +2462,7 @@ into 'added' / 'removed' / 'changed' with one line per field.`);
process.exit(positional.length < 2 ? 2 : 0);
}
const [idA, idB] = positional;
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
const [respA, respB] = await Promise.all([
fetch(`${base}/api/plugins/${encodeURIComponent(idA)}`),
fetch(`${base}/api/plugins/${encodeURIComponent(idB)}`),
@ -2507,7 +2511,7 @@ async function runPluginUpgrade(rest) {
console.error('Usage: od plugin upgrade <id>');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
@ -2557,7 +2561,7 @@ async function runPluginUninstall(rest) {
console.error('Usage: od plugin uninstall <id>');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/uninstall`;
const resp = await fetch(url, { method: 'POST' });
if (!resp.ok) {
console.error(`POST /api/plugins/${id}/uninstall failed: ${resp.status} ${await resp.text()}`);
@ -2604,7 +2608,7 @@ async function runPluginApply(rest) {
const grantCaps = typeof flags['grant-caps'] === 'string' && flags['grant-caps'].length > 0
? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean)
: [];
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/apply`;
let resp;
try {
resp = await fetch(url, {
@ -2615,7 +2619,7 @@ async function runPluginApply(rest) {
} catch (err) {
return exitWithStructuredError({
code: 'daemon-not-running',
message: `Cannot reach daemon at ${pluginDaemonUrl(flags)}: ${err?.message ?? err}`,
message: `Cannot reach daemon at ${await pluginDaemonUrl(flags)}: ${err?.message ?? err}`,
});
}
const data = await resp.json().catch(() => ({}));
@ -2688,7 +2692,7 @@ publish from a frozen run snapshot rather than the live installed copy.`);
console.error('--to <catalog> is required (one of: anthropics-skills, awesome-agent-skills, clawhub, skills-sh)');
process.exit(2);
}
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
// Pull the plugin metadata from the daemon. We do this through the
// existing /api/plugins/:id endpoint so the CLI never needs a direct
// SQLite handle; everything stays loopback-mediated.
@ -2753,7 +2757,7 @@ async function runPluginDoctor(rest) {
console.error('Usage: od plugin doctor <id> [--strict] [--json]');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/doctor`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/doctor`;
const resp = await fetch(url, { method: 'POST' });
if (!resp.ok) {
console.error(`POST /api/plugins/${id}/doctor failed: ${resp.status} ${await resp.text()}`);
@ -2811,7 +2815,7 @@ async function runPluginReplay(rest) {
console.error('--snapshot-id is required (runs are in-memory in Phase 2A; pass the snapshot id returned by od plugin apply)');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/replay`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -2856,7 +2860,7 @@ async function runPluginTrust(rest) {
process.exit(2);
}
const action = flags.revoke ? 'revoke' : 'grant';
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`;
const url = `${(await pluginDaemonUrl(flags)).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/trust`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -2906,13 +2910,13 @@ async function runUi(args) {
}
}
function uiDaemonUrl(flags) {
return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
async function uiDaemonUrl(flags) {
return cliDaemonUrl(flags);
}
async function runUiList(rest) {
const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS });
const base = uiDaemonUrl(flags).replace(/\/$/, '');
const base = (await uiDaemonUrl(flags)).replace(/\/$/, '');
let url;
if (flags.run) url = `${base}/api/runs/${encodeURIComponent(flags.run)}/genui`;
else if (flags.project) url = `${base}/api/projects/${encodeURIComponent(flags.project)}/genui`;
@ -2958,7 +2962,7 @@ async function runUiShow(rest) {
console.error('Usage: od ui show --run <runId> <surfaceId>');
process.exit(2);
}
const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`;
const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`;
const resp = await fetch(url);
if (!resp.ok) {
console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`);
@ -3008,7 +3012,7 @@ async function runUiRespond(rest) {
} else if (typeof flags.value === 'string') {
value = flags.value;
}
const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`;
const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -3044,7 +3048,7 @@ async function runUiRevoke(rest) {
console.error('Usage: od ui revoke --project <projectId> <surfaceId>');
process.exit(2);
}
const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`;
const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`;
const resp = await fetch(url, { method: 'POST' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
@ -3086,7 +3090,7 @@ async function runUiPrefill(rest) {
} else if (typeof flags.value === 'string') {
value = flags.value;
}
const url = `${uiDaemonUrl(flags).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`;
const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -3123,7 +3127,7 @@ function printUiHelp() {
Pre-answer a surface so the run never broadcasts it.
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456).
--json Emit raw JSON (suitable for scripts) instead of human-readable output.`);
}
@ -3162,7 +3166,7 @@ function printPluginHelp() {
folder for distribution.
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456).
--json Emit raw JSON (suitable for scripts) instead of human-readable output.
Phase 1 only supports local-folder installs. The github / https tarball
@ -3180,8 +3184,8 @@ sources arrive in Phase 2A. The marketplace surface comes in Phase 4.`);
// reachable via the CLI; we wrap rather than duplicate.
// ---------------------------------------------------------------------------
function projectDaemonUrl(flags) {
return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
async function projectDaemonUrl(flags) {
return cliDaemonUrl(flags);
}
function safeReadJsonFile(p) {
@ -3212,7 +3216,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
const base = projectDaemonUrl(flags).replace(/\/$/, '');
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const resp = await fetch(`${base}/api/projects`);
@ -3324,7 +3328,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
const base = projectDaemonUrl(flags).replace(/\/$/, '');
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const url = flags.project
@ -3485,7 +3489,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
const base = projectDaemonUrl(flags).replace(/\/$/, '');
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const id = rest.find((a) => !a.startsWith('-'));
@ -3608,7 +3612,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
const base = projectDaemonUrl(flags).replace(/\/$/, '');
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const id = rest.find((a) => !a.startsWith('-'));
@ -3660,7 +3664,6 @@ async function runDaemon(args) {
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>] [--no-open]
[--namespace <ns>]
Start the daemon (Phase 1.5 headless mode).
od daemon status [--json] [--daemon-url <url>]
Print the daemon's runtime snapshot.
@ -3718,7 +3721,7 @@ vacuum:
sizes + elapsed ms.`);
process.exit(sub ? 0 : 2);
}
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
if (sub === 'vacuum') {
const resp = await fetch(`${base}/api/daemon/db/vacuum`, { method: 'POST' });
if (!resp.ok) {
@ -3807,7 +3810,6 @@ async function runDaemonStart(flags) {
const port = Number(flags.port ?? process.env.OD_PORT ?? 7456);
const host = String(flags.host ?? process.env.OD_BIND_HOST ?? '127.0.0.1');
const headless = Boolean(flags.headless || flags['no-open'] || flags['serve-web']);
if (flags.namespace) process.env.OD_NAMESPACE = String(flags.namespace);
process.env.OD_BIND_HOST = host;
process.env.OD_PORT = String(port);
const { startServer: startHeadless } = await import('./server.js');
@ -3847,7 +3849,7 @@ async function runDaemonStart(flags) {
}
async function runDaemonStatus(flags) {
const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, '');
const base = await cliDaemonBaseUrl(flags);
let resp;
try {
resp = await fetch(`${base}/api/daemon/status`);
@ -3860,11 +3862,11 @@ async function runDaemonStatus(flags) {
if (!resp.ok) return structuredHttpFailure(resp);
const data = await resp.json();
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins} namespace=${data.namespace ?? '-'}`);
console.log(`[daemon] ${data.bindHost}:${data.port} v${data.version} pid=${data.pid} plugins=${data.installedPlugins}`);
}
async function runDaemonStop(flags) {
const base = (flags['daemon-url'] || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456').replace(/\/$/, '');
const base = await cliDaemonBaseUrl(flags);
let resp;
try {
resp = await fetch(`${base}/api/daemon/shutdown`, { method: 'POST' });
@ -3886,8 +3888,8 @@ async function runDaemonStop(flags) {
// (the §11.7 "headless = canonical" invariant).
// ---------------------------------------------------------------------------
function libraryDaemonUrl(flags) {
return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
async function libraryDaemonUrl(flags) {
return cliDaemonUrl(flags);
}
async function runAtoms(args) {
@ -3905,7 +3907,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
switch (sub) {
case 'list': {
const resp = await fetch(`${base}/api/atoms`);
@ -3979,7 +3981,7 @@ async function runLibraryList(name, args) {
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
const apiPath = name === 'design-systems' ? '/api/design-systems' : `/api/${name}`;
switch (sub) {
case 'list': {
@ -4023,7 +4025,7 @@ async function runStatus(args) {
async function runVersion(args) {
const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
let resp;
try {
resp = await fetch(`${base}/api/version`);
@ -4072,7 +4074,7 @@ Exit code is non-zero when any installed plugin's doctor returns ok=false
or the daemon cannot be reached.`);
process.exit(0);
}
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
const report = {
daemon: null,
plugins: [],
@ -4189,7 +4191,7 @@ Common options:
const sub = args[0];
const rest = args.slice(1);
const flags = parseFlags(rest, { string: CONFIG_STRING_FLAGS, boolean: CONFIG_BOOLEAN_FLAGS });
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
const fetchConfig = async () => {
const resp = await fetch(`${base}/api/app-config`);

View file

@ -1,38 +1,31 @@
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_DEFAULTS,
SIDECAR_ENV,
SIDECAR_MESSAGES,
type DaemonStatusSnapshot,
} from "@open-design/sidecar-proto";
import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
import { requestJsonIpc } from "@open-design/sidecar";
export const MCP_DEFAULT_DAEMON_URL = "http://127.0.0.1:7456";
export const DEFAULT_DAEMON_URL = "http://127.0.0.1:7456";
export interface ResolveMcpDaemonUrlOptions {
export interface ResolveDaemonUrlOptions {
/** Value passed via `--daemon-url`. Empty string is treated as unset. */
flagUrl?: string | null;
/** Defaults to `process.env`; injected for tests. */
env?: NodeJS.ProcessEnv;
/** IPC discovery timeout. Short by default so an absent daemon does not stall MCP startup. */
/** IPC discovery timeout. Short by default so an absent daemon does not stall CLI startup. */
timeoutMs?: number;
}
/**
* Resolve the daemon HTTP base URL for `od mcp`.
* Resolve the daemon HTTP base URL for `od` client commands.
*
* Spawn order: explicit `--daemon-url` flag, `OD_DAEMON_URL` env, then
* a STATUS roundtrip to the sidecar IPC socket the running daemon
* already publishes (`/tmp/open-design/ipc/<namespace>/daemon.sock`).
* Falls back to the legacy default for direct `od` launches that do
* not run as a sidecar. Discovery means the install snippet never has
* to bake a port: every spawn rediscovers the live URL, so an
* ephemeral daemon port (tools-dev, packaged) cannot invalidate a
* previously-installed MCP client config.
* a STATUS roundtrip to the concrete sidecar IPC endpoint supplied by
* the lifecycle owner in `OD_SIDECAR_IPC_PATH`. Falls back to the
* legacy default for direct `od` launches that do not run as a sidecar.
*/
export async function resolveMcpDaemonUrl(
options: ResolveMcpDaemonUrlOptions = {},
export async function resolveDaemonUrl(
options: ResolveDaemonUrlOptions = {},
): Promise<string> {
const env = options.env ?? process.env;
const flagUrl = options.flagUrl ?? null;
@ -41,20 +34,16 @@ export async function resolveMcpDaemonUrl(
if (envUrl != null && envUrl.length > 0) return envUrl;
const discovered = await discoverDaemonUrlFromIpc(env, options.timeoutMs ?? 800);
if (discovered != null) return discovered;
return MCP_DEFAULT_DAEMON_URL;
return DEFAULT_DAEMON_URL;
}
async function discoverDaemonUrlFromIpc(
env: NodeJS.ProcessEnv,
timeoutMs: number,
): Promise<string | null> {
const socketPath = env[SIDECAR_ENV.IPC_PATH];
if (socketPath == null || socketPath.length === 0) return null;
try {
const socketPath = resolveAppIpcPath({
app: APP_KEYS.DAEMON,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
env,
namespace: env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace,
});
const status = await requestJsonIpc<DaemonStatusSnapshot>(
socketPath,
{ type: SIDECAR_MESSAGES.STATUS },

View file

@ -3,7 +3,7 @@
// share the exact env/argv/buildHint shape; a divergence here is the
// difference between an MCP snippet that works and one that EPERMs out
// when pasted into Antigravity / Cursor / VS Code (issue #848), or
// silently misses a non-default sidecar namespace.
// silently misses the sidecar transport endpoint.
//
// Side effects (the fs.existsSync probes, process.execPath, the
// ELECTRON_RUN_AS_NODE env read, OD_DATA_DIR resolution, sidecar IPC
@ -24,7 +24,7 @@ export interface BuildMcpInstallPayloadInputs {
* spawned `od mcp` should discover the live URL via the IPC
* status socket instead of a baked --daemon-url. */
isSidecarMode: boolean;
/** Already-filtered sidecar env entries (namespace, IPC base) the
/** Already-filtered sidecar transport env entries the
* caller wants propagated into the snippet. The caller decides
* what's worth propagating; this builder just merges. */
sidecarEnv: Record<string, string>;

View file

@ -1,6 +1,6 @@
import type { Express } from 'express';
import fs from 'node:fs';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { SIDECAR_ENV } from '@open-design/sidecar-proto';
import { buildMcpInstallPayload } from './mcp-install-info.js';
import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js';
import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js';
@ -42,27 +42,16 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
// The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff
// bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env.
// In sidecar mode the snippet omits --daemon-url and the spawned
// `od mcp` discovers the live URL via the IPC status socket on
// `od mcp` discovers the live URL via the concrete IPC endpoint on
// every spawn, so the client config survives ephemeral-port
// restarts. We also propagate OD_SIDECAR_NAMESPACE (and IPC_BASE
// when overridden) so a non-default namespace daemon stays
// reachable - the MCP client does not inherit the daemon's env,
// so without this the spawned `od mcp` would probe the default
// namespace socket and miss. For direct `od` / `od --port X`
// launches there is no IPC socket; the helper bakes --daemon-url
// so custom ports keep working.
// restarts. For direct `od` / `od --port X` launches there is no
// IPC socket; the helper bakes --daemon-url so custom ports keep
// working.
const sidecarIpcPath = process.env[SIDECAR_ENV.IPC_PATH];
const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0;
const sidecarEnv: Record<string, string> = {};
if (isSidecarMode) {
const ns = process.env[SIDECAR_ENV.NAMESPACE];
if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) {
sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns;
}
const ipcBase = process.env[SIDECAR_ENV.IPC_BASE];
if (ipcBase != null && ipcBase.length > 0) {
sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase;
}
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
const payload = buildMcpInstallPayload({
cliPath,

View file

@ -179,8 +179,9 @@ export async function applyPatchForStep(input: ApplyPatchInput): Promise<ApplyPa
added += result.added;
removed += result.removed;
} catch (err) {
const touched = hunk.targetFile ?? hunk.sourceFile ?? '<unknown>';
return { status: 'failed', filesTouched: [...filesTouched], added, removed,
reason: `hunk apply failed for ${hunk.targetFile}: ${(err as Error).message}` };
reason: `hunk apply failed for ${touched}: ${(err as Error).message}` };
}
}
@ -314,30 +315,42 @@ function parseHunkHeader(line: string): { oldStart: number; oldLines: number; ne
};
}
function resolvePatchFile(cwd: string, file: string): string {
const unsafe = `unsafe path '${file}'`;
if (file.includes('\0')) throw new Error(unsafe);
if (/^[A-Za-z]:/.test(file)) throw new Error(unsafe);
if (path.isAbsolute(file) || path.win32.isAbsolute(file) || path.posix.isAbsolute(file)) {
throw new Error(unsafe);
}
if (file.replace(/\\/g, '/').split('/').some((segment) => segment === '..')) {
throw new Error(unsafe);
}
const abs = path.resolve(cwd, file);
const relative = path.relative(cwd, abs);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(unsafe);
}
return abs;
}
async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ added: number; removed: number }> {
if (bundle.sourceFile === null && bundle.targetFile === null) {
throw new Error('hunk has /dev/null on both sides');
}
// Path-traversal guard.
for (const f of [bundle.sourceFile, bundle.targetFile]) {
if (f && (f.startsWith('/') || f.includes('..'))) {
throw new Error(`unsafe path '${f}'`);
}
}
const sourceAbs = bundle.sourceFile === null ? null : resolvePatchFile(cwd, bundle.sourceFile);
const targetAbs = bundle.targetFile === null ? null : resolvePatchFile(cwd, bundle.targetFile);
if (bundle.targetFile === null) {
// File deletion.
const abs = path.join(cwd, bundle.sourceFile!);
let body: string;
try { body = await fsp.readFile(abs, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); }
await fsp.unlink(abs);
try { body = await fsp.readFile(sourceAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); }
await fsp.unlink(sourceAbs!);
return { added: 0, removed: body.split('\n').length };
}
if (bundle.sourceFile === null) {
// File creation.
const abs = path.join(cwd, bundle.targetFile);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.mkdir(path.dirname(targetAbs!), { recursive: true });
let added = 0;
const lines: string[] = [];
for (const hunk of bundle.hunks) {
@ -346,14 +359,13 @@ async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{
else if (l === '' || l.startsWith('\\')) { /* trailing newline marker */ }
}
}
await atomicWriteFile(abs, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
await atomicWriteFile(targetAbs!, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
return { added, removed: 0 };
}
// Plain edit.
const abs = path.join(cwd, bundle.targetFile);
let original: string;
try { original = await fsp.readFile(abs, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); }
try { original = await fsp.readFile(targetAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); }
const originalLines = original.split('\n');
// Trailing newline produces an empty last element after split; we
// preserve that and add it back at the end.
@ -373,7 +385,7 @@ async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{
working = result.working;
}
const final = working.join('\n') + (trailingNL ? '\n' : '');
await atomicWriteFile(abs, final);
await atomicWriteFile(targetAbs!, final);
return { added, removed };
}

View file

@ -3,7 +3,8 @@
// - `./folder` / `/abs/path` — local-copy backend (Phase 1).
// - `github:owner/repo[@ref][/subpath]` — fetched from
// codeload.github.com as a tar.gz, extracted into a temp dir, then
// copied into ~/.open-design/plugins/<id>/ via the local backend.
// copied into the daemon data-root-derived plugin registry via the local
// backend.
// - `https://…tar.gz` / `…tgz` — same extraction path, no path-rewrite.
//
// Hard install constraints (spec §7.2 / plan §3.A6):
@ -55,8 +56,8 @@ export type InstallEvent = InstallProgressEvent | InstallSuccessEvent | InstallE
export interface InstallOptions {
source: string;
// Forwarded via env override or CLI flag; defaults to defaultRegistryRoots()
// so daemon tests can point at a sandboxed home.
// Forwarded from daemon runtime context; defaults to defaultRegistryRoots()
// so daemon tests can point at a sandboxed data root.
roots?: RegistryRoots;
// 50 MiB default mirrors spec §7.2; tests pin a tighter cap.
maxBytes?: number;

View file

@ -1,7 +1,7 @@
// Plugin registry. Phase 1 scope:
//
// - Scans `<userHome>/.open-design/plugins/<id>/` (the OD-canonical install
// root) for manifest folders.
// - Scans `<daemonDataDir>/plugins/<id>/` (the OD-canonical install root) for
// manifest folders.
// - Resolves a plugin folder into either an `open-design.json`-anchored
// manifest or a synthesized one derived from `SKILL.md` /
// `.claude-plugin/plugin.json` (per spec §3 compatibility matrix).
@ -14,7 +14,6 @@
// schema migration.
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { promises as fsp } from 'node:fs';
import {
@ -37,16 +36,21 @@ type SqliteDb = Database.Database;
type DbRow = Record<string, unknown>;
export interface RegistryRoots {
// Defaults to `<os.homedir()>/.open-design/plugins/`.
// User-installed plugin bytes. Production passes a daemon data-root-derived
// value; tests can point this at a sandbox.
userPluginsRoot: string;
}
export function defaultRegistryRoots(): RegistryRoots {
export function registryRootsForDataDir(dataDir: string): RegistryRoots {
return {
userPluginsRoot: path.join(os.homedir(), '.open-design', 'plugins'),
userPluginsRoot: path.join(dataDir, 'plugins'),
};
}
export function defaultRegistryRoots(): RegistryRoots {
return registryRootsForDataDir(path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od')));
}
export interface ScannedPlugin {
record: InstalledPluginRecord;
warnings: string[];

View file

@ -47,7 +47,6 @@ import { listDesignSystems, readDesignSystem, readDesignSystemAssets } from './d
import {
applyDiffReviewDecisionToCwd,
applyPlugin,
defaultRegistryRoots,
defaultBundledRoot,
doctorPlugin,
FIRST_PARTY_ATOMS,
@ -63,6 +62,7 @@ import {
pruneExpiredSnapshots,
registerBuiltInAtomWorkers,
registerBundledPlugins,
registryRootsForDataDir,
resolvePluginSnapshot,
runPipelineForRun,
runStageWithRegistry,
@ -1090,6 +1090,7 @@ const CRITIQUE_ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'critique-artifacts')
const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects');
const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills');
const USER_DESIGN_SYSTEMS_DIR = path.join(RUNTIME_DATA_DIR, 'design-systems');
const PLUGIN_REGISTRY_ROOTS = registryRootsForDataDir(RUNTIME_DATA_DIR);
// User-imported design templates mirror USER_SKILLS_DIR but are scanned
// against DESIGN_TEMPLATES_DIR rather than SKILLS_DIR so the EntryView
// Templates surface and the Settings → Skills surface stay decoupled.
@ -1109,7 +1110,7 @@ const ALL_SKILL_LIKE_ROOTS = [
DESIGN_TEMPLATES_DIR,
];
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR]) {
for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR, PLUGIN_REGISTRY_ROOTS.userPluginsRoot]) {
fs.mkdirSync(dir, { recursive: true });
}
fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true });
@ -2699,7 +2700,7 @@ export async function startServer({
});
// Plan §3.F2 / spec §11.7 — daemon lifecycle status. Returns the
// host / port the server is bound to plus the data dir + namespace,
// host / port the server is bound to plus the data dir,
// so `od daemon status --json` can render a one-shot health snapshot
// without depending on /api/version's content shape.
app.get('/api/daemon/status', async (_req, res) => {
@ -2711,7 +2712,6 @@ export async function startServer({
port: Number(process.env.OD_PORT ?? 7456),
dataDir: RUNTIME_DATA_DIR,
mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null,
namespace: process.env.OD_NAMESPACE ?? null,
pid: process.pid,
shuttingDown: daemonShuttingDown,
installedPlugins: (() => {
@ -4245,7 +4245,7 @@ export async function startServer({
};
try {
for await (const ev of installPlugin(db, { source })) {
for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS })) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;
}
@ -4258,7 +4258,7 @@ export async function startServer({
app.post('/api/plugins/:id/uninstall', async (req, res) => {
try {
const result = await uninstallPlugin(db, req.params.id, defaultRegistryRoots());
const result = await uninstallPlugin(db, req.params.id, PLUGIN_REGISTRY_ROOTS);
if (!result.ok && !result.removedFolder) {
return res.status(404).json({ error: 'plugin not found', warning: result.warning });
}
@ -4318,7 +4318,7 @@ export async function startServer({
writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source}` });
try {
for await (const ev of installPlugin(db, { source, eventKind: 'upgraded' })) {
for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS, eventKind: 'upgraded' })) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;
}
@ -6465,7 +6465,7 @@ export async function startServer({
const log = [];
let plugin = null;
let message = 'Install finished.';
for await (const ev of installPlugin(db, { source: folder })) {
for await (const ev of installPlugin(db, { source: folder, roots: PLUGIN_REGISTRY_ROOTS })) {
if (ev.message) log.push(ev.message);
if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings);
if (ev.kind === 'success') {

View file

@ -41,6 +41,7 @@ describe('GET /api/daemon/status', () => {
pid: unknown;
installedPlugins: unknown;
shuttingDown: boolean;
namespace?: unknown;
};
expect(body.ok).toBe(true);
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
@ -49,6 +50,7 @@ describe('GET /api/daemon/status', () => {
expect(typeof body.pid).toBe('number');
expect(typeof body.installedPlugins).toBe('number');
expect(body.shuttingDown).toBe(false);
expect(body).not.toHaveProperty('namespace');
});
});

View file

@ -1,22 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, test } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createJsonIpcServer, type JsonIpcServerHandle } from "@open-design/sidecar";
import { SIDECAR_ENV, SIDECAR_MESSAGES } from "@open-design/sidecar-proto";
import { resolveMcpDaemonUrl, MCP_DEFAULT_DAEMON_URL } from "../src/mcp-daemon-url.js";
// On Windows the sidecar IPC contract switches to named pipes whose
// names are not relocatable via OD_SIDECAR_IPC_BASE, so the discovery
// case cannot use a per-test temp socket; skip just that case there.
const ipcTest = process.platform === "win32" ? test.skip : test;
import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js";
// Verifies the resolution chain: --daemon-url > OD_DAEMON_URL > sidecar
// IPC status discovery > legacy default. Each layer must short-circuit
// the next so the spawned `od mcp` follows the live daemon across
// restarts without re-pasting the install snippet.
// IPC status discovery > legacy default. Each layer must short-circuit the next
// so `od` clients follow the live daemon across ephemeral-port restarts.
describe("resolveMcpDaemonUrl", () => {
describe("resolveDaemonUrl", () => {
let ipcBaseDir: string;
beforeAll(() => {
@ -28,44 +22,40 @@ describe("resolveMcpDaemonUrl", () => {
});
it("prefers the explicit --daemon-url flag", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
flagUrl: "http://flag.example:1111",
env: {
OD_DAEMON_URL: "http://env.example:2222",
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"),
},
});
expect(url).toBe("http://flag.example:1111");
});
it("falls back to OD_DAEMON_URL when no flag given", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
OD_DAEMON_URL: "http://env.example:2222",
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"),
},
});
expect(url).toBe("http://env.example:2222");
});
it("returns the legacy default when no flag/env/socket is available", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
// Point IPC discovery at a directory with no socket; discovery
// should fail silently and we fall back to the default.
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.NAMESPACE]: "missing-ns",
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "missing.sock"),
},
timeoutMs: 200,
});
expect(url).toBe(MCP_DEFAULT_DAEMON_URL);
expect(url).toBe(DEFAULT_DAEMON_URL);
});
ipcTest("discovers the live daemon URL via the sidecar IPC status socket", async () => {
const namespace = "discover-test";
const namespaceDir = path.join(ipcBaseDir, namespace);
fs.mkdirSync(namespaceDir, { recursive: true });
const socketPath = path.join(namespaceDir, "daemon.sock");
it("discovers the live daemon URL via the concrete sidecar IPC status endpoint", async () => {
const socketPath = process.platform === "win32"
? `\\\\.\\pipe\\open-design-daemon-url-${process.pid}-${Date.now()}`
: path.join(ipcBaseDir, "daemon.sock");
let ipc: JsonIpcServerHandle | null = null;
try {
ipc = await createJsonIpcServer({
@ -87,10 +77,9 @@ describe("resolveMcpDaemonUrl", () => {
},
});
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.NAMESPACE]: namespace,
[SIDECAR_ENV.IPC_PATH]: socketPath,
},
timeoutMs: 1000,
});

View file

@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import express from 'express';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { SIDECAR_ENV } from '@open-design/sidecar-proto';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isLocalSameOrigin } from '../src/origin-validation.js';
import { buildMcpInstallPayload } from '../src/mcp-install-info.js';
@ -20,7 +20,7 @@ interface InstallInfoOpts {
cliPath: string;
port: number;
/** Stand-in for `process.env`. Lets each test simulate sidecar vs
* non-sidecar daemon launches and custom namespaces without
* non-sidecar daemon launches and custom transport endpoints without
* mutating the real process env. */
env?: NodeJS.ProcessEnv;
/** Stand-in for the daemon's resolved RUNTIME_DATA_DIR (issue #848).
@ -72,14 +72,7 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt
const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0;
const sidecarEnv: Record<string, string> = {};
if (isSidecarMode) {
const ns = env[SIDECAR_ENV.NAMESPACE];
if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) {
sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns;
}
const ipcBase = env[SIDECAR_ENV.IPC_BASE];
if (ipcBase != null && ipcBase.length > 0) {
sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase;
}
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
const payload = buildMcpInstallPayload({
cliPath,
@ -249,12 +242,11 @@ describe('GET /api/mcp/install-info', () => {
expect(after - before).toBeLessThanOrEqual(1);
});
it('sidecar default namespace omits --daemon-url and emits only OD_DATA_DIR', async () => {
it('sidecar launch omits --daemon-url and emits the concrete IPC path with OD_DATA_DIR', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock',
[SIDECAR_ENV.NAMESPACE]: SIDECAR_DEFAULTS.namespace,
},
dataDir,
);
@ -262,41 +254,37 @@ describe('GET /api/mcp/install-info', () => {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Default namespace + default IPC base means the spawned `od mcp`
// can derive the right socket without any sidecar env hints. The
// OD_DATA_DIR pin still rides along so the data dir is correct.
expect(body.env).toEqual({ OD_DATA_DIR: dataDir });
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar non-default namespace propagates OD_SIDECAR_NAMESPACE alongside OD_DATA_DIR', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
[SIDECAR_ENV.NAMESPACE]: 'foo',
},
dataDir,
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Without this propagation the MCP client would launch `od mcp`
// with no namespace env, fall back to "default", and miss the
// foo daemon entirely.
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.NAMESPACE]: 'foo',
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar with custom IPC base propagates OD_SIDECAR_IPC_BASE alongside OD_DATA_DIR', async () => {
it('sidecar non-default endpoint still propagates only the concrete IPC path', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
},
dataDir,
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar with custom IPC base does not propagate namespace or base hints', async () => {
const { port, server } = await startHarness(
cliPath,
{
@ -311,8 +299,7 @@ describe('GET /api/mcp/install-info', () => {
const body = await readInstallInfo(res);
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.NAMESPACE]: 'foo',
[SIDECAR_ENV.IPC_BASE]: '/var/run/open-design',
[SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));

View file

@ -6,6 +6,7 @@
// - Throw MissingInputError when a required input is absent.
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { applyPlugin, MissingInputError } from '../src/plugins/apply.js';
import { defaultRegistryRoots } from '../src/plugins/registry.js';
import { TRUSTED_DEFAULT_CAPABILITIES } from '../src/plugins/trust.js';
@ -119,7 +120,8 @@ describe('applyPlugin', () => {
it('does not require a registry roots argument (no FS access at apply time)', () => {
// Sanity: the function must not reach for the on-disk plugin folder.
const roots = defaultRegistryRoots();
expect(roots.userPluginsRoot).toMatch(/\.open-design[\/\\]plugins$/);
const expectedDataDir = path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od'));
expect(roots.userPluginsRoot).toBe(path.join(expectedDataDir, 'plugins'));
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
expect(result.result.appliedPlugin.pluginId).toBe('sample-plugin');
});

View file

@ -145,6 +145,20 @@ describe('applyPatchForStep — safety guards', () => {
expect(result.reason).toMatch(/unsafe path/);
});
it('refuses Windows absolute targets', async () => {
await seedPlan([
{ id: 'naughty', files: ['C:/escape.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
const diff = `--- /dev/null
+++ b/C:/escape.ts
@@ -0,0 +1,1 @@
+x
`;
const result = await applyPatchForStep({ cwd, stepId: 'naughty', diff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/unsafe path/);
});
it('rejects context mismatches (stale patch detection)', async () => {
await seedPlan([
{ id: 'rewrite-x', files: ['x.ts'], rationale: '', risk: 'low', status: 'pending' },

View file

@ -68,7 +68,7 @@ describe('od plugin upgrade — installer round-trip', () => {
await writeSource('1.0.1', { description: 'with notes' });
// Upgrade re-runs the installer against the recorded source.
await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator<unknown>);
await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator<unknown>);
const after = getInstalledPlugin(db, 'upgrade-fixture');
expect(after?.version).toBe('1.0.1');
expect(after?.manifest.description).toBe('with notes');
@ -77,7 +77,7 @@ describe('od plugin upgrade — installer round-trip', () => {
it('re-running with the same source on disk is idempotent on the registry version', async () => {
await drain(installFromLocalFolder(db, { source: sourceFolder, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator<unknown>);
const before = getInstalledPlugin(db, 'upgrade-fixture');
await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator<unknown>);
await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator<unknown>);
const after = getInstalledPlugin(db, 'upgrade-fixture');
expect(after?.version).toBe(before!.version);
expect(after?.id).toBe(before!.id);
@ -87,7 +87,7 @@ describe('od plugin upgrade — installer round-trip', () => {
await drain(installFromLocalFolder(db, { source: sourceFolder, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator<unknown>);
const before = getInstalledPlugin(db, 'upgrade-fixture');
await writeSource('1.0.2');
await drain(installPlugin(db, { source: before!.source }) as AsyncGenerator<unknown>);
await drain(installPlugin(db, { source: before!.source, roots: { userPluginsRoot: pluginsRoot } }) as AsyncGenerator<unknown>);
// The on-disk manifest the installer just wrote should match the
// one in the SQLite row.

View file

@ -15,7 +15,7 @@ import {
} from "@open-design/sidecar-proto";
import { bootstrapSidecarRuntime, createJsonIpcServer, resolveAppIpcPath } from "@open-design/sidecar";
import type { PackagedConfig } from "./config.js";
import { PACKAGED_NAMESPACE_ENV, type PackagedConfig } from "./config.js";
import { writePackagedDesktopIdentity, writePackagedWebIdentity } from "./identity.js";
import { resolvePackagedNamespacePaths } from "./paths.js";
import { startPackagedSidecars } from "./sidecars.js";
@ -38,9 +38,7 @@ function resolveHeadlessNamespaceBaseRoot(): string {
function resolveHeadlessConfig(): PackagedConfig {
const namespace =
OPEN_DESIGN_SIDECAR_CONTRACT.normalizeNamespace(
process.env.OD_NAMESPACE ??
process.env.OD_SIDECAR_NAMESPACE ??
SIDECAR_DEFAULTS.namespace,
process.env[PACKAGED_NAMESPACE_ENV] ?? SIDECAR_DEFAULTS.namespace,
);
const namespaceBaseRoot = resolveHeadlessNamespaceBaseRoot();

View file

@ -375,7 +375,7 @@ Deliverables
- [x] `od daemon start --headless` flag (no electron, no web bundle).
- [x] `od daemon start --serve-web` flag (web UI without electron). Today this is an alias of `--headless` because the v1 daemon serves both API and web UI from the same Express app; the flag is reserved so packaged callers can branch on it.
- [x] Honor `OD_BIND_HOST`, `OD_PORT`, `OD_NAMESPACE` in headless mode (the flags forward into the env so the existing daemon code path picks them up unchanged).
- [x] Honor `OD_BIND_HOST` and `OD_PORT` in headless mode (the flags forward into the env so the existing daemon code path picks them up unchanged).
- [x] `od daemon stop`, `od daemon status --json`.
Validation
@ -549,7 +549,7 @@ These were originally spec §18 open questions; they are now resolved and propag
v1 ships when **all** of the following pass on a clean Linux CI container without electron. Each row links to the daemon / e2e test path that asserts it.
- [x] **e2e-1 cold install**`od plugin install ./fixtures/sample-plugin`
- `~/.open-design/plugins/sample-plugin/` exists.
- `<OD_DATA_DIR>/plugins/sample-plugin/` exists.
- `installed_plugins` has one row with `trust='restricted'`, `source_kind='local'`.
- Test path: `apps/daemon/tests/plugins-e2e-fixture.test.ts`
- [x] **e2e-2 pure apply** — two consecutive applies share `manifestSourceDigest`; the project cwd byte size is unchanged; `applied_plugin_snapshots` is not written by `applyPlugin()` itself (the resolver is the writer).

View file

@ -450,12 +450,12 @@ Multiple marketplaces coexist — the user runs `od marketplace add <url>` to re
| -------- | ------------------------------------------------ | ------------------ | ---------------------------------------------------------------------- |
| 1 | `<projectCwd>/.open-design/plugins/<id>/` | plugin bundle | New; explicitly installed into the project and committed with user code |
| 2 | `<projectCwd>/.claude/skills/<id>/` | legacy `SKILL.md` | Keeps the project-private skill path from [`skills-protocol.md`](skills-protocol.md) compatible |
| 3 | `~/.open-design/plugins/<id>/` | plugin bundle | New; written by `od plugin install` |
| 3 | `<daemonDataDir>/plugins/<id>/` | plugin bundle | New; written by `od plugin install` under the daemon data root |
| 4 | `~/.open-design/skills/<id>/` | legacy `SKILL.md` | OD canonical skill install path; may symlink into other agents |
| 5 | `~/.claude/skills/<id>/` | legacy `SKILL.md` | Compatibility scan for external Claude Code / skills tooling |
| 6 | repo root `skills/`, `design-systems/`, `craft/` | bundled resources | Existing first-party resources, unchanged |
Conflict resolution uses normalized `name` / plugin id; lower numeric priority wins. Legacy `SKILL.md` locations are synthesized into plugin records by the adapter, but are not copied into `~/.open-design/plugins/` unless the user explicitly runs `od plugin install`. This keeps existing Claude skills zero-config while giving plugin bundles a clear install root.
Conflict resolution uses normalized `name` / plugin id; lower numeric priority wins. Legacy `SKILL.md` locations are synthesized into plugin records by the adapter, but are not copied into `<daemonDataDir>/plugins/` unless the user explicitly runs `od plugin install`. This keeps existing Claude skills zero-config while giving plugin bundles a clear install root.
### 7.2 Install sources
@ -1389,12 +1389,12 @@ od atoms list [--json] # first-party atoms (
#### Daemon control (new)
```
od daemon start [--headless] [--serve-web] [--port <n>] [--host <h>] [--namespace <ns>]
od daemon start [--headless] [--serve-web] [--port <n>] [--host <h>]
# explicit lifecycle (§11.7);
# default `od` (no args) keeps current behavior
od daemon stop [--namespace <ns>]
od daemon stop [--daemon-url <url>]
od daemon status [--json] # alias of `od status`
od status [--json] # daemon up? port? namespace? installed plugins count
od status [--json] # daemon up? port? installed plugins count
od doctor # diagnostics: skills/DS/craft/plugins, providers, MCP
od version [--json]
od config get|set|list|unset [--key ...] [--value ...] # backed by media-config.json + db
@ -1428,7 +1428,7 @@ od mcp live-artifacts # specialized MCP server
| Exit | Meaning | Recovery hint | Structured stderr `data` (excerpt) |
| --- | --- | --- | --- |
| 64 | Daemon not running | `od status`, then start daemon | `{ host, port, namespace }` |
| 64 | Daemon not running | `od status`, then start daemon | `{ host, port }` |
| 65 | Plugin not found / not installed | `od plugin list` then `od plugin install <source>` | `{ pluginId, candidateSources[] }` |
| 66 | Plugin restricted, capability required | `od plugin trust <id> --caps …` or retry with `--grant-caps …` | `{ pluginId, pluginVersion, required[], granted[], remediation[] }` |
| 67 | Required input missing on apply | re-run with `--input k=v` for each missing field | `{ pluginId, missing[], schema }` |
@ -1617,7 +1617,7 @@ Three paths the operator should mount as volumes; they map onto existing OD env
| Mount path | Env var | Purpose |
| ----------------- | ------------------------ | ------------------------------------------------------ |
| `/data/od` | `OD_DATA_DIR` | Projects, SQLite, artifacts, installed plugins (`.od/` and `~/.open-design/plugins/` collapse here) |
| `/data/od` | `OD_DATA_DIR` | Projects, SQLite, artifacts, installed plugins (`<OD_DATA_DIR>/plugins`) |
| `/data/config` | `OD_MEDIA_CONFIG_DIR` | Provider credentials (`media-config.json`) |
| `/data/marketplaces` | (under `OD_DATA_DIR`) | Cached marketplace indexes |
@ -1632,7 +1632,6 @@ OD_PORT=17456
OD_BIND_HOST=0.0.0.0 # the variable the daemon already reads ([`apps/daemon/src/server.ts`](../apps/daemon/src/server.ts))
OD_DATA_DIR=/data/od
OD_MEDIA_CONFIG_DIR=/data/config
OD_NAMESPACE=production # multi-tenant isolation key
OD_TRUST_DEFAULT=restricted # safe default for hosted (§9) — introduced in Phase 5
OD_AGENT_BACKEND=claude # default code agent backend
OD_API_TOKEN=<random> # required when OD_BIND_HOST != 127.0.0.1 — Phase 5 introduces the bearer middleware
@ -1755,7 +1754,7 @@ Phase 1 contents (merges the original Phase 1 with the minimum subset of the ori
- Endpoints: `GET /api/plugins`, `GET /api/plugins/:id`, `POST /api/plugins/install` (folder + github tarball), `POST /api/plugins/:id/uninstall`, `POST /api/plugins/:id/apply`, `GET /api/atoms`, `GET /api/applied-plugins/:snapshotId`.
- **Plugin CLI verbs:** `od plugin install/list/info/uninstall/apply/doctor`. `od plugin apply --json` is required by Phase 2's inline rail and by external code agents, and must already return an `ApplyResult` containing `appliedPlugin: AppliedPluginSnapshot`.
- **Headless MVP CLI loop (newly pulled forward):** `od project create/list/info`, `od run start/watch/cancel` (with `--follow` and ND-JSON streaming), `od files list/read`. These wrap endpoints already used by the desktop UI today (`POST /api/projects`, `POST /api/runs`, `GET /api/runs/:id/events`, project list/read endpoints) — no new HTTP surface, only CLI surface.
- `~/.open-design/plugins/<id>/` write path with safe extraction (path-traversal guard, size cap, symlink rejection).
- `<daemonDataDir>/plugins/<id>/` write path with safe extraction (path-traversal guard, size cap, symlink rejection).
Validation:
@ -1957,7 +1956,7 @@ Open questions worth confirming before code lands:
- **`od plugin run` headless contract** — sufficient as-is, or also expose an HTTP POST endpoint for non-CLI agents? (Default: CLI only in v1; HTTP added in Phase 4 if needed.)
- **Multi-tenant auth (per-user OAuth, RBAC, project ownership, billing)** is explicitly out of scope for v1. The Docker image is single-tenant by design (one `OD_API_TOKEN`). Multi-tenancy is a post-v1 story that needs its own spec — confirm this scoping is acceptable for the first ecosystem release.
- **Trust propagation in hosted mode** — current spec locks arbitrary GitHub / URL / local plugins to `restricted` by default, and third-party marketplaces do not propagate trust by default. Confirm whether hosted deployments may trust individual plugins through `OD_TRUSTED_PLUGINS`, or whether operators must first trust the source marketplace.
- **Discovery-time hot reload** — should the daemon watch `~/.open-design/plugins/` for filesystem changes (developer ergonomics), or only reload after `od plugin install/update/uninstall` (stability)? (Default: watch, with a 500ms debounce.)
- **Discovery-time hot reload** — should the daemon watch `<daemonDataDir>/plugins/` for filesystem changes (developer ergonomics), or only reload after `od plugin install/update/uninstall` (stability)? (Default: watch, with a 500ms debounce.)
- **Versioning policy** — pin to a tag/SHA on install, or always track the default branch with an opt-in pin? (Default: pin to the resolved ref at install time; `od plugin update` re-resolves.)
- ~~**When to lift the plugin prompt block into contracts**~~**resolved (PB1, see `docs/plans/plugins-implementation.md` §7).** Lift in Phase 2A as a pure `renderPluginBlock(snapshot)` function in `packages/contracts/src/prompts/plugin-block.ts`; both composers import it; v1 fallback rejection rule (§11.8) is preserved; Phase 4 turns on fallback support as a one-line wiring change. The Phase 14 byte-equality CI fixture is no longer needed.
- ~~**`AppliedPluginSnapshot` retention**~~**resolved (PB2, see `docs/plans/plugins-implementation.md` §7).** Snapshots referenced by any run / conversation / project stay pinned forever (`expires_at = NULL`); unreferenced snapshots get `expires_at = applied_at + OD_SNAPSHOT_UNREFERENCED_TTL_DAYS` (default `30`, `0` disables). The "expire even referenced rows" knob `OD_SNAPSHOT_RETENTION_DAYS` is operator-opt-in only (default unset), and applies only when the referencing row is terminal. The `expires_at` column lands in Phase 1 (§11.4); the GC worker lands in Phase 5 (§16). The `od plugin snapshots prune` CLI remains as a forced-cleanup escape hatch.
@ -2314,7 +2313,7 @@ Three categories cover everything OD does today:
| --- | --- | --- |
| **A. Userspace (movable to plugin)** | A first-party plugin's manifest + SKILL.md | atom prompt fragments, default reference pipelines, atom-bundled MCP servers, critique scoring axes, discovery question wording |
| **B. Kernel (must stay in daemon)** | `apps/daemon/src/...` | snapshot SQLite writes, GenUI persistence + reuse, capability gate + tool-token issuance, devloop scheduler + `until` evaluator + ceiling, OAuth token storage, `composeSystemPrompt()` *as assembler*, project metadata block |
| **C. Already plugin-driven in v1** | Already in `~/.open-design/plugins/...` or per-project plugin folders | Active SKILL.md, DESIGN.md, craft .md, plugin-declared MCP servers, plugin-declared GenUI surfaces, plugin-declared pipelines |
| **C. Already plugin-driven in v1** | Already in `<daemonDataDir>/plugins/...` or per-project plugin folders | Active SKILL.md, DESIGN.md, craft .md, plugin-declared MCP servers, plugin-declared GenUI surfaces, plugin-declared pipelines |
The category-B list is the **kernel boundary**: not "these things would be hard to plugin-ize", but "these things must stay in the daemon for security, persistence, or runtime-state reasons." A plugin runtime that lets plugins write `applied_plugin_snapshots` rows or issue connector tool tokens is a broken runtime.
@ -2383,7 +2382,7 @@ The `bundled` tier is what makes patches 2 and 3 safe: the daemon does not capab
Daemon startup adds a step:
1. Walk `<repo-root>/plugins/_official/**` and register every plugin under `installed_plugins.source_kind='bundled'`, `trust='bundled'`, capabilities = the plugin's declared `od.capabilities`.
2. Bundled plugins are not copied into `~/.open-design/plugins/`; they live and reload from the repo path so daemon upgrades replace them in lockstep with daemon code.
2. Bundled plugins are not copied into `<daemonDataDir>/plugins/`; they live and reload from the repo path so daemon upgrades replace them in lockstep with daemon code.
3. `od plugin uninstall` refuses to uninstall a `bundled` plugin (would break the daemon); `od plugin update` is a no-op for bundled.
4. A user may install a `trusted` or `restricted` plugin with the same id as a bundled one; the user-installed copy wins for normal apply, but the daemon retains the bundled copy as a fallback for replays of older `AppliedPluginSnapshot` rows that pinned the bundled version.

View file

@ -450,12 +450,12 @@ Marketplace 顶层 `version` 是 catalog snapshot 版本;每个 `plugins[]` en
| --- | --- | --- | --- |
| 1 | `<projectCwd>/.open-design/plugins/<id>/` | plugin bundle | 新增,与用户代码一起提交;必须显式安装到 project |
| 2 | `<projectCwd>/.claude/skills/<id>/` | legacy `SKILL.md` | 沿用 [`skills-protocol.md`](skills-protocol.md) 的 project-private skill 兼容路径 |
| 3 | `~/.open-design/plugins/<id>/` | plugin bundle | 新增,由 `od plugin install` 写入 |
| 3 | `<daemonDataDir>/plugins/<id>/` | plugin bundle | 新增,由 `od plugin install` 写入 daemon data root |
| 4 | `~/.open-design/skills/<id>/` | legacy `SKILL.md` | OD canonical skill install path可 symlink 到其它 agent |
| 5 | `~/.claude/skills/<id>/` | legacy `SKILL.md` | 外部 Claude Code / skills 工具写入的兼容路径,只读扫描 |
| 6 | repo root `skills/`, `design-systems/`, `craft/` | bundled resources | 现有一方资源,不变 |
冲突解决:按 normalized `name` / plugin id数字越小优先级越高。legacy `SKILL.md` location 被 adapter 合成为 plugin record但不会被复制到 `~/.open-design/plugins/`,除非用户显式执行 `od plugin install`。这样现有 Claude skills 继续零配置可用,新 plugin bundle 也有清晰的安装根。
冲突解决:按 normalized `name` / plugin id数字越小优先级越高。legacy `SKILL.md` location 被 adapter 合成为 plugin record但不会被复制到 `<daemonDataDir>/plugins/`,除非用户显式执行 `od plugin install`。这样现有 Claude skills 继续零配置可用,新 plugin bundle 也有清晰的安装根。
### 7.2 安装源
@ -1387,12 +1387,12 @@ od atoms list [--json] # first-party atoms (
#### Daemon control新增
```
od daemon start [--headless] [--serve-web] [--port <n>] [--host <h>] [--namespace <ns>]
od daemon start [--headless] [--serve-web] [--port <n>] [--host <h>]
# explicit lifecycle (§11.7);
# default `od` (no args) keeps current behavior
od daemon stop [--namespace <ns>]
od daemon stop [--daemon-url <url>]
od daemon status [--json] # alias of `od status`
od status [--json] # daemon up? port? namespace? installed plugins count
od status [--json] # daemon up? port? installed plugins count
od doctor # diagnostics: skills/DS/craft/plugins, providers, MCP
od version [--json]
od config get|set|list|unset [--key ...] [--value ...] # backed by media-config.json + db
@ -1426,7 +1426,7 @@ od mcp live-artifacts # specialized MCP server
| Exit | Meaning | Recovery hint | Structured stderr `data` 字段(节选) |
| --- | --- | --- | --- |
| 64 | Daemon not running | `od status`,然后启动 daemon | `{ host, port, namespace }` |
| 64 | Daemon not running | `od status`,然后启动 daemon | `{ host, port }` |
| 65 | Plugin not found / not installed | `od plugin list``od plugin install <source>` | `{ pluginId, candidateSources[] }` |
| 66 | Plugin restricted, capability required | `od plugin trust <id> --caps …` 或重试时加 `--grant-caps …` | `{ pluginId, pluginVersion, required[], granted[], remediation[] }` |
| 67 | Required input missing on apply | 用每个缺失字段重新运行 `--input k=v` | `{ pluginId, missing[], schema }` |
@ -1615,7 +1615,7 @@ operator 应挂载三个 volumes它们映射到根 [`AGENTS.md`](../AGENTS.md
| Mount path | Env var | Purpose |
| --- | --- | --- |
| `/data/od` | `OD_DATA_DIR` | Projects、SQLite、artifacts、installed plugins`.od/` 与 `~/.open-design/plugins/` 合并到这里 |
| `/data/od` | `OD_DATA_DIR` | Projects、SQLite、artifacts、installed plugins`<OD_DATA_DIR>/plugins` |
| `/data/config` | `OD_MEDIA_CONFIG_DIR` | Provider credentials`media-config.json` |
| `/data/marketplaces` | (位于 `OD_DATA_DIR` 下) | Cached marketplace indexes |
@ -1630,7 +1630,6 @@ OD_PORT=17456
OD_BIND_HOST=0.0.0.0 # 当前 daemon 已读取的变量名([`apps/daemon/src/server.ts`](../apps/daemon/src/server.ts)
OD_DATA_DIR=/data/od
OD_MEDIA_CONFIG_DIR=/data/config
OD_NAMESPACE=production # multi-tenant isolation key
OD_TRUST_DEFAULT=restricted # safe default for hosted (§9) — Phase 5 引入
OD_AGENT_BACKEND=claude # default code agent backend
OD_API_TOKEN=<random> # required when OD_BIND_HOST != 127.0.0.1 — Phase 5 引入 bearer middleware
@ -1750,7 +1749,7 @@ Phase 1 内容(合并原 Phase 1 + Phase 2C 的最小子集):
- Endpoints`GET /api/plugins`、`GET /api/plugins/:id`、`POST /api/plugins/install`folder + github tarball、`POST /api/plugins/:id/uninstall`、`POST /api/plugins/:id/apply`、`GET /api/atoms`、`GET /api/applied-plugins/:snapshotId`。
- **Plugin CLI verbs** `od plugin install/list/info/uninstall/apply/doctor`。`od plugin apply --json` 是 Phase 2 inline rail 与外部 code agents 的必要能力,必须在此返回包含 `appliedPlugin: AppliedPluginSnapshot``ApplyResult`
- **Headless MVP CLI 闭环(新提前):** `od project create/list/info`、`od run start/watch/cancel`(含 `--follow` 与 ND-JSON streaming、`od files list/read`。这些包装 desktop UI 今天已经使用的 endpoints`POST /api/projects`、`POST /api/runs`、`GET /api/runs/:id/events`、project list/read endpoints不新增 HTTP surface只新增 CLI surface。
- `~/.open-design/plugins/<id>/` 写入路径,带 safe extractionpath-traversal guard、size cap、symlink rejection
- `<daemonDataDir>/plugins/<id>/` 写入路径,带 safe extractionpath-traversal guard、size cap、symlink rejection
Validation
@ -1952,7 +1951,7 @@ installer 会把 nested skills/design-systems/craft fan out 到 registry 的 nam
- **`od plugin run` headless contract**:当前是否足够,还是也为非 CLI agents 暴露 HTTP POST endpoint默认v1 只 CLIPhase 4 如需要再补 HTTP。
- **Multi-tenant authper-user OAuth、RBAC、project ownership、billing** 明确不属于 v1。Docker image 设计为 single-tenant一个 `OD_API_TOKEN`。Multi-tenancy 是 post-v1 story需要单独 spec。请确认 first ecosystem release 这个 scope 是否可接受。
- **Hosted mode 的 trust propagation**:当前 spec 已锁定 arbitrary GitHub / URL / local 插件默认 `restricted`,第三方 marketplace 也默认不传递 trust。还需确认 hosted deployments 是否允许 operator 通过 `OD_TRUSTED_PLUGINS` 直接 trust 单个插件,还是必须先 trust 它所属 marketplace。
- **Discovery-time hot reload**daemon 是否 watch `~/.open-design/plugins/`(开发体验好),还是只在 `od plugin install/update/uninstall` 后 reload稳定性高默认watch500ms debounce。
- **Discovery-time hot reload**daemon 是否 watch `<daemonDataDir>/plugins/`(开发体验好),还是只在 `od plugin install/update/uninstall` 后 reload稳定性高默认watch500ms debounce。
- **Versioning policy**:安装时 pin tag/SHA还是默认跟踪 default branch 并提供 opt-in pin默认安装时 pin resolved ref`od plugin update` 重新 resolve。
- ~~**Plugin prompt block 提到 contracts 共享层的时机**~~**已按 PB1 解决。** Phase 2A 已把 `renderPluginBlock(snapshot)` 放到 `packages/contracts/src/prompts/plugin-block.ts`;两份 composer import 同一函数v1 fallback rejection 规则保留。
- ~~**`AppliedPluginSnapshot` 留存策略**~~**已按 PB2 解决。** 被 run / conversation / project 引用的 snapshot 永久 pin未引用 snapshot 默认 30 天过期;`OD_SNAPSHOT_RETENTION_DAYS` 是 operator opt-in`od plugin snapshots prune` 保留为强制清理出口。
@ -2309,7 +2308,7 @@ OD 今天做的事可以归到三类:
| --- | --- | --- |
| **A. 用户态(可移到 plugin** | 一方 plugin 的 manifest + SKILL.md | atom prompt fragment、默认 reference pipeline、atom 自带的 MCP server、critique 评分坐标、discovery 提问措辞 |
| **B. 内核(必须留在 daemon** | `apps/daemon/src/...` | snapshot SQLite 写表、GenUI 持久化与复用、capability gate + tool-token 颁发、devloop scheduler + `until` 求值 + ceiling、OAuth token 存储、`composeSystemPrompt()` *作为 assembler*、project metadata block |
| **C. v1 已经是 plugin 驱动** | 已经在 `~/.open-design/plugins/...` 或 per-project plugin 文件夹 | 当前 SKILL.md、DESIGN.md、craft .md、plugin 声明的 MCP server、plugin 声明的 GenUI surface、plugin 声明的 pipeline |
| **C. v1 已经是 plugin 驱动** | 已经在 `<daemonDataDir>/plugins/...` 或 per-project plugin 文件夹 | 当前 SKILL.md、DESIGN.md、craft .md、plugin 声明的 MCP server、plugin 声明的 GenUI surface、plugin 声明的 pipeline |
B 类的清单是**内核边界**:不是"难以 plugin 化",而是"出于安全、持久化、运行时状态原因必须留在 daemon"。一个让 plugin 直接写 `applied_plugin_snapshots` 行或颁发 connector tool token 的 plugin runtime 是坏掉的 runtime。
@ -2378,7 +2377,7 @@ Bundled scenario plugins 与 pipeline fallback resolver 现在已经存在。剩
daemon 启动多一步:
1. 走 `<repo-root>/plugins/_official/**`,把每个 plugin 注册到 `installed_plugins` 中:`source_kind='bundled'`、`trust='bundled'`、capabilities = 该 plugin 声明的 `od.capabilities`
2. bundled plugin **不**复制到 `~/.open-design/plugins/`;它们直接从 repo path 加载并热重载daemon 升级时与 daemon 代码同步替换。
2. bundled plugin **不**复制到 `<daemonDataDir>/plugins/`;它们直接从 repo path 加载并热重载daemon 升级时与 daemon 代码同步替换。
3. `od plugin uninstall` 拒绝 uninstall `bundled` plugin会让 daemon 失能);`od plugin update` 对 bundled 是 no-op。
4. 用户可以安装一个 id 与 bundled 相同的 `trusted``restricted` plugin正常 apply 时用户 copy 胜出,但 daemon 保留 bundled copy 作为 fallback给那些 pin 了 bundled 版本的旧 `AppliedPluginSnapshot` replay 用。

View file

@ -5,6 +5,8 @@ import { promisify } from 'node:util';
import { e2eWorkspaceRoot, type SmokeSuite } from './smoke-suite.ts';
const execFileAsync = promisify(execFile);
const pnpmCommand = process.env.OD_E2E_PNPM_COMMAND ?? 'pnpm';
const pnpmExecPath = process.env.npm_execpath;
export type ToolsDevAppStatus = {
pid?: number;
@ -138,7 +140,13 @@ export async function readToolsDevLogs(suite: SmokeSuite): Promise<Record<string
}
async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T> {
const { stdout } = await execFileAsync('pnpm', ['tools-dev', ...args], {
const command = process.env.OD_E2E_PNPM_COMMAND == null && pnpmExecPath
? process.execPath
: pnpmCommand;
const commandArgs = command === process.execPath && process.env.OD_E2E_PNPM_COMMAND == null && pnpmExecPath
? [pnpmExecPath, 'tools-dev', ...args]
: ['tools-dev', ...args];
const { stdout } = await execFileAsync(command, commandArgs, {
cwd: e2eWorkspaceRoot(),
env: {
...process.env,
@ -147,6 +155,7 @@ async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T>
OD_MEDIA_CONFIG_DIR: suite.dataDir,
},
maxBuffer: 20 * 1024 * 1024,
shell: process.platform === 'win32' && command !== process.execPath,
});
return parseJsonOutput<T>(stdout);
}

View file

@ -0,0 +1,235 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { describe, expect, test } from 'vitest';
import { requestJson } from '@/vitest/http';
import { createSmokeSuite, e2eWorkspaceRoot } from '@/vitest/smoke-suite';
const execFileAsync = promisify(execFile);
type DaemonStatusResponse = {
dataDir?: string;
installedPlugins?: unknown;
mediaConfigDir?: string | null;
namespace?: unknown;
pid?: number;
port?: number;
};
type McpInstallInfoResponse = {
args: string[];
env: Record<string, string>;
};
type InstalledPlugin = {
fsPath?: string;
id: string;
source?: string;
sourceKind?: string;
version?: string;
};
type SseEvent = {
data: unknown;
event: string;
};
describe('namespace isolation spec', () => {
test('keeps namespace in lifecycle infrastructure while daemon clients use URL or concrete IPC transport', async () => {
const suite = await createSmokeSuite('namespace-isolation');
await suite.with.toolsDev(async ({ runtime, status, webUrl }) => {
expect(status.namespace).toBe(suite.namespace);
const daemonStatus = await requestJson<DaemonStatusResponse>(webUrl, '/api/daemon/status');
expect(daemonStatus.port).toBe(runtime.daemonPort);
expect(daemonStatus.dataDir).toBe(suite.dataDir);
expect(daemonStatus).not.toHaveProperty('namespace');
const installInfo = await requestJson<McpInstallInfoResponse>(webUrl, '/api/mcp/install-info');
const ipcPath = installInfo.env.OD_SIDECAR_IPC_PATH;
if (typeof ipcPath !== 'string' || ipcPath.length === 0) {
throw new Error('MCP install-info did not include OD_SIDECAR_IPC_PATH');
}
expect(ipcPath).toEqual(expect.any(String));
expect(installInfo.args).not.toContain('--daemon-url');
expect(installInfo.env.OD_DATA_DIR).toBe(suite.dataDir);
expect(installInfo.env).not.toHaveProperty('OD_NAMESPACE');
expect(installInfo.env).not.toHaveProperty('OD_SIDECAR_NAMESPACE');
expect(installInfo.env).not.toHaveProperty('OD_SIDECAR_IPC_BASE');
const cliStatus = await runDaemonCliJson<DaemonStatusResponse>(
['daemon', 'status', '--json'],
{
OD_DATA_DIR: suite.dataDir,
OD_NAMESPACE: 'wrong-daemon-namespace',
OD_SIDECAR_IPC_BASE: path.join(suite.scratchDir, 'wrong-ipc-base'),
OD_SIDECAR_IPC_PATH: ipcPath,
OD_SIDECAR_NAMESPACE: 'wrong-sidecar-namespace',
},
);
expect(cliStatus.port).toBe(runtime.daemonPort);
expect(cliStatus.dataDir).toBe(suite.dataDir);
expect(cliStatus).not.toHaveProperty('namespace');
const rejected = await runDaemonCliExpectFailure(
['daemon', 'status', '--json', '--namespace', 'should-not-parse'],
{ OD_SIDECAR_IPC_PATH: ipcPath },
);
expect(`${rejected.stdout}\n${rejected.stderr}`).toContain('unknown flag: --namespace');
const pluginSource = await writeLocalPluginFixture(suite.scratchDir);
const sourceForDaemon = `./${path.relative(e2eWorkspaceRoot(), pluginSource).replace(/\\/g, '/')}`;
const installResponse = await fetch(new URL('/api/plugins/install', ensureTrailingSlash(webUrl)), {
body: JSON.stringify({ source: sourceForDaemon }),
headers: { 'content-type': 'application/json' },
method: 'POST',
});
expect(installResponse.ok).toBe(true);
const installEvents = parseSseEvents(await installResponse.text());
const success = installEvents.find((entry) => entry.event === 'success')?.data as
| { plugin?: InstalledPlugin }
| undefined;
const expectedPluginRoot = path.join(suite.dataDir, 'plugins', 'e2e-namespace-plugin');
expect(success?.plugin?.id).toBe('e2e-namespace-plugin');
expect(success?.plugin?.fsPath).toBe(expectedPluginRoot);
const pluginInfo = await requestJson<InstalledPlugin>(webUrl, '/api/plugins/e2e-namespace-plugin');
expect(pluginInfo.fsPath).toBe(expectedPluginRoot);
await access(path.join(expectedPluginRoot, 'open-design.json'));
expect(await readFile(path.join(expectedPluginRoot, 'SKILL.md'), 'utf8')).toContain('E2E namespace plugin');
await suite.report.json('summary.json', {
cliStatus,
daemonStatus,
installInfo: {
args: installInfo.args,
envKeys: Object.keys(installInfo.env).sort(),
ipcPath,
},
plugin: pluginInfo,
runtime,
toolsDevNamespace: status.namespace,
});
});
}, 180_000);
});
async function writeLocalPluginFixture(root: string): Promise<string> {
const pluginRoot = path.join(root, 'plugin-source');
await mkdir(pluginRoot, { recursive: true });
await writeFile(
path.join(pluginRoot, 'open-design.json'),
JSON.stringify(
{
name: 'e2e-namespace-plugin',
od: {
inputs: [{ name: 'topic', required: true, type: 'string' }],
kind: 'skill',
taskKind: 'new-generation',
useCase: { query: 'Make a {{topic}} brief.' },
},
title: 'E2E Namespace Plugin',
version: '1.0.0',
},
null,
2,
),
);
await writeFile(
path.join(pluginRoot, 'SKILL.md'),
[
'# E2E namespace plugin',
'',
'Create a deterministic fixture artifact for namespace isolation tests.',
'',
].join('\n'),
);
return pluginRoot;
}
async function runDaemonCliJson<T>(args: string[], env: Record<string, string>): Promise<T> {
const result = await runDaemonCli(args, env);
return parseJsonOutput<T>(result.stdout);
}
async function runDaemonCliExpectFailure(
args: string[],
env: Record<string, string>,
): Promise<{ stderr: string; stdout: string }> {
try {
await runDaemonCli(args, env);
} catch (error) {
const failure = error as { stderr?: string; stdout?: string };
return {
stderr: failure.stderr ?? '',
stdout: failure.stdout ?? '',
};
}
throw new Error(`expected daemon CLI to fail for args: ${args.join(' ')}`);
}
async function runDaemonCli(
args: string[],
env: Record<string, string>,
): Promise<{ stderr: string; stdout: string }> {
const mergedEnv: NodeJS.ProcessEnv = {
...process.env,
...env,
};
delete mergedEnv.OD_DAEMON_URL;
delete mergedEnv.OD_PORT;
const { stderr, stdout } = await execFileAsync(
process.execPath,
['--import', 'tsx', 'apps/daemon/src/cli.ts', ...args],
{
cwd: e2eWorkspaceRoot(),
env: mergedEnv,
maxBuffer: 20 * 1024 * 1024,
},
);
return { stderr, stdout };
}
function parseJsonOutput<T>(stdout: string): T {
const trimmed = stdout.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return JSON.parse(trimmed) as T;
}
const objectStart = stdout.lastIndexOf('\n{');
const arrayStart = stdout.lastIndexOf('\n[');
const jsonStart = Math.max(objectStart, arrayStart);
if (jsonStart < 0) {
throw new Error(`Expected JSON output from daemon CLI, got: ${stdout}`);
}
return JSON.parse(stdout.slice(jsonStart + 1)) as T;
}
function parseSseEvents(text: string): SseEvent[] {
return text
.split(/\r?\n\r?\n/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => {
let event = 'message';
const dataLines: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) event = line.slice('event:'.length).trim();
if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim());
}
return {
data: dataLines.length > 0 ? JSON.parse(dataLines.join('\n')) : null,
event,
};
});
}
function ensureTrailingSlash(value: string): string {
return value.endsWith('/') ? value : `${value}/`;
}

View file

@ -9,12 +9,15 @@
// pnpm seed:test-projects # default bundle
// pnpm seed:test-projects --decks 2 --webs 2 # cap counts
// pnpm seed:test-projects --daemon http://127.0.0.1:17456
// pnpm seed:test-projects --namespace work-a # discover tools-dev namespace
// pnpm seed:test-projects --offline # ingest into ./.od before boot
// pnpm seed:test-projects --clear # remove previously seeded projects
//
// The daemon URL is resolved in this order: --daemon flag > $OD_DAEMON_URL >
// http://127.0.0.1:$OD_PORT > whatever `pnpm tools-dev status --json` reports
// for the daemon app. The discovery step is what makes the two-shell flow
// for the daemon app. --namespace is only passed to that tools-dev discovery
// step; it is not forwarded to the od CLI or stored in daemon data. The
// discovery step is what makes the two-shell flow
// (`pnpm tools-dev` then `pnpm seed:test-projects`) work without extra flags,
// because tools-dev defaults to an ephemeral daemon port that isn't exported
// to sibling shells.
@ -224,6 +227,7 @@ const COMMUNITY_PLUGINS: SeedFixture[] = [
interface Args {
daemonUrl: string | null;
dataDir: string | null;
namespace: string | null;
mode: SeedMode;
decks: number;
webs: number;
@ -236,6 +240,7 @@ function parseArgs(argv: string[]): Args {
const out: Args = {
daemonUrl: null,
dataDir: null,
namespace: null,
mode: 'auto',
decks: DECKS.length,
webs: WEBS.length,
@ -259,6 +264,13 @@ function parseArgs(argv: string[]): Args {
process.exit(2);
}
out.dataDir = value;
} else if (a === '--namespace') {
const value = argv[++i];
if (!value) {
console.error('--namespace requires a name argument');
process.exit(2);
}
out.namespace = value;
} else if (a === '--mode') {
const value = argv[++i];
if (value !== 'auto' && value !== 'online' && value !== 'offline') {
@ -317,6 +329,9 @@ Options:
--online Alias for --mode online.
--offline Alias for --mode offline.
--data-dir <dir> Offline target data dir (default: \$OD_DATA_DIR or ./.od).
--namespace <name> Tools-dev namespace for online auto-discovery. This does
not affect od CLI behavior. Offline mode requires
--data-dir or OD_DATA_DIR when --namespace is set.
--decks <n> Number of slide decks to seed (default: ${DECKS.length}, max: ${DECKS.length})
--webs <n> Number of web prototypes to seed (default: ${WEBS.length}, max: ${WEBS.length})
--default-plugins <n>
@ -337,6 +352,8 @@ Online daemon URL resolution (first match wins):
extra flags:
pnpm tools-dev # in one shell
pnpm seed:test-projects # in another discovers the running daemon
For a non-default tools-dev namespace, pass \`--namespace <name>\` so the
status lookup reads that namespace.
Offline ingest before boot:
pnpm seed:test-projects --offline --data-dir ./.od
@ -353,7 +370,7 @@ function isDiscoverablePort(value: string | undefined): value is string {
return Number.isInteger(n) && n > 0 && n < 65536;
}
async function discoverDaemonUrlFromToolsDev(): Promise<string | null> {
async function discoverDaemonUrlFromToolsDev(namespace: string | null): Promise<string | null> {
return await new Promise<string | null>((resolve) => {
let child;
try {
@ -361,7 +378,9 @@ async function discoverDaemonUrlFromToolsDev(): Promise<string | null> {
// engine" when the local node version doesn't match the repo's
// engines.node). Without it, those warnings land on stdout under a
// nested pnpm context and break the JSON parse below.
child = spawn('pnpm', ['--silent', 'exec', 'tools-dev', 'status', '--json'], {
const statusArgs = ['--silent', 'exec', 'tools-dev', 'status', '--json'];
if (namespace) statusArgs.push('--namespace', namespace);
child = spawn('pnpm', statusArgs, {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
});
@ -410,7 +429,7 @@ async function resolveDaemonUrl(args: Args, { required }: { required: boolean })
if (isDiscoverablePort(process.env.OD_PORT)) {
return `http://127.0.0.1:${process.env.OD_PORT}`;
}
const discovered = await discoverDaemonUrlFromToolsDev();
const discovered = await discoverDaemonUrlFromToolsDev(args.namespace);
if (discovered) return discovered;
if (!required) return null;
throw new Error(
@ -576,6 +595,16 @@ function resolveDataDir(raw: string | null): string {
return path.isAbsolute(expanded) ? expanded : path.resolve(REPO_ROOT, expanded);
}
function assertOfflineDataDirIsExplicit(args: Args): void {
if (args.namespace && !args.dataDir && !process.env.OD_DATA_DIR) {
throw new Error(
'--namespace is only a tools-dev discovery selector. Offline mode with ' +
'--namespace requires --data-dir or OD_DATA_DIR so the script does not ' +
'guess a namespace-scoped daemon data directory.',
);
}
}
function loadBetterSqlite(): new (filename: string) => OfflineDatabase {
const daemonRequire = createRequire(path.join(REPO_ROOT, 'apps', 'daemon', 'package.json'));
return daemonRequire('better-sqlite3') as new (filename: string) => OfflineDatabase;
@ -664,6 +693,7 @@ function migrateOffline(db: OfflineDatabase): void {
}
async function openOfflineSeedContext(args: Args): Promise<OfflineSeedContext> {
assertOfflineDataDirIsExplicit(args);
const dataDir = resolveDataDir(args.dataDir);
await mkdir(dataDir, { recursive: true });
const Database = loadBetterSqlite();

View file

@ -39,7 +39,6 @@ services:
# exposing to the public internet. Phase 5 wires the daemon to
# refuse OD_BIND_HOST=0.0.0.0 without OD_API_TOKEN.
OD_API_TOKEN: ${OD_API_TOKEN:-}
OD_NAMESPACE: ${OD_NAMESPACE:-production}
# Snapshot retention knobs (spec §11.4 PB2). Default ttl is 30
# days; set to 0 to keep snapshots forever.
OD_SNAPSHOT_UNREFERENCED_TTL_DAYS: ${OD_SNAPSHOT_UNREFERENCED_TTL_DAYS:-30}

View file

@ -41,7 +41,6 @@ env:
OD_PORT: "7456"
OD_DATA_DIR: /data/od
OD_MEDIA_CONFIG_DIR: /data/config
OD_NAMESPACE: production
OD_SNAPSHOT_UNREFERENCED_TTL_DAYS: "30"
OD_SNAPSHOT_GC_INTERVAL_MS: "21600000"

View file

@ -183,16 +183,20 @@ async function copyOptional(sourcePath, destinationPath, options = {}) {
return true;
}
async function linkRelative(sourcePath, destinationPath) {
async function linkRelative(sourcePath, destinationPath, options = {}) {
if (!(await pathExists(sourcePath))) return false;
if (await pathLstatExists(destinationPath)) return false;
await mkdir(path.dirname(destinationPath), { recursive: true });
if (options.copyInsteadOfSymlink === true) {
await copyRequired(sourcePath, destinationPath, { dereference: true });
return true;
}
const relativeTarget = path.relative(path.dirname(destinationPath), sourcePath);
await symlink(relativeTarget.length === 0 ? "." : relativeTarget, destinationPath);
return true;
}
async function linkPnpmPublicHoist(destinationRoot) {
async function linkPnpmPublicHoist(destinationRoot, options = {}) {
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
const hoistRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules");
const entries = await readdir(hoistRoot, { withFileTypes: true }).catch(() => []);
@ -205,13 +209,13 @@ async function linkPnpmPublicHoist(destinationRoot) {
for (const scopedEntry of scopedEntries) {
const scopedSource = path.join(sourcePath, scopedEntry);
const scopedDestination = path.join(nodeModulesRoot, entry.name, scopedEntry);
if (await linkRelative(scopedSource, scopedDestination)) linked.push(scopedDestination);
if (await linkRelative(scopedSource, scopedDestination, options)) linked.push(scopedDestination);
}
continue;
}
const destinationPath = path.join(nodeModulesRoot, entry.name);
if (await linkRelative(sourcePath, destinationPath)) linked.push(destinationPath);
if (await linkRelative(sourcePath, destinationPath, options)) linked.push(destinationPath);
}
return linked;
@ -243,7 +247,7 @@ async function installStandaloneResource(config, resourcesRoot, platformName) {
await copyRequired(path.join(sourceWebRoot, "server.js"), path.join(destinationWebRoot, "server.js"));
await copyOptional(path.join(sourceWebRoot, "package.json"), path.join(destinationWebRoot, "package.json"));
const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"), copyOptions);
const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot);
const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot, { copyInsteadOfSymlink: platformName === "win32" });
await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next"));
const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static"));
const copiedPublic = await copyOptional(config.webPublicSourceRoot, path.join(destinationWebRoot, "public"));

View file

@ -158,7 +158,7 @@ export class ToolPackCache {
const nextManifest = manifest ?? await (async () => {
status = existingManifest == null ? "miss" : "stale";
reason = invalidation?.reason ?? "missing manifest";
const stagingPath = join(dirname(entryPath), `${basename(entryPath)}.tmp-${process.pid}-${randomUUID()}`);
const stagingPath = join(dirname(entryPath), `${basename(entryPath).slice(0, 12)}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`);
await rm(stagingPath, { force: true, recursive: true });
await mkdir(stagingPath, { recursive: true });
try {

View file

@ -38,6 +38,8 @@ const INTERNAL_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },
@ -301,6 +303,8 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]);
await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]);
await runPnpm(config, ["--filter", "@open-design/platform", "build"]);
await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]);
await runPnpm(config, ["--filter", "@open-design/plugin-runtime", "build"]);
await runPnpm(config, ["--filter", "@open-design/daemon", "build"]);
try {
await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: "server" });
@ -1185,7 +1189,7 @@ export async function installPackedLinuxHeadless(config: ToolPackConfig): Promis
const script = [
"#!/bin/sh",
`# Open Design headless launcher — namespace: ${config.namespace}`,
`OD_NAMESPACE=${JSON.stringify(config.namespace)} OD_DATA_DIR=${JSON.stringify(dataDir)} OD_RESOURCE_ROOT=${JSON.stringify(paths.resourceRoot)} exec ${JSON.stringify(nodePath)} ${JSON.stringify(entryPath)} "$@"`,
`OD_PACKAGED_NAMESPACE=${JSON.stringify(config.namespace)} OD_DATA_DIR=${JSON.stringify(dataDir)} OD_RESOURCE_ROOT=${JSON.stringify(paths.resourceRoot)} exec ${JSON.stringify(nodePath)} ${JSON.stringify(entryPath)} "$@"`,
].join("\n") + "\n";
await writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
@ -1226,9 +1230,9 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
cwd: dirname(entryPath),
env: {
...process.env,
// Bake in the namespace so headless uses the same namespace as the
// tools-pack config regardless of the caller's environment.
OD_NAMESPACE: config.namespace,
// Bake in the packaged namespace so headless uses the same namespace
// as the tools-pack config regardless of the caller's environment.
OD_PACKAGED_NAMESPACE: config.namespace,
// Point the headless data root at the tools-pack runtime directory so
// the identity marker is written to the path this function polls.
// headless.ts computes: join(OD_DATA_DIR, "namespaces") which must

View file

@ -19,7 +19,6 @@ export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = {
} as const;
export const MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
"@open-design/contracts",
"@open-design/daemon",
"@open-design/desktop",
"@open-design/packaged",

View file

@ -5,6 +5,8 @@ export const INTERNAL_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },

View file

@ -19,7 +19,6 @@ export const WIN_PREBUNDLE_RUNTIME_DEPENDENCIES = {
} as const;
export const WIN_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
"@open-design/contracts",
"@open-design/daemon",
"@open-design/desktop",
"@open-design/packaged",

View file

@ -152,7 +152,7 @@ export async function createWorkspaceTarballsCacheKey(config: ToolPackConfig): P
packageManager: rootPackageJson.packageManager,
pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")),
prebundle: shouldUseWinStandalonePrebundle(config.webOutputMode),
schemaVersion: 5,
schemaVersion: 6,
webOutputMode: config.webOutputMode,
});
}

View file

@ -34,6 +34,8 @@ export const INTERNAL_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },

View file

@ -11,6 +11,8 @@ const WORKSPACE_BUILD_PACKAGES = [
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "packages/agui-adapter", name: "@open-design/agui-adapter" },
{ directory: "packages/plugin-runtime", name: "@open-design/plugin-runtime" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },
@ -22,6 +24,8 @@ const BUILD_COMMANDS = [
{ args: ["--filter", "@open-design/sidecar-proto", "build"] },
{ args: ["--filter", "@open-design/sidecar", "build"] },
{ args: ["--filter", "@open-design/platform", "build"] },
{ args: ["--filter", "@open-design/agui-adapter", "build"] },
{ args: ["--filter", "@open-design/plugin-runtime", "build"] },
{ args: ["--filter", "@open-design/daemon", "build"] },
{ args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] },
{ args: ["--filter", "@open-design/web", "build:sidecar"] },
@ -74,7 +78,7 @@ async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise<str
packageManager: await readPackageManager(config.workspaceRoot),
platform: config.platform,
pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")),
schemaVersion: 4,
schemaVersion: 5,
webOutputMode: config.webOutputMode,
});
}
@ -93,6 +97,10 @@ function workspaceBuildOutputFiles(config: ToolPackConfig): string[] {
"packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts",
"packages/agui-adapter/dist/index.mjs",
"packages/agui-adapter/dist/index.d.ts",
"packages/plugin-runtime/dist/index.mjs",
"packages/plugin-runtime/dist/index.d.ts",
"apps/daemon/dist/cli.js",
"apps/daemon/dist/cli.d.ts",
"apps/daemon/dist/sidecar/index.js",
@ -112,6 +120,8 @@ function workspaceBuildArtifacts(config: ToolPackConfig): WorkspaceBuildArtifact
"packages/sidecar-proto/dist",
"packages/sidecar/dist",
"packages/platform/dist",
"packages/agui-adapter/dist",
"packages/plugin-runtime/dist",
"apps/daemon/dist",
"apps/web/dist",
"apps/desktop/dist",
@ -132,7 +142,7 @@ async function copyWorkspaceBuildArtifactsToCache(config: ToolPackConfig, entryR
for (const artifact of workspaceBuildArtifacts(config)) {
const targetPath = join(entryRoot, artifact.cachePath);
await mkdir(dirname(targetPath), { recursive: true });
await cp(join(config.workspaceRoot, artifact.workspacePath), targetPath, { recursive: true });
await cp(join(config.workspaceRoot, artifact.workspacePath), targetPath, { dereference: true, recursive: true });
}
}

View file

@ -42,7 +42,6 @@ describe("mac standalone prebundle policy", () => {
it("excludes internal packages replaced by mac standalone prebundles", () => {
for (const packageName of [
"@open-design/contracts",
"@open-design/daemon",
"@open-design/desktop",
"@open-design/packaged",
@ -58,6 +57,12 @@ describe("mac standalone prebundle policy", () => {
}),
).toBe(false);
}
expect(
shouldInstallInternalPackageForMacPrebundle({
packageName: "@open-design/contracts",
webOutputMode: "standalone",
}),
).toBe(true);
});
it("documents the explicit code-level bundle boundaries", () => {

View file

@ -9,6 +9,7 @@ const require = createRequire(import.meta.url);
const runWebStandaloneAfterPack = require("../resources/web-standalone-after-pack.cjs") as (context: unknown) => Promise<void>;
const CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG";
const darwinSymlinkIt = process.platform === "win32" ? it.skip : it;
async function pathExists(filePath: string): Promise<boolean> {
try {
@ -246,7 +247,7 @@ describe("web standalone afterPack hook", () => {
}
});
it("rewrites darwin copied pnpm symlinks to stay inside the packaged resource", async () => {
darwinSymlinkIt("rewrites darwin copied pnpm symlinks to stay inside the packaged resource", async () => {
const fixture = await runFixture({
includeWebNext: true,
platformName: "darwin",

View file

@ -42,7 +42,6 @@ describe("win standalone prebundle policy", () => {
it("excludes internal packages replaced by win standalone prebundles", () => {
for (const packageName of [
"@open-design/contracts",
"@open-design/daemon",
"@open-design/desktop",
"@open-design/packaged",
@ -58,6 +57,12 @@ describe("win standalone prebundle policy", () => {
}),
).toBe(false);
}
expect(
shouldInstallInternalPackageForWinPrebundle({
packageName: "@open-design/contracts",
webOutputMode: "standalone",
}),
).toBe(true);
});
it("documents the explicit code-level bundle boundaries", () => {

View file

@ -13,6 +13,8 @@ const PACKAGE_DIRS = [
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"packages/agui-adapter",
"packages/plugin-runtime",
"apps/daemon",
"apps/web",
"apps/desktop",
@ -28,6 +30,10 @@ const OUTPUT_FILES = [
"packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts",
"packages/agui-adapter/dist/index.mjs",
"packages/agui-adapter/dist/index.d.ts",
"packages/plugin-runtime/dist/index.mjs",
"packages/plugin-runtime/dist/index.d.ts",
"apps/daemon/dist/cli.js",
"apps/daemon/dist/cli.d.ts",
"apps/daemon/dist/sidecar/index.js",