fix(daemon): update artifact refs during rename

This commit is contained in:
Denis Redozubov 2026-05-30 20:55:09 +04:00
parent 328d893b4f
commit f2e04df500
2 changed files with 218 additions and 0 deletions

View file

@ -880,6 +880,7 @@ export async function renameProjectFile(projectsRoot, projectId, fromName, toNam
await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath }); await projectFileRenameTestHooks.beforeCommit?.({ source, target: targetPath });
await renameFilePath(source, targetPath, { noOverwrite: true }); await renameFilePath(source, targetPath, { noOverwrite: true });
await commitArtifactManifestRename(manifestRename, newName); await commitArtifactManifestRename(manifestRename, newName);
await updateArtifactManifestRefsForRename(dir, oldName, newName);
const st = await stat(targetPath); const st = await stat(targetPath);
const manifest = await readManifestForPath(dir, newName); const manifest = await readManifestForPath(dir, newName);
@ -999,6 +1000,153 @@ async function commitArtifactManifestRename(manifestRename, newName) {
await renameFilePath(oldManifestPath, newManifestPath, { noOverwrite: true }); 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) { export async function removeProjectDir(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId); const dir = projectDir(projectsRoot, projectId);
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });

View file

@ -77,6 +77,15 @@ describe('project export manifest route', () => {
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
} }
async function renameFile(projectId: string, from: string, to: string): Promise<void> {
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 () => { it('lists exportable project files and artifact sidecar metadata without exposing sidecars', async () => {
const projectId = await createProject(); const projectId = await createProject();
await writeFile(projectId, { 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: '<!doctype html><main>fallback</main>',
});
await writeFile(projectId, {
name: 'reviewed.html',
content: '<!doctype html><main>reviewed</main>',
});
await writeFile(projectId, {
name: 'preview/wrapper.html',
content: '<!doctype html><iframe src="../reviewed.html"></iframe>',
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 () => { it('rejects invalid project ids before listing files', async () => {
const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`); const response = await fetch(`${baseUrl}/api/projects/bad:id/export/manifest`);
expect(response.status).toBe(400); expect(response.status).toBe(400);