Skip to content

Commit 4ff7b4b

Browse files
authored
Merge pull request #34 from TrueNine/dev
refactor: bidirectional config sync via ensureConfigLink (5 modified)
2 parents 834753c + 8874658 commit 4ff7b4b

File tree

12 files changed

+227
-109
lines changed

12 files changed

+227
-109
lines changed

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@truenine/memory-sync-cli",
33
"type": "module",
4-
"version": "2026.10221.10118",
4+
"version": "2026.10221.10208",
55
"description": "TrueNine Memory Synchronization CLI",
66
"author": "TrueNine",
77
"license": "AGPL-3.0-only",

cli/public/tnmsc.example.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "2026.10221.10118",
2+
"version": "2026.10221.10208",
33
"workspaceDir": "~/project",
44
"shadowSourceProject": {
55
"name": "tnmsc-shadow",

cli/src/ConfigLoader.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import * as fs from 'node:fs'
22
import * as os from 'node:os'
33
import * as path from 'node:path'
44
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
5-
import {ConfigLoader, DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR, loadUserConfig} from './ConfigLoader'
5+
import {ConfigLoader, DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR, ensureConfigLink, loadUserConfig} from './ConfigLoader'
66

77
vi.mock('node:fs') // Mock fs module
88
vi.mock('node:os')
9+
vi.mock('@truenine/desk-paths', () => ({
10+
isSymlink: vi.fn(),
11+
readSymlinkTarget: vi.fn(),
12+
deletePathSync: vi.fn()
13+
}))
914

1015
describe('configLoader', () => {
1116
const mockHomedir = '/home/testuser'
@@ -328,3 +333,135 @@ describe('configLoader', () => {
328333
})
329334
})
330335
})
336+
337+
describe('ensureConfigLink', () => {
338+
let deskPaths: typeof import('@truenine/desk-paths')
339+
340+
const LOCAL = '/shadow/.tnmsc.json'
341+
const GLOBAL = '/home/testuser/.aindex/.tnmsc.json'
342+
343+
const logger = {
344+
trace: vi.fn(),
345+
debug: vi.fn(),
346+
info: vi.fn(),
347+
warn: vi.fn(),
348+
error: vi.fn(),
349+
fatal: vi.fn()
350+
}
351+
352+
beforeEach(async () => {
353+
deskPaths = await import('@truenine/desk-paths')
354+
vi.mocked(os.homedir).mockReturnValue('/home/testuser')
355+
vi.mocked(fs.existsSync).mockReturnValue(false)
356+
vi.mocked(fs.symlinkSync).mockImplementation(() => void 0)
357+
vi.mocked(fs.copyFileSync).mockImplementation(() => void 0)
358+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
359+
vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue(null)
360+
vi.mocked(deskPaths.deletePathSync).mockImplementation(() => void 0)
361+
})
362+
363+
afterEach(() => vi.clearAllMocks())
364+
365+
it('no-op when global config does not exist', () => {
366+
vi.mocked(fs.existsSync).mockReturnValue(false)
367+
368+
ensureConfigLink(LOCAL, GLOBAL, logger)
369+
370+
expect(fs.symlinkSync).not.toHaveBeenCalled()
371+
expect(fs.copyFileSync).not.toHaveBeenCalled()
372+
})
373+
374+
it('creates symlink when local file does not exist', () => {
375+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL)
376+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
377+
378+
ensureConfigLink(LOCAL, GLOBAL, logger)
379+
380+
expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file')
381+
})
382+
383+
it('no-op when local is a correct symlink pointing to global', () => {
384+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL)
385+
vi.mocked(deskPaths.isSymlink).mockReturnValue(true)
386+
vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue(GLOBAL)
387+
388+
ensureConfigLink(LOCAL, GLOBAL, logger)
389+
390+
expect(fs.symlinkSync).not.toHaveBeenCalled()
391+
expect(deskPaths.deletePathSync).not.toHaveBeenCalled()
392+
})
393+
394+
it('deletes stale symlink and recreates when target differs', () => {
395+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL)
396+
vi.mocked(deskPaths.isSymlink).mockReturnValue(true)
397+
vi.mocked(deskPaths.readSymlinkTarget).mockReturnValue('/other/path/.tnmsc.json')
398+
399+
ensureConfigLink(LOCAL, GLOBAL, logger)
400+
401+
expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL)
402+
expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file')
403+
})
404+
405+
it('syncs regular file back to global when local is newer, then recreates symlink', () => {
406+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL)
407+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
408+
vi.mocked(fs.statSync).mockImplementation(p => {
409+
if (p === LOCAL) return {mtimeMs: 2000} as fs.Stats
410+
return {mtimeMs: 1000} as fs.Stats
411+
})
412+
413+
ensureConfigLink(LOCAL, GLOBAL, logger)
414+
415+
expect(fs.copyFileSync).toHaveBeenCalledWith(LOCAL, GLOBAL)
416+
expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL)
417+
expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file')
418+
})
419+
420+
it('deletes regular file without sync-back when local is older than global', () => {
421+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL || p === LOCAL)
422+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
423+
vi.mocked(fs.statSync).mockImplementation(p => {
424+
if (p === LOCAL) return {mtimeMs: 500} as fs.Stats
425+
return {mtimeMs: 1000} as fs.Stats
426+
})
427+
428+
ensureConfigLink(LOCAL, GLOBAL, logger)
429+
430+
expect(fs.copyFileSync).not.toHaveBeenCalledWith(LOCAL, GLOBAL)
431+
expect(deskPaths.deletePathSync).toHaveBeenCalledWith(LOCAL)
432+
expect(fs.symlinkSync).toHaveBeenCalledWith(GLOBAL, LOCAL, 'file')
433+
})
434+
435+
it('falls back to copy when symlink fails', () => {
436+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL)
437+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
438+
vi.mocked(fs.symlinkSync).mockImplementation(() => {
439+
throw new Error('EPERM: operation not permitted')
440+
})
441+
442+
ensureConfigLink(LOCAL, GLOBAL, logger)
443+
444+
expect(fs.copyFileSync).toHaveBeenCalledWith(GLOBAL, LOCAL)
445+
expect(logger.warn).toHaveBeenCalledWith(
446+
'symlink unavailable, copied config (auto-sync disabled)',
447+
expect.objectContaining({dest: LOCAL})
448+
)
449+
})
450+
451+
it('logs warn and does not throw when both symlink and copy fail', () => {
452+
vi.mocked(fs.existsSync).mockImplementation(p => p === GLOBAL)
453+
vi.mocked(deskPaths.isSymlink).mockReturnValue(false)
454+
vi.mocked(fs.symlinkSync).mockImplementation(() => {
455+
throw new Error('EPERM')
456+
})
457+
vi.mocked(fs.copyFileSync).mockImplementation(() => {
458+
throw new Error('ENOENT')
459+
})
460+
461+
expect(() => ensureConfigLink(LOCAL, GLOBAL, logger)).not.toThrow()
462+
expect(logger.warn).toHaveBeenCalledWith(
463+
'failed to link or copy config',
464+
expect.objectContaining({path: LOCAL, error: 'ENOENT'})
465+
)
466+
})
467+
})

cli/src/ConfigLoader.ts

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as fs from 'node:fs'
44
import * as os from 'node:os'
55
import * as path from 'node:path'
66
import process from 'node:process'
7+
import {deletePathSync, isSymlink, readSymlinkTarget} from '@truenine/desk-paths'
78
import {DEFAULT_USER_CONFIG} from '@/constants'
89
import {createLogger} from '@/log'
910
import {ZUserConfigFile} from '@/types/ConfigTypes.schema'
@@ -226,42 +227,74 @@ export function loadUserConfig(cwd?: string): MergedConfigResult {
226227
}
227228

228229
/**
229-
* Ensure the shadow source project directory has a .tnmsc.json symlink
230-
* pointing to the global config. Creates a symlink where possible, falls
231-
* back to a file copy when symlinks are unavailable (e.g. Windows without
232-
* Developer Mode). Skips if the file already exists.
230+
* Ensure a local config file is linked (symlink preferred) to the global config.
231+
* On every run:
232+
* - If local is a correct symlink → no-op
233+
* - If local is a stale symlink → delete and recreate
234+
* - If local is a regular file newer than global → sync back to global, then recreate link
235+
* - If local is a regular file older than global → delete and recreate link
236+
* Falls back to a file copy with a warning when symlink creation fails.
233237
*/
234-
export function ensureShadowProjectConfigLink(shadowProjectDir: string, logger: ILogger): void {
235-
const resolved = shadowProjectDir.startsWith('~')
236-
? path.join(os.homedir(), shadowProjectDir.slice(1))
237-
: shadowProjectDir
238-
239-
if (!fs.existsSync(resolved)) return
240-
241-
const globalConfigPath = getGlobalConfigPath()
238+
export function ensureConfigLink(
239+
localConfigPath: string,
240+
globalConfigPath: string,
241+
logger: ILogger
242+
): void {
242243
if (!fs.existsSync(globalConfigPath)) return
243244

244-
const configPath = path.join(resolved, DEFAULT_CONFIG_FILE_NAME)
245-
if (fs.existsSync(configPath)) return
245+
if (fs.existsSync(localConfigPath) || isSymlink(localConfigPath)) {
246+
if (isSymlink(localConfigPath)) {
247+
const target = readSymlinkTarget(localConfigPath)
248+
if (target !== null && path.resolve(target) === path.resolve(globalConfigPath)) return // correct symlink, no-op
249+
deletePathSync(localConfigPath) // stale symlink — delete and fall through
250+
} else {
251+
const localStat = fs.statSync(localConfigPath) // regular file — compare mtime for sync-back
252+
const globalStat = fs.statSync(globalConfigPath)
253+
if (localStat.mtimeMs > globalStat.mtimeMs) {
254+
fs.copyFileSync(localConfigPath, globalConfigPath) // local is newer: sync back to global
255+
logger.debug('synced local config back to global', {src: localConfigPath, dest: globalConfigPath})
256+
}
257+
deletePathSync(localConfigPath)
258+
}
259+
}
246260

247261
try {
248-
fs.symlinkSync(globalConfigPath, configPath, 'file')
249-
logger.debug('linked config to shadow project', {link: configPath, target: globalConfigPath})
262+
fs.symlinkSync(globalConfigPath, localConfigPath, 'file')
263+
logger.debug('linked config', {link: localConfigPath, target: globalConfigPath})
250264
}
251265
catch {
252266
try {
253-
fs.copyFileSync(globalConfigPath, configPath)
254-
logger.debug('copied config to shadow project (symlink unavailable)', {dest: configPath})
267+
fs.copyFileSync(globalConfigPath, localConfigPath)
268+
logger.warn('symlink unavailable, copied config (auto-sync disabled)', {dest: localConfigPath})
255269
}
256270
catch (copyErr) {
257-
logger.warn('failed to link or copy config to shadow project', {
258-
path: configPath,
271+
logger.warn('failed to link or copy config', {
272+
path: localConfigPath,
259273
error: copyErr instanceof Error ? copyErr.message : String(copyErr)
260274
})
261275
}
262276
}
263277
}
264278

279+
/**
280+
* Ensure the shadow source project directory has a .tnmsc.json symlink
281+
* pointing to the global config. Creates a symlink where possible, falls
282+
* back to a file copy when symlinks are unavailable (e.g. Windows without
283+
* Developer Mode). On every run, syncs edits made inside the shadow back
284+
* to the global config before relinking.
285+
*/
286+
export function ensureShadowProjectConfigLink(shadowProjectDir: string, logger: ILogger): void {
287+
const resolved = shadowProjectDir.startsWith('~')
288+
? path.join(os.homedir(), shadowProjectDir.slice(1))
289+
: shadowProjectDir
290+
291+
if (!fs.existsSync(resolved)) return
292+
293+
const globalConfigPath = getGlobalConfigPath()
294+
const configPath = path.join(resolved, DEFAULT_CONFIG_FILE_NAME)
295+
ensureConfigLink(configPath, globalConfigPath, logger)
296+
}
297+
265298
/**
266299
* Validate global config file strictly.
267300
* - If config doesn't exist: create default config, log warn, continue

0 commit comments

Comments
 (0)