This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { build } from "esbuild";
|
||||
import JSZip from "jszip";
|
||||
import { spawn } from "node:child_process";
|
||||
import { projectRoot, workspaceRoot } from "./storage";
|
||||
import { validateArtifact } from "./validation";
|
||||
|
||||
const activeOperations = new Set<string>();
|
||||
export function isBuildActive(id: string) { return activeOperations.has(id); }
|
||||
|
||||
export async function buildProject(id: string) {
|
||||
if (activeOperations.has(id)) throw new Error("Project is already busy");
|
||||
activeOperations.add(id);
|
||||
const workspace = workspaceRoot(id);
|
||||
const dist = path.join(projectRoot(id), "dist");
|
||||
try {
|
||||
await fs.rm(dist, { recursive: true, force: true });
|
||||
await fs.mkdir(path.join(dist, "assets"), { recursive: true });
|
||||
await build({
|
||||
absWorkingDir: workspace,
|
||||
entryPoints: ["src/App.jsx"],
|
||||
bundle: true,
|
||||
outfile: path.join(dist, "assets", "main.js"),
|
||||
format: "iife",
|
||||
target: ["es2017", "chrome61"],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
legalComments: "inline",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
nodePaths: [path.join(process.cwd(), "node_modules"), path.join(workspace, "node_modules")],
|
||||
logLevel: "silent",
|
||||
});
|
||||
await fs.copyFile(path.join(workspace, "src", "index.html"), path.join(dist, "index.html"));
|
||||
await fs.copyFile(path.join(workspace, "src", "style.css"), path.join(dist, "assets", "style.css"));
|
||||
return { dist, report: await validateArtifact(dist) };
|
||||
} finally {
|
||||
activeOperations.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installProjectDependencies(id: string) {
|
||||
if (activeOperations.has(id)) throw new Error("Project is already busy");
|
||||
activeOperations.add(id);
|
||||
const workspace = workspaceRoot(id);
|
||||
try {
|
||||
await fs.access(path.join(workspace, "package.json"));
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: workspace, env: { ...process.env, npm_config_update_notifier: "false" } });
|
||||
let text = "";
|
||||
child.stdout.on("data", (chunk) => { text += chunk; });
|
||||
child.stderr.on("data", (chunk) => { text += chunk; });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => code === 0 ? resolve(text.trim()) : reject(new Error(`npm install failed (${code}): ${text.trim()}`)));
|
||||
});
|
||||
return { output };
|
||||
} finally {
|
||||
activeOperations.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirectoryToZip(zip: JSZip, root: string, dir = root) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await addDirectoryToZip(zip, root, full);
|
||||
else zip.file(path.relative(root, full).split(path.sep).join("/"), await fs.readFile(full));
|
||||
}
|
||||
}
|
||||
|
||||
export async function packageProject(id: string) {
|
||||
const { dist, report } = await buildProject(id);
|
||||
if (report.status === "failed") return { report };
|
||||
const artifacts = path.join(projectRoot(id), "artifacts");
|
||||
await fs.mkdir(artifacts, { recursive: true });
|
||||
const zipPath = path.join(artifacts, "minitool.zip");
|
||||
const zip = new JSZip();
|
||||
await addDirectoryToZip(zip, dist);
|
||||
await fs.writeFile(zipPath, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 9 } }));
|
||||
return { zipPath, report: await validateArtifact(dist, zipPath) };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { CLEANUP_INTERVAL_MS, DEFAULT_IDLE_HOURS, PROJECTS_ROOT } from "./config";
|
||||
import { isBuildActive } from "./build";
|
||||
import type { ProjectMeta } from "./types";
|
||||
|
||||
let schedulerStarted = false;
|
||||
|
||||
export function isProjectIdle(lastAccessAt: string, idleHours: number, now = Date.now()) {
|
||||
const timestamp = Date.parse(lastAccessAt);
|
||||
return Number.isFinite(timestamp) && timestamp <= now - idleHours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export async function cleanupIdleNodeModules(idleHours = DEFAULT_IDLE_HOURS) {
|
||||
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
|
||||
const cutoff = Date.now() - idleHours * 60 * 60 * 1000;
|
||||
const cleaned: string[] = [];
|
||||
for (const entry of await fs.readdir(PROJECTS_ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || isBuildActive(entry.name)) continue;
|
||||
const root = path.join(PROJECTS_ROOT, entry.name);
|
||||
try {
|
||||
const metaPath = path.join(root, "project.json");
|
||||
const meta: ProjectMeta = JSON.parse(await fs.readFile(metaPath, "utf8"));
|
||||
if (!isProjectIdle(meta.lastAccessAt, idleHours, Date.now())) continue;
|
||||
const modules = path.join(root, "workspace", "node_modules");
|
||||
const stat = await fs.lstat(modules).catch(() => null);
|
||||
if (!stat?.isDirectory() || stat.isSymbolicLink()) continue;
|
||||
await fs.rm(modules, { recursive: true, force: true });
|
||||
meta.nodeModulesCleanedAt = new Date().toISOString();
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
|
||||
cleaned.push(entry.name);
|
||||
} catch { /* one broken project must not stop maintenance */ }
|
||||
}
|
||||
return { cleaned, idleHours, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export function startCleanupScheduler() {
|
||||
if (schedulerStarted || process.env.NODE_ENV === "test") return;
|
||||
schedulerStarted = true;
|
||||
const timer = setInterval(() => { void cleanupIdleNodeModules(); }, CLEANUP_INTERVAL_MS);
|
||||
timer.unref();
|
||||
void cleanupIdleNodeModules();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
export const APP_ROOT = process.cwd();
|
||||
export const PROJECTS_ROOT = path.join(APP_ROOT, "data", "projects");
|
||||
export const TEMPLATES_ROOT = path.join(APP_ROOT, "templates");
|
||||
export const SKILL_ROOT = path.join(APP_ROOT, "vendor", "minitool-zip-builder");
|
||||
export const SKILL_VERSION = "1.6.0";
|
||||
export const DEFAULT_IDLE_HOURS = Number(process.env.NODE_MODULES_IDLE_HOURS || 24);
|
||||
export const CLEANUP_INTERVAL_MS = Number(process.env.CLEANUP_INTERVAL_MS || 60 * 60 * 1000);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function errorResponse(error: unknown) {
|
||||
const value = error as Error & { status?: number; code?: string };
|
||||
const status = value.status || (value.code === "ENOENT" ? 404 : 400);
|
||||
return NextResponse.json({ error: value.message || "Unknown error" }, { status });
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
|
||||
import type { ProjectMeta } from "./types";
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
|
||||
|
||||
export function assertProjectId(id: string) {
|
||||
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
|
||||
}
|
||||
|
||||
export function projectRoot(id: string) {
|
||||
assertProjectId(id);
|
||||
return path.join(PROJECTS_ROOT, id);
|
||||
}
|
||||
|
||||
export function workspaceRoot(id: string) {
|
||||
return path.join(projectRoot(id), "workspace");
|
||||
}
|
||||
|
||||
export function safeWorkspacePath(id: string, segments: string[]) {
|
||||
const root = workspaceRoot(id);
|
||||
const target = path.resolve(root, ...segments);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes workspace");
|
||||
if (segments.some((part) => part === "node_modules" || part === ".git")) throw new Error("Protected path");
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readMetaFile(id: string): Promise<ProjectMeta> {
|
||||
return JSON.parse(await fs.readFile(path.join(projectRoot(id), "project.json"), "utf8"));
|
||||
}
|
||||
|
||||
export async function touchProject(id: string) {
|
||||
const meta = await readMetaFile(id);
|
||||
meta.lastAccessAt = new Date().toISOString();
|
||||
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function getProject(id: string, touch = true) {
|
||||
return touch ? touchProject(id) : readMetaFile(id);
|
||||
}
|
||||
|
||||
export async function listProjects() {
|
||||
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
|
||||
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
|
||||
const results = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
||||
try { return await readMetaFile(entry.name); } catch { return null; }
|
||||
}));
|
||||
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export async function createProject(input: { name?: string; template?: string }) {
|
||||
const template = input.template || "basic";
|
||||
const templateRoot = path.join(TEMPLATES_ROOT, template);
|
||||
await fs.access(templateRoot);
|
||||
const slug = (input.name || "新小工具").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "tool";
|
||||
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
|
||||
const root = projectRoot(id);
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
|
||||
const now = new Date().toISOString();
|
||||
const meta: ProjectMeta = { id, name: input.name || "新小工具", template, revision: 1, createdAt: now, updatedAt: now, lastAccessAt: now };
|
||||
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
|
||||
return meta;
|
||||
}
|
||||
|
||||
export async function listFiles(id: string) {
|
||||
await touchProject(id);
|
||||
const root = workspaceRoot(id);
|
||||
const files: string[] = [];
|
||||
async function walk(dir: string) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
if (["node_modules", ".git", "dist"].includes(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full);
|
||||
else files.push(path.relative(root, full).split(path.sep).join("/"));
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export async function readProjectFile(id: string, segments: string[]) {
|
||||
await touchProject(id);
|
||||
return fs.readFile(safeWorkspacePath(id, segments), "utf8");
|
||||
}
|
||||
|
||||
export async function writeProjectFiles(id: string, changes: Array<{ path: string; content: string }>, expectedRevision?: number) {
|
||||
const meta = await readMetaFile(id);
|
||||
if (expectedRevision !== undefined && expectedRevision !== meta.revision) {
|
||||
const error = new Error(`Revision conflict: expected ${expectedRevision}, current ${meta.revision}`);
|
||||
(error as Error & { status?: number }).status = 409;
|
||||
throw error;
|
||||
}
|
||||
for (const change of changes) {
|
||||
const target = safeWorkspacePath(id, change.path.split("/"));
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, change.content, "utf8");
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
meta.revision += 1;
|
||||
meta.updatedAt = now;
|
||||
meta.lastAccessAt = now;
|
||||
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
|
||||
return meta;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type ProjectMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
template: string;
|
||||
revision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAccessAt: string;
|
||||
nodeModulesCleanedAt?: string;
|
||||
};
|
||||
|
||||
export type ValidationIssue = {
|
||||
severity: "error" | "warning";
|
||||
code: string;
|
||||
file: string;
|
||||
line?: number;
|
||||
message: string;
|
||||
suggestion: string;
|
||||
};
|
||||
|
||||
export type ValidationReport = {
|
||||
status: "passed" | "failed";
|
||||
skillVersion: string;
|
||||
checkedAt: string;
|
||||
issues: ValidationIssue[];
|
||||
auditOutput: string;
|
||||
limits: { zipBytes?: number; unpackedBytes: number };
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { SKILL_ROOT, SKILL_VERSION } from "./config";
|
||||
import type { ValidationIssue, ValidationReport } from "./types";
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".woff", ".woff2", ".json"]);
|
||||
const FORBIDDEN = [
|
||||
["NETWORK_FETCH", /\bfetch\s*\(|\bXMLHttpRequest\b|\bnew\s+(?:WebSocket|EventSource|RTCPeerConnection)\s*\(/, "删除联网逻辑,改用包内资源或本地数据"],
|
||||
["WORKER_FORBIDDEN", /\bnew\s+(?:Worker|SharedWorker)\s*\(|navigator\.serviceWorker/, "将逻辑放回主线程"],
|
||||
["DYNAMIC_CODE", /\beval\s*\(|\bnew\s+Function\s*\(|\bWebAssembly\b/, "改为静态 JavaScript 逻辑"],
|
||||
["DEVICE_API", /navigator\.(?:geolocation|clipboard|bluetooth|usb|hid|serial|getBattery|connection|credentials|locks)/, "移除容器禁用的设备能力"],
|
||||
["WINDOW_API", /window\.(?:open|prompt)\s*\(|\.requestFullscreen\s*\(/, "使用单页视图或页内弹层"],
|
||||
] as const;
|
||||
|
||||
async function collectFiles(root: string) {
|
||||
const files: string[] = [];
|
||||
async function walk(dir: string) {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(full); else files.push(full);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
function lineOf(text: string, index: number) { return text.slice(0, index).split("\n").length; }
|
||||
|
||||
function runAudit(target: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const child = spawn(process.execPath, [path.join(SKILL_ROOT, "scripts", "audit_artifact.mjs"), target]);
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr.on("data", (chunk) => { output += chunk; });
|
||||
child.on("close", () => resolve(output.trim()));
|
||||
child.on("error", (error) => resolve(`Audit unavailable: ${error.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateArtifact(distRoot: string, zipPath?: string): Promise<ValidationReport> {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const files = await collectFiles(distRoot);
|
||||
const rel = (file: string) => path.relative(distRoot, file).split(path.sep).join("/");
|
||||
if (!files.some((file) => rel(file) === "index.html")) issues.push({ severity: "error", code: "MISSING_INDEX", file: "index.html", message: "ZIP 根目录缺少 index.html", suggestion: "将入口文件输出到产物根目录" });
|
||||
let unpackedBytes = 0;
|
||||
for (const file of files) {
|
||||
const name = rel(file);
|
||||
const stat = await fs.stat(file);
|
||||
unpackedBytes += stat.size;
|
||||
if (!ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase())) issues.push({ severity: "error", code: "UNSUPPORTED_FILE", file: name, message: "产物包含不支持的文件类型", suggestion: "从最终 ZIP 中移除此文件" });
|
||||
if (![".html", ".css", ".js", ".json"].includes(path.extname(file))) continue;
|
||||
const text = await fs.readFile(file, "utf8");
|
||||
for (const [code, pattern, suggestion] of FORBIDDEN) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `发现受限能力:${match[0]}`, suggestion });
|
||||
}
|
||||
if (path.extname(file) === ".html") {
|
||||
const checks: Array<[string, RegExp, string]> = [
|
||||
["INLINE_SCRIPT", /<script(?![^>]*\bsrc=)[^>]*>/i, "将脚本移动到包内 .js 文件"],
|
||||
["INLINE_EVENT", /\son[a-z]+\s*=/i, "使用 addEventListener 或 React 事件"],
|
||||
["MODULE_SCRIPT", /<script[^>]+type=["']module["']/i, "构建为普通经典脚本"],
|
||||
["FORBIDDEN_ELEMENT", /<(?:iframe|object|base)\b/i, "移除容器不支持的元素"],
|
||||
];
|
||||
for (const [code, pattern, suggestion] of checks) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) issues.push({ severity: "error", code, file: name, line: lineOf(text, match.index), message: `HTML 不符合离线规范:${match[0]}`, suggestion });
|
||||
}
|
||||
const resourcePattern = /(?:src|href)=["']([^"']+)["']/gi;
|
||||
for (const match of text.matchAll(resourcePattern)) {
|
||||
const resource = match[1];
|
||||
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并改为 ./ 开头的包内相对路径" });
|
||||
else if (resource.startsWith("./")) {
|
||||
const target = path.resolve(path.dirname(file), resource);
|
||||
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path.extname(file) === ".css") {
|
||||
const urlPattern = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
|
||||
for (const match of text.matchAll(urlPattern)) {
|
||||
const resource = match[1];
|
||||
if (/^https?:\/\//i.test(resource)) issues.push({ severity: "error", code: "EXTERNAL_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `发现外部资源:${resource}`, suggestion: "下载资源并使用包内相对路径" });
|
||||
else if (!/^(?:data:|blob:|#)/i.test(resource)) {
|
||||
const target = path.resolve(path.dirname(file), resource);
|
||||
if (!target.startsWith(`${distRoot}${path.sep}`) || !(await fs.stat(target).catch(() => null))?.isFile()) issues.push({ severity: "error", code: "MISSING_RESOURCE", file: name, line: lineOf(text, match.index || 0), message: `引用的包内资源不存在:${resource}`, suggestion: "修正路径或将资源加入产物" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const directoryAudit = await runAudit(distRoot);
|
||||
const zipAudit = zipPath ? await runAudit(zipPath) : "";
|
||||
const auditOutput = [directoryAudit, zipAudit].filter(Boolean).join("\n");
|
||||
if (/\bERROR\b/.test(auditOutput)) issues.push({ severity: "error", code: "SKILL_AUDIT", file: ".", message: auditOutput, suggestion: "按 minitool-zip-builder 审计信息缩减或修复产物" });
|
||||
const zipBytes = zipPath ? (await fs.stat(zipPath)).size : undefined;
|
||||
return { status: issues.some((issue) => issue.severity === "error") ? "failed" : "passed", skillVersion: SKILL_VERSION, checkedAt: new Date().toISOString(), issues, auditOutput, limits: { zipBytes, unpackedBytes } };
|
||||
}
|
||||
Reference in New Issue
Block a user