feat(cli)!: dsh boots profiles; plugin subcommand manages them via pnpm

dsh --profile <name> replaces the fixed entry modes: --config and -p are
removed, --patch adds overlays over the composed profile, a positional task
selects one-shot mode (requires the headless-runner row), and dsh web stays as
the alias for --profile web carrying the Web flag family as patches. dsh
plugin --profile <name> forwards verbatim to pnpm in the profile directory,
initializes on first use, and reconciles the dsh.plugins layer list after
add/remove (patch-less packages warn and stay plain dependencies). Config
dumps and the keyless web e2e scaffold compose the same bundle layers over the
same empty root as the boot.
This commit is contained in:
Turtle
2026-08-06 04:40:32 +08:00
parent 9235d0f90f
commit cd6b4ee3c9
44 changed files with 1126 additions and 1845 deletions

View File

@@ -22,9 +22,9 @@
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright'
import { expect } from 'vitest'
@@ -53,8 +53,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
import { addHarnessSourceSection, healProfilesModuleFallback } from '@deepseek-ai/dsh-app-boot'
import { REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
@@ -70,9 +70,11 @@ export function webSnapshotMode(): WebSnapshotMode {
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
}
/** The shipped composition under test: apps/cli's shared base and web overlay. */
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml')
const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml')
/** The shipped composition under test: the dsh-base and dsh-web-app bundle patches over the empty profile root. */
const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
/** The installation anchor whose dependency surface the profile module fallback mirrors. */
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
// Replay publishes the provider catalog the gateway routes to (providers
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
@@ -117,7 +119,7 @@ export interface WebScaffold {
export interface LaunchOptions {
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
* the scaffold's hermetic test patches, matching the launcher's `--patch`
* ordering.
*/
extraOverlayPath?: string
@@ -240,14 +242,17 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
// The include patch set — the same mechanism AppCLIEntry and the ACP
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
// stops matching a row fails the boot sweep loudly instead of drifting).
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
// The include patch set — the same layer stack the profile boot composes
// (bundle patches in dsh.plugins order), applied over the SAME empty root (a
// patch id that stops matching a row fails the boot sweep loudly instead of
// drifting).
const basePatches = loadOverlayPatches('web e2e scaffold', BASE_PATCH_PATH)
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_PATCH_PATH)
const extraOverlayPatches = options.extraOverlayPath === undefined
? []
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
const patches: PatchOptions[] = [
...basePatches,
...surfacePatches,
...extraOverlayPatches,
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
@@ -280,8 +285,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
{ id: 'telemetry-otel', disabled: true },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
config: { host: '127.0.0.1', port: 0 },
},
// The bundle's web-runtime row resolves the same built dist under test
// (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
{ id: 'web-runtime', config: { mode: 'production', printUrl: false } },
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
@@ -321,7 +329,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
let replayHandle: ReplayHandle | undefined
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
// The production resolution shape: an empty profile root inside the temp
// harness home, with bare plugin names resolving through the flat module
// fallback the launcher heals under <home>/profiles.
healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
const profileDir = join(harnessHome, 'profiles', 'scaffold')
await mkdir(profileDir, { recursive: true })
const rootConfig = join(profileDir, 'cordis.yml')
await writeFile(rootConfig, '[]\n')
ctx.baseUrl = pathToFileURL(profileDir).href + '/'
// This direct Loader harness supplies the same root-path capability as app-boot.
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
@@ -329,10 +345,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// The shipped CLI deliberately has no dependency on this opt-in package.
// Keep the Loader row real without broadening the product installation.
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) })
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
config: { path: pathToFileURL(rootConfig).href, patches },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')

View File

@@ -482,7 +482,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
// Pin the in-browser picker: the shipped `-auto` row would resolve to
// the native OS chooser on this bind, and no page can drive that.
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
],
{
cwd: sessionsDir,