diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index a6f2fdd103..6e6eda478a 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -148,6 +148,25 @@ export abstract class PackageManager { await this.runChecked(runner, this.buildCommand(), cwd, 'build') } + /** + * Build add-dependency command arguments for one already-normalized source spec. + * @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`). + * @returns arguments following the manager executable. + */ + addCommand(spec: string): readonly string[] { + return ['add', spec] + } + + /** + * Add one dependency from a native source spec and fail on non-zero or signalled exit. + * @param spec - a package-manager-native dependency source. + * @param cwd - project directory. + * @param runner - optional subprocess boundary. + */ + async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { + await this.runChecked(runner, this.addCommand(spec), cwd, 'add') + } + private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise { const result = await runner.run(this.name, args, cwd) if (result.signal !== null) { @@ -184,6 +203,11 @@ export class NpmPackageManager extends PackageManager { override linkSpec(relativePath: string): string { return `file:${relativePath}` } + + /** npm adds a dependency through `install ` rather than an `add` verb. */ + override addCommand(spec: string): readonly string[] { + return ['install', spec] + } } /** pnpm workspace behavior. */ diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 9b2c885ac5..0d0a1cb6dd 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView { this.addedPlugins.add(entry.id) } + /** + * Mount a Cordis entry for an external dependency the package manager has already + * added (github or npm), without generating files or re-adding the dependency. + * @param id - stable Cordis config entry id. + * @param packageName - the installed dependency's package name. + */ + addExternalPlugin(id: string, packageName: string): void { + this.assertOpen() + if (!this.manifest().npmDependency(packageName)) { + throw new Error(`external plugin dependency is not installed: ${packageName}`) + } + const cordis = this.cordis() + if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`) + cordis.addEntry({ id, name: packageName }) + this.addedPlugins.add(id) + } + /** Enable or disable one custom/manual Cordis config entry by stable id. */ setCustomPluginDisabled(id: string, disabled: boolean): void { this.assertOpen() diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 1ba8b9bd68..3ce5eeccea 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -298,6 +298,11 @@ describe('package manager strategies', () => { await npm.install('/tmp', runner) await npm.build('/tmp', runner) expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']]) + await npm.add('some-pkg@1.0.0', '/tmp', runner) + const pnpm = createPackageManager('pnpm', '10.0.0') + await pnpm.add('github:o/r#sha', '/tmp', runner) + expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0']) + expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha']) const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) } await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2') const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) } diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d55bb16be9..6a6268b554 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -690,6 +690,28 @@ describe('SdkProject and ProjectEditSession', () => { expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') }) + + it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-')) + temporary.push(root) + const creation = request() + 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 manifestPath = join(root, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' } + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + const reopened = await SdkProject.open(root) + const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile)) + edit2.addExternalPlugin('ext-plugin', 'ext-plugin') + expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists') + expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed') + const commit = await edit2.commit() + expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin') + }) }) describe('extension points', () => { diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index e87c375a4f..39d706177e 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -8,6 +8,7 @@ The `dsh-sdk` launcher owns SDK project startup and configuration. | `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path | | `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments | | `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed | +| `dsh-sdk create ` | Add an external Cordis plugin from a native package-manager source (`pkg@version` or `github:owner/repo#ref`): confirm, ` add `, then mount the resolved dependency in `cordis.yml`. No giget/pacote; the package manager resolves and pins the source (github deps build via their own `prepare` under the manager's policy) | `ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`. diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 1b91269592..4d1ce867de 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -8,12 +8,13 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' /** Commands implemented by the dsh-sdk launcher. */ -type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' +type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' /** Parsed dsh-sdk invocation. */ export interface DshSdkArgs { command?: DshSdkCommand target?: string + source?: string forwarded: readonly string[] help: boolean } @@ -60,6 +61,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { program.command('config').helpOption(false).action(() => { parsed = { command: 'config', forwarded: [], help: false } }) + program.command('create ').helpOption(false).action((source: string) => { + parsed = { command: 'create', source, forwarded: [], help: false } + }) program.parse([...launcherArgv], { from: 'user' }) /* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */ if (!parsed) throw new Error('dsh-sdk command did not resolve') diff --git a/packages/sdk/scripts/src/command.ts b/packages/sdk/scripts/src/command.ts index 9351cfa39c..5174ec5ded 100644 --- a/packages/sdk/scripts/src/command.ts +++ b/packages/sdk/scripts/src/command.ts @@ -7,6 +7,7 @@ import { parseDshSdkArgs } from './args.ts' import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' +import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' @@ -19,6 +20,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { run?: typeof runSDK build?: typeof runProjectBuild config?: typeof runConfigCommand + createPlugin?: typeof runCreatePluginCommand } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -40,6 +42,7 @@ export async function runDshSdkCommand( const run = context.run ?? runSDK const build = context.build ?? runProjectBuild const config = context.config ?? runConfigCommand + const createPlugin = context.createPlugin ?? runCreatePluginCommand switch (args.command) { case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break @@ -49,6 +52,8 @@ export async function runDshSdkCommand( if (result.installError) return 1 break } + /* v8 ignore next -- Commander requires , so create never dispatches without it */ + case 'create': await createPlugin(args.source ?? '', context); break } return 0 } catch (error) { diff --git a/packages/sdk/scripts/src/create-plugin.ts b/packages/sdk/scripts/src/create-plugin.ts new file mode 100644 index 0000000000..f658451fde --- /dev/null +++ b/packages/sdk/scripts/src/create-plugin.ts @@ -0,0 +1,91 @@ +/** + * dsh-sdk create command: add an external Cordis plugin (github or npm) as a + * native package-manager dependency and mount it in cordis.yml. + * + * @module @deepseek-ai/dsh-scripts/create-plugin + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + ClackPromptPort, + ConfirmQuestion, + SdkProject, + createBuiltinRegistry, + requireAnswer, + type PackageManager, + type ProjectCommitResult, + type PromptPort, +} from '@deepseek-ai/dsh-helper' + +/** Process and interaction slice required by dsh-sdk create. */ +export interface CreatePluginContext { + cwd: string + stdin: NodeJS.ReadStream + stdout: NodeJS.WriteStream + port?: PromptPort + add?: (manager: PackageManager, spec: string, cwd: string) => Promise +} + +/** Result of a create run; `undefined` when the confirmation was declined. */ +export type CreatePluginResult = ProjectCommitResult | undefined + +/** Derive a stable cordis entry id from a package name's last path segment. */ +function pluginId(packageName: string): string { + const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName + const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + /* v8 ignore next -- a valid npm package name always yields a non-empty id */ + if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`) + return id +} + +/** Read the direct dependency names declared in a project's package.json. */ +async function dependencyNames(cwd: string): Promise> { + const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as { + dependencies?: Record + } + /* v8 ignore next -- generated projects always declare a dependencies map */ + return new Set(Object.keys(manifest.dependencies ?? {})) +} + +/** + * Add one external plugin dependency to the current project and mount it. + * @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`). + * @param context - process, interaction, and dependency-add boundaries. + * @returns the commit result, or `undefined` when the confirmation was declined. + */ +export async function runCreatePluginCommand( + source: string, + context: CreatePluginContext, +): Promise { + const spec = source.trim() + if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)') + if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { + throw new Error('dsh-sdk create requires an interactive TTY') + } + const project = await SdkProject.open(context.cwd) + /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ + const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout) + const confirmed = requireAnswer(await new ConfirmQuestion({ + id: 'create.confirm', + message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`, + initialValue: true, + }).resolve(port)) + if (!confirmed) return undefined + + const before = await dependencyNames(context.cwd) + /* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */ + const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd)) + await add(project.profile.packageManager, spec, context.cwd) + const after = await dependencyNames(context.cwd) + const added = [...after].filter(name => !before.has(name)) + if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`) + + const reopened = await SdkProject.open(context.cwd) + const registry = createBuiltinRegistry(reopened.profile) + const edit = reopened.edit(registry) + for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName) + const commit = await edit.commit() + context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`) + return commit +} diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 42c74f5631..faa025926e 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -31,6 +31,7 @@ import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' +import { runCreatePluginCommand } from '../src/create-plugin.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -559,3 +560,62 @@ describe('ConfigWorkflow', () => { expect(output.read()).toContain('Disable feature: ask-user') }) }) + +describe('dsh-sdk create', () => { + const writeDependency = (name: string) => async (_m: unknown, spec: string, cwd: string): Promise => { + const path = join(cwd, 'package.json') + const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, [name]: spec } + await writeFile(path, JSON.stringify(manifest, null, 2)) + } + + it('adds a dependency and mounts it after confirmation', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('my-ext-plugin') } + const result = await runCreatePluginCommand('github:o/r#sha', context) + expect(result?.project.cordis.entry('my-ext-plugin')?.name).toBe('my-ext-plugin') + expect(context.readStdout()).toContain('Mounted my-ext-plugin') + }) + + it('derives the cordis id from a scoped package name', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('@acme/cool-plugin') } + const result = await runCreatePluginCommand('@acme/cool-plugin@1.0.0', context) + expect(result?.project.cordis.entry('cool-plugin')?.name).toBe('@acme/cool-plugin') + }) + + it('returns undefined and adds nothing when declined', async () => { + const project = await committedProject() + let added = false + const context = { + ...commandContext(project.root), + port: new QueuePort([false]), + add: async () => { added = true }, + } + await expect(runCreatePluginCommand('pkg@1.0.0', context)).resolves.toBeUndefined() + expect(added).toBe(false) + }) + + it('rejects an empty source, a non-TTY session, and a no-op add', async () => { + const project = await committedProject() + await expect(runCreatePluginCommand(' ', { ...commandContext(project.root), port: new QueuePort([]) })) + .rejects.toThrow('requires a plugin source') + const noTty = commandContext(project.root) + noTty.stdin.isTTY = false + noTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noTty)).rejects.toThrow('interactive TTY') + const noOutTty = commandContext(project.root) + noOutTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noOutTty)).rejects.toThrow('interactive TTY') + await expect(runCreatePluginCommand('pkg@1.0.0', { + ...commandContext(project.root), port: new QueuePort([true]), add: async () => {}, + })).rejects.toThrow('added no new dependency') + }) + + it('dispatches create through the launcher', async () => { + const project = await committedProject() + const context = commandContext(project.root) + context.createPlugin = async () => undefined + await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) + }) +})