|
|
@@ -0,0 +1,162 @@
|
|
|
+/**
|
|
|
+ * Personal-config behavior of `dsh-app-boot`: the `~/.config/dsh` directory
|
|
|
+ * resolution, the `config.yaml` overlay loader, and `boot()` applying the
|
|
|
+ * personal overlay over a real Loader tree.
|
|
|
+ */
|
|
|
+
|
|
|
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { join, resolve, sep } from 'node:path'
|
|
|
+import { afterEach, describe, expect, it } from 'vitest'
|
|
|
+import type { Context } from 'cordis'
|
|
|
+import {
|
|
|
+ boot,
|
|
|
+ DSH_CONFIG_HOME_ENV,
|
|
|
+ loadPersonalPatches,
|
|
|
+ PERSONAL_CONFIG_FILENAME,
|
|
|
+ resolvePersonalConfigDir,
|
|
|
+} from '../src/index.ts'
|
|
|
+
|
|
|
+const NAME = 'dsh-test-bin'
|
|
|
+
|
|
|
+const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
|
|
|
+
|
|
|
+describe('resolvePersonalConfigDir', () => {
|
|
|
+ it('prefers $DSH_CONFIG_HOME, then $XDG_CONFIG_HOME/dsh, then ~/.config/dsh', () => {
|
|
|
+ const home = `${sep}home${sep}user`
|
|
|
+ expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: `${sep}explicit`, XDG_CONFIG_HOME: `${sep}xdg` }, home))
|
|
|
+ .toBe(resolve(`${sep}explicit`))
|
|
|
+ expect(resolvePersonalConfigDir({ XDG_CONFIG_HOME: `${sep}xdg` }, home))
|
|
|
+ .toBe(resolve(`${sep}xdg`, 'dsh'))
|
|
|
+ expect(resolvePersonalConfigDir({}, home)).toBe(resolve(home, '.config', 'dsh'))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('treats empty variables as unset and defaults to the real env and home', () => {
|
|
|
+ const home = `${sep}home${sep}user`
|
|
|
+ expect(resolvePersonalConfigDir({ DSH_CONFIG_HOME: '', XDG_CONFIG_HOME: '' }, home))
|
|
|
+ .toBe(resolve(home, '.config', 'dsh'))
|
|
|
+ // Default-arg arm: resolves against the ambient environment without throwing.
|
|
|
+ expect(resolvePersonalConfigDir().length).toBeGreaterThan(0)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('loadPersonalPatches', () => {
|
|
|
+ afterEach(() => {
|
|
|
+ delete process.env.DSH_CONFIG_HOME
|
|
|
+ })
|
|
|
+
|
|
|
+ it('returns undefined when no personal patches file exists', () => {
|
|
|
+ expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
|
|
|
+ const dir = tmp()
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
|
|
|
+ '- id: tui-agent',
|
|
|
+ " name: '@deepseek-ai/dsh-tui-demo'",
|
|
|
+ ' config:',
|
|
|
+ ' model: !!js process.env.DSH_SPEC_MODEL',
|
|
|
+ '- insert:',
|
|
|
+ ' - id: llm',
|
|
|
+ " name: '@deepseek-ai/dsh-llm-pi-ai'",
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ const patches = loadPersonalPatches(NAME, dir)
|
|
|
+ expect(patches).toHaveLength(2)
|
|
|
+ expect(patches?.[0]).toMatchObject({
|
|
|
+ id: 'tui-agent',
|
|
|
+ config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
|
|
|
+ })
|
|
|
+ expect(patches?.[1]?.insert).toHaveLength(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('defaults its directory to the resolved personal config dir', () => {
|
|
|
+ const dir = tmp()
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
|
|
|
+ process.env[DSH_CONFIG_HOME_ENV] = dir
|
|
|
+ expect(loadPersonalPatches(NAME)).toHaveLength(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
|
|
|
+ const dir = tmp()
|
|
|
+ mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
|
|
|
+ expect(() => loadPersonalPatches(NAME, dir))
|
|
|
+ .toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
|
|
|
+ const dir = tmp()
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
|
|
|
+ expect(() => loadPersonalPatches(NAME, dir))
|
|
|
+ .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
|
|
|
+ expect(() => loadPersonalPatches(NAME, dir))
|
|
|
+ .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails loud when the file is not a top-level array or an entry is not an object', () => {
|
|
|
+ const dir = tmp()
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
|
|
|
+ expect(() => loadPersonalPatches(NAME, dir))
|
|
|
+ .toThrow('must be a top-level YAML array of loader patch entries')
|
|
|
+ writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
|
|
|
+ expect(() => loadPersonalPatches(NAME, dir))
|
|
|
+ .toThrow(`${NAME}: personal patches entry 1 in`)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('boot with personal patches', () => {
|
|
|
+ function writeTree(dir: string): string {
|
|
|
+ writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
|
|
+ return join(dir, 'cordis.yml')
|
|
|
+ }
|
|
|
+
|
|
|
+ function entryConfig(ctx: Context, id: string): unknown {
|
|
|
+ return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
|
|
+ }
|
|
|
+
|
|
|
+ it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
|
|
+ const dir = tmp()
|
|
|
+ const personal = tmp()
|
|
|
+ writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
|
|
|
+ '- id: noop',
|
|
|
+ ' name: ./noop.mjs',
|
|
|
+ ' config:',
|
|
|
+ ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
|
|
|
+ '- insert:',
|
|
|
+ ' - id: personal-extra',
|
|
|
+ ' name: ./noop.mjs',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
|
|
|
+ const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
|
|
|
+ try {
|
|
|
+ const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
|
|
|
+ // The mounted plugin received the interpolated environment value.
|
|
|
+ expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
|
|
|
+ expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('mounts no patch layer for an absent or empty personal overlay', async () => {
|
|
|
+ const dir = tmp()
|
|
|
+ const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
|
|
|
+ try {
|
|
|
+ expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ const empty = tmp()
|
|
|
+ writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
|
|
|
+ const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
|
|
|
+ try {
|
|
|
+ expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
|
|
|
+ } finally {
|
|
|
+ await ctxEmpty.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|