feat(plugins): bundle project scaffold marketplace plugin

This commit is contained in:
2026-09-04 22:21:24 +08:00
parent 2207806038
commit ddb678b46f
23 changed files with 218 additions and 86 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "makelore.project-scaffold",
"version": "1.0.0",
"description": "为 MakeLore 交互式 AI 应用生成工程骨架并检查一键提交要求。",
"author": {
"name": "MakeLore Team"
},
"skills": "./skills/",
"interface": {
"displayName": "MakeLore 项目初始化与发布检查",
"shortDescription": "初始化工程骨架并检查发布要求。",
"longDescription": "读取当前 MakeLore 项目类型,生成固定、可构建的交互式 AI 应用工程骨架,并按平台的一键提交合同区分必需项、直接阻断项、打包排除项和运行期检查。",
"developerName": "MakeLore",
"category": "Developer Tools",
"capabilities": ["Write"],
"defaultPrompt": "使用 $makelore-project-scaffold 初始化当前 MakeLore 交互式 AI 应用项目,并检查是否满足一键提交要求。"
}
}

View File

@@ -0,0 +1,17 @@
{
"schemaVersion": 2,
"pluginId": "makelore.project-scaffold",
"contractVersion": 1,
"scope": "project",
"runtime": {
"kind": "skill_only"
},
"skills": [
{
"id": "makelore-project-scaffold",
"entry": "../skills/makelore-project-scaffold/SKILL.md",
"grants": []
}
],
"tools": []
}

View File

@@ -0,0 +1,6 @@
{
"name": "makelore.project-scaffold",
"version": "1.0.0",
"private": true,
"type": "module"
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "makelore.project-scaffold",
"version": "1.0.0",
"description": "为 MakeLore 交互式 AI 应用生成固定工程骨架并检查发布与审批提交要求。",
"author": {
"name": "MakeLore"
},
"extensions": {
"com.makelore": {
"capabilityManifest": "./com.makelore/capability.json"
}
}
}

View File

@@ -0,0 +1,74 @@
---
name: makelore-project-scaffold
description: Initialize a MakeLore interactive AI application from its .makelore/project.json metadata, or assess it against MakeLore publication and approval-submission requirements. Use when the user asks to initialize or scaffold an interactive_ai_app project, an existing legacy mini_game or mini_program project, asks what publishing requires or forbids, asks why a project cannot be published, or requests a release-readiness check. Do not use for creating the MakeLore project record itself, custom-project scaffolding, or general architecture discussion.
---
# MakeLore Project Scaffold
Use this skill only after MakeLore has created the project space. The project type in `.makelore/project.json` is authoritative; never infer or override it from the user's wording.
## Route the request
- If the user asks to initialize or scaffold the project, run the scaffold workflow.
- If the user asks what publishing or approval requires, what a project cannot contain, why publishing failed, or whether the project is ready to submit, read `references/submission-requirements.md` in full and follow the release-readiness workflow below.
- If the user asks for both, scaffold first, then assess submission readiness.
## Scaffold workflow
1. Confirm the current working directory is the intended project root and that `.makelore/project.json` exists.
2. Require a non-empty `MAKELORE_NODE_EXECUTABLE`. This value is supplied by the MakeLore host. If it is missing, do not invoke the script; report `HOST_RUNTIME_UNSUPPORTED` and tell the user that the current MakeLore version does not support this plugin. Do not fall back to `node` on `PATH`, Python, another runtime, or a package-manager command.
3. Resolve the absolute directory containing this `SKILL.md`.
4. Run exactly one scaffold command from the project root:
```bash
"$MAKELORE_NODE_EXECUTABLE" "<absolute-skill-directory>/scripts/scaffold.mjs" --project-root "$PWD"
```
5. Parse the command's single version-1 JSON result. Success is written only to stdout and includes `status: created`; failure is written only to stderr and includes top-level `code`, `message`, and optional project-relative `paths`. On success, report only the returned `createdFiles`. On failure, report the returned code, message, and paths when present.
The script performs a complete conflict preflight before writing. Never add `--force`, delete or rename existing user files, retry by overwriting conflicts, or manually fill gaps after a script failure. Resolve the reported conflict with the user before running again.
## Release-readiness workflow
Keep this workflow read-only unless the user also asks to fix a reported issue or explicitly starts the product's existing one-click submission flow.
1. Read `references/submission-requirements.md` in full. Its four categories are semantically different: required input, direct blocker, source-package exclusion, and authoritative runtime/platform check.
2. For a general requirements question, explain those categories and do not claim to have inspected a project.
3. For a project-specific check, gather project-relative evidence for:
- the validity and publishable type of `.makelore/project.json`;
- the required root files and the relevant `package.json` and `package-lock.json` fields;
- source file count, byte limits, path shapes, links or special files, and paths the source packager excludes;
- project-authored source or assets that appear to exist only under an excluded path;
- statically visible build-output risks such as configuration that emits a root `release.json`.
4. Classify every finding instead of flattening it into a blacklist:
- **阻断项**: current evidence proves the one-click flow will reject or cannot accept the project;
- **打包排除影响**: a path will not enter the source archive; its mere presence is not a blocker, but required content there is a release risk;
- **待权威流程验证**: npm installation, Vite execution, built output, both browser viewports, account/ownership, upload, cloud build, and review state;
- **已通过静态项**: a requirement supported by concrete file evidence.
5. Report the evidence path and observed value for every blocker. Do not infer an external-network blocker merely because source text contains an HTTP(S) URL; only an actual cross-origin request during authoritative preflight proves that failure. You may report the literal as a runtime risk.
6. Use exactly one of these conclusions:
- `不能进入一键提交` when at least one confirmed blocker exists;
- `可进入一键提交,仍需运行期验证` when static blockers are absent but the product flow has not run;
- `本地构建与预检已通过,仍待平台结果` only when the current one-click flow actually returned that evidence.
Never say that a project is approved based on this Skill's inspection. Do not run `npm ci`, Vite configuration, project scripts, upload, or submission merely to answer a readiness question: those steps execute code or change external state and belong to the explicit product flow. Do not delete excluded files just to make the report look clean.
## Error handling
Treat these codes as stable outcomes:
- `USAGE_INVALID`: the script invocation is malformed.
- `PROJECT_CONFIG_MISSING`: `.makelore/project.json` is absent.
- `PROJECT_CONFIG_INVALID`: project metadata is unreadable or unsupported.
- `PROJECT_TYPE_UNSUPPORTED`: the project is neither `interactive_ai_app` nor an existing legacy `mini_game` / `mini_program` project.
- `TARGET_CONFLICT`: at least one target path already exists or cannot be used; show every returned conflict.
- `TEMPLATE_UNAVAILABLE`: the installed plugin is incomplete.
- `WRITE_FAILED`: file creation failed and the script attempted to roll back only files and directories it created in this run.
## Ownership boundaries
- MakeLore owns project creation and `.makelore/project.json`.
- This skill owns only the fixed starter files listed by the scaffold script and never edits project metadata.
- New projects use `interactive_ai_app`. Existing `mini_game` and `mini_program` metadata are read-only compatibility aliases and receive the same unified scaffold; the script reports the canonical type without rewriting metadata.
- MakeLore's existing release builder and publisher remain authoritative for build, preflight, packaging, upload, and approval submission.
- The requirements reference is this plugin version's release contract snapshot. If it differs from runtime validation, report the mismatch, follow the runtime result, and do not silently reinterpret an exclusion as a prohibition.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "MakeLore 项目初始化与发布检查"
short_description: "创建交互式 AI 应用骨架并检查发布要求"
default_prompt: "使用 $makelore-project-scaffold 初始化当前 MakeLore 交互式 AI 应用项目,并检查是否满足一键提交要求。"

View File

@@ -0,0 +1,15 @@
{
"name": "makelore-project",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "npm@11.6.2",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"devDependencies": {
"vite": "7.3.1"
}
}

View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
});

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MakeLore 交互式 AI 应用</title>
</head>
<body>
<main class="page" id="app">
<section class="card">
<p class="eyebrow">MAKELORE</p>
<h1>交互式 AI 应用</h1>
<p>这是你的创作起点。修改页面、加入交互,让想法真正运行起来。</p>
<button type="button">开始互动</button>
<p class="status" aria-live="polite">已经准备好了</p>
</section>
</main>
<script type="module" src="./src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import './style.css';
const button = document.querySelector('button');
const status = document.querySelector('.status');
let interactions = 0;
button.addEventListener('click', () => {
interactions += 1;
status.textContent = `已完成 ${interactions} 次互动`;
});

View File

@@ -0,0 +1,16 @@
:root {
color: #192033;
background: #eef1ff;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(520px, 100%); border: 1px solid rgb(86 91 150 / 18%); border-radius: 28px; background: rgb(255 255 255 / 88%); padding: clamp(28px, 7vw, 52px); box-shadow: 0 24px 80px rgb(62 68 128 / 16%); }
.eyebrow { margin: 0 0 12px; color: #6558d9; font-size: 12px; font-weight: 800; letter-spacing: 0.18em; }
h1 { margin: 0; font-size: clamp(34px, 8vw, 58px); line-height: 1.05; letter-spacing: -0.04em; }
.card > p:not(.eyebrow) { line-height: 1.7; }
button { min-height: 44px; margin-top: 12px; border: 0; border-radius: 14px; background: #6558d9; color: #fff; padding: 12px 20px; font: inherit; font-weight: 750; cursor: pointer; }
button:hover { background: #5145c5; }
.status { color: #596078; font-size: 14px; }

View File

@@ -0,0 +1,141 @@
# MakeLore 发布与提交审批要求
这份文件是本插件版本对 MakeLore 一键提交合同的说明供发布前检查和失败归因使用。MakeLore 客户端实际执行的源码打包、固定构建、双视口预检、上传以及平台返回结果始终是唯一权威。若本说明与当前客户端不一致,必须报告差异并以客户端结果为准。
## 正确区分四类结果
- **必须具备**:缺失或格式不符会直接阻断发布。
- **直接阻断**:项目当前存在已知会被本地发布链拒绝的内容或状态。
- **打包排除**:文件可以留在项目里,但不会进入源码归档。存在本身不等于违规;只有把构建或运行必需内容放在这些位置才会造成问题。
- **运行时或平台确认**:仅凭静态文件不能下结论,必须由 MakeLore 一键提交实际运行后确认。
静态检查没有阻断项时,只能得出“可进入一键提交,仍需运行期验证”,不能称为“发布通过”或“审批通过”。
## 必须具备的发布输入
### 项目身份
- 项目目录必须存在且可读。
- `.makelore/project.json` 必须是 MakeLore 可读取的完整 schema v2 项目配置;不仅 `projectType`配置中的版本、项目身份、初始化状态、知识目录、Agent 和时间字段也必须保持有效。不要手工缩减为只有项目类型的 JSON。
- 新项目的 `projectType` 必须是 `interactive_ai_app`。已有项目中的 `mini_game``mini_program` 会在读取时归一化为同一个“交互式 AI 应用”类型;`custom`、缺失配置和无效配置都不能发布。
### 根目录构建输入
- `package.json``package-lock.json``index.html` 必须位于项目根目录,且都是普通文件而非符号链接。
- `package.json``package-lock.json` 必须分别是有效 JSON 对象,不能是数组、字符串或其他 JSON 值。
- `package.json``dependencies.vite``devDependencies.vite` 必须是非空字符串。
- `packageManager` 可以省略;若存在,必须是以 `npm@` 开头的字符串。
- `package-lock.json.lockfileVersion` 必须是整数 `2``3`
-`index.html` 去除空白后必须非空。
- 当前插件脚手架以 npm 11.6.2 生成锁文件;实际发布仍以当前 MakeLore 客户端随附的固定 npm 运行时为准。
### 固定构建合同
- 发布器在隔离的源码快照中执行 `npm ci --ignore-scripts --no-audit --no-fund --prefer-offline`,不会运行依赖安装生命周期脚本。项目不得依赖 `preinstall``install``postinstall` 等脚本生成发布必需文件。
- 随后使用锁文件实际安装出的项目 Vite 执行构建,并强制 `--base ./`、独立输出目录和清空输出目录。项目根目录已有的 `dist/``build/` 不会被直接发布。
- Vite 配置及其插件会以当前桌面用户权限执行;这个构建步骤不是代码沙箱,也不是可信性证明。
- 最终构建产物根目录必须包含普通、非空、有效 UTF-8 的 `index.html`
### 数量与大小上限
- 源码归档最多 2,000 个文件,其中包括发布器生成的 `niancode.yml`,因此用户源码最多占其余名额。
- 纳入源码包的文件总量最多 200 MiB生成的源码 ZIP 最多 50 MiB。
- `package.json` 最大 2 MiB`package-lock.json` 最大 50 MiB`index.html` 最大 10 MiB。
- 构建产物最多 2,000 个文件,总量最多 50 MiB。
## 会直接阻断发布的内容或状态
### 源项目与路径
- 项目配置缺失、无效、类型为 `custom`,或项目目录不存在/不可读。
- 必需根文件缺失、不是普通文件、超过单文件上限、JSON 结构无效、未声明 Vite、声明了非 npm `packageManager`、锁文件版本不受支持,或 `index.html` 为空。
- 未被排除的任意符号链接、目录链接、socket、设备文件等非普通文件类型。
- 相对归档路径为空,含反斜杠、空段、`.``..`、冒号,或任一路径段以空格或点结尾。
- 任一路径段是 Windows 保留名 `CON``PRN``AUX``NUL``COM1``COM9``LPT1``LPT9`,包括这些名称带扩展名的形式。
- 在大小写不敏感比较下重复的归档路径,例如同时存在 `Logo.png``logo.png`
- 源码扫描、读取或写入归档期间文件或目录发生变化。
- 超出源码文件数、源码字节数或源码 ZIP 上限。
### 构建与产物
- 固定 npm 运行时不可用、`npm ci` 失败、Vite 不可用、Vite 构建失败、构建超时或被取消。
- 构建输出包含符号链接或其他非普通文件。
- 构建输出超过 2,000 个文件或 50 MiB。
- 构建产物根目录缺少 `index.html`,该文件为空或不是有效 UTF-8。
- 构建产物根目录包含大小写不限的 `release.json`。这里禁止的是**最终构建产物的根文件**;源码中同名文件只有在最终被输出到该位置时才会阻断。
### 页面运行预检
- 构建首页未能在 1280×720 和 390×844 两个视口中全部加载完成并显示可见文字、背景、有效图片/视频、SVG 或尺寸合理的 Canvas。
- 未捕获异常、`console.error`、Chromium Log error、非取消的资源加载失败、渲染进程故障或任意 HTTP 响应状态大于等于 400。
- 预检期间实际发起到本地预览 origin 以外的 HTTP/HTTPS 请求,或页面最终跳转离开精确预览 URL。源码里仅出现 URL 字符串还不能证明会触发该阻断。
- 两个视口共用的 30 秒预检时限耗尽。每个视口加载后只等待短暂稳定时间,因此首屏不能依赖长时间后台任务才出现。
新窗口会被预检环境拒绝打开;项目不应依赖弹窗才能展示首屏或完成启动。
## 会从源码归档排除、但存在本身不阻断的内容
以下匹配均不区分大小写。目录规则按任意层级的目录名匹配;文件名规则按任意层级的 basename 匹配。
### 排除目录名
`.cache``.git``.gnupg``.idea``.kube``.next``.makelore``.niancode``.opencode``.project-docs``.turbo``.vite``.vscode``.aws``.docker``.ssh``build``coverage``deploy``dist``knowledge``node_modules``secrets`
### 排除文件名
`.dockerignore``.ds_store``.git-credentials``.netrc``.npmrc``art_guide.md``asset_plan.md``credentials.json``debug.log``dockerfile``docker-compose.yaml``docker-compose.yml``findings.md``gdd.md``nginx.conf``niancode.yml``_netrc``id_dsa``id_ecdsa``id_ed25519``id_rsa``product_overview.md``progress.md``release.md``task_plan.md``tasks.md``thumbs.db``version.md``works-cloud-deploy.json``works-deploy-check.json``works-publish.json``部署报告.md`
### 排除模式
- 文件名以 `.env` 开头。
- 文件名以 `.crt``.jks``.key``.keystore``.log``.p12``.pem``.pfx``.p8``.ppk``.tfstate``.tfvars` 结尾。
- 文件名任意位置包含 `.tfstate``.tfvars`
- JSON 文件名去掉非字母数字字符后包含 `serviceaccount``firebaseadmin``applicationdefaultcredentials`
- 项目根相对路径以 `works-` 开头的文件,包括这类顶层目录中的文件。
排除规则的后果:
- 不要把项目自己编写的源代码、首屏素材或构建配置只放在上述位置;发布快照看不到它们。
- `node_modules/` 被排除是正常行为,发布器会根据根 `package-lock.json` 重新安装依赖。
- `.npmrc` 被排除,发布器还会显式使用空的临时 npm 配置;项目不能依赖本机或仓库 `.npmrc` 才能安装。
- 项目里的 `niancode.yml` 被排除,发布器会生成自己的固定清单。
- `VERSION.md` 不是必需文件。若它是 64 KiB 以内的稳定普通文件,`Current:` 后不超过 80 字符的值可用于版本名;否则发布器自动生成版本名。无论哪种情况,该文件都不进入源码归档。
- 源码中已有 `dist/``build/` 不会被上传;最终发布的是固定流程本次生成的构建产物。
不要因为检查发现排除项就要求删除它们。应报告哪些路径被排除,以及是否有证据表明构建或运行依赖这些路径。
## 首次提交必须提供的平台资料
当前 MakeLore 界面合同要求:
- 项目名称:必填,最多 160 个字符。
- 项目简介:必填,最多 2,000 个字符。
- 发布者姓名:必填,最多 160 个字符。
- 发布者年龄:必填,为 1150 的整数。
- 项目封面:首次提交必填,仅支持 PNG、JPEG 或 WebP必须非空、内容签名与声明格式相符最大 10 MiB。
- 详细介绍可选,最多 10,000 个字符。
- 适龄范围和难度可选,各最多 40 个字符。
作品 `app_id` 和类别由当前项目生成,不要求用户另造。若平台已存在状态为 `draft``published` 的该作品,当前流程只提交新构建版本,并保留平台上的名称、简介、作者信息和封面。窗口打开后平台状态发生变化会产生资料冲突,本次不会覆盖或继续提交。
## 只能由一键提交和平台确认
- 当前账号是否已登录,登录态是否仍有效。
- 当前本地项目是否仍存在,以及本机固定 Node/npm 发布运行时、npm 缓存和依赖来源是否可用。
- `npm ci`、项目 Vite 配置/插件、实际构建输出和双视口页面运行是否通过。
- 当前账号是否拥有目标作品,平台资料状态是否与发布窗口一致。
- 同一项目是否已有本地构建占用,以及平台是否因现有构建、版本或审核状态拒绝新版本。
- 平台是否接受首次作品资料、封面、源码归档、构建归档和版本化产物合同。
- 云端构建最终是成功、失败、取消还是状态未知,以及运营审核最终是通过还是拒绝。
本 Skill 的静态检查不会安装依赖、执行 Vite 配置、启动浏览器预检、上传归档或提交审核。用户明确发起一键提交后,以该流程返回的错误码、构建状态和平台审核状态为准。
## 项目检查时的输出顺序
对具体项目做发布就绪检查时,按以下顺序报告,不打分:
1. **结论**:只能使用 Skill 工作流定义的三种结论之一。
2. **阻断项**:仅列有当前证据的直接阻断,并附项目相对路径和观察值。
3. **打包排除影响**:列出实际命中的排除路径及其可能丢失的必需内容;若没有依赖证据,明确写“存在但不阻断”。
4. **待权威流程验证**:列出本次没有也不能静态证明的构建、浏览器、账号和平台项。
5. **已通过静态项**:列出已读取并确认的必要输入,不把未检查项写成通过。

View File

@@ -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;
}
}