From f2e04df500c1e99d6104f4b22ec75bfd4832b3fa Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sat, 30 May 2026 20:55:09 +0400 Subject: [PATCH] fix(daemon): update artifact refs during rename --- apps/daemon/src/projects.ts | 148 ++++++++++++++++++ .../tests/export-manifest-route.test.ts | 70 +++++++++ 2 files changed, 218 insertions(+) diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index cfd59f6ae..1b8e72627 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -880,6 +880,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath }); await renameFilePath(source, targetPath, { noOverwrite: true }); await commitArtifactManifestRename(manifestRename, newName); + await updateArtifactManifestRefsForRename(dir, oldName, newName); const st = await stat(targetPath); const manifest = await readManifestForPath(dir, newName); @@ -999,6 +1000,153 @@ async function commitArtifactManifestRename(manifestRename, newName) { await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); } +async function updateArtifactManifestRefsForRename(dir, oldName, newName) { + const manifests = []; + await collectArtifactManifestFiles(dir, '', manifests); + for (const manifestFile of manifests) { + const ownerName = ownerNameForArtifactManifest(manifestFile.relPath); + if (!ownerName) continue; + let raw; + try { + raw = await readFile(manifestFile.fullPath, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') continue; + throw err; + } + const parsed = parseManifest(raw); + if (!parsed) continue; + + const updated = rewriteArtifactManifestRenameRefs(parsed, { + ownerName, + oldName, + newName, + }); + if (!updated.changed) continue; + + const validated = validateArtifactManifestInput(updated.manifest, ownerName); + if (!validated.ok || !validated.value) continue; + await writeFile(manifestFile.fullPath, JSON.stringify(validated.value, null, 2)); + } +} + +async function collectArtifactManifestFiles(dir, relDir, out) { + let entries = []; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err && err.code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await collectArtifactManifestFiles(fullPath, relPath, out); + continue; + } + if (entry.isFile() && entry.name.endsWith('.artifact.json')) { + out.push({ relPath, fullPath }); + } + } +} + +function ownerNameForArtifactManifest(manifestName) { + const suffix = '.artifact.json'; + if (!manifestName.endsWith(suffix)) return null; + return manifestName.slice(0, -suffix.length); +} + +function rewriteArtifactManifestRenameRefs(manifest, { ownerName, oldName, newName }) { + let changed = false; + const next = { ...manifest }; + + const entry = rewriteManifestRefForRename(next.entry, ownerName, oldName, newName, { + preferProjectRoot: true, + }); + if (entry.changed) { + next.entry = entry.value; + changed = true; + } + + if (typeof next.primary === 'string') { + const primary = rewriteManifestRefForRename(next.primary, ownerName, oldName, newName, { + preferProjectRoot: true, + }); + if (primary.changed) { + next.primary = primary.value; + changed = true; + } + } + + if (Array.isArray(next.supportingFiles)) { + const supportingFiles = next.supportingFiles.map((ref) => { + const updated = rewriteManifestRefForRename(ref, ownerName, oldName, newName); + if (updated.changed) changed = true; + return updated.value; + }); + if (changed) next.supportingFiles = supportingFiles; + } + + return { changed, manifest: next }; +} + +function rewriteManifestRefForRename( + ref, + ownerName, + oldName, + newName, + options = {}, +) { + if (typeof ref !== 'string') return { changed: false, value: ref }; + const normalized = ref.replace(/\\/g, '/').trim(); + if (!normalized) return { changed: false, value: ref }; + + if (options.preferProjectRoot && normalizeManifestProjectRootRef(normalized) === oldName) { + return { changed: true, value: newName }; + } + + if (normalizeManifestProjectRef(normalized, ownerName) === oldName) { + return { + changed: true, + value: relativeManifestRefForOwner(ownerName, newName), + }; + } + + if (normalized === oldName) { + return { changed: true, value: newName }; + } + + return { changed: false, value: ref }; +} + +function relativeManifestRefForOwner(ownerName, targetName) { + const ownerDir = path.posix.dirname(ownerName); + if (ownerDir === '.') return targetName; + const relative = path.posix.relative(ownerDir, targetName); + if (!relative || relative === '.' || relative.startsWith('../') || relative.includes('/../')) { + return targetName; + } + return relative; +} + +function normalizeManifestProjectRootRef(ref) { + return normalizeManifestProjectRef(ref, ''); +} + +function normalizeManifestProjectRef(ref, ownerName) { + if (typeof ref !== 'string' || !ref.trim()) return null; + const value = ref.trim().replace(/\\/g, '/'); + if (value.includes('\0') || value.startsWith('/')) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null; + const ownerDir = path.posix.dirname(ownerName); + const joined = ownerDir === '.' ? value : `${ownerDir}/${value}`; + const normalized = path.posix.normalize(joined).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized.startsWith('../')) return null; + if (normalized.split('/').some((segment) => segment === '..' || segment === '.')) return null; + return normalized; +} + export async function removeProjectDir(projectsRoot, projectId) { const dir = projectDir(projectsRoot, projectId); await rm(dir, { recursive: true, force: true }); diff --git a/apps/daemon/tests/export-manifest-route.test.ts b/apps/daemon/tests/export-manifest-route.test.ts index 2ae5a924c..fe79e7d1d 100644 --- a/apps/daemon/tests/export-manifest-route.test.ts +++ b/apps/daemon/tests/export-manifest-route.test.ts @@ -77,6 +77,15 @@ describe('project export manifest route', () => { expect(response.ok).toBe(true); } + async function renameFile(projectId: string, from: string, to: string): Promise { + const response = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from, to }), + }); + expect(response.ok).toBe(true); + } + it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => { const projectId = await createProject(); await writeFile(projectId, { @@ -214,6 +223,67 @@ describe('project export manifest route', () => { }); }); + it('keeps artifact entry refs current when a referenced file is renamed', async () => { + const projectId = await createProject({ kind: 'prototype' }); + await writeFile(projectId, { + name: 'index.html', + content: '
fallback
', + }); + await writeFile(projectId, { + name: 'reviewed.html', + content: '
reviewed
', + }); + await writeFile(projectId, { + name: 'preview/wrapper.html', + content: '', + artifactManifest: { + version: 1, + kind: 'html', + title: 'Review wrapper', + entry: 'reviewed.html', + renderer: 'html', + status: 'complete', + exports: ['html'], + primary: 'reviewed.html', + supportingFiles: ['reviewed.html'], + }, + }); + + await renameFile(projectId, 'reviewed.html', 'reviewed-renamed.html'); + + const response = await fetch(`${baseUrl}/api/projects/${projectId}/export/manifest`); + expect(response.ok).toBe(true); + const body = await response.json() as { + entryFile: string; + files: Array<{ name: string; role: string; reasons: string[] }>; + }; + + expect(body.entryFile).toBe('reviewed-renamed.html'); + expect(body.files.find((file) => file.name === 'reviewed-renamed.html')).toMatchObject({ + role: 'entry', + reasons: expect.arrayContaining(['artifact-entry', 'artifact-primary', 'project-entry-file']), + }); + + const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`); + expect(filesResponse.ok).toBe(true); + const filesBody = await filesResponse.json() as { + files: Array<{ + name: string; + artifactManifest?: { + entry?: string; + primary?: string | boolean; + supportingFiles?: string[]; + }; + }>; + }; + expect(filesBody.files.find((file) => file.name === 'preview/wrapper.html')?.artifactManifest) + .toMatchObject({ + entry: 'reviewed-renamed.html', + primary: 'reviewed-renamed.html', + supportingFiles: ['reviewed-renamed.html'], + }); + }); + it('rejects invalid project ids before listing files', async () => { const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); expect(response.status).toBe(400);