feat(plugins): bundle project scaffold marketplace plugin
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user