Преглед изворни кода

fix(sdk): address remaining tooling review findings

imccyu пре 1 месец
родитељ
комит
394706fa3c

+ 4 - 2
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. */

+ 52 - 32
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<string>()
-  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()
+    const document = new PnpmWorkspaceFile(parseYaml('{}\n'))
+    document.mapping().set('packages', document.document.createNode([]))
+    document.mapping().set('allowBuilds', document.document.createNode({ esbuild: true }))
+    return document
   }
 
-  /** Parse the workspace fields the SDK owns. */
+  /** Parse the workspace fields the SDK owns while retaining all other YAML. */
   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<string, unknown>
-    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') {
+    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')
     }
-    document.autoInstallPeers = input.autoInstallPeers !== false
     return document
   }
 
-  /** Clone workspace globs and peer-install policy. */
+  /** 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<Scalar<string>> {
+    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<Scalar<string>>
+  }
+
+  private packageValues(): string[] {
+    return this.packageSequence().items.map(item => item.value)
   }
 }

+ 9 - 1
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()`,
+            },
           ],
         },
       ],

+ 3 - 0
packages/sdk/helper/src/features/define-feature.ts

@@ -31,6 +31,7 @@ interface NpmCordisConfigEntrySpec {
   package: string
   config?: Readonly<Record<string, unknown>>
   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<Record<string, unknown>>
   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 } : {},
         },
       ]

+ 1 - 0
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<Record<string, unknown>>) => readonly string[]
 }
 

+ 29 - 5
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<void> {
     for (const path of paths) {
       const source = this.source.document(path)

+ 18 - 1
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', () => {

+ 28 - 1
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)

+ 16 - 1
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')
 }
 
 /**

+ 16 - 0
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<string, NestedMultiSelectValue<string, string>>,
+): 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)

+ 1 - 1
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<unknown> {
   /* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */

+ 54 - 3
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<SdkProject> {
   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')
+  })
 })