fix(sdk): address remaining tooling review findings
This commit is contained in:
@@ -135,9 +135,11 @@ export class CordisYamlFile extends ProjectFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Add one new top-level entry, rejecting duplicate ids. */
|
/** 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}`)
|
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. */
|
/** Remove an entry by id and report whether it existed. */
|
||||||
|
|||||||
@@ -4,73 +4,93 @@
|
|||||||
* @module @deepseek-ai/dsh-helper/documents/pnpm-workspace-file
|
* @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'
|
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. */
|
/** Generated pnpm-workspace.yaml model. */
|
||||||
export class PnpmWorkspaceFile extends ProjectFile {
|
export class PnpmWorkspaceFile extends ProjectFile {
|
||||||
private readonly packages = new Set<string>()
|
private readonly document: Document.Parsed
|
||||||
private autoInstallPeers = true
|
|
||||||
|
|
||||||
private constructor(originalText?: string) {
|
private constructor(document: Document.Parsed, originalText?: string) {
|
||||||
super('pnpm-workspace.yaml', originalText)
|
super('pnpm-workspace.yaml', originalText)
|
||||||
|
this.document = document
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a pnpm workspace document. */
|
/** Create a pnpm workspace document. */
|
||||||
static create(): PnpmWorkspaceFile {
|
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 }))
|
||||||
/** 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<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') {
|
|
||||||
throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean')
|
|
||||||
}
|
|
||||||
document.autoInstallPeers = input.autoInstallPeers !== false
|
|
||||||
return document
|
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 {
|
override clone(): PnpmWorkspaceFile {
|
||||||
const clone = new PnpmWorkspaceFile(this.originalText)
|
return new PnpmWorkspaceFile(parseYaml(this.serialize()), this.originalText)
|
||||||
for (const pattern of this.packages) clone.packages.add(pattern)
|
|
||||||
clone.autoInstallPeers = this.autoInstallPeers
|
|
||||||
return clone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add one package workspace glob. */
|
/** Add one package workspace glob. */
|
||||||
addPackage(pattern: string): void {
|
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. */
|
/** Disable registry peer auto-installation for live-link projects. */
|
||||||
disableAutoInstallPeers(): void {
|
disableAutoInstallPeers(): void {
|
||||||
this.autoInstallPeers = false
|
this.mapping().set('autoInstallPeers', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate workspace globs. */
|
/** Validate workspace globs. */
|
||||||
override validate(): void {
|
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')
|
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 {
|
override serialize(): string {
|
||||||
return withTrailingNewline(stringify({
|
return withTrailingNewline(this.document.toString({ lineWidth: 0 }))
|
||||||
packages: [...this.packages].sort(),
|
}
|
||||||
...this.autoInstallPeers ? {} : { autoInstallPeers: false },
|
|
||||||
allowBuilds: { esbuild: true },
|
private mapping(): YAMLMap {
|
||||||
}, { lineWidth: 0 }))
|
/* 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,15 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
|
|||||||
label: 'Sandboxed executor',
|
label: 'Sandboxed executor',
|
||||||
resources: [
|
resources: [
|
||||||
{ kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' },
|
{ 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()`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface NpmCordisConfigEntrySpec {
|
|||||||
package: string
|
package: string
|
||||||
config?: Readonly<Record<string, unknown>>
|
config?: Readonly<Record<string, unknown>>
|
||||||
ownedConfigKeys?: readonly string[]
|
ownedConfigKeys?: readonly string[]
|
||||||
|
commentedExample?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Relative or absolute file Cordis config entry with no NPM dependency. */
|
/** Relative or absolute file Cordis config entry with no NPM dependency. */
|
||||||
@@ -40,6 +41,7 @@ interface FileCordisConfigEntrySpec {
|
|||||||
path: string
|
path: string
|
||||||
config?: Readonly<Record<string, unknown>>
|
config?: Readonly<Record<string, unknown>>
|
||||||
ownedConfigKeys?: readonly string[]
|
ownedConfigKeys?: readonly string[]
|
||||||
|
commentedExample?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Static complete file owned by one feature option. */
|
/** Static complete file owned by one feature option. */
|
||||||
@@ -147,6 +149,7 @@ function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] {
|
|||||||
...config ? { config } : {},
|
...config ? { config } : {},
|
||||||
},
|
},
|
||||||
ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}),
|
ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}),
|
||||||
|
...spec.commentedExample ? { commentedExample: spec.commentedExample } : {},
|
||||||
...validateConfig ? { validateConfig } : {},
|
...validateConfig ? { validateConfig } : {},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface CordisConfigEntryResource {
|
|||||||
key: ResourceKey
|
key: ResourceKey
|
||||||
entry: CordisConfigEntry
|
entry: CordisConfigEntry
|
||||||
ownedConfigKeys: readonly string[]
|
ownedConfigKeys: readonly string[]
|
||||||
|
commentedExample?: string
|
||||||
validateConfig?: (config: Readonly<Record<string, unknown>>) => readonly string[]
|
validateConfig?: (config: Readonly<Record<string, unknown>>) => readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { EnvFile } from '../documents/env-file.ts'
|
|||||||
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
|
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
|
||||||
import { ProjectFile } from '../documents/project-file.ts'
|
import { ProjectFile } from '../documents/project-file.ts'
|
||||||
import { TsConfigFile } from '../documents/tsconfig-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 { LinkWorkspace } from '../package-managers/link-workspace.ts'
|
||||||
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||||
import type { FeatureSelection, ProjectProfile } from './types.ts'
|
import type { FeatureSelection, ProjectProfile } from './types.ts'
|
||||||
@@ -390,7 +390,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
|||||||
}
|
}
|
||||||
case 'cordis-config-entry': {
|
case 'cordis-config-entry': {
|
||||||
const current = this.cordis().entry(resource.entry.id)
|
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 {
|
else {
|
||||||
if (current.name !== resource.entry.name) {
|
if (current.name !== resource.entry.name) {
|
||||||
throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${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 {
|
private validateFinalState(): void {
|
||||||
for (const document of this.documents.values()) document.validate()
|
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()) {
|
for (const feature of this.registry.all()) {
|
||||||
if (!feature.isApplicable(this.profile)) continue
|
const state = this.states.get(feature.id)
|
||||||
const installation = feature.inspect(this)
|
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 */
|
/* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */
|
||||||
if (installation.state === 'inconsistent') {
|
if (installation.state === 'inconsistent') {
|
||||||
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
|
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
|
if (installation.state !== 'enabled' || !installation.selection) continue
|
||||||
for (const requirement of feature.requirements(installation.selection)) {
|
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 */
|
/* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */
|
||||||
if (required.state !== 'enabled') {
|
if (required.state !== 'enabled') {
|
||||||
throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`)
|
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> {
|
private async assertUnchanged(paths: readonly string[]): Promise<void> {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
const source = this.source.document(path)
|
const source = this.source.document(path)
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ describe('structured project documents', () => {
|
|||||||
|
|
||||||
it('round-trips Cordis comments and !!js while editing owned fields', () => {
|
it('round-trips Cordis comments and !!js while editing owned fields', () => {
|
||||||
const created = CordisYamlFile.create().clone()
|
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()).toMatch(/^- id: created/m)
|
||||||
|
expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true')
|
||||||
expect(created.serialize()).not.toMatch(/^\[/)
|
expect(created.serialize()).not.toMatch(/^\[/)
|
||||||
const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n')
|
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')
|
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(' ')
|
invalid.addPackage(' ')
|
||||||
expect(() => { invalid.validate() }).toThrow('must not be empty')
|
expect(() => { invalid.validate() }).toThrow('must not be empty')
|
||||||
expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers')
|
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', () => {
|
it('renders strict complete-file templates without escaping code text', () => {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function request(
|
|||||||
extra: readonly FeatureSelection[] = [],
|
extra: readonly FeatureSelection[] = [],
|
||||||
plugins: readonly LocalPluginBlueprint[] = [],
|
plugins: readonly LocalPluginBlueprint[] = [],
|
||||||
app: 'acp' | 'stdio' | 'embed' = 'stdio',
|
app: 'acp' | 'stdio' | 'embed' = 'stdio',
|
||||||
|
bash: 'local' | 'sandbox' = 'local',
|
||||||
): ProjectCreationRequest {
|
): ProjectCreationRequest {
|
||||||
return {
|
return {
|
||||||
name: 'test-agent',
|
name: 'test-agent',
|
||||||
@@ -61,7 +62,7 @@ function request(
|
|||||||
releaseVersion: '0.0.1',
|
releaseVersion: '0.0.1',
|
||||||
features: [
|
features: [
|
||||||
selection('provider', ['deepseek'], { apiKey: 'test-key' }),
|
selection('provider', ['deepseek'], { apiKey: 'test-key' }),
|
||||||
selection('bash', ['local']),
|
selection('bash', [bash]),
|
||||||
selection('app', [app]),
|
selection('app', [app]),
|
||||||
selection('persistence', ['jsonl']),
|
selection('persistence', ['jsonl']),
|
||||||
...extra,
|
...extra,
|
||||||
@@ -203,6 +204,24 @@ describe('SdkProject and ProjectEditSession', () => {
|
|||||||
expect(committed.cordis.entry('stdio')).toBeUndefined()
|
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 () => {
|
it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-'))
|
const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-'))
|
||||||
temporary.push(root)
|
temporary.push(root)
|
||||||
@@ -285,6 +304,14 @@ describe('SdkProject and ProjectEditSession', () => {
|
|||||||
.toContain('missing package.json script dev')
|
.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 () => {
|
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||||
const project = await createCommitted([selection('todo', ['default'])])
|
const project = await createCommitted([selection('todo', ['default'])])
|
||||||
const registry = createBuiltinRegistry(project.profile)
|
const registry = createBuiltinRegistry(project.profile)
|
||||||
|
|||||||
@@ -18,8 +18,23 @@ function hasLocalPluginPackages(root: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasTsdownConfig(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)))
|
.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')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type NestedMultiSelectValue,
|
type NestedMultiSelectValue,
|
||||||
type ProjectCommitResult,
|
type ProjectCommitResult,
|
||||||
type PromptPort,
|
type PromptPort,
|
||||||
|
type RunInterface,
|
||||||
type SdkProject,
|
type SdkProject,
|
||||||
} from '@deepseek-ai/dsh-helper'
|
} from '@deepseek-ai/dsh-helper'
|
||||||
import { DSH_SDK_TEMPLATES } from '../templates/dsh-sdk-templates.ts'
|
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')
|
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. */
|
/** Reconcile one tree selection into domain commands, then review and commit once. */
|
||||||
export class ConfigWorkflow {
|
export class ConfigWorkflow {
|
||||||
private readonly port: PromptPort
|
private readonly port: PromptPort
|
||||||
@@ -100,6 +109,13 @@ export class ConfigWorkflow {
|
|||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
const desiredByTarget = new Map(desired.map(item => [item.value, item]))
|
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) {
|
for (const feature of features) {
|
||||||
const installation = inspections.get(feature.id)
|
const installation = inspections.get(feature.id)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export async function startSDK(
|
|||||||
* @returns target main result or live Cordis context.
|
* @returns target main result or live Cordis context.
|
||||||
*/
|
*/
|
||||||
export async function runSDK(
|
export async function runSDK(
|
||||||
target: string | undefined,
|
target?: string,
|
||||||
options: BootProjectOptions = {},
|
options: BootProjectOptions = {},
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
/* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */
|
/* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { PassThrough, Writable } from 'node:stream'
|
import { PassThrough, Writable } from 'node:stream'
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
LocalPluginBlueprint,
|
LocalPluginBlueprint,
|
||||||
NpmPackageManager,
|
NpmPackageManager,
|
||||||
@@ -82,6 +82,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () =>
|
|||||||
function creation(
|
function creation(
|
||||||
extra: ProjectCreationRequest['features'] = [],
|
extra: ProjectCreationRequest['features'] = [],
|
||||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||||
|
app: 'acp' | 'stdio' | 'embed' = 'embed',
|
||||||
): ProjectCreationRequest {
|
): ProjectCreationRequest {
|
||||||
return {
|
return {
|
||||||
name: 'config-agent',
|
name: 'config-agent',
|
||||||
@@ -92,7 +93,7 @@ function creation(
|
|||||||
features: [
|
features: [
|
||||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
||||||
{ id: featureId('bash'), options: ['local'] },
|
{ id: featureId('bash'), options: ['local'] },
|
||||||
{ id: featureId('app'), options: ['embed'] },
|
{ id: featureId('app'), options: [app] },
|
||||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||||
...extra,
|
...extra,
|
||||||
],
|
],
|
||||||
@@ -103,10 +104,11 @@ function creation(
|
|||||||
async function committedProject(
|
async function committedProject(
|
||||||
extra: ProjectCreationRequest['features'] = [],
|
extra: ProjectCreationRequest['features'] = [],
|
||||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||||
|
app: 'acp' | 'stdio' | 'embed' = 'embed',
|
||||||
): Promise<SdkProject> {
|
): Promise<SdkProject> {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
||||||
temporary.push(root)
|
temporary.push(root)
|
||||||
const request = creation(extra, localPlugins)
|
const request = creation(extra, localPlugins, app)
|
||||||
const project = SdkProject.create(root, request)
|
const project = SdkProject.create(root, request)
|
||||||
const registry = createBuiltinRegistry(project.profile)
|
const registry = createBuiltinRegistry(project.profile)
|
||||||
const edit = project.edit(registry)
|
const edit = project.edit(registry)
|
||||||
@@ -219,6 +221,33 @@ describe('build profiles and invocation', () => {
|
|||||||
await expect(runProjectBuild([], root, killed)).rejects.toThrow('killed by SIGTERM')
|
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 () => {
|
it('reports missing and malformed project tsdown executables', async () => {
|
||||||
const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-'))
|
const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-'))
|
||||||
temporary.push(missing)
|
temporary.push(missing)
|
||||||
@@ -279,6 +308,7 @@ describe('build profiles and invocation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('boots empty Cordis configs and delegates targetless runs', async () => {
|
it('boots empty Cordis configs and delegates targetless runs', async () => {
|
||||||
|
expectTypeOf(runSDK).toBeCallableWith()
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-'))
|
const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-'))
|
||||||
temporary.push(root)
|
temporary.push(root)
|
||||||
await writeFile(join(root, 'cordis.yml'), '[]\n')
|
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-loop')).toBeDefined()
|
||||||
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user