diff --git a/API.md b/API.md index 027455f..db388ab 100644 --- a/API.md +++ b/API.md @@ -2450,6 +2450,9 @@ None - [meta.getProjectMetadata](#metagetprojectmetadata) - [meta.setProjectMetadata](#metasetprojectmetadata) - [meta.updateProjectMetadata](#metaupdateprojectmetadata) +- [meta.getPublishingPreferences](#metagetpublishingpreferences) +- [meta.setPublishingPreferences](#metasetpublishingpreferences) +- [meta.clearPublishingPreferences](#metaclearpublishingpreferences) ### meta.getTags @@ -2766,6 +2769,89 @@ None # or } ``` +### meta.getPublishingPreferences + +Get publishing preferences for the active project. + +**Parameters** + +- None + +**Response specification** + +- Return type: `PublishingPreferences | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `PublishingPreferences` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.get_publishing_preferences() +``` + +**Example response** + +```python +None # or +{ + 'sshHost': 'value', + 'sshUser': 'value', + 'sshRemotePath': 'value', + 'sshMode': 'scp' +} +``` + +### meta.setPublishingPreferences + +Set publishing preferences for the active project. + +**Parameters** + +- prefs (dict, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.set_publishing_preferences(prefs={}) +``` + +**Example response** + +```python +None +``` + +### meta.clearPublishingPreferences + +Clear publishing preferences for the active project. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.clear_publishing_preferences() +``` + +**Example response** + +```python +None +``` + [↑ Back to Table of contents](#table-of-contents) ## tags @@ -3396,6 +3482,19 @@ result = await bds.publish.upload_site(credentials={}) Shared structures referenced by response types are defined once here. +### PublishingPreferences + +Publishing connection preferences stored in meta/publishing.json (shareable, no secrets). + +**Fields** + +- sshHost (`string`, required): SSH hostname for publishing. +- sshUser (`string`, required): SSH username for publishing. +- sshRemotePath (`string`, required): Remote path on the server. +- sshMode (`'scp' | 'rsync'`, required): Upload mode (scp or rsync). + +[↑ Back to Table of contents](#table-of-contents) + ### ProjectData Project metadata stored in the app database. diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 85ad141..25763e1 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -35,6 +35,17 @@ export interface CategoryRenderSettings { showTitle: boolean; } +/** + * Publishing preferences stored in meta/publishing.json. + * Contains only non-secret connection details that can be shared among collaborators. + */ +export interface PublishingPreferences { + sshHost: string; + sshUser: string; + sshRemotePath: string; + sshMode: 'scp' | 'rsync'; +} + export interface CategoryMetadata extends CategoryRenderSettings { title: string; } @@ -73,6 +84,15 @@ function sanitizePublicUrl(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function normalizePublishingPreferences(prefs: PublishingPreferences): PublishingPreferences { + return { + sshHost: String(prefs.sshHost ?? '').trim(), + sshUser: String(prefs.sshUser ?? '').trim(), + sshRemotePath: String(prefs.sshRemotePath ?? '').trim(), + sshMode: prefs.sshMode === 'rsync' ? 'rsync' : 'scp', + }; +} + function sanitizeCategoryTitle(value: unknown, fallback: string): string { const trimmed = typeof value === 'string' ? value.trim() : ''; return trimmed.length > 0 ? trimmed : fallback; @@ -183,6 +203,7 @@ export class MetaEngine extends EventEmitter { private tags: Set = new Set(); private categories: Set = new Set(); private projectMetadata: ProjectMetadata | null = null; + private publishingPreferences: PublishingPreferences | null = null; private initialized: boolean = false; private startupSyncPromise: Promise | null = null; @@ -226,6 +247,10 @@ export class MetaEngine extends EventEmitter { return path.join(this.getMetaDir(), 'category-meta.json'); } + private getPublishingPreferencesFilePath(): string { + return path.join(this.getMetaDir(), 'publishing.json'); + } + setProjectContext(projectId: string, dataDir?: string): void { const nextDataDir = dataDir || null; if (this.currentProjectId === projectId && this.dataDir === nextDataDir) { @@ -238,6 +263,7 @@ export class MetaEngine extends EventEmitter { this.tags.clear(); this.categories.clear(); this.projectMetadata = null; + this.publishingPreferences = null; this.initialized = false; this.startupSyncPromise = null; } @@ -327,6 +353,43 @@ export class MetaEngine extends EventEmitter { this.emit('projectMetadataChanged', this.projectMetadata); } + // ── Publishing Preferences ─────────────────────────────────────────── + + /** + * Get publishing preferences for the current project. + */ + async getPublishingPreferences(): Promise { + return this.publishingPreferences; + } + + /** + * Set publishing preferences for the current project. + * Persists to meta/publishing.json so they can be shared across collaborators. + */ + async setPublishingPreferences(prefs: PublishingPreferences): Promise { + this.publishingPreferences = normalizePublishingPreferences(prefs); + await this.savePublishingPreferences(); + this.emit('publishingPreferencesChanged', this.publishingPreferences); + } + + /** + * Clear publishing preferences for the current project. + * Removes meta/publishing.json. + */ + async clearPublishingPreferences(): Promise { + this.publishingPreferences = null; + try { + const filePath = this.getPublishingPreferencesFilePath(); + await fs.unlink(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[MetaEngine] Failed to delete publishing preferences:', error); + throw error; + } + } + this.emit('publishingPreferencesChanged', null); + } + /** * Add a new tag to the available tags list (in-memory only). * Note: Tag persistence is handled by TagEngine. @@ -455,6 +518,47 @@ export class MetaEngine extends EventEmitter { } } + /** + * Save publishing preferences to the filesystem. + */ + private async savePublishingPreferences(): Promise { + if (!this.publishingPreferences) { + return; + } + try { + await this.ensureMetaDirExists(); + const filePath = this.getPublishingPreferencesFilePath(); + await this.writeJsonFileAtomically(filePath, this.publishingPreferences); + } catch (error) { + console.error('[MetaEngine] Failed to save publishing preferences:', error); + throw error; + } + } + + /** + * Load publishing preferences from the filesystem. + */ + private async loadPublishingPreferences(): Promise { + try { + const filePath = this.getPublishingPreferencesFilePath(); + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content) as PublishingPreferences; + this.publishingPreferences = normalizePublishingPreferences(parsed); + } catch (error) { + if (isJsonParseError(error)) { + console.warn('[MetaEngine] Failed to parse publishing preferences JSON, using null:', error); + this.publishingPreferences = null; + return; + } + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[MetaEngine] Failed to load publishing preferences:', error); + throw error; + } + // File doesn't exist, that's OK + this.publishingPreferences = null; + } + } + /** * Load project metadata from the filesystem. */ @@ -772,6 +876,9 @@ export class MetaEngine extends EventEmitter { await this.saveProjectMetadata(); await this.saveCategoryMetadata(); } + + // Handle publishing preferences (load from file if it exists) + await this.loadPublishingPreferences(); this.initialized = true; console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`); diff --git a/src/main/engine/mainProcessPythonApiInvoker.ts b/src/main/engine/mainProcessPythonApiInvoker.ts index 83e920b..05c7865 100644 --- a/src/main/engine/mainProcessPythonApiInvoker.ts +++ b/src/main/engine/mainProcessPythonApiInvoker.ts @@ -170,6 +170,9 @@ const METHOD_NAME_MAP: Record = { 'meta.getProjectMetadata': 'getProjectMetadata', 'meta.setProjectMetadata': 'setProjectMetadata', 'meta.updateProjectMetadata': 'updateProjectMetadata', + 'meta.getPublishingPreferences': 'getPublishingPreferences', + 'meta.setPublishingPreferences': 'setPublishingPreferences', + 'meta.clearPublishingPreferences': 'clearPublishingPreferences', 'tags.getAll': 'getAllTags', 'tags.getWithCounts': 'getTagsWithCounts', 'tags.get': 'getTag', diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0b1fbc6..3e17156 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1090,6 +1090,24 @@ export function registerIpcHandlers(): void { return engine.getProjectMetadata(); }); + safeHandle('meta:getPublishingPreferences', async () => { + const engine = getMetaEngine(); + await ensureMetaReady(engine); + return engine.getPublishingPreferences(); + }); + + safeHandle('meta:setPublishingPreferences', async (_, prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => { + const engine = getMetaEngine(); + await ensureMetaContext(engine); + await engine.setPublishingPreferences(prefs); + }); + + safeHandle('meta:clearPublishingPreferences', async () => { + const engine = getMetaEngine(); + await ensureMetaContext(engine); + await engine.clearPublishingPreferences(); + }); + // ============ Tag Management Handlers ============ safeHandle('tags:getAll', async () => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 7c5f8ae..16ee1a1 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -177,6 +177,9 @@ export const electronAPI: ElectronAPI = { getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + getPublishingPreferences: () => ipcRenderer.invoke('meta:getPublishingPreferences'), + setPublishingPreferences: (prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => ipcRenderer.invoke('meta:setPublishingPreferences', prefs), + clearPublishingPreferences: () => ipcRenderer.invoke('meta:clearPublishingPreferences'), }, // Tag Management (advanced tag operations) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index f9a01e6..cb96cf4 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -58,6 +58,13 @@ export interface CategoryMetadata extends CategoryRenderSettings { title: string; } +export interface PublishingPreferences { + sshHost: string; + sshUser: string; + sshRemotePath: string; + sshMode: 'scp' | 'rsync'; +} + export interface ProjectData { id: string; name: string; @@ -637,6 +644,9 @@ export interface ElectronAPI { getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; + getPublishingPreferences: () => Promise; + setPublishingPreferences: (prefs: PublishingPreferences) => Promise; + clearPublishingPreferences: () => Promise; }; tags: { getAll: () => Promise; diff --git a/src/main/shared/pythonApiContractV1.ts b/src/main/shared/pythonApiContractV1.ts index ad96331..4f4e1e2 100644 --- a/src/main/shared/pythonApiContractV1.ts +++ b/src/main/shared/pythonApiContractV1.ts @@ -158,6 +158,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'), method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'), method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'), + method('meta.getPublishingPreferences', 'Get publishing preferences for the active project.', [], 'PublishingPreferences | null'), + method('meta.setPublishingPreferences', 'Set publishing preferences for the active project.', [requiredObject('prefs')], 'void'), + method('meta.clearPublishingPreferences', 'Clear publishing preferences for the active project.', [], 'void'), method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'), method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'), @@ -191,6 +194,16 @@ const METHODS_V1: PythonApiMethodContractV1[] = [ ]; const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ + { + name: 'PublishingPreferences', + description: 'Publishing connection preferences stored in meta/publishing.json (shareable, no secrets).', + fields: [ + { name: 'sshHost', type: 'string', required: true, description: 'SSH hostname for publishing.' }, + { name: 'sshUser', type: 'string', required: true, description: 'SSH username for publishing.' }, + { name: 'sshRemotePath', type: 'string', required: true, description: 'Remote path on the server.' }, + { name: 'sshMode', type: "'scp' | 'rsync'", required: true, description: 'Upload mode (scp or rsync).' }, + ], + }, { name: 'ProjectData', description: 'Project metadata stored in the app database.', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7472ecc..38bd4ea 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -507,17 +507,16 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:uploadSite', async () => { try { - const stored = localStorage.getItem('bds-credentials'); - if (!stored) { + const prefs = await window.electronAPI?.meta.getPublishingPreferences(); + if (!prefs) { showToast.error(tr('app.uploadSiteNoCredentials')); return; } - const credentials = JSON.parse(stored); - if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) { + if (!prefs.sshHost || !prefs.sshUser || !prefs.sshRemotePath) { showToast.error(tr('app.uploadSiteNoCredentials')); return; } - await window.electronAPI?.publish.uploadSite(credentials); + await window.electronAPI?.publish.uploadSite(prefs); } catch (error) { console.error('Site upload failed:', error); showToast.error(tr('app.uploadSiteFailed')); diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 91eb656..0efc3d4 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -234,9 +234,27 @@ export const SettingsView: React.FC = () => { useEffect(() => { const loadSettings = async () => { try { - const savedCreds = localStorage.getItem('bds-credentials'); - if (savedCreds) { - setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) }); + // Load publishing preferences from project meta (shareable) + const publishingPrefs = await window.electronAPI?.meta.getPublishingPreferences(); + if (publishingPrefs) { + setCredentials({ + sshHost: publishingPrefs.sshHost || '', + sshUser: publishingPrefs.sshUser || '', + sshRemotePath: publishingPrefs.sshRemotePath || '', + sshMode: publishingPrefs.sshMode || 'scp', + }); + } else { + // Migrate from localStorage if meta file doesn't exist yet + const savedCreds = localStorage.getItem('bds-credentials'); + if (savedCreds) { + const parsed = { ...defaultCredentials, ...JSON.parse(savedCreds) }; + setCredentials(parsed); + // Migrate to meta file and remove from localStorage + if (parsed.sshHost || parsed.sshRemotePath) { + await window.electronAPI?.meta.setPublishingPreferences(parsed); + localStorage.removeItem('bds-credentials'); + } + } } // Load categories from backend (project-scoped) @@ -290,7 +308,7 @@ export const SettingsView: React.FC = () => { const handleSavePublishing = async () => { try { - localStorage.setItem('bds-credentials', JSON.stringify(credentials)); + await window.electronAPI?.meta.setPublishingPreferences(credentials); showToast.success(t('settings.toast.publishingSaved')); } catch (error) { console.error('Failed to save publishing credentials:', error); @@ -298,10 +316,10 @@ export const SettingsView: React.FC = () => { } }; - const handleClearCredentials = () => { + const handleClearCredentials = async () => { const newCreds = { ...credentials, sshHost: '', sshUser: '', sshRemotePath: '', sshMode: 'scp' as const }; setCredentials(newCreds); - localStorage.setItem('bds-credentials', JSON.stringify(newCreds)); + await window.electronAPI?.meta.clearPublishingPreferences(); showToast.success(t('settings.toast.credentialsCleared', { type: 'SSH' })); }; diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index dd85e90..35f33e6 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -1076,6 +1076,148 @@ describe('MetaEngine', () => { expect(collectTagsSpy).toHaveBeenCalledTimes(1); }); + it('should normalize sshMode to scp when publishing.json has invalid sshMode', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ + name: 'Project', + })); + mockFiles.set(normalizePath(`${metaDir}/publishing.json`), JSON.stringify({ + sshHost: 'example.com', + sshUser: 'deploy', + sshRemotePath: '/var/www', + sshMode: 'invalid-mode', + })); + + await metaEngine.syncOnStartup(); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs?.sshMode).toBe('scp'); + }); + }); + + describe('Publishing Preferences', () => { + beforeEach(async () => { + await metaEngine.syncOnStartup(); + }); + + it('should save and load publishing preferences', async () => { + await metaEngine.setPublishingPreferences({ + sshHost: 'myserver.com', + sshUser: 'webmaster', + sshRemotePath: '/srv/blog', + sshMode: 'rsync', + }); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs).toEqual({ + sshHost: 'myserver.com', + sshUser: 'webmaster', + sshRemotePath: '/srv/blog', + sshMode: 'rsync', + }); + }); + + it('should persist publishing preferences to meta/publishing.json', async () => { + const metaDir = metaEngine.getMetaDir(); + + await metaEngine.setPublishingPreferences({ + sshHost: 'host.example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'scp', + }); + + const publishingPath = normalizePath(`${metaDir}/publishing.json`); + expect(mockFiles.has(publishingPath)).toBe(true); + const parsed = JSON.parse(mockFiles.get(publishingPath)!); + expect(parsed.sshHost).toBe('host.example.com'); + expect(parsed.sshUser).toBe('user'); + expect(parsed.sshRemotePath).toBe('/var/www'); + expect(parsed.sshMode).toBe('scp'); + }); + + it('should clear publishing preferences by removing the file', async () => { + const metaDir = metaEngine.getMetaDir(); + + await metaEngine.setPublishingPreferences({ + sshHost: 'example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'scp', + }); + + await metaEngine.clearPublishingPreferences(); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs).toBeNull(); + + const publishingPath = normalizePath(`${metaDir}/publishing.json`); + expect(mockFiles.has(publishingPath)).toBe(false); + }); + + it('should trim whitespace from string fields', async () => { + await metaEngine.setPublishingPreferences({ + sshHost: ' example.com ', + sshUser: ' user ', + sshRemotePath: ' /var/www ', + sshMode: 'rsync', + }); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs?.sshHost).toBe('example.com'); + expect(prefs?.sshUser).toBe('user'); + expect(prefs?.sshRemotePath).toBe('/var/www'); + }); + + it('should default sshMode to scp when invalid', async () => { + await metaEngine.setPublishingPreferences({ + sshHost: 'example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'invalid' as any, + }); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs?.sshMode).toBe('scp'); + }); + + it('should emit publishingPreferencesChanged event on set', async () => { + const listener = vi.fn(); + metaEngine.on('publishingPreferencesChanged', listener); + + await metaEngine.setPublishingPreferences({ + sshHost: 'example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'scp', + }); + + expect(listener).toHaveBeenCalledWith({ + sshHost: 'example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'scp', + }); + }); + + it('should emit publishingPreferencesChanged with null on clear', async () => { + await metaEngine.setPublishingPreferences({ + sshHost: 'example.com', + sshUser: 'user', + sshRemotePath: '/var/www', + sshMode: 'scp', + }); + + const listener = vi.fn(); + metaEngine.on('publishingPreferencesChanged', listener); + + await metaEngine.clearPublishingPreferences(); + + expect(listener).toHaveBeenCalledWith(null); + }); + }); + + describe('Sync on Startup (continued)', () => { it('should use custom dataDir when provided in setProjectContext', () => { const customDataDir = path.join('custom', 'data', 'path'); metaEngine.setProjectContext('project-with-custom-dir', customDataDir); @@ -1084,6 +1226,49 @@ describe('MetaEngine', () => { expect(normalizePath(metaDir)).toContain(normalizePath(customDataDir)); }); + it('should load publishing preferences from publishing.json during syncOnStartup', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ + name: 'Synced Project', + })); + mockFiles.set(normalizePath(`${metaDir}/publishing.json`), JSON.stringify({ + sshHost: 'example.com', + sshUser: 'deploy', + sshRemotePath: '/var/www/blog', + sshMode: 'rsync', + })); + + await metaEngine.syncOnStartup(); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs).toEqual({ + sshHost: 'example.com', + sshUser: 'deploy', + sshRemotePath: '/var/www/blog', + sshMode: 'rsync', + }); + }); + + it('should return null publishing preferences when publishing.json does not exist', async () => { + await metaEngine.syncOnStartup(); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs).toBeNull(); + }); + + it('should handle malformed publishing.json gracefully during syncOnStartup', async () => { + const metaDir = metaEngine.getMetaDir(); + mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ + name: 'Synced Project', + })); + mockFiles.set(normalizePath(`${metaDir}/publishing.json`), '{"sshHost":'); + + await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined(); + + const prefs = await metaEngine.getPublishingPreferences(); + expect(prefs).toBeNull(); + }); + it('should ignore and remove dataPath from project.json during syncOnStartup', async () => { const metaDir = metaEngine.getMetaDir(); const oldPath = path.join('old', 'path', 'from', 'file');