feat: add agent-first minitool studio MVP
CI / verify (push) Has been cancelled

This commit is contained in:
2026-09-17 21:54:33 +08:00
commit 74a8f7cdf3
51 changed files with 3855 additions and 0 deletions
+80
View File
@@ -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) };
}
+43
View File
@@ -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();
}
+9
View File
@@ -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);
+7
View File
@@ -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
View File
@@ -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;
}
+28
View File
@@ -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 };
};
+97
View File
@@ -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 } };
}