diff --git a/scripts/tests/test-qmd-lib.mjs b/scripts/tests/test-qmd-lib.mjs index e360742..29f6045 100644 --- a/scripts/tests/test-qmd-lib.mjs +++ b/scripts/tests/test-qmd-lib.mjs @@ -1,10 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as YAML from './fixtures/yaml-shim.mjs'; -import { collectionName, deviceEnv, idleMinutes, qmdConfigPath, writePrivateJson, readJson, runQmdCli, MODELS } from '../../shared/qmd/qmd-lib.mjs'; +import { collectionName, deviceEnv, idleMinutes, qmdConfigPath, writePrivateJson, writeTextAtomic, readJson, runQmdCli, MODELS } from '../../shared/qmd/qmd-lib.mjs'; import { setModels, unsetModels, previousModels, restoreModels } from '../../shared/qmd/qmd-config.mjs'; test('collection name is stable, prefixed, and path-safe', () => { @@ -93,3 +93,12 @@ test('restoreModels writes the previous block back and keeps comments and other assert.match(restored, /collections:/); assert.doesNotMatch(restored, /Qwen/); }); + +test('writeTextAtomic replaces the file through a temp file in the same directory', () => { + const dir = join(mkdtempSync(join(tmpdir(), 'qmd-lib-')), 'cfg'); + const file = join(dir, 'index.yml'); + writeTextAtomic(file, 'a: 1\n'); + writeTextAtomic(file, 'b: 2\n'); + assert.equal(readFileSync(file, 'utf8'), 'b: 2\n'); + assert.deepEqual(readdirSync(dir), ['index.yml']); +}); diff --git a/shared/qmd/qmd-config.mjs b/shared/qmd/qmd-config.mjs index e1a8a9a..e069107 100644 --- a/shared/qmd/qmd-config.mjs +++ b/shared/qmd/qmd-config.mjs @@ -1,8 +1,8 @@ // shared/qmd/qmd-config.mjs — edits only the models: block of QMD's index.yml. -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { MODELS, qmdConfigPath, qmdPackageDir } from './qmd-lib.mjs'; +import { MODELS, qmdConfigPath, qmdPackageDir, writeTextAtomic } from './qmd-lib.mjs'; export function setModels(text, YAML) { const document = YAML.parseDocument(text || ''); @@ -49,8 +49,7 @@ async function main(action, argument) { if (mapping === undefined) throw new Error('restore-models needs a JSON value'); next = restoreModels(current, YAML, mapping); } else next = unsetModels(current, YAML); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, next); + writeTextAtomic(path, next); } if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { diff --git a/shared/qmd/qmd-lib.mjs b/shared/qmd/qmd-lib.mjs index 8aba9da..d5467b2 100644 --- a/shared/qmd/qmd-lib.mjs +++ b/shared/qmd/qmd-lib.mjs @@ -54,14 +54,19 @@ export function readJson(path, fallback) { } } -// The state directory lives in the user profile; mode 0600 restricts the token on POSIX. -export function writePrivateJson(path, value) { +// Readers never see a half-written file: write a temp file next to the target, then rename it. +export function writeTextAtomic(path, text, options = {}) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); + writeFileSync(temporary, text, options); renameSync(temporary, path); } +// The state directory lives in the user profile; mode 0600 restricts the token on POSIX. +export function writePrivateJson(path, value) { + writeTextAtomic(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); +} + let cachedPackageDir; export function qmdPackageDir() {