305 lines
7.9 KiB
JavaScript
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;
|
|
}
|
|
}
|