|
|
@@ -11,11 +11,12 @@ import {
|
|
|
PROTOCOL_VERSION,
|
|
|
type SessionNotification,
|
|
|
} from '@agentclientprotocol/sdk'
|
|
|
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
|
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
|
|
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
|
|
import { execa } from 'execa'
|
|
|
import * as yaml from 'js-yaml'
|
|
|
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
|
|
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
|
|
@@ -114,6 +115,12 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
|
|
].join('\n'))
|
|
|
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
|
|
'- insert:',
|
|
|
+ ' - id: hmr-timer',
|
|
|
+ " name: '@deepseek-ai/cordis-plugin-timer'",
|
|
|
+ ' - id: hmr',
|
|
|
+ " name: '@deepseek-ai/dsh-hmr'",
|
|
|
+ ' config:',
|
|
|
+ ' root: []',
|
|
|
' - id: profile-lifecycle-fixture',
|
|
|
` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
|
|
|
'',
|
|
|
@@ -278,6 +285,12 @@ function createStartupFixture(): StartupFixture {
|
|
|
].join('\n'))
|
|
|
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
|
|
'- insert:',
|
|
|
+ ' - id: hmr-timer',
|
|
|
+ " name: '@deepseek-ai/cordis-plugin-timer'",
|
|
|
+ ' - id: hmr',
|
|
|
+ " name: '@deepseek-ai/dsh-hmr'",
|
|
|
+ ' config:',
|
|
|
+ ' root: []',
|
|
|
' - id: startup-fixture',
|
|
|
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
|
|
|
' inject: [fixtureStartup]',
|
|
|
@@ -665,12 +678,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
const dir = join(home, 'profiles', 'rescue')
|
|
|
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
|
|
|
dependencies: Record<string, string>
|
|
|
- dsh: { profile: { bundles: string[]; patchReload: string } }
|
|
|
+ dsh: { profile: { bundles: string[] } }
|
|
|
}
|
|
|
expect(manifest.dependencies).toEqual({})
|
|
|
expect(manifest.dsh.profile).toEqual({
|
|
|
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
|
|
- patchReload: 'live',
|
|
|
})
|
|
|
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
|
|
|
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
|
|
@@ -801,12 +813,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
const configFile = join(fixture.home, 'config-echo')
|
|
|
try {
|
|
|
await waitForFile(fixture.settled)
|
|
|
- // The live profile layer: even without an hmr row in the composition,
|
|
|
- // the launcher mounts a config-only watcher, so an edited
|
|
|
- // cordis.patch.yml lands in the running tree (the reload disposes the
|
|
|
- // patched row's old fiber — observable as the disposed marker — and
|
|
|
- // mounts the new config, which echoes its generation and re-writes the
|
|
|
- // ready marker).
|
|
|
+ // The YAML HMR entry applies the patch and awaits the replaced plugin.
|
|
|
rmSync(fixture.ready)
|
|
|
writeFileSync(profilePatch, [
|
|
|
'- id: profile-lifecycle-fixture',
|
|
|
@@ -839,6 +846,114 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
|
|
expect(result.signal).toBeUndefined()
|
|
|
expect(existsSync(fixture.disposed)).toBe(true)
|
|
|
+ } catch (error) {
|
|
|
+ child.kill('SIGKILL')
|
|
|
+ const result = await child
|
|
|
+ throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
|
|
|
+ } finally {
|
|
|
+ child.kill('SIGKILL')
|
|
|
+ await child
|
|
|
+ rmSync(fixture.home, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ }, SPAWN_TIMEOUT_MS + 30_000)
|
|
|
+
|
|
|
+ it('recomposes bundle selections after a shared profile transaction releases its lock', async () => {
|
|
|
+ const fixture = createProfileLifecycleFixture()
|
|
|
+ const dir = join(fixture.home, 'profiles', 'lifecycle')
|
|
|
+ const manifestPath = join(dir, 'package.json')
|
|
|
+ const bundleDir = join(dir, 'node_modules', 'extra-bundle')
|
|
|
+ const mounted = join(fixture.home, 'extra-mounted')
|
|
|
+ const unmounted = join(fixture.home, 'extra-unmounted')
|
|
|
+ mkdirSync(bundleDir, { recursive: true })
|
|
|
+ writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
|
|
+ name: 'extra-bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }))
|
|
|
+ writeFileSync(join(bundleDir, 'cordis.patch.yml'), '- insert:\n - id: extra\n name: ./plugin.mjs\n')
|
|
|
+ writeFileSync(join(bundleDir, 'plugin.mjs'), `
|
|
|
+ import { writeFileSync } from 'node:fs'
|
|
|
+ export function apply(ctx) {
|
|
|
+ writeFileSync(${JSON.stringify(mounted)}, 'mounted')
|
|
|
+ ctx.effect(() => () => { writeFileSync(${JSON.stringify(unmounted)}, 'unmounted') })
|
|
|
+ }
|
|
|
+ `)
|
|
|
+ const child = startProfileLifecycle(fixture)
|
|
|
+ try {
|
|
|
+ await waitForFile(fixture.settled)
|
|
|
+ await withFileLock(manifestPath, async () => {
|
|
|
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
+ manifest.dsh.profile.bundles.push('extra-bundle')
|
|
|
+ await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
|
|
|
+ })
|
|
|
+ await waitForFile(mounted)
|
|
|
+ await withFileLock(manifestPath, async () => {
|
|
|
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
+ manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => name !== 'extra-bundle')
|
|
|
+ await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
|
|
|
+ })
|
|
|
+ await waitForFile(unmounted)
|
|
|
+ requestProfileShutdown(child, fixture)
|
|
|
+ expect((await child).exitCode).toBe(0)
|
|
|
+ } catch (error) {
|
|
|
+ child.kill('SIGKILL')
|
|
|
+ const result = await child
|
|
|
+ throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
|
|
|
+ } finally {
|
|
|
+ child.kill('SIGKILL')
|
|
|
+ await child
|
|
|
+ rmSync(fixture.home, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ }, SPAWN_TIMEOUT_MS + 30_000)
|
|
|
+
|
|
|
+ it('coordinates source-module replacement and profile patches through dsh-hmr', async () => {
|
|
|
+ const fixture = createProfileLifecycleFixture()
|
|
|
+ const dir = join(fixture.home, 'profiles', 'lifecycle')
|
|
|
+ const source = join(fixture.home, 'lifecycle-bundle', 'plugin.mjs')
|
|
|
+ const mounted = join(fixture.home, 'module-reloaded')
|
|
|
+ const echo = join(fixture.home, 'config-echo')
|
|
|
+ const original = readFileSync(source, 'utf8')
|
|
|
+ .replace('void ctx.loader.await().then', 'ctx.appReady.onReady')
|
|
|
+ + "\nexport const inject = ['hmr', 'appReady']\n"
|
|
|
+ writeFileSync(source, original)
|
|
|
+ const hmrPatch = [
|
|
|
+ '- id: hmr',
|
|
|
+ ' config:',
|
|
|
+ ` root: [${JSON.stringify(join(fixture.home, 'lifecycle-bundle'))}]`,
|
|
|
+ ' ignored: []',
|
|
|
+ ' usePolling: true',
|
|
|
+ ' debounce: 0',
|
|
|
+ '',
|
|
|
+ ].join('\n')
|
|
|
+ const patch = join(dir, 'cordis.patch.yml')
|
|
|
+ writeFileSync(patch, hmrPatch)
|
|
|
+ const child = startProfileLifecycle(fixture)
|
|
|
+ try {
|
|
|
+ await waitForFile(fixture.settled)
|
|
|
+ await withFileLock(join(dir, 'package.json'), async () => {
|
|
|
+ writeFileSync(source, original.replace(' let active = true',
|
|
|
+ ` writeFileSync(${JSON.stringify(mounted)}, 'mounted')\n let active = true`))
|
|
|
+ await writeFileAtomic(patch, hmrPatch
|
|
|
+ + '- id: profile-lifecycle-fixture\n config:\n generation: configuration-reloaded\n', { mode: 0o600 })
|
|
|
+ await waitForFile(mounted)
|
|
|
+ await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded') }, { timeout: SPAWN_TIMEOUT_MS })
|
|
|
+ })
|
|
|
+ const replacedAgain = join(fixture.home, 'module-reloaded-again')
|
|
|
+ writeFileSync(source, original.replace(' let active = true',
|
|
|
+ ` writeFileSync(${JSON.stringify(replacedAgain)}, 'mounted')\n let active = true`))
|
|
|
+ await waitForFile(replacedAgain)
|
|
|
+ expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded')
|
|
|
+ const reconfiguredHmr = hmrPatch.replace('debounce: 0', 'debounce: 1')
|
|
|
+ await writeFileAtomic(patch, reconfiguredHmr
|
|
|
+ + '- id: profile-lifecycle-fixture\n config:\n generation: hmr-reconfigured\n', { mode: 0o600 })
|
|
|
+ await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('hmr-reconfigured') }, { timeout: SPAWN_TIMEOUT_MS })
|
|
|
+ await writeFileAtomic(patch, reconfiguredHmr, { mode: 0o600 })
|
|
|
+ await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('bundle-default') }, { timeout: SPAWN_TIMEOUT_MS })
|
|
|
+ requestProfileShutdown(child, fixture)
|
|
|
+ const result = await child
|
|
|
+ expect(result.exitCode).toBe(0)
|
|
|
+ } catch (error) {
|
|
|
+ child.kill('SIGKILL')
|
|
|
+ const result = await child
|
|
|
+ throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
|
|
|
} finally {
|
|
|
child.kill('SIGKILL')
|
|
|
await child
|
|
|
@@ -924,6 +1039,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
}
|
|
|
}, SPAWN_TIMEOUT_MS + 30_000)
|
|
|
|
|
|
+ it('forwards CLI authentication and stdin through pnpm while preserving its exit code', async () => {
|
|
|
+ const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-interaction-'))
|
|
|
+ try {
|
|
|
+ const child = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'interactive', 'exec', process.execPath, '-e',
|
|
|
+ "const fs = require('node:fs'); const input = fs.readFileSync(0, 'utf8'); const auth = ['NPM_TOKEN','NODE_AUTH_TOKEN','GH_TOKEN','GITHUB_TOKEN'].every(name => process.env[name] === 'fixture-auth'); process.stdout.write(JSON.stringify({ input, auth })); process.exit(42)",
|
|
|
+ ], {
|
|
|
+ input: 'fixture-input', timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false,
|
|
|
+ env: { DSH_HOME: home, NPM_TOKEN: 'fixture-auth', NODE_AUTH_TOKEN: 'fixture-auth', GH_TOKEN: 'fixture-auth', GITHUB_TOKEN: 'fixture-auth' },
|
|
|
+ })
|
|
|
+ expect(child.exitCode).toBe(42)
|
|
|
+ expect(child.stdout).toContain('{"input":"fixture-input","auth":true}')
|
|
|
+ } finally { rmSync(home, { recursive: true, force: true }) }
|
|
|
+ }, SPAWN_TIMEOUT_MS + 30_000)
|
|
|
+
|
|
|
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
|
|
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
|
|
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
|
|
@@ -973,7 +1102,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
}
|
|
|
}, SPAWN_TIMEOUT_MS * 2 + 30_000)
|
|
|
|
|
|
- it('reconciles a real pnpm alias and keeps ordinary dependencies outside the bundle list', async () => {
|
|
|
+ it('reconciles a real pnpm alias without reactivating it and keeps ordinary dependencies outside the bundle list', async () => {
|
|
|
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-alias-'))
|
|
|
try {
|
|
|
const bundle = join(home, 'bundle-source')
|
|
|
@@ -1003,7 +1132,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
expect(refreshed.code).toBe(0)
|
|
|
expect(refreshed.stderr).not.toContain('declares no dsh.bundle')
|
|
|
const active = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
- expect(active.dsh.profile.bundles).toContain('bundle-alias')
|
|
|
+ expect(active.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
|
|
const removed = await runBuiltBin(['plugin', '--profile', 'alias', 'remove', 'bundle-alias'], { DSH_HOME: home }, home)
|
|
|
expect(removed.code).toBe(0)
|
|
|
const remaining = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
|
|
|
@@ -1017,11 +1146,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('activates a dependency that gained dsh.bundle in a later update', async () => {
|
|
|
- // Reconcile runs against the INSTALLED state on every successful pnpm
|
|
|
- // run, so `update` (not only `add`) activates a package whose newer
|
|
|
- // version declares dsh.bundle. Simulated without a registry: hand-place
|
|
|
- // the installed package, flip its manifest, and run a benign pnpm verb.
|
|
|
+ it('keeps existing dependencies inactive when package metadata gains a bundle declaration', async () => {
|
|
|
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
|
|
|
try {
|
|
|
const profileDir = join(home, 'profiles', 'up')
|
|
|
@@ -1048,7 +1173,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
|
|
const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
|
|
|
expect(second.code).toBe(0)
|
|
|
manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
|
|
|
- expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
|
|
|
+ expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
|
|
} finally {
|
|
|
rmSync(home, { recursive: true, force: true })
|
|
|
}
|