diff --git a/scripts/tests/fixtures/yaml-shim.mjs b/scripts/tests/fixtures/yaml-shim.mjs index d6075a5..9c50053 100644 --- a/scripts/tests/fixtures/yaml-shim.mjs +++ b/scripts/tests/fixtures/yaml-shim.mjs @@ -1,7 +1,5 @@ -// Loads QMD's bundled yaml package for tests. -import { qmdPackageDir } from '../../../shared/qmd/qmd-lib.mjs'; -import { pathToFileURL } from 'node:url'; -import { join } from 'node:path'; -const YAML = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'yaml', 'dist', 'index.js')).href); +// Loads QMD's yaml dependency for tests, including hoisted installations. +import { importQmdDependency } from '../../../shared/qmd/qmd-lib.mjs'; +const YAML = await importQmdDependency('yaml'); export const parseDocument = YAML.parseDocument; export default YAML; diff --git a/scripts/tests/test-qmd-lib.mjs b/scripts/tests/test-qmd-lib.mjs index 6f91273..e148300 100644 --- a/scripts/tests/test-qmd-lib.mjs +++ b/scripts/tests/test-qmd-lib.mjs @@ -1,12 +1,34 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import * as YAML from './fixtures/yaml-shim.mjs'; -import { collectionName, deviceEnv, idleMinutes, qmdConfigPath, writePrivateJson, writeTextAtomic, readJson, runQmdCli, MODELS } from '../../shared/qmd/qmd-lib.mjs'; +import { collectionName, deviceEnv, idleMinutes, qmdConfigPath, qmdPackageDir, writePrivateJson, writeTextAtomic, readJson, runQmdCli, MODELS } from '../../shared/qmd/qmd-lib.mjs'; import { setModels, unsetModels, previousModels, restoreModels, setCollectionIgnore } from '../../shared/qmd/qmd-config.mjs'; +for (const layout of ['nested', 'hoisted']) { + test(`config CLI and YAML shim load ${layout} dependencies`, (t) => { + const root = mkdtempSync(join(tmpdir(), 'qmd dependencies ')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const pkg = join(root, 'node_modules', '@tobilu', 'qmd'); + mkdirSync(pkg, { recursive: true }); + const require = createRequire(join(qmdPackageDir(), 'package.json')); + const source = dirname(require.resolve('yaml/package.json')); + cpSync(source, join(layout === 'nested' ? pkg : root, 'node_modules', 'yaml'), { recursive: true }); + const env = { ...process.env, QMD_PACKAGE_DIR: pkg, QMD_CONFIG_DIR: join(root, 'config') }; + const output = execFileSync(process.execPath, [fileURLToPath(new URL('../../shared/qmd/qmd-config.mjs', import.meta.url)), 'set-models'], { env, encoding: 'utf8' }); + assert.deepEqual(JSON.parse(output), { previous: null }); + assert.deepEqual(YAML.default.parse(readFileSync(join(env.QMD_CONFIG_DIR, 'index.yml'), 'utf8')).models, MODELS); + const shim = new URL('./fixtures/yaml-shim.mjs', import.meta.url).href; + const parsed = execFileSync(process.execPath, ['--input-type=module', '-e', `import YAML from ${JSON.stringify(shim)}; console.log(JSON.stringify(YAML.parse('answer: 42')));`], { env, encoding: 'utf8' }); + assert.deepEqual(JSON.parse(parsed), { answer: 42 }); + }); +} + test('collection name is stable, prefixed, and path-safe', () => { const a = collectionName('/tmp/My Repo/ü'); assert.match(a, /^repo-[0-9a-f]{12}$/); diff --git a/shared/qmd/qmd-config.mjs b/shared/qmd/qmd-config.mjs index 437b860..77f139d 100644 --- a/shared/qmd/qmd-config.mjs +++ b/shared/qmd/qmd-config.mjs @@ -1,8 +1,7 @@ // shared/qmd/qmd-config.mjs — edits only the models: block and setup-owned collection ignore lists in QMD's index.yml. import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { MODELS, qmdConfigPath, qmdPackageDir, writeTextAtomic } from './qmd-lib.mjs'; +import { MODELS, importQmdDependency, qmdConfigPath, writeTextAtomic } from './qmd-lib.mjs'; export function setModels(text, YAML) { const document = YAML.parseDocument(text || ''); @@ -45,7 +44,7 @@ export function setCollectionIgnore(text, YAML, name, ignore) { async function main(action, argument) { if (!['set-models', 'unset-models', 'restore-models'].includes(action)) throw new Error('usage: qmd-config.mjs set-models|unset-models|restore-models '); - const YAML = await import(pathToFileURL(join(qmdPackageDir(), 'node_modules', 'yaml', 'dist', 'index.js')).href); + const YAML = await importQmdDependency('yaml'); const path = qmdConfigPath(); const current = existsSync(path) ? readFileSync(path, 'utf8') : ''; let next; diff --git a/shared/qmd/qmd-daemon.mjs b/shared/qmd/qmd-daemon.mjs index 3e70489..8685cab 100644 --- a/shared/qmd/qmd-daemon.mjs +++ b/shared/qmd/qmd-daemon.mjs @@ -6,7 +6,7 @@ import { createServer as createHttpServer } from 'node:http'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { setCollectionIgnore } from './qmd-config.mjs'; -import { MASK, collectionName, idleMinutes, importQmd, qmdConfigPath, qmdDbPath, readJson, runQmdCli, stateDir, writePrivateJson, writeTextAtomic } from './qmd-lib.mjs'; +import { MASK, collectionName, idleMinutes, importQmd, importQmdDependency, qmdConfigPath, qmdDbPath, readJson, runQmdCli, stateDir, writePrivateJson, writeTextAtomic } from './qmd-lib.mjs'; const MAX_BODY = 64 * 1024; @@ -204,8 +204,8 @@ async function main() { keepModelsLoaded(store); // QMD keeps index.yml authoritative: createStore and the CLI re-sync it into SQLite, and its addCollection // write-through drops `ignore`, so the list is written into index.yml and re-synced the way QMD does it. - const fastGlob = (await importQmd(join('node_modules', 'fast-glob', 'out', 'index.js'))).default; - const YAML = await importQmd(join('node_modules', 'yaml', 'dist', 'index.js')); + const fastGlob = (await importQmdDependency('fast-glob')).default; + const YAML = await importQmdDependency('yaml'); const { loadConfig } = await importQmd(join('dist', 'collections.js')); const { syncConfigToDb } = await importQmd(join('dist', 'store.js')); const collectionsFile = join(dir, 'collections.json'); diff --git a/shared/qmd/qmd-lib.mjs b/shared/qmd/qmd-lib.mjs index d5467b2..9c6b7ec 100644 --- a/shared/qmd/qmd-lib.mjs +++ b/shared/qmd/qmd-lib.mjs @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; +import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -82,6 +83,12 @@ export function importQmd(subpath = join('dist', 'index.js')) { return import(pathToFileURL(join(qmdPackageDir(), subpath)).href); } +// Resolve from QMD so both nested and hoisted dependency installations work. +export function importQmdDependency(name) { + const require = createRequire(pathToFileURL(join(qmdPackageDir(), 'package.json'))); + return import(pathToFileURL(require.resolve(name)).href); +} + // Runs the QMD CLI through node without a shell, so free-text arguments are never re-parsed. export function runQmdCli(args, options = {}) { return execFileSync(process.execPath, [join(qmdPackageDir(), 'dist', 'cli', 'qmd.js'), ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...options });