Files
makelore/resources/coding-plugins/project-scaffold/skills/makelore-project-scaffold/scripts/scaffold.mjs

305 lines
7.9 KiB
JavaScript

#!/usr/bin/env node
import {
lstat,
mkdir,
open,
readFile,
rmdir,
unlink,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const skillDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const templateDirectory = path.join(skillDirectory, "assets", "templates");
const fileOperations = { mkdir, open, rmdir, unlink };
const commonTargets = [
["package.json", "common/package.json"],
["package-lock.json", "common/package-lock.json"],
["vite.config.js", "common/vite.config.js"],
];
const targetsByProjectType = {
interactive_ai_app: [
...commonTargets,
["index.html", "interactive_ai_app/index.html"],
["src/main.js", "interactive_ai_app/main.js"],
["src/style.css", "interactive_ai_app/style.css"],
],
};
const canonicalProjectTypes = {
interactive_ai_app: "interactive_ai_app",
mini_game: "interactive_ai_app",
mini_program: "interactive_ai_app",
};
class ScaffoldError extends Error {
constructor(code, message, details = {}) {
super(message);
this.code = code;
this.details = details;
}
}
function parseArguments(argv) {
if (argv.length !== 2 || argv[0] !== "--project-root" || !argv[1]?.trim()) {
throw new ScaffoldError(
"USAGE_INVALID",
"脚本参数无效,必须提供 --project-root <project-directory>。",
);
}
return path.resolve(argv[1]);
}
async function readProjectType(projectRoot) {
const configPath = path.join(projectRoot, ".makelore", "project.json");
let source;
try {
source = await readFile(configPath, "utf8");
} catch (error) {
if (error?.code === "ENOENT") {
throw new ScaffoldError(
"PROJECT_CONFIG_MISSING",
"当前目录缺少 .makelore/project.json。",
);
}
throw new ScaffoldError(
"PROJECT_CONFIG_INVALID",
"无法读取 .makelore/project.json。",
);
}
let config;
try {
config = JSON.parse(source);
} catch {
throw new ScaffoldError(
"PROJECT_CONFIG_INVALID",
".makelore/project.json 不是有效 JSON。",
);
}
if (!config || typeof config !== "object" || config.schemaVersion !== 2) {
throw new ScaffoldError(
"PROJECT_CONFIG_INVALID",
".makelore/project.json 必须使用 schemaVersion 2。",
);
}
const projectType = typeof config.projectType === "string"
? canonicalProjectTypes[config.projectType]
: undefined;
if (!projectType || !Object.hasOwn(targetsByProjectType, projectType)) {
throw new ScaffoldError(
"PROJECT_TYPE_UNSUPPORTED",
"仅支持初始化 interactive_ai_app 项目。",
);
}
return projectType;
}
function toNativePath(root, relativePath) {
return path.join(root, ...relativePath.split("/"));
}
async function readTemplates(targets) {
const loaded = [];
const missing = [];
for (const [targetPath, templatePath] of targets) {
try {
loaded.push({
targetPath,
contents: await readFile(toNativePath(templateDirectory, templatePath)),
});
} catch {
missing.push(targetPath);
}
}
if (missing.length > 0) {
throw new ScaffoldError(
"TEMPLATE_UNAVAILABLE",
"插件缺少必需的工程模板,请重新安装。",
{ paths: missing.sort() },
);
}
return loaded;
}
async function pathState(candidate) {
try {
return await lstat(candidate);
} catch (error) {
if (error?.code === "ENOENT") {
return undefined;
}
throw error;
}
}
function parentDirectories(relativePath) {
const parents = [];
let current = path.posix.dirname(relativePath);
while (current !== ".") {
parents.push(current);
current = path.posix.dirname(current);
}
return parents.reverse();
}
async function preflightTargets(projectRoot, templates) {
const conflicts = new Set();
const missingDirectories = new Set();
for (const { targetPath } of templates) {
let blockedByParent = false;
for (const directory of parentDirectories(targetPath)) {
const state = await pathState(toNativePath(projectRoot, directory));
if (!state) {
missingDirectories.add(directory);
} else if (!state.isDirectory()) {
conflicts.add(directory);
blockedByParent = true;
break;
}
}
if (!blockedByParent && await pathState(toNativePath(projectRoot, targetPath))) {
conflicts.add(targetPath);
}
}
if (conflicts.size > 0) {
throw new ScaffoldError(
"TARGET_CONFLICT",
"目标路径已存在或必需的父路径不是目录,未写入任何文件。",
{ paths: [...conflicts].sort() },
);
}
return [...missingDirectories].sort((left, right) => {
const depthDifference = left.split("/").length - right.split("/").length;
return depthDifference || left.localeCompare(right);
});
}
function relativeProjectPath(projectRoot, absolutePath) {
const relativePath = path.relative(projectRoot, absolutePath);
return path.sep === "/" ? relativePath : relativePath.split(path.sep).join("/");
}
async function rollback(projectRoot, createdFiles, createdDirectories, operations) {
const remainingPaths = [];
for (const filePath of [...createdFiles].reverse()) {
try {
await operations.unlink(filePath);
} catch {
remainingPaths.push(relativeProjectPath(projectRoot, filePath));
}
}
for (const directoryPath of [...createdDirectories].reverse()) {
try {
await operations.rmdir(directoryPath);
} catch {
remainingPaths.push(relativeProjectPath(projectRoot, directoryPath));
}
}
return [...new Set(remainingPaths)].sort();
}
export async function writeScaffold(
projectRoot,
templates,
missingDirectories,
operations = fileOperations,
) {
const createdFiles = [];
const createdDirectories = [];
try {
for (const relativeDirectory of missingDirectories) {
const absoluteDirectory = toNativePath(projectRoot, relativeDirectory);
await operations.mkdir(absoluteDirectory);
createdDirectories.push(absoluteDirectory);
}
for (const { targetPath, contents } of templates) {
const absoluteTarget = toNativePath(projectRoot, targetPath);
let handle;
try {
handle = await operations.open(absoluteTarget, "wx");
createdFiles.push(absoluteTarget);
await handle.writeFile(contents);
} finally {
await handle?.close();
}
}
} catch {
const remainingPaths = await rollback(
projectRoot,
createdFiles,
createdDirectories,
operations,
);
throw new ScaffoldError(
"WRITE_FAILED",
"创建工程骨架失败;本次新建内容已尽力回滚。",
remainingPaths.length > 0 ? { paths: remainingPaths } : {},
);
}
}
async function scaffold(argv) {
const projectRoot = parseArguments(argv);
const projectType = await readProjectType(projectRoot);
const templates = await readTemplates(targetsByProjectType[projectType]);
const missingDirectories = await preflightTargets(projectRoot, templates);
await writeScaffold(projectRoot, templates, missingDirectories);
return {
schemaVersion: 1,
ok: true,
status: "created",
projectType,
createdFiles: templates.map(({ targetPath }) => targetPath).sort(),
};
}
const isMain = process.argv[1]
&& pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isMain) {
try {
const result = await scaffold(process.argv.slice(2));
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
const knownError =
error instanceof ScaffoldError
? error
: new ScaffoldError("WRITE_FAILED", "创建工程骨架失败;未写入未知来源的内容。");
process.stderr.write(
`${JSON.stringify({
schemaVersion: 1,
ok: false,
code: knownError.code,
message: knownError.message,
...knownError.details,
})}\n`,
);
process.exitCode = 1;
}
}