mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(daemon): update artifact refs during rename
This commit is contained in:
parent
328d893b4f
commit
f2e04df500
2 changed files with 218 additions and 0 deletions
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue