diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml new file mode 100644 index 0000000000..a85f2eaf2c --- /dev/null +++ b/apps/cli/cordis.yml @@ -0,0 +1,224 @@ +# dsh web — the full web-shape composition: host runtime (layer 1), the +# transport/service layer (layer 2), and the browser plugin roster (dshClient +# rows the modules node half scans into window.__DSH_BOOT__). Row order +# carries no load semantics (activation is service-availability driven); the +# grouping below is for readers. `--dev` appends the dsh-client-hmr row in +# code (AppCLIEntry) — prod and dev differ by exactly that one row. +# AppCLIEntry patches this tree before boot: profile json + CLI flags + +# distIndex land as config patches over the rows below (yaml = engineering +# defaults, json = user config, user wins per field). + +# ── layer 1: runtime ──────────────────────────────────────────────────────── + +- id: timer + name: '@cordisjs/plugin-timer' + +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +# Model-made titles on the first-message cadence (the web sidebar renders +# session/title). Same values as the TUI composition. +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +- id: tools + name: '@deepseek-ai/dsh-tools' + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks' + +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The native DeepSeek adapter; reads the key/base-url the boot's layered +# .env loading (cwd then $DSH_HOME) left in the environment. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + +- id: bash-local + name: '@deepseek-ai/dsh-bash-local' + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# fs cwd stays the package default (process.cwd()) — the same value the +# gateway injects into session.cwd, so paths and sessions agree. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +- id: skill + name: '@deepseek-ai/dsh-skill' + +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# token-meter rejects unknown config keys — keep this row bare. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +# Omitting maxInlineBytes makes the whole policy a silent no-op — always +# state it explicitly. +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + +# The API gateway: the transport-agnostic dispatch face every client shape +# shares. provider/model are the host default routing — the profile json's +# mapping target (user config overrides these engineering defaults). +- id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + +# ── layer 2: transport/service ────────────────────────────────────────────── + +# Plain route-registration carrier. distIndex is an assembly fact, not user +# config — AppCLIEntry resolves the frontend dist and patches it in; host and +# port arrive as CLI-flag patches over these defaults. +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + +# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + +# Dual-face: node half scans this very tree for dshClient rows, composes +# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the +# module table the shell kernel constructs before cordis exists (§4.7 — +# adopted as a plugin entry by the kernel, never fetched). +- id: modules + name: '@deepseek-ai/dsh-client-modules' + +# Owns both ends of the web transport: node half binds the gateway to the +# webserver under /api; browser half is the fetch/SSE client. +- id: connection + name: '@deepseek-ai/dsh-client-connection' + +- id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + +- id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + +- id: i18n + name: '@deepseek-ai/dsh-client-i18n' + +- id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + +- id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + +- id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + +- id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + +- id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8557965d34..a39ce726bb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,14 +9,22 @@ }, "files": [ "lib/bin.js", + "cordis.yml", "src" ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-loader": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", @@ -24,13 +32,48 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-spill-local": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "^4.0.0-rc.7", + "js-yaml": "^4.2.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts new file mode 100644 index 0000000000..1bb6678023 --- /dev/null +++ b/apps/cli/src/app-cli-entry.ts @@ -0,0 +1,229 @@ +/** + * AppCLIEntry — the pre-cordis boot glue every dsh surface shape shares + * (config-tree boot wired for `dsh web` this round; TUI/headless migrate + * later). Everything here is what must exist before the Loader runs: layered + * env, the patch composition over the shipped cordis.yml (profile json + CLI + * flags + the resolved frontend dist), and the fail-loud triple after the + * tree settles. + */ + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import type { FiberState } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import yaml from 'js-yaml' +import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */ +const PROFILE_DIR = '.dsh-tmp-profile' +const PROFILE_FILE = 'config.json' + +/** One profile-json key mapped onto a yml row's config field. */ +interface ProfileMapping { + jsonPath: string + entryId: string + configKey: string +} + +/** + * The static profile→row mapping table. json is user config and wins over the + * yml engineering default per field; a json key absent from this table fails + * loud (a typo silently ignored would read as "setting has no effect"). + * Developers extend deployments by adding rows here. + */ +export const PROFILE_MAPPINGS: ProfileMapping[] = [ + { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' }, + { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' }, + { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' }, +] + +// The include's YAML dialect: `!!js` scalars become expression nodes the +// Loader evaluates at entry activation. The bypass parse below must accept +// them (and passing one through a patch unchanged is legal). +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: data => ({ __jsExpr: String(data) }), +}) +const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** + * Value mirror of cordis's `FiberState` const enum members the sweep needs + * (a const enum has no runtime object to import; same rationale as the + * client-side mirror in dsh-client-web). + */ +const FIBER_ACTIVE = 2 as FiberState.ACTIVE +const FIBER_PENDING = 0 as FiberState.PENDING + +/** Constructor facts for one `dsh web` invocation (argv already parsed by web.ts). */ +export interface AppCLIEntryOptions { + /** Absolute path of the shipped cordis.yml. */ + configPath: string + /** Whether to append the HMR row (the whole prod/dev difference). */ + dev: boolean + /** --host when explicitly passed; undefined keeps the yml engineering default. */ + host?: string + /** --port when explicitly passed; undefined keeps the yml engineering default. */ + port?: number +} + +/** + * Boot driver for the config-tree `dsh web` shape: holds only what exists + * independently of (and prior to) cordis — argv facts, the composed patch + * set, and finally the root ctx. + */ +export class AppCLIEntry { + /** The root context, set by {@link run}. */ + ctx!: Context + + private patches: PatchOptions[] = [] + + constructor(private readonly options: AppCLIEntryOptions) {} + + /** + * Run the boot chain: layered env → patch composition → Loader include + * boot (dev row before await) → fail-loud triple. + * @returns the settled root context and the listening port. + */ + async run(): Promise<{ ctx: Context; port: number }> { + this.loadEnvLayers() + this.composePatches() + await this.bootTree() + this.assertBoot() + const port = this.ctx.get('httpServer')?.port + /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */ + if (port === undefined) throw new Error('dsh web: httpServer service missing after settled boot') + return { ctx: this.ctx, port } + } + + /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ + private loadEnvLayers(): void { + loadEnv('dsh web', resolveDshHome()) + } + + /** + * Compose the patch set from the three non-yml config sources: profile + * json (user config), CLI flags, and the resolved frontend dist. Patches + * replace a row's config wholesale, so each patched row's yml static + * values are re-read here (bypass parse) and merged under the overrides. + */ + private composePatches(): void { + const rows = this.parseYmlRows() + const overrides = new Map>() + const put = (entryId: string, key: string, value: unknown): void => { + const bag = overrides.get(entryId) ?? {} + bag[key] = value + overrides.set(entryId, bag) + } + + // Source 1: profile json (missing file = empty; unmapped key = loud). + for (const [key, value] of Object.entries(this.readProfile())) { + const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) + if (mapping === undefined) { + throw new Error(`dsh web: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`) + } + put(mapping.entryId, mapping.configKey, value) + } + + // Source 2: CLI flags (field set disjoint from the json mappings). + if (this.options.host !== undefined) put('webserver', 'host', this.options.host) + if (this.options.port !== undefined) put('webserver', 'port', this.options.port) + + // Source 3: the frontend dist — an assembly fact of this app, never yml + // user config. Workspace knowledge stays here. + put('webserver', 'distIndex', this.resolveDistIndex()) + + this.patches = [...overrides.entries()].map(([id, bag]) => { + const yml = rows.get(id) + if (yml === undefined) throw new Error(`dsh web: patch target row "${id}" not found in ${this.options.configPath}`) + return { id, config: { ...yml.config as Record ?? {}, ...bag } } + }) + } + + /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ + private async bootTree(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { + path: pathToFileURL(resolve(this.options.configPath)).href, + ...this.patches.length > 0 ? { patches: this.patches } : {}, + }, + }) + if (this.options.dev) { + await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) + } + this.ctx = ctx + await ctx.loader.await() + } + + /** + * Fail-loud triple: assertEntriesLoaded catches import failures, + * installFailLoud catches late apply rejections, and the all-ACTIVE sweep + * below catches PENDING fibers (cordis inject waiting has no timeout). + */ + private assertBoot(): void { + installFailLoud('dsh web') + assertEntriesLoaded(this.ctx, 'dsh web') + const failures: string[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.fiber === undefined || entry.disabled) continue + const state = entry.fiber.state + if (state === FIBER_ACTIVE) continue + if (state === FIBER_PENDING) { + const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined) + failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + } else { + failures.push(`${entry.options.name}: fiber state ${String(state)}`) + } + } + if (failures.length > 0) { + throw new Error(`dsh web: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + } + } + + /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ + private parseYmlRows(): Map { + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh web: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows + } + + /** Profile json under cwd; read-only — never created here, absent = no user config. */ + private readProfile(): Record { + let raw: string + try { + raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } + const parsed: unknown = JSON.parse(raw) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`dsh web: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`) + } + return parsed as Record + } + + /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */ + private resolveDistIndex(): string { + const require = createRequire(import.meta.url) + try { + return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') + } catch { + throw new Error('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') + } + } +} diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 7518804ebb..bcd1482df3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,160 +1,66 @@ /** - * `dsh web` — the web-shape assembly: startHost + dist resolution + - * startWebServer + the URL line + signal wiring. Mixing host and carrier - * concerns is this app module's job (packages stay single-sided). + * `dsh web` — thin bin over the config-tree boot: parse argv, run + * AppCLIEntry, print the URL line, wire signals. All composition lives in + * cordis.yml; all boot glue lives in AppCLIEntry. */ import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' -import { createRequire } from 'node:module' -import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' -import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import { fileURLToPath } from 'node:url' +import { AppCLIEntry } from './app-cli-entry.ts' const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' -// --- Client composition (composition decisions live in the composing app) --- -// The composition layer owns one decision: which plugin packages mount (the -// roster). Dependency edges and the boot prefetch tier live in each package's -// dshClient declaration. - -/** - * Dev-only plugin: the client HMR driver. Whether it composes in is a - * deployment decision — the dev graph includes its row, the prod graph does - * not mount it at all. - */ -const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr' - -/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */ -const CLIENT_BUNDLE_POLL_MS = 500 - -/** The client plugin roster (flat; per-row boot behavior comes from manifests). */ -const CLIENT_PACKAGES = [ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-theme', - '@deepseek-ai/dsh-client-i18n', - '@deepseek-ai/dsh-client-ui-layout', - '@deepseek-ai/dsh-client-ui-sidebar', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-question', - '@deepseek-ai/dsh-client-ui-trajectory', -] as const +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, options: { - host: { type: 'string', default: LOOPBACK_HOST }, - port: { type: 'string', default: '3080' }, + host: { type: 'string' }, + port: { type: 'string' }, dev: { type: 'boolean', default: false }, }, allowPositionals: false, }) - if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { + if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { process.stderr.write( `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, ) process.exit(1) } - const hostAddress = values.host - const port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ - boot: { - persistenceRoot: './.sessions', - workspaceContext: { maxBytes: 65_536 }, - sessionTitleLlm: true, - }, - }) - - // Client plugin chain: in-memory Loader tree over the composed roster, then - // the registry that feeds the __DSH_BOOT__ entry graph and - // /plugins//client.js. All row content comes from dshClient discovery - // over the mounted roster (dev adds the HMR driver row and turns on the - // bundle watch that drives rebuilt frames). - const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []] - const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url) - const webPlugins = createHostWebPluginRegistry({ - ctx: host.ctx, - loader: mounted.loader, - resolvePkgJson: mounted.resolvePkgJson, - onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) }, - ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {}, - }) - if (values.dev) { - // Dev visibility (the registry is a library and never prints): list what - // the bundle watch covers, then log every observed rebuild. This is a - // second onRebuilt subscription — the SSE relay inside the webserver is - // unaffected (multicast). - const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev])) - const bundlePaths = [...revs.keys()] - .map(id => webPlugins.clientPath(id)) - .filter((path): path is string => path !== undefined) - console.log( - `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`, - ) - webPlugins.onRebuilt((id, rev) => { - console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`) - revs.set(id, rev) - }) - } - // Published so the webserver invariant companion can audit manifest/bundle - // consistency; nothing else reads this key. - host.ctx.reflect.provide('webPlugins', webPlugins) - - // Dist location is workspace knowledge of this app: resolved through - // @deepseek-ai/dsh-frontend's package exports, not configured. - const require = createRequire(import.meta.url) - let distIndex: string - try { - distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') - } catch { - process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n') - await host.dispose() - process.exit(1) - } - - let exiting = false - async function shutdown(code: number): Promise { - if (exiting) return - exiting = true - try { - await server.close() - await host.dispose() - } finally { - process.exit(code) + let port: number | undefined + if (values.port !== undefined) { + port = Number(values.port) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + process.stderr.write(`dsh web: invalid --port ${values.port}\n`) + process.exit(1) } } - let server: Awaited> - try { - server = await startWebServer( - { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, - (err: Error) => { - process.stderr.write(`dsh web: ${String(err)}\n`) - void shutdown(1) - }, - ) - } catch (error: unknown) { - // listen failed (EADDRINUSE…): no server to close, dispose the host directly. - process.stderr.write(`dsh web: ${String(error)}\n`) - await host.dispose() - process.exit(1) + const entry = new AppCLIEntry({ + configPath: CONFIG_PATH, + dev: values.dev, + ...values.host !== undefined ? { host: values.host } : {}, + ...port !== undefined ? { port } : {}, + }) + const { ctx, port: boundPort } = await entry.run() + + let exiting = false + const shutdown = (code: number): void => { + if (exiting) return + exiting = true + void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lan = hostAddress === ALL_INTERFACES_HOST + const lanCandidate = values.host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined - const localUrl = `http://${LOOPBACK_HOST}:${server.port}` - console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`) + const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) - process.on('SIGTERM', () => { void shutdown(0) }) - process.on('SIGINT', () => { void shutdown(130) }) + process.on('SIGTERM', () => { shutdown(0) }) + process.on('SIGINT', () => { shutdown(130) }) }