From 394706fa3c30772ef703e27ebae0326e5bf8d5da Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:08:06 +0800 Subject: [PATCH] fix(sdk): address remaining tooling review findings --- .../helper/src/documents/cordis-yaml-file.ts | 6 +- .../src/documents/pnpm-workspace-file.ts | 94 +++++++++++-------- .../sdk/helper/src/features/builtin/index.ts | 10 +- .../sdk/helper/src/features/define-feature.ts | 3 + packages/sdk/helper/src/features/resources.ts | 1 + .../src/project/project-edit-session.ts | 34 ++++++- packages/sdk/helper/tests/documents.spec.ts | 19 +++- packages/sdk/helper/tests/project.spec.ts | 29 +++++- packages/sdk/scripts/src/build.ts | 17 +++- .../sdk/scripts/src/config/config-workflow.ts | 16 ++++ packages/sdk/scripts/src/runtime.ts | 2 +- packages/sdk/scripts/tests/scripts.spec.ts | 57 ++++++++++- 12 files changed, 236 insertions(+), 52 deletions(-) diff --git a/packages/sdk/helper/src/documents/cordis-yaml-file.ts b/packages/sdk/helper/src/documents/cordis-yaml-file.ts index 2f00db5f80..39fd8fd596 100644 --- a/packages/sdk/helper/src/documents/cordis-yaml-file.ts +++ b/packages/sdk/helper/src/documents/cordis-yaml-file.ts @@ -135,9 +135,11 @@ export class CordisYamlFile extends ProjectFile { } /** Add one new top-level entry, rejecting duplicate ids. */ - addEntry(entry: CordisConfigEntry): void { + addEntry(entry: CordisConfigEntry, commentedExample?: string): void { if (this.entryNode(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`) - this.sequence().items.push(this.document.createNode(entry)) + const node = this.document.createNode(entry) + if (commentedExample) node.comment = commentedExample.split('\n').map(line => ` ${line}`).join('\n') + this.sequence().items.push(node) } /** Remove an entry by id and report whether it existed. */ diff --git a/packages/sdk/helper/src/documents/pnpm-workspace-file.ts b/packages/sdk/helper/src/documents/pnpm-workspace-file.ts index 6e2cb25243..b980434bad 100644 --- a/packages/sdk/helper/src/documents/pnpm-workspace-file.ts +++ b/packages/sdk/helper/src/documents/pnpm-workspace-file.ts @@ -4,73 +4,93 @@ * @module @deepseek-ai/dsh-helper/documents/pnpm-workspace-file */ -import { parse, stringify } from 'yaml' +import { + isMap, isScalar, isSeq, parseDocument, + type Document, type Scalar, type YAMLMap, type YAMLSeq, +} from 'yaml' import { ProjectFile, withTrailingNewline } from './project-file.ts' +function parseYaml(text: string): Document.Parsed { + const document = parseDocument(text, { keepSourceTokens: true, prettyErrors: true }) + if (document.errors.length > 0) { + throw new Error(`invalid pnpm-workspace.yaml: ${document.errors.map(error => error.message).join('; ')}`) + } + if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml root must be an object') + return document +} + /** Generated pnpm-workspace.yaml model. */ export class PnpmWorkspaceFile extends ProjectFile { - private readonly packages = new Set() - private autoInstallPeers = true + private readonly document: Document.Parsed - private constructor(originalText?: string) { + private constructor(document: Document.Parsed, originalText?: string) { super('pnpm-workspace.yaml', originalText) + this.document = document } /** Create a pnpm workspace document. */ static create(): PnpmWorkspaceFile { - return new PnpmWorkspaceFile() - } - - /** Parse the workspace fields the SDK owns. */ - static parse(text: string): PnpmWorkspaceFile { - const value: unknown = parse(text) - if (value === null || Array.isArray(value) || typeof value !== 'object') { - throw new Error('pnpm-workspace.yaml root must be an object') - } - const input = value as Record - if (!Array.isArray(input.packages) || input.packages.some(item => typeof item !== 'string')) { - throw new Error('pnpm-workspace.yaml packages must be an array of strings') - } - const document = new PnpmWorkspaceFile(text) - for (const pattern of input.packages) document.packages.add(pattern as string) - if (input.autoInstallPeers !== undefined && typeof input.autoInstallPeers !== 'boolean') { - throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean') - } - document.autoInstallPeers = input.autoInstallPeers !== false + const document = new PnpmWorkspaceFile(parseYaml('{}\n')) + document.mapping().set('packages', document.document.createNode([])) + document.mapping().set('allowBuilds', document.document.createNode({ esbuild: true })) return document } - /** Clone workspace globs and peer-install policy. */ + /** Parse the workspace fields the SDK owns while retaining all other YAML. */ + static parse(text: string): PnpmWorkspaceFile { + const document = new PnpmWorkspaceFile(parseYaml(text), text) + document.packageSequence() + const autoInstallPeers = document.mapping().get('autoInstallPeers') + if (autoInstallPeers !== undefined && typeof autoInstallPeers !== 'boolean') { + throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean') + } + return document + } + + /** Clone the complete comment-preserving workspace document. */ override clone(): PnpmWorkspaceFile { - const clone = new PnpmWorkspaceFile(this.originalText) - for (const pattern of this.packages) clone.packages.add(pattern) - clone.autoInstallPeers = this.autoInstallPeers - return clone + return new PnpmWorkspaceFile(parseYaml(this.serialize()), this.originalText) } /** Add one package workspace glob. */ addPackage(pattern: string): void { - this.packages.add(pattern) + const packages = this.packageSequence() + if (packages.items.some(item => item.value === pattern)) return + packages.add(this.document.createNode(pattern)) } /** Disable registry peer auto-installation for live-link projects. */ disableAutoInstallPeers(): void { - this.autoInstallPeers = false + this.mapping().set('autoInstallPeers', false) } /** Validate workspace globs. */ override validate(): void { - for (const pattern of this.packages) { + for (const pattern of this.packageValues()) { if (pattern.trim().length === 0) throw new Error('pnpm workspace pattern must not be empty') } } - /** Serialize pnpm's workspace and peer policy. */ + /** Serialize the workspace while retaining unknown settings and comments. */ override serialize(): string { - return withTrailingNewline(stringify({ - packages: [...this.packages].sort(), - ...this.autoInstallPeers ? {} : { autoInstallPeers: false }, - allowBuilds: { esbuild: true }, - }, { lineWidth: 0 })) + return withTrailingNewline(this.document.toString({ lineWidth: 0 })) + } + + private mapping(): YAMLMap { + /* v8 ignore next -- parseYaml and create both establish a mapping root */ + if (!isMap(this.document.contents)) throw new Error('pnpm-workspace.yaml root must be an object') + return this.document.contents + } + + private packageSequence(): YAMLSeq> { + const packages = this.mapping().get('packages', true) + if (!isSeq(packages) || packages.items.some(item => !isScalar(item) || typeof item.value !== 'string')) { + throw new Error('pnpm-workspace.yaml packages must be an array of strings') + } + return packages as YAMLSeq> + } + + private packageValues(): string[] { + return this.packageSequence().items.map(item => item.value) } } diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index abf4915f07..e8289bb971 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -55,7 +55,15 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry label: 'Sandboxed executor', resources: [ { kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' }, - { kind: 'npm-cordis-config-entry', id: 'bash', package: '@deepseek-ai/dsh-bash-sandbox' }, + { + kind: 'npm-cordis-config-entry', + id: 'bash', + package: '@deepseek-ai/dsh-bash-sandbox', + commentedExample: `Uncomment to allow writes under the project workspace. +config: + mode: workspace-write + workspaceRoot: !!js process.cwd()`, + }, ], }, ], diff --git a/packages/sdk/helper/src/features/define-feature.ts b/packages/sdk/helper/src/features/define-feature.ts index 9fd6ced1d2..7c90a2853a 100644 --- a/packages/sdk/helper/src/features/define-feature.ts +++ b/packages/sdk/helper/src/features/define-feature.ts @@ -31,6 +31,7 @@ interface NpmCordisConfigEntrySpec { package: string config?: Readonly> ownedConfigKeys?: readonly string[] + commentedExample?: string } /** Relative or absolute file Cordis config entry with no NPM dependency. */ @@ -40,6 +41,7 @@ interface FileCordisConfigEntrySpec { path: string config?: Readonly> ownedConfigKeys?: readonly string[] + commentedExample?: string } /** Static complete file owned by one feature option. */ @@ -147,6 +149,7 @@ function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] { ...config ? { config } : {}, }, ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}), + ...spec.commentedExample ? { commentedExample: spec.commentedExample } : {}, ...validateConfig ? { validateConfig } : {}, }, ] diff --git a/packages/sdk/helper/src/features/resources.ts b/packages/sdk/helper/src/features/resources.ts index 6528451e10..14b76db57a 100644 --- a/packages/sdk/helper/src/features/resources.ts +++ b/packages/sdk/helper/src/features/resources.ts @@ -31,6 +31,7 @@ export interface CordisConfigEntryResource { key: ResourceKey entry: CordisConfigEntry ownedConfigKeys: readonly string[] + commentedExample?: string validateConfig?: (config: Readonly>) => readonly string[] } diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 4e47a5a53f..9b2c885ac5 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -19,7 +19,7 @@ import { EnvFile } from '../documents/env-file.ts' import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts' import { ProjectFile } from '../documents/project-file.ts' import { TsConfigFile } from '../documents/tsconfig-file.ts' -import type { FeatureId, ResourceKey } from '../ids.ts' +import { featureId, type FeatureId, type ResourceKey } from '../ids.ts' import { LinkWorkspace } from '../package-managers/link-workspace.ts' import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureSelection, ProjectProfile } from './types.ts' @@ -390,7 +390,7 @@ export class ProjectEditSession implements FeatureProjectView { } case 'cordis-config-entry': { const current = this.cordis().entry(resource.entry.id) - if (!current) this.cordis().addEntry(resource.entry) + if (!current) this.cordis().addEntry(resource.entry, resource.commentedExample) else { if (current.name !== resource.entry.name) { throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${resource.entry.name}`) @@ -486,9 +486,17 @@ export class ProjectEditSession implements FeatureProjectView { private validateFinalState(): void { for (const document of this.documents.values()) document.validate() + const profile = this.finalProfile() + const view = this.projectView(profile) for (const feature of this.registry.all()) { - if (!feature.isApplicable(this.profile)) continue - const installation = feature.inspect(this) + const state = this.states.get(feature.id) + if (!feature.isApplicable(profile)) { + if (state?.state === 'enabled') { + throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`) + } + continue + } + const installation = feature.inspect(view) /* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */ if (installation.state === 'inconsistent') { throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`) @@ -499,7 +507,7 @@ export class ProjectEditSession implements FeatureProjectView { } if (installation.state !== 'enabled' || !installation.selection) continue for (const requirement of feature.requirements(installation.selection)) { - const required = this.registry.get(requirement.id).inspect(this) + const required = this.registry.get(requirement.id).inspect(view) /* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */ if (required.state !== 'enabled') { throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`) @@ -522,6 +530,22 @@ export class ProjectEditSession implements FeatureProjectView { } } + private finalProfile(): ProjectProfile { + const runInterface = this.states.get(featureId('app'))?.selection?.options[0] + if (runInterface !== 'acp' && runInterface !== 'stdio' && runInterface !== 'embed') return this.profile + return { ...this.profile, runInterface } + } + + private projectView(profile: ProjectProfile): FeatureProjectView { + return { + profile, + cordisConfigEntries: () => this.cordisConfigEntries(), + packageManifest: () => this.packageManifest(), + hasDocument: path => this.hasDocument(path), + readEnvironment: (path, name) => this.readEnvironment(path, name), + } + } + private async assertUnchanged(paths: readonly string[]): Promise { for (const path of paths) { const source = this.source.document(path) diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index f71f016ab5..f5d3e13244 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -74,8 +74,11 @@ describe('structured project documents', () => { it('round-trips Cordis comments and !!js while editing owned fields', () => { const created = CordisYamlFile.create().clone() - created.addEntry({ id: 'created', name: 'created-package' }) + created.addEntry({ id: 'created', name: 'created-package' }, `Uncomment this example. +config: + value: true`) expect(created.serialize()).toMatch(/^- id: created/m) + expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true') expect(created.serialize()).not.toMatch(/^\[/) const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n') expect(flow.serialize()).toContain('- id: flow\n name: flow-package\n config:\n root: ./flow') @@ -205,6 +208,20 @@ describe('structured project documents', () => { invalid.addPackage(' ') expect(() => { invalid.validate() }).toThrow('must not be empty') expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers') + const preserved = PnpmWorkspaceFile.parse(`# keep workspace settings +packages: + - apps/* +catalog: + react: ^19.0.0 +overrides: + legacy: modern +`) + preserved.disableAutoInstallPeers() + const preservedText = preserved.clone().serialize() + expect(preservedText).toContain('# keep workspace settings') + expect(preservedText).toContain('catalog:\n react: ^19.0.0') + expect(preservedText).toContain('overrides:\n legacy: modern') + expect(preservedText).toContain('autoInstallPeers: false') }) it('renders strict complete-file templates without escaping code text', () => { diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index e888d7854f..100d81d7b2 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -52,6 +52,7 @@ function request( extra: readonly FeatureSelection[] = [], plugins: readonly LocalPluginBlueprint[] = [], app: 'acp' | 'stdio' | 'embed' = 'stdio', + bash: 'local' | 'sandbox' = 'local', ): ProjectCreationRequest { return { name: 'test-agent', @@ -61,7 +62,7 @@ function request( releaseVersion: '0.0.1', features: [ selection('provider', ['deepseek'], { apiKey: 'test-key' }), - selection('bash', ['local']), + selection('bash', [bash]), selection('app', [app]), selection('persistence', ['jsonl']), ...extra, @@ -203,6 +204,24 @@ describe('SdkProject and ProjectEditSession', () => { expect(committed.cordis.entry('stdio')).toBeUndefined() }) + it('emits the sandbox workspace-write example as inactive Cordis config', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-')) + temporary.push(root) + const creation = request([], [], 'stdio', 'sandbox') + const project = SdkProject.create(root, creation) + const registry = createBuiltinRegistry(project.profile) + const edit = project.edit(registry) + for (const item of creation.features) edit.installFeature(registry.get(item.id), item) + await edit.commit() + const cordis = await readFile(join(root, 'cordis.yml'), 'utf8') + expect(cordis).toContain(`- id: bash + name: "@deepseek-ai/dsh-bash-sandbox" + # Uncomment to allow writes under the project workspace. + # config: + # mode: workspace-write + # workspaceRoot: !!js process.cwd()`) + }) + it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-')) temporary.push(root) @@ -285,6 +304,14 @@ describe('SdkProject and ProjectEditSession', () => { .toContain('missing package.json script dev') }) + it('rejects enabled features that do not apply to the target app interface', async () => { + const project = await createCommitted([selection('ask-user', ['default'])]) + const registry = createBuiltinRegistry(project.profile) + const edit = project.edit(registry) + edit.configureFeature(registry.get(featureId('app')), selection('app', ['embed'])) + await expect(edit.commit()).rejects.toThrow('feature ask-user is not available for embed') + }) + it('supports disabled feature reconfiguration and rejects invalid state operations', async () => { const project = await createCommitted([selection('todo', ['default'])]) const registry = createBuiltinRegistry(project.profile) diff --git a/packages/sdk/scripts/src/build.ts b/packages/sdk/scripts/src/build.ts index 4b83a24b71..025ac4b2ab 100644 --- a/packages/sdk/scripts/src/build.ts +++ b/packages/sdk/scripts/src/build.ts @@ -18,8 +18,23 @@ function hasLocalPluginPackages(root: string): boolean { } function hasTsdownConfig(root: string): boolean { - return ['tsdown.config.ts', 'tsdown.config.js', 'tsdown.config.mjs', 'tsdown.config.mts'] + const hasConfigFile = [ + 'tsdown.config.ts', 'tsdown.config.mts', 'tsdown.config.cts', + 'tsdown.config.js', 'tsdown.config.mjs', 'tsdown.config.cjs', + 'tsdown.config.json', + ] .some(name => existsSync(resolve(root, name))) + if (hasConfigFile) return true + let manifestText: string + try { + manifestText = readFileSync(resolve(root, 'package.json'), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } + const manifest: unknown = JSON.parse(manifestText) + return manifest !== null && !Array.isArray(manifest) && typeof manifest === 'object' + && Object.hasOwn(manifest, 'tsdown') } /** diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 318ecd4ece..7d3d7ee43a 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -17,6 +17,7 @@ import { type NestedMultiSelectValue, type ProjectCommitResult, type PromptPort, + type RunInterface, type SdkProject, } from '@deepseek-ai/dsh-helper' import { DSH_SDK_TEMPLATES } from '../templates/dsh-sdk-templates.ts' @@ -39,6 +40,14 @@ function sameOptions(left: readonly string[], right: readonly string[]): boolean return [...left].sort().join('\0') === [...right].sort().join('\0') } +function targetRunInterface( + current: RunInterface, + desired: ReadonlyMap>, +): RunInterface { + const selected = desired.get('feature:app')?.choices[0] + return selected === 'acp' || selected === 'stdio' || selected === 'embed' ? selected : current +} + /** Reconcile one tree selection into domain commands, then review and commit once. */ export class ConfigWorkflow { private readonly port: PromptPort @@ -100,6 +109,13 @@ export class ConfigWorkflow { ], })) const desiredByTarget = new Map(desired.map(item => [item.value, item])) + const targetProfile = { + ...project.profile, + runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget), + } + for (const feature of features) { + if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) + } for (const feature of features) { const installation = inspections.get(feature.id) diff --git a/packages/sdk/scripts/src/runtime.ts b/packages/sdk/scripts/src/runtime.ts index b25563bc07..426ac90e5b 100644 --- a/packages/sdk/scripts/src/runtime.ts +++ b/packages/sdk/scripts/src/runtime.ts @@ -109,7 +109,7 @@ export async function startSDK( * @returns target main result or live Cordis context. */ export async function runSDK( - target: string | undefined, + target?: string, options: BootProjectOptions = {}, ): Promise { /* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */ diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 0ed3fed411..03e38b5c26 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { PassThrough, Writable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { LocalPluginBlueprint, NpmPackageManager, @@ -82,6 +82,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () => function creation( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], + app: 'acp' | 'stdio' | 'embed' = 'embed', ): ProjectCreationRequest { return { name: 'config-agent', @@ -92,7 +93,7 @@ function creation( features: [ { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } }, { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['embed'] }, + { id: featureId('app'), options: [app] }, { id: featureId('persistence'), options: ['jsonl'] }, ...extra, ], @@ -103,10 +104,11 @@ function creation( async function committedProject( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], + app: 'acp' | 'stdio' | 'embed' = 'embed', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-')) temporary.push(root) - const request = creation(extra, localPlugins) + const request = creation(extra, localPlugins, app) const project = SdkProject.create(root, request) const registry = createBuiltinRegistry(project.profile) const edit = project.edit(registry) @@ -219,6 +221,33 @@ describe('build profiles and invocation', () => { await expect(runProjectBuild([], root, killed)).rejects.toThrow('killed by SIGTERM') }) + it('recognizes every tsdown config source', async () => { + const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json')) + for (const extension of ['cts', 'cjs', 'json']) { + const root = await mkdtemp(join(tmpdir(), `dsh-build-${extension}-`)) + temporary.push(root) + await writeFile(join(root, 'package.json'), '{"type":"module"}\n') + await writeFile(join(root, `tsdown.config.${extension}`), '{}\n') + await mkdir(join(root, 'node_modules'), { recursive: true }) + await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown')) + let called = false + await runProjectBuild([], root, { + run: async () => { called = true; return { exitCode: 0, signal: null } }, + }) + expect(called).toBe(true) + } + const root = await mkdtemp(join(tmpdir(), 'dsh-build-package-json-')) + temporary.push(root) + await writeFile(join(root, 'package.json'), '{"type":"module","tsdown":{}}\n') + await mkdir(join(root, 'node_modules'), { recursive: true }) + await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown')) + let called = false + await runProjectBuild([], root, { + run: async () => { called = true; return { exitCode: 0, signal: null } }, + }) + expect(called).toBe(true) + }) + it('reports missing and malformed project tsdown executables', async () => { const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-')) temporary.push(missing) @@ -279,6 +308,7 @@ describe('build profiles and invocation', () => { }) it('boots empty Cordis configs and delegates targetless runs', async () => { + expectTypeOf(runSDK).toBeCallableWith() const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-')) temporary.push(root) await writeFile(join(root, 'cordis.yml'), '[]\n') @@ -480,4 +510,25 @@ describe('ConfigWorkflow', () => { expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined() }) + + it('disables ask-user when switching its app interface to embed', async () => { + const project = await committedProject([ + { id: featureId('ask-user'), options: ['default'] }, + ], [], 'acp') + const registry = createBuiltinRegistry(project.profile) + const output = outputBuffer() + const workflow = new ConfigWorkflow(new QueuePort([ + [ + { value: 'feature:provider', choices: ['deepseek'] }, + { value: 'feature:app', choices: ['embed'] }, + { value: 'feature:persistence', choices: ['jsonl'] }, + { value: 'feature:ask-user', choices: ['default'] }, + ], + true, + ]), output.stream, async () => {}) + const result = await workflow.run(project, registry) + expect(result.commit?.project.profile.runInterface).toBe('embed') + expect(result.commit?.project.cordis.entry('tool-ask-user')?.disabled).toBe(true) + expect(output.read()).toContain('Disable feature: ask-user') + }) })