mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
chore: align namespace lifecycle packaging
This commit is contained in:
parent
d83b228c81
commit
cba8bf151d
39 changed files with 572 additions and 276 deletions
2
.github/workflows/release-beta.yml
vendored
2
.github/workflows/release-beta.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/release-stable.yml
vendored
2
.github/workflows/release-stable.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, …).
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 1–4 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 extraction(path-traversal guard、size cap、symlink rejection)。
|
||||
- `<daemonDataDir>/plugins/<id>/` 写入路径,带 safe extraction(path-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 只 CLI;Phase 4 如需要再补 HTTP。)
|
||||
- **Multi-tenant auth(per-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(稳定性高)?(默认:watch,500ms debounce。)
|
||||
- **Discovery-time hot reload**:daemon 是否 watch `<daemonDataDir>/plugins/`(开发体验好),还是只在 `od plugin install/update/uninstall` 后 reload(稳定性高)?(默认:watch,500ms 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 用。
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
235
e2e/specs/namespace/main.spec.ts
Normal file
235
e2e/specs/namespace/main.spec.ts
Normal 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}/`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue