fix(docker): include pnpm workspace in deps stage

- copy pnpm-workspace.yaml in docker deps stage
- fix pnpm frozen install failure in buildx workflow
- keep allowed build scripts available during image build
- apply repository formatting updates in store module
This commit is contained in:
siujamo committed 2026-08-31 14:08:08 +08:00
1 parent f3e31d1a06
commit 971a59a997
2 files changed
+135 -132

No files matched your search

+134 -131
View File
@@ -1,47 +1,47 @@
import AdmZip from "adm-zip";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { PrototypeSummary } from "@/lib/prototype";
import AdmZip from "adm-zip"
import { randomUUID } from "node:crypto"
import { promises as fs } from "node:fs"
import path from "node:path"
import { PrototypeSummary } from "@/lib/prototype"
interface PrototypeRecord {
id: string;
name: string;
description: string;
entryFile: string;
createdAt: string;
id: string
name: string
description: string
entryFile: string
createdAt: string
}
const DATA_ROOT = path.join(process.cwd(), "data", "prototypes");
const INDEX_FILE = path.join(DATA_ROOT, "index.json");
const DATA_ROOT = path.join(process.cwd(), "data", "prototypes")
const INDEX_FILE = path.join(DATA_ROOT, "index.json")
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
const MAX_FILES = 20_000;
const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024;
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024
const MAX_FILES = 20_000
const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024
let writeQueue: Promise<void> = Promise.resolve();
let writeQueue: Promise<void> = Promise.resolve()
function queueWrite<T>(work: () => Promise<T>): Promise<T> {
const next = writeQueue.then(work, work);
const next = writeQueue.then(work, work)
writeQueue = next.then(
() => undefined,
() => undefined,
);
return next;
() => undefined
)
return next
}
async function ensureDataRoot() {
await fs.mkdir(DATA_ROOT, { recursive: true });
await fs.mkdir(DATA_ROOT, { recursive: true })
}
async function readIndex(): Promise<PrototypeRecord[]> {
await ensureDataRoot();
await ensureDataRoot()
try {
const raw = await fs.readFile(INDEX_FILE, "utf-8");
const parsed = JSON.parse(raw);
const raw = await fs.readFile(INDEX_FILE, "utf-8")
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return [];
return []
}
return parsed.filter((item): item is PrototypeRecord => {
@@ -51,47 +51,47 @@ async function readIndex(): Promise<PrototypeRecord[]> {
typeof item?.description === "string" &&
typeof item?.entryFile === "string" &&
typeof item?.createdAt === "string"
);
});
)
})
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
return []
}
throw error;
throw error
}
}
async function writeIndex(records: PrototypeRecord[]) {
await ensureDataRoot();
const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8");
await fs.rename(tempFile, INDEX_FILE);
await ensureDataRoot()
const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8")
await fs.rename(tempFile, INDEX_FILE)
}
function toSummary(record: PrototypeRecord): PrototypeSummary {
return {
...record,
viewUrl: `/api/prototypes/${encodeURIComponent(record.id)}/${encodePathSegments(record.entryFile)}`,
};
}
}
function generateId() {
return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`;
return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`
}
function isPathInside(rootDir: string, candidatePath: string) {
const root = path.resolve(rootDir);
const candidate = path.resolve(candidatePath);
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
const root = path.resolve(rootDir)
const candidate = path.resolve(candidatePath)
return candidate === root || candidate.startsWith(`${root}${path.sep}`)
}
function sanitizeZipEntry(entryName: string) {
const normalized = entryName.replace(/\\/g, "/");
const cleaned = path.posix.normalize(normalized);
const normalized = entryName.replace(/\\/g, "/")
const cleaned = path.posix.normalize(normalized)
if (!cleaned || cleaned === ".") {
return null;
return null
}
if (
@@ -100,46 +100,46 @@ function sanitizeZipEntry(entryName: string) {
cleaned.startsWith("/") ||
/^[A-Za-z]:/.test(cleaned)
) {
throw new Error(`ZIP 中包含非法路径:${entryName}`);
throw new Error(`ZIP 中包含非法路径:${entryName}`)
}
return cleaned;
return cleaned
}
function detectMimeType(filePath: string) {
const ext = path.extname(filePath).toLowerCase();
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".html":
case ".htm":
return "text/html; charset=utf-8";
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8";
return "text/css; charset=utf-8"
case ".js":
return "application/javascript; charset=utf-8";
return "application/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8";
return "application/json; charset=utf-8"
case ".svg":
return "image/svg+xml";
return "image/svg+xml"
case ".png":
return "image/png";
return "image/png"
case ".jpg":
case ".jpeg":
return "image/jpeg";
return "image/jpeg"
case ".gif":
return "image/gif";
return "image/gif"
case ".webp":
return "image/webp";
return "image/webp"
case ".ico":
return "image/x-icon";
return "image/x-icon"
case ".woff":
return "font/woff";
return "font/woff"
case ".woff2":
return "font/woff2";
return "font/woff2"
case ".ttf":
return "font/ttf";
return "font/ttf"
default:
return "application/octet-stream";
return "application/octet-stream"
}
}
@@ -147,185 +147,188 @@ function encodePathSegments(relativePath: string) {
return relativePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
.join("/")
}
export async function listPrototypes(): Promise<PrototypeSummary[]> {
const records = await readIndex();
const records = await readIndex()
return records
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
.map(toSummary)
}
export async function findPrototypeById(id: string): Promise<PrototypeSummary | null> {
const records = await readIndex();
const found = records.find((item) => item.id === id);
return found ? toSummary(found) : null;
const records = await readIndex()
const found = records.find((item) => item.id === id)
return found ? toSummary(found) : null
}
export async function createPrototypeFromZip(input: {
name: string;
description?: string;
zipFileName: string;
zipBuffer: Buffer;
name: string
description?: string
zipFileName: string
zipBuffer: Buffer
}): Promise<PrototypeSummary> {
return queueWrite(async () => {
const name = input.name.trim();
const name = input.name.trim()
if (!name) {
throw new Error("原型名称不能为空");
throw new Error("原型名称不能为空")
}
if (!input.zipFileName.toLowerCase().endsWith(".zip")) {
throw new Error("仅支持上传 ZIP 文件");
throw new Error("仅支持上传 ZIP 文件")
}
if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) {
throw new Error("上传文件过大,请控制在 200MB 以内");
throw new Error("上传文件过大,请控制在 200MB 以内")
}
const zip = new AdmZip(input.zipBuffer);
const entries = zip.getEntries();
const zip = new AdmZip(input.zipBuffer)
const entries = zip.getEntries()
if (!entries.length) {
throw new Error("ZIP 文件为空");
throw new Error("ZIP 文件为空")
}
if (entries.length > MAX_FILES) {
throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`);
throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`)
}
await ensureDataRoot();
await ensureDataRoot()
const id = generateId();
const tempDir = path.join(DATA_ROOT, `.tmp-${id}`);
const finalDir = path.join(DATA_ROOT, id);
const id = generateId()
const tempDir = path.join(DATA_ROOT, `.tmp-${id}`)
const finalDir = path.join(DATA_ROOT, id)
const extractedFiles: string[] = [];
let totalUncompressed = 0;
const extractedFiles: string[] = []
let totalUncompressed = 0
await fs.mkdir(tempDir, { recursive: true });
await fs.mkdir(tempDir, { recursive: true })
try {
for (const entry of entries) {
const safePath = sanitizeZipEntry(entry.entryName);
const safePath = sanitizeZipEntry(entry.entryName)
if (!safePath) {
continue;
continue
}
const target = path.join(tempDir, ...safePath.split("/"));
const target = path.join(tempDir, ...safePath.split("/"))
if (!isPathInside(tempDir, target)) {
throw new Error(`ZIP 中包含非法路径:${entry.entryName}`);
throw new Error(`ZIP 中包含非法路径:${entry.entryName}`)
}
if (entry.isDirectory) {
await fs.mkdir(target, { recursive: true });
continue;
await fs.mkdir(target, { recursive: true })
continue
}
totalUncompressed += entry.header.size;
totalUncompressed += entry.header.size
if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
throw new Error("解压后文件总体积过大");
throw new Error("解压后文件总体积过大")
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, entry.getData());
extractedFiles.push(safePath);
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, entry.getData())
extractedFiles.push(safePath)
}
const startCandidates = extractedFiles
.filter((item) => item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html")
.sort((a, b) => a.length - b.length);
.filter(
(item) =>
item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html"
)
.sort((a, b) => a.length - b.length)
if (!startCandidates.length) {
throw new Error("ZIP 中未找到 start.html");
throw new Error("ZIP 中未找到 start.html")
}
const entryFile = startCandidates[0];
const entryFile = startCandidates[0]
await fs.rm(finalDir, { recursive: true, force: true });
await fs.rename(tempDir, finalDir);
await fs.rm(finalDir, { recursive: true, force: true })
await fs.rename(tempDir, finalDir)
const records = await readIndex();
const records = await readIndex()
const record: PrototypeRecord = {
id,
name,
description: input.description?.trim() ?? "",
createdAt: new Date().toISOString(),
entryFile,
};
}
records.push(record);
await writeIndex(records);
records.push(record)
await writeIndex(records)
return toSummary(record);
return toSummary(record)
} catch (error) {
await fs.rm(tempDir, { recursive: true, force: true });
throw error;
await fs.rm(tempDir, { recursive: true, force: true })
throw error
}
});
})
}
export async function deletePrototype(id: string): Promise<boolean> {
return queueWrite(async () => {
const records = await readIndex();
const target = records.find((item) => item.id === id);
const records = await readIndex()
const target = records.find((item) => item.id === id)
if (!target) {
return false;
return false
}
const remained = records.filter((item) => item.id !== id);
await writeIndex(remained);
const remained = records.filter((item) => item.id !== id)
await writeIndex(remained)
const targetDir = path.join(DATA_ROOT, id);
const targetDir = path.join(DATA_ROOT, id)
if (isPathInside(DATA_ROOT, targetDir)) {
await fs.rm(targetDir, { recursive: true, force: true });
await fs.rm(targetDir, { recursive: true, force: true })
}
return true;
});
return true
})
}
export async function readPrototypeFile(id: string, relativePath?: string) {
const records = await readIndex();
const target = records.find((item) => item.id === id);
const records = await readIndex()
const target = records.find((item) => item.id === id)
if (!target) {
return null;
return null
}
const requested = relativePath?.trim() ? relativePath : target.entryFile;
const normalized = sanitizeZipEntry(requested);
const requested = relativePath?.trim() ? relativePath : target.entryFile
const normalized = sanitizeZipEntry(requested)
if (!normalized) {
return null;
return null
}
const filePath = path.join(DATA_ROOT, id, ...normalized.split("/"));
const filePath = path.join(DATA_ROOT, id, ...normalized.split("/"))
if (!isPathInside(path.join(DATA_ROOT, id), filePath)) {
return null;
return null
}
try {
const stat = await fs.stat(filePath);
const stat = await fs.stat(filePath)
if (!stat.isFile()) {
return null;
return null
}
const content = await fs.readFile(filePath);
const content = await fs.readFile(filePath)
return {
content,
mimeType: detectMimeType(filePath),
size: stat.size,
lastModified: stat.mtime.toUTCString(),
};
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
return null
}
throw error;
throw error
}
}