create-koishi-ce 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bin.mjs +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.mjs +1 -1
- package/lib/{src-B_s70NbL.mjs → src-CUSQBq1d.mjs} +189 -27
- package/package.json +1 -1
- package/src/__tests__/run-default.test.ts +275 -0
- package/src/__tests__/run-help.test.ts +41 -0
- package/src/__tests__/run-remote.test.ts +242 -0
- package/src/__tests__/template.test.ts +57 -0
- package/src/index.ts +52 -23
- package/src/template.ts +167 -0
package/lib/bin.mjs
CHANGED
package/lib/index.d.ts
CHANGED
package/lib/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as start, i as renderManifest, n as getLocalRegistry, r as readNpmrcRegistry, t as detectAgent } from "./src-
|
|
1
|
+
import { a as start, i as renderManifest, n as getLocalRegistry, r as readNpmrcRegistry, t as detectAgent } from "./src-CUSQBq1d.mjs";
|
|
2
2
|
export { detectAgent, getLocalRegistry, readNpmrcRegistry, renderManifest, start };
|
|
@@ -7,23 +7,11 @@ import kleur from "kleur";
|
|
|
7
7
|
import prompts from "prompts";
|
|
8
8
|
import { extract } from "tar";
|
|
9
9
|
import parse from "yargs-parser";
|
|
10
|
-
//#
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* create-koishi-ce 脚手架(npm 包名 create-koishi-ce,目录名为
|
|
14
|
-
* apps/koishi-create,二者不一致是历史遗留,以目录名为准)。
|
|
15
|
-
*
|
|
16
|
-
* 通过 `bunx create-koishi-ce [name]`(npx 亦可)交互式创建 Koishi 机器人
|
|
17
|
-
* 应用项目:确定项目名 → 准备目标目录 → 从 npm registry 下载模板包(默认
|
|
18
|
-
* @koishijs/boilerplate,刻意沿用上游官方模板以保持与上游插件生态一致)
|
|
19
|
-
* 并解包 → 改写 package.json → 按需初始化 git → 询问是否立即安装依赖并
|
|
20
|
-
* 启动。CLI 可执行入口在 src/bin.ts(构建产物 lib/bin.mjs,bin 字段指向
|
|
21
|
-
* 它);本文件只承载主流程与可单测的纯函数(范式对齐 @koishi-ce/scripts)。
|
|
22
|
-
*/
|
|
23
|
-
const { version } = {
|
|
10
|
+
//#region package.json
|
|
11
|
+
var package_default = {
|
|
24
12
|
name: "create-koishi-ce",
|
|
25
13
|
description: "Setup a Koishi application",
|
|
26
|
-
version: "1.0
|
|
14
|
+
version: "1.1.0",
|
|
27
15
|
type: "module",
|
|
28
16
|
main: "./lib/index.mjs",
|
|
29
17
|
module: "./lib/index.mjs",
|
|
@@ -72,6 +60,163 @@ const { version } = {
|
|
|
72
60
|
"./package.json": "./package.json"
|
|
73
61
|
}
|
|
74
62
|
};
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/template.ts
|
|
65
|
+
/** 模板静态文件(相对项目根的路径 → 文件内容),package.json 另行渲染 */
|
|
66
|
+
const templateFiles = {
|
|
67
|
+
".env": [
|
|
68
|
+
"GITHUB_MIRROR = https://ghproxy.com/https://github.com",
|
|
69
|
+
"GITHUB_CONTENT_MIRROR = https://ghproxy.com/https://raw.githubusercontent.com",
|
|
70
|
+
"GRAVATAR_MIRROR = https://cravatar.cn"
|
|
71
|
+
].join("\n"),
|
|
72
|
+
".gitignore": ["node_modules/", "data/"].join("\n"),
|
|
73
|
+
"koishi.yml": [
|
|
74
|
+
"plugins:",
|
|
75
|
+
" group:server:",
|
|
76
|
+
" server:",
|
|
77
|
+
" port: 5140",
|
|
78
|
+
" maxPort: 5149",
|
|
79
|
+
" group:basic:",
|
|
80
|
+
" ~admin: {}",
|
|
81
|
+
" ~bind: {}",
|
|
82
|
+
" commands: {}",
|
|
83
|
+
" help: {}",
|
|
84
|
+
" http: {}",
|
|
85
|
+
" ~inspect: {}",
|
|
86
|
+
" locales: {}",
|
|
87
|
+
" proxy-agent: {}",
|
|
88
|
+
" group:console:",
|
|
89
|
+
" actions: {}",
|
|
90
|
+
" ~analytics: {}",
|
|
91
|
+
" ~auth: {}",
|
|
92
|
+
" config: {}",
|
|
93
|
+
" console:",
|
|
94
|
+
" open: true",
|
|
95
|
+
" explorer: {}",
|
|
96
|
+
" insight: {}",
|
|
97
|
+
" logger: {}",
|
|
98
|
+
" market:",
|
|
99
|
+
" search:",
|
|
100
|
+
" endpoint: https://registry.koishi.chat/index.json",
|
|
101
|
+
" notifier: {}",
|
|
102
|
+
" oobe: {}",
|
|
103
|
+
" sandbox: {}",
|
|
104
|
+
" status: {}",
|
|
105
|
+
" group:develop:",
|
|
106
|
+
" $if: env.NODE_ENV === 'development'",
|
|
107
|
+
" hmr:",
|
|
108
|
+
" root: ."
|
|
109
|
+
].join("\n"),
|
|
110
|
+
"tsconfig.json": [
|
|
111
|
+
"{",
|
|
112
|
+
" \"compilerOptions\": {",
|
|
113
|
+
" \"target\": \"ESNext\",",
|
|
114
|
+
" \"module\": \"ESNext\",",
|
|
115
|
+
" \"moduleResolution\": \"bundler\",",
|
|
116
|
+
" \"lib\": [\"ESNext\"],",
|
|
117
|
+
" \"types\": [\"bun-types\"],",
|
|
118
|
+
" \"strict\": true,",
|
|
119
|
+
" \"skipLibCheck\": true,",
|
|
120
|
+
" \"noEmit\": true,",
|
|
121
|
+
" \"allowImportingTsExtensions\": true,",
|
|
122
|
+
" \"verbatimModuleSyntax\": true,",
|
|
123
|
+
" \"erasableSyntaxOnly\": true",
|
|
124
|
+
" },",
|
|
125
|
+
" \"include\": [\"plugins/*/src\", \"external/*/src\"]",
|
|
126
|
+
"}"
|
|
127
|
+
].join("\n"),
|
|
128
|
+
"README.md": `# Koishi 机器人项目
|
|
129
|
+
|
|
130
|
+
由 \`bun create koishi-ce\` 生成,运行于 [@koishi-ce 社区再分发版](https://github.com/Koishi-CE/koishi) 生态。
|
|
131
|
+
|
|
132
|
+
## 环境要求
|
|
133
|
+
|
|
134
|
+
- [Bun](https://bun.sh) ≥ 1.2(运行时:koishi CLI 与插件加载链以 Bun 为准)
|
|
135
|
+
|
|
136
|
+
## 快速开始
|
|
137
|
+
|
|
138
|
+
\`\`\`bash
|
|
139
|
+
bun install
|
|
140
|
+
bun run start # 启动(生产模式)
|
|
141
|
+
bun run dev # 启动(开发模式,启用 HMR 热更新)
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
启动后访问控制台:<http://127.0.0.1:5140>
|
|
145
|
+
|
|
146
|
+
## 安装插件
|
|
147
|
+
|
|
148
|
+
推荐在控制台「插件市场」页安装(已预配 registry.koishi.chat 镜像源);也可以手动 \`bun add <包名>\` 后在 \`koishi.yml\` 中启用。
|
|
149
|
+
|
|
150
|
+
上游官方 adapter(如 adapter-discord / adapter-telegram)、数据库插件(如 database-sqlite)与社区 koishi-plugin-* 插件均可直接安装:根依赖中的 \`"koishi": "npm:@koishi-ce/koishi-shim@^4.18.11"\` 已把上游生态对 \`koishi\` 的依赖钉回 @koishi-ce 框架(**请勿删除或改写该行**),不会形成第二份框架副本。analytics 等依赖数据库的插件请先安装数据库插件,再去掉配置中对应的 \`~\` 前缀启用。
|
|
151
|
+
|
|
152
|
+
## 自定义插件
|
|
153
|
+
|
|
154
|
+
在 \`plugins/\` 目录下创建插件包(可用 \`bun run new <名称>\` 生成骨架),在 \`koishi.yml\` 中以相对路径引用(如 \`./plugins/my-plugin\`)即可启用;Bun 直接加载 TypeScript 源码,无需预编译。
|
|
155
|
+
`
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* 内置模板的 package.json 基础内容(name/version 会被 renderManifest 覆写,
|
|
159
|
+
* prod 模式下 workspaces 与 devDependencies 会被移除)。
|
|
160
|
+
*/
|
|
161
|
+
function baseManifest() {
|
|
162
|
+
return {
|
|
163
|
+
type: "module",
|
|
164
|
+
workspaces: ["plugins/*", "external/*"],
|
|
165
|
+
scripts: {
|
|
166
|
+
start: "koishi start",
|
|
167
|
+
dev: "cross-env NODE_ENV=development koishi start",
|
|
168
|
+
new: "koishi-scripts setup"
|
|
169
|
+
},
|
|
170
|
+
dependencies: {
|
|
171
|
+
"@koishi-ce/koishi": "^1.0.0",
|
|
172
|
+
"@koishi-ce/plugin-actions": "^1.0.0",
|
|
173
|
+
"@koishi-ce/plugin-admin": "^1.0.0",
|
|
174
|
+
"@koishi-ce/plugin-analytics": "^1.0.0",
|
|
175
|
+
"@koishi-ce/plugin-auth": "^1.0.0",
|
|
176
|
+
"@koishi-ce/plugin-bind": "^1.0.0",
|
|
177
|
+
"@koishi-ce/plugin-commands": "^1.0.0",
|
|
178
|
+
"@koishi-ce/plugin-config": "^1.0.0",
|
|
179
|
+
"@koishi-ce/plugin-console": "^1.0.0",
|
|
180
|
+
"@koishi-ce/plugin-explorer": "^1.0.0",
|
|
181
|
+
"@koishi-ce/plugin-help": "^1.0.0",
|
|
182
|
+
"@koishi-ce/plugin-http": "^1.0.0",
|
|
183
|
+
"@koishi-ce/plugin-insight": "^1.0.0",
|
|
184
|
+
"@koishi-ce/plugin-inspect": "^1.0.0",
|
|
185
|
+
"@koishi-ce/plugin-locales": "^1.0.0",
|
|
186
|
+
"@koishi-ce/plugin-logger": "^1.0.0",
|
|
187
|
+
"@koishi-ce/plugin-market": "^1.0.0",
|
|
188
|
+
"@koishi-ce/plugin-notifier": "^1.0.0",
|
|
189
|
+
"@koishi-ce/plugin-oobe": "^1.0.0",
|
|
190
|
+
"@koishi-ce/plugin-proxy-agent": "^1.0.0",
|
|
191
|
+
"@koishi-ce/plugin-sandbox": "^1.0.0",
|
|
192
|
+
"@koishi-ce/plugin-server": "^1.0.0",
|
|
193
|
+
"@koishi-ce/plugin-status": "^1.0.0",
|
|
194
|
+
koishi: "npm:@koishi-ce/koishi-shim@^4.18.11"
|
|
195
|
+
},
|
|
196
|
+
devDependencies: {
|
|
197
|
+
"@koishi-ce/client": "^1.0.0",
|
|
198
|
+
"@koishi-ce/plugin-hmr": "^1.0.0",
|
|
199
|
+
"@koishi-ce/scripts": "^1.0.0",
|
|
200
|
+
"bun-types": "^1.4.0",
|
|
201
|
+
"cross-env": "^7.0.3"
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/index.ts
|
|
207
|
+
/**
|
|
208
|
+
* create-koishi-ce 脚手架(npm 包名 create-koishi-ce,目录名为
|
|
209
|
+
* apps/koishi-create,二者不一致是历史遗留,以目录名为准)。
|
|
210
|
+
*
|
|
211
|
+
* 通过 `bunx create-koishi-ce [name]`(npx 亦可)交互式创建 Koishi 机器人
|
|
212
|
+
* 应用项目:确定项目名 → 准备目标目录 → 写入内置 @koishi-ce 模板(默认,
|
|
213
|
+
* 见 src/template.ts;--template <包名> 可改用 npm registry 远程模板,如
|
|
214
|
+
* 上游官方 @koishijs/boilerplate)→ 按需初始化 git → 询问是否立即安装
|
|
215
|
+
* 依赖并启动。CLI 可执行入口在 src/bin.ts(构建产物 lib/bin.mjs,bin 字段
|
|
216
|
+
* 指向它);本文件只承载主流程与可单测的纯函数(范式对齐
|
|
217
|
+
* @koishi-ce/scripts)。
|
|
218
|
+
*/
|
|
219
|
+
const { version } = package_default;
|
|
75
220
|
/** fetch 请求非 2xx 时抛出的错误,携带 HTTP 状态码与状态文本 */
|
|
76
221
|
var HttpError = class extends Error {
|
|
77
222
|
status;
|
|
@@ -217,18 +362,22 @@ function writePackageJson() {
|
|
|
217
362
|
writeFileSync(filename, renderManifest(meta, project, argv.prod === true));
|
|
218
363
|
}
|
|
219
364
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
365
|
+
* 写入内置模板(默认路径):静态文件(src/template.ts 的 templateFiles)
|
|
366
|
+
* 加上由 baseManifest() 渲染出的 package.json。纯本地写入,无网络请求。
|
|
367
|
+
*/
|
|
368
|
+
function scaffoldBuiltin() {
|
|
369
|
+
for (const [file, content] of Object.entries(templateFiles)) writeFileSync(join(rootDir, file), content);
|
|
370
|
+
writeFileSync(join(rootDir, "package.json"), renderManifest(baseManifest(), project, argv.prod === true));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 远程模板下载与解包(--template 指定时):
|
|
374
|
+
* 1. 拉取模板包元数据,按 dist-tags 解析目标版本(--ref,默认 latest);
|
|
375
|
+
* 2. 流式下载 tarball 并解包到目标目录(strip: 1 去掉包根目录层级),
|
|
224
376
|
* 网络错误统一以 HttpError 提示后退出;
|
|
225
|
-
*
|
|
377
|
+
* 3. 最后改写 package.json。
|
|
226
378
|
*/
|
|
227
|
-
async function
|
|
228
|
-
|
|
229
|
-
const registry = (argv.registry || getLocalRegistry() || "https://registry.npmjs.org").replace(/\/$/, "");
|
|
230
|
-
console.log(kleur.dim(` 使用 registry:${registry}\n`));
|
|
231
|
-
const template = argv.template || "@koishijs/boilerplate";
|
|
379
|
+
async function scaffoldRemote(registry) {
|
|
380
|
+
const template = argv.template;
|
|
232
381
|
const ref = argv.ref || "latest";
|
|
233
382
|
try {
|
|
234
383
|
const metaRes = await fetch(`${registry}/${template}`);
|
|
@@ -253,6 +402,19 @@ async function scaffold() {
|
|
|
253
402
|
process.exit(1);
|
|
254
403
|
}
|
|
255
404
|
writePackageJson();
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 生成项目的主入口:默认写内置 @koishi-ce 模板(scaffoldBuiltin);
|
|
408
|
+
* --template <包名> 时改为从 npm registry 下载远程模板(scaffoldRemote),
|
|
409
|
+
* registry 取值 --registry 参数 > 本机 npm 配置 > 官方源。
|
|
410
|
+
*/
|
|
411
|
+
async function scaffold() {
|
|
412
|
+
console.log(kleur.dim(" 正在 ") + project + kleur.dim(" 中生成项目 ..."));
|
|
413
|
+
if (argv.template) {
|
|
414
|
+
const registry = (argv.registry || getLocalRegistry() || "https://registry.npmjs.org").replace(/\/$/, "");
|
|
415
|
+
console.log(kleur.dim(` 使用 registry:${registry}\n`));
|
|
416
|
+
await scaffoldRemote(registry);
|
|
417
|
+
} else scaffoldBuiltin();
|
|
256
418
|
console.log(kleur.green(" 完成。\n"));
|
|
257
419
|
}
|
|
258
420
|
/**
|
|
@@ -313,8 +475,8 @@ async function start() {
|
|
|
313
475
|
用法:create-koishi-ce [名称] [选项]
|
|
314
476
|
|
|
315
477
|
选项:
|
|
316
|
-
-t, --template <名称>
|
|
317
|
-
-r, --ref <引用>
|
|
478
|
+
-t, --template <名称> 从 npm registry 下载指定模板包(默认使用内置 @koishi-ce 模板)
|
|
479
|
+
-r, --ref <引用> 远程模板版本引用(默认 latest)
|
|
318
480
|
-f, --forced 强制清空目标目录
|
|
319
481
|
-g, --git 初始化 git 仓库
|
|
320
482
|
--registry <地址> 指定 npm registry(如 https://registry.npmmirror.com)
|
package/package.json
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* start() 默认(内置模板)主流程的端到端测试。
|
|
17
|
+
*
|
|
18
|
+
* 手段:把 process.argv 指为无位置参数 + --git,chdir 到临时目录后动态
|
|
19
|
+
* 导入被测模块(其内部 cwd / argv 在模块顶层固化);prompts 与
|
|
20
|
+
* node:child_process 均 mock,分别提供可编程的问询答案与子进程记录,
|
|
21
|
+
* process.exit 替换为可捕获的异常,从而覆盖全部交互分支。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), "ckc-run-default-"));
|
|
25
|
+
const previousCwd = process.cwd();
|
|
26
|
+
const previousArgv = process.argv.slice();
|
|
27
|
+
const previousExit = process.exit;
|
|
28
|
+
// 保持无 user-agent 的探测环境(detectAgent 走 bun 分支)
|
|
29
|
+
const userAgentKey = "npm_config_user_agent";
|
|
30
|
+
const hadUserAgent = userAgentKey in process.env;
|
|
31
|
+
const previousUserAgent = process.env[userAgentKey];
|
|
32
|
+
delete process.env[userAgentKey];
|
|
33
|
+
|
|
34
|
+
/** prompts 的可编程答案队列(按 type 分发) */
|
|
35
|
+
const nameAnswers: string[] = [];
|
|
36
|
+
const confirmAnswers: boolean[] = [];
|
|
37
|
+
|
|
38
|
+
mock.module("prompts", () => ({
|
|
39
|
+
default: async (question: { type?: string }) => {
|
|
40
|
+
if (question.type === "confirm") {
|
|
41
|
+
return { yes: confirmAnswers.shift() ?? false };
|
|
42
|
+
}
|
|
43
|
+
return { name: nameAnswers.shift() ?? "" };
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
/** spawnSync 的调用记录与可编程行为 */
|
|
48
|
+
const spawnLog: Array<{ cmd: string; args: string[]; cwd?: string }> = [];
|
|
49
|
+
let installExit = 0;
|
|
50
|
+
|
|
51
|
+
// 本文件求值时 node:child_process 恒为真实实现(本仓测试中唯一的
|
|
52
|
+
// child_process mock 注册点),持有真实引用供未知命令透传;mock 的
|
|
53
|
+
// 命名空间改写会波及其它测试文件经活绑定发起的调用(如 setup 的
|
|
54
|
+
// git init、release 的子进程测试),透传保证它们拿到真实行为
|
|
55
|
+
const realSpawnSync = spawnSync;
|
|
56
|
+
|
|
57
|
+
mock.module("node:child_process", () => ({
|
|
58
|
+
spawnSync: (
|
|
59
|
+
cmd: string,
|
|
60
|
+
args: readonly string[],
|
|
61
|
+
options: { cwd?: string },
|
|
62
|
+
) => {
|
|
63
|
+
if (cmd === "git" && args[0] === "--version") return { status: 0 };
|
|
64
|
+
if (cmd === "git" && args[0] === "config" && args[1] === "--get") {
|
|
65
|
+
const key = args[2];
|
|
66
|
+
const table: Record<string, string> = {
|
|
67
|
+
"user.name": "Tester",
|
|
68
|
+
"user.email": "t@e.st",
|
|
69
|
+
"init.defaultBranch": "trunk",
|
|
70
|
+
};
|
|
71
|
+
const value = key !== undefined ? table[key] : undefined;
|
|
72
|
+
return value === undefined
|
|
73
|
+
? { status: 1, stdout: "" }
|
|
74
|
+
: { status: 0, stdout: `${value}\n` };
|
|
75
|
+
}
|
|
76
|
+
// exactOptionalPropertyTypes:cwd 未传时不落键,避免显式写入 undefined
|
|
77
|
+
spawnLog.push({
|
|
78
|
+
cmd,
|
|
79
|
+
args: [...args],
|
|
80
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
81
|
+
});
|
|
82
|
+
// 依赖安装可编程;agent 启动不真实拉起
|
|
83
|
+
if (args[0] === "install") return { status: installExit };
|
|
84
|
+
if (cmd === "bun" && args[0] === "run") return { status: 0 };
|
|
85
|
+
// 其余命令透传真实执行(副作用均在各自测试的临时目录内)
|
|
86
|
+
return realSpawnSync(cmd, args, options as Parameters<typeof spawnSync>[2]);
|
|
87
|
+
},
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
/** process.exit 替换为异常,供断言「直接退出」的分支 */
|
|
91
|
+
// erasableSyntaxOnly 禁用参数属性,改为显式字段声明
|
|
92
|
+
class ExitError extends Error {
|
|
93
|
+
code: number;
|
|
94
|
+
constructor(code: number) {
|
|
95
|
+
super(`process.exit(${code})`);
|
|
96
|
+
this.code = code;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
process.exit = ((code?: number) => {
|
|
101
|
+
throw new ExitError(code ?? 0);
|
|
102
|
+
}) as typeof process.exit;
|
|
103
|
+
|
|
104
|
+
process.argv = [process.argv[0] ?? "bun", "create-koishi-ce", "--git"];
|
|
105
|
+
process.chdir(workspaceRoot);
|
|
106
|
+
// query 强制独立实例(说明与写法见 run-help.test.ts)
|
|
107
|
+
const specifier = "../index.ts?default-run";
|
|
108
|
+
const { start, getLocalRegistry, readNpmrcRegistry } = (await import(
|
|
109
|
+
specifier
|
|
110
|
+
)) as typeof import("../index.ts");
|
|
111
|
+
// cwd 已在被测模块内固化,立即切回,缩小对进程全局的影响窗口
|
|
112
|
+
process.chdir(previousCwd);
|
|
113
|
+
|
|
114
|
+
const logs: string[] = [];
|
|
115
|
+
const originalLog = console.log;
|
|
116
|
+
|
|
117
|
+
beforeAll(() => {
|
|
118
|
+
console.log = (...args: unknown[]) => {
|
|
119
|
+
logs.push(args.map((arg) => `${arg}`).join(" "));
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterAll(() => {
|
|
124
|
+
console.log = originalLog;
|
|
125
|
+
process.exit = previousExit;
|
|
126
|
+
process.argv = previousArgv;
|
|
127
|
+
if (hadUserAgent) {
|
|
128
|
+
process.env[userAgentKey] = previousUserAgent;
|
|
129
|
+
} else {
|
|
130
|
+
delete process.env[userAgentKey];
|
|
131
|
+
}
|
|
132
|
+
rmSync(workspaceRoot, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** 断言内置模板的静态文件齐备 */
|
|
136
|
+
function expectTemplateFiles(dir: string): void {
|
|
137
|
+
for (const file of [
|
|
138
|
+
"package.json",
|
|
139
|
+
"koishi.yml",
|
|
140
|
+
"tsconfig.json",
|
|
141
|
+
".env",
|
|
142
|
+
".gitignore",
|
|
143
|
+
"README.md",
|
|
144
|
+
]) {
|
|
145
|
+
expect(existsSync(join(dir, file)), file).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
const manifest = JSON.parse(
|
|
148
|
+
readFileSync(join(dir, "package.json"), "utf8"),
|
|
149
|
+
) as Record<string, unknown>;
|
|
150
|
+
expect(manifest["private"]).toBe(true);
|
|
151
|
+
expect(manifest["version"]).toBe("0.0.0");
|
|
152
|
+
expect(manifest["workspaces"]).toEqual(["plugins/*", "external/*"]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("create-koishi-ce 默认模板主流程", () => {
|
|
156
|
+
// 补齐本实例下纯函数分支的行覆盖(registry 既有测试走的是共享实例)
|
|
157
|
+
it("registry 探测纯函数在本实例同样可用", () => {
|
|
158
|
+
const npmrc = join(workspaceRoot, ".npmrc");
|
|
159
|
+
writeFileSync(npmrc, "registry=https://registry.example.com/\n");
|
|
160
|
+
expect(readNpmrcRegistry(npmrc)).toBe("https://registry.example.com/");
|
|
161
|
+
expect(readNpmrcRegistry(join(workspaceRoot, "absent"))).toBeUndefined();
|
|
162
|
+
expect(getLocalRegistry(workspaceRoot, workspaceRoot)).toBe(
|
|
163
|
+
"https://registry.example.com/",
|
|
164
|
+
);
|
|
165
|
+
expect(getLocalRegistry(workspaceRoot, workspaceRoot)).toBeDefined();
|
|
166
|
+
rmSync(npmrc, { force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("项目名指向当前目录(rootDir === cwd):直接在原地生成,不打印 cd 提示", async () => {
|
|
170
|
+
// workspaceRoot 此刻为空目录:prepare 直接通过,无需清空确认
|
|
171
|
+
nameAnswers.push(".");
|
|
172
|
+
confirmAnswers.push(false);
|
|
173
|
+
await start();
|
|
174
|
+
expectTemplateFiles(workspaceRoot);
|
|
175
|
+
expect(
|
|
176
|
+
(
|
|
177
|
+
JSON.parse(
|
|
178
|
+
readFileSync(join(workspaceRoot, "package.json"), "utf8"),
|
|
179
|
+
) as Record<string, unknown>
|
|
180
|
+
)["name"],
|
|
181
|
+
).toBe(basename(workspaceRoot));
|
|
182
|
+
// rootDir 与 cwd 相同 → 稍后启动提示里没有 cd 行
|
|
183
|
+
expect(logs.join("\n")).not.toContain("cd ");
|
|
184
|
+
logs.length = 0;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("常规新目录:内置模板 + git init(分支名取 init.defaultBranch)+ 安装并启动", async () => {
|
|
188
|
+
nameAnswers.push("app-a");
|
|
189
|
+
confirmAnswers.push(true);
|
|
190
|
+
installExit = 0;
|
|
191
|
+
await start();
|
|
192
|
+
expectTemplateFiles(join(workspaceRoot, "app-a"));
|
|
193
|
+
expect(
|
|
194
|
+
(
|
|
195
|
+
JSON.parse(
|
|
196
|
+
readFileSync(join(workspaceRoot, "app-a", "package.json"), "utf8"),
|
|
197
|
+
) as Record<string, unknown>
|
|
198
|
+
)["name"],
|
|
199
|
+
).toBe("app-a");
|
|
200
|
+
// git init 使用 git config 探测到的分支名
|
|
201
|
+
expect(spawnLog).toContainEqual({
|
|
202
|
+
cmd: "git",
|
|
203
|
+
args: ["init", "-b", "trunk"],
|
|
204
|
+
cwd: join(workspaceRoot, "app-a"),
|
|
205
|
+
});
|
|
206
|
+
// install 确认后:agent install → agent run start(bun-first 探测)
|
|
207
|
+
expect(spawnLog).toContainEqual({
|
|
208
|
+
cmd: "bun",
|
|
209
|
+
args: ["install"],
|
|
210
|
+
cwd: join(workspaceRoot, "app-a"),
|
|
211
|
+
});
|
|
212
|
+
expect(spawnLog).toContainEqual({
|
|
213
|
+
cmd: "bun",
|
|
214
|
+
args: ["run", "start"],
|
|
215
|
+
cwd: join(workspaceRoot, "app-a"),
|
|
216
|
+
});
|
|
217
|
+
logs.length = 0;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("目标目录非空且用户拒绝清空:直接退出且不改动现有文件", async () => {
|
|
221
|
+
const dir = join(workspaceRoot, "app-b");
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
writeFileSync(join(dir, "occupier.txt"), "保留我");
|
|
224
|
+
nameAnswers.push("app-b");
|
|
225
|
+
confirmAnswers.push(false);
|
|
226
|
+
await expect(start()).rejects.toThrow("process.exit(0)");
|
|
227
|
+
expect(readFileSync(join(dir, "occupier.txt"), "utf8")).toBe("保留我");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("用户确认清空:emptyDir 后重建模板", async () => {
|
|
231
|
+
const dir = join(workspaceRoot, "app-b");
|
|
232
|
+
nameAnswers.push("app-b");
|
|
233
|
+
confirmAnswers.push(true);
|
|
234
|
+
await start();
|
|
235
|
+
expect(existsSync(join(dir, "occupier.txt"))).toBe(false);
|
|
236
|
+
expectTemplateFiles(dir);
|
|
237
|
+
logs.length = 0;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("项目名问询被取消(空白输入):直接退出", async () => {
|
|
241
|
+
nameAnswers.push(" ");
|
|
242
|
+
await expect(start()).rejects.toThrow("process.exit(0)");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("依赖安装失败:提示后不再尝试启动", async () => {
|
|
246
|
+
nameAnswers.push("app-f");
|
|
247
|
+
confirmAnswers.push(true);
|
|
248
|
+
installExit = 1;
|
|
249
|
+
spawnLog.length = 0;
|
|
250
|
+
await start();
|
|
251
|
+
const commands = spawnLog.map(
|
|
252
|
+
(call) => `${call.cmd} ${call.args.join(" ")}`,
|
|
253
|
+
);
|
|
254
|
+
expect(commands).toContain("bun install");
|
|
255
|
+
expect(commands).not.toContain("bun run start");
|
|
256
|
+
expect(logs.join("\n")).toContain("依赖安装失败");
|
|
257
|
+
logs.length = 0;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("拒绝立即安装:打印后续手动命令(含相对路径 cd 提示)", async () => {
|
|
261
|
+
nameAnswers.push("sub/app-c");
|
|
262
|
+
confirmAnswers.push(false);
|
|
263
|
+
await start();
|
|
264
|
+
const output = logs.join("\n");
|
|
265
|
+
expect(output).toContain("cd");
|
|
266
|
+
expect(output).toContain("bun install");
|
|
267
|
+
expect(output).toContain("bun run start");
|
|
268
|
+
expect(existsSync(join(workspaceRoot, "sub", "app-c", "koishi.yml"))).toBe(
|
|
269
|
+
true,
|
|
270
|
+
);
|
|
271
|
+
expect(
|
|
272
|
+
readdirSync(join(workspaceRoot, "sub", "app-c")).length,
|
|
273
|
+
).toBeGreaterThan(0);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* start() 的 --help 分支:打印用法后直接返回(不产生任何写盘)。
|
|
5
|
+
* argv 在模块顶层解析,故在动态导入前改写 process.argv。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const logs: string[] = [];
|
|
9
|
+
const originalLog = console.log;
|
|
10
|
+
|
|
11
|
+
process.argv = [process.argv[0] ?? "bun", "create-koishi-ce", "--help"];
|
|
12
|
+
|
|
13
|
+
// 以 query 强制独立实例:index.ts 顶层固化 argv / cwd,而 bun test 的
|
|
14
|
+
// 模块注册表跨测试文件共享(不带 query 会拿到既有测试文件已加载的
|
|
15
|
+
// 实例,其 argv / cwd 均为测试进程默认值)。query 须以相对说明符的
|
|
16
|
+
// 形式交给运行时(file URL 形式的 query 不参与缓存键;TS 亦不解析
|
|
17
|
+
// 带 query 的说明符),故经变量动态构造,类型以 typeof import() 收紧
|
|
18
|
+
const specifier = "../index.ts?help";
|
|
19
|
+
const { start } = (await import(specifier)) as typeof import("../index.ts");
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
console.log = (...args: unknown[]) => {
|
|
23
|
+
logs.push(args.map((arg) => `${arg}`).join(" "));
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
console.log = originalLog;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("create-koishi-ce --help", () => {
|
|
32
|
+
it("打印用法说明后即返回", async () => {
|
|
33
|
+
await start();
|
|
34
|
+
const output = logs.join("\n");
|
|
35
|
+
expect(output).toContain("用法:create-koishi-ce [名称] [选项]");
|
|
36
|
+
expect(output).toContain("--template");
|
|
37
|
+
expect(output).toContain("--prod");
|
|
38
|
+
// 帮助分支不应询问项目名(无 "项目名:" 提示输出)
|
|
39
|
+
expect(output).not.toContain("项目名:");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { c as tarCreate } from "tar";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* start() 远程模板分支(--template)的端到端测试。
|
|
17
|
+
*
|
|
18
|
+
* 以 Bun.serve 起本地伪 registry(127.0.0.1 随机端口),按用例切换
|
|
19
|
+
* 场景(正常 / 元数据 404 / dist-tag 缺失 / tarball 404 / tarball 损坏);
|
|
20
|
+
* tarball 由 tar 包真实打包,scaffoldRemote 的下载-解包-改写全链路真实执行。
|
|
21
|
+
*
|
|
22
|
+
* 覆盖率口径说明:bun 的覆盖率对同一路径的多个 query 实例只统计最后
|
|
23
|
+
* 求值的那份,本文件是 koishi-create 全部测试中最后加载的实例,因此
|
|
24
|
+
* 在此补齐 registry 探测纯函数、emptyDir 与 initGit(--git)等只在其它
|
|
25
|
+
* 实例出现过的分支,保证 src/index.ts 的行覆盖完整。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), "ckc-run-remote-"));
|
|
29
|
+
const previousCwd = process.cwd();
|
|
30
|
+
const previousArgv = process.argv.slice();
|
|
31
|
+
const previousExit = process.exit;
|
|
32
|
+
|
|
33
|
+
/** 伪 registry 的场景开关 */
|
|
34
|
+
let scenario: "ok" | "meta-404" | "missing-ref" | "tarball-404" | "corrupt" =
|
|
35
|
+
"ok";
|
|
36
|
+
|
|
37
|
+
/** 预先打包好的模板 tarball(package/ 前缀目录,strip:1 解包) */
|
|
38
|
+
let tarball: Buffer;
|
|
39
|
+
|
|
40
|
+
// fetch 回调内引用 registry.port,显式标注返回类型以切断 initializer 的循环推断
|
|
41
|
+
const registry = Bun.serve({
|
|
42
|
+
port: 0,
|
|
43
|
+
hostname: "127.0.0.1",
|
|
44
|
+
// serve 的 fetch 允许同步或异步返回,显式标注以切断 registry 的循环推断
|
|
45
|
+
fetch: (request): Response | Promise<Response> => {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
if (url.pathname === "/fake-tpl") {
|
|
48
|
+
if (scenario === "meta-404") {
|
|
49
|
+
return new Response(null, { status: 404, statusText: "Not Found" });
|
|
50
|
+
}
|
|
51
|
+
if (scenario === "missing-ref") {
|
|
52
|
+
return Response.json({
|
|
53
|
+
"dist-tags": { latest: "1.0.0" },
|
|
54
|
+
versions: { "1.0.0": {} },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return Response.json({
|
|
58
|
+
"dist-tags": { latest: "1.0.0", beta: "2.0.0" },
|
|
59
|
+
versions: {
|
|
60
|
+
"2.0.0": {
|
|
61
|
+
dist: {
|
|
62
|
+
tarball: `http://127.0.0.1:${registry.port}/fake-tpl/-/fake-tpl-2.0.0.tgz`,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (url.pathname === "/fake-tpl/-/fake-tpl-2.0.0.tgz") {
|
|
69
|
+
if (scenario === "tarball-404") {
|
|
70
|
+
return new Response(null, { status: 404, statusText: "Not Found" });
|
|
71
|
+
}
|
|
72
|
+
if (scenario === "corrupt") {
|
|
73
|
+
return new Response("definitely not a tarball", { status: 200 });
|
|
74
|
+
}
|
|
75
|
+
return new Response(tarball);
|
|
76
|
+
}
|
|
77
|
+
return new Response(null, { status: 404 });
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** prompts mock:install 的 confirm 默认拒绝,可按用例入队覆盖 */
|
|
82
|
+
const confirmAnswers: boolean[] = [];
|
|
83
|
+
|
|
84
|
+
mock.module("prompts", () => ({
|
|
85
|
+
default: async (question: { type?: string }) => {
|
|
86
|
+
if (question.type === "confirm") {
|
|
87
|
+
return { yes: confirmAnswers.shift() ?? false };
|
|
88
|
+
}
|
|
89
|
+
return { name: "" };
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
// erasableSyntaxOnly 禁用参数属性,改为显式字段声明
|
|
94
|
+
class ExitError extends Error {
|
|
95
|
+
code: number;
|
|
96
|
+
constructor(code: number) {
|
|
97
|
+
super(`process.exit(${code})`);
|
|
98
|
+
this.code = code;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
process.exit = ((code?: number) => {
|
|
103
|
+
throw new ExitError(code ?? 0);
|
|
104
|
+
}) as typeof process.exit;
|
|
105
|
+
|
|
106
|
+
process.argv = [
|
|
107
|
+
process.argv[0] ?? "bun",
|
|
108
|
+
"create-koishi-ce",
|
|
109
|
+
"myapp",
|
|
110
|
+
"--template",
|
|
111
|
+
"fake-tpl",
|
|
112
|
+
"--registry",
|
|
113
|
+
`http://127.0.0.1:${registry.port}`,
|
|
114
|
+
"--ref",
|
|
115
|
+
"beta",
|
|
116
|
+
// 附带 --git 使 initGit 的 supports / gitConfig 分支也得到执行
|
|
117
|
+
// (spawnSync 由 run-default 注册的 mock 拦截,无真实子进程)
|
|
118
|
+
"--git",
|
|
119
|
+
];
|
|
120
|
+
process.chdir(workspaceRoot);
|
|
121
|
+
// query 强制独立实例(说明见 run-help.test.ts)
|
|
122
|
+
const specifier = "../index.ts?remote-run";
|
|
123
|
+
const { start, getLocalRegistry, readNpmrcRegistry } = (await import(
|
|
124
|
+
specifier
|
|
125
|
+
)) as typeof import("../index.ts");
|
|
126
|
+
process.chdir(previousCwd);
|
|
127
|
+
|
|
128
|
+
const logs: string[] = [];
|
|
129
|
+
const originalLog = console.log;
|
|
130
|
+
|
|
131
|
+
beforeAll(async () => {
|
|
132
|
+
console.log = (...args: unknown[]) => {
|
|
133
|
+
logs.push(args.map((arg) => `${arg}`).join(" "));
|
|
134
|
+
};
|
|
135
|
+
// 造模板载荷并打包:package/package.json + package/index.js
|
|
136
|
+
const payload = join(workspaceRoot, "payload", "package");
|
|
137
|
+
mkdirSync(payload, { recursive: true });
|
|
138
|
+
writeFileSync(
|
|
139
|
+
join(payload, "package.json"),
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
name: "fake-tpl",
|
|
142
|
+
version: "2.0.0",
|
|
143
|
+
private: false,
|
|
144
|
+
workspaces: ["src/*"],
|
|
145
|
+
scripts: { start: "node index.js" },
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
writeFileSync(join(payload, "index.js"), "console.log('tpl');\n");
|
|
149
|
+
const tarballPath = join(workspaceRoot, "fake-tpl.tgz");
|
|
150
|
+
await tarCreate(
|
|
151
|
+
{ file: tarballPath, cwd: join(workspaceRoot, "payload"), portable: true },
|
|
152
|
+
["package"],
|
|
153
|
+
);
|
|
154
|
+
tarball = readFileSync(tarballPath);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
afterAll(() => {
|
|
158
|
+
console.log = originalLog;
|
|
159
|
+
process.exit = previousExit;
|
|
160
|
+
process.argv = previousArgv;
|
|
161
|
+
void registry.stop(true);
|
|
162
|
+
rmSync(workspaceRoot, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** 每个场景前移除上一场景的产物,prepare 走「新建目录」分支 */
|
|
166
|
+
function reset(sc: typeof scenario): void {
|
|
167
|
+
scenario = sc;
|
|
168
|
+
logs.length = 0;
|
|
169
|
+
rmSync(join(workspaceRoot, "myapp"), { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe("create-koishi-ce 远程模板", () => {
|
|
173
|
+
it("正常链路:元数据 → tarball → 解包 → package.json 改写", async () => {
|
|
174
|
+
reset("ok");
|
|
175
|
+
await start();
|
|
176
|
+
const dir = join(workspaceRoot, "myapp");
|
|
177
|
+
expect(existsSync(join(dir, "index.js"))).toBe(true);
|
|
178
|
+
const manifest = JSON.parse(
|
|
179
|
+
readFileSync(join(dir, "package.json"), "utf8"),
|
|
180
|
+
) as Record<string, unknown>;
|
|
181
|
+
// writePackageJson:替换项目名、标记 private、版本归零;模板其余字段保留
|
|
182
|
+
expect(manifest["name"]).toBe("myapp");
|
|
183
|
+
expect(manifest["private"]).toBe(true);
|
|
184
|
+
expect(manifest["version"]).toBe("0.0.0");
|
|
185
|
+
expect(manifest["workspaces"]).toEqual(["src/*"]);
|
|
186
|
+
expect(logs.join("\n")).toContain("使用 registry");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("模板包元数据 404:HttpError 提示后以码 1 退出", async () => {
|
|
190
|
+
reset("meta-404");
|
|
191
|
+
await expect(start()).rejects.toThrow("process.exit(1)");
|
|
192
|
+
expect(logs.join("\n")).toContain("请求失败:HTTP 404");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("dist-tag 引用不存在:以码 1 退出", async () => {
|
|
196
|
+
reset("missing-ref");
|
|
197
|
+
await expect(start()).rejects.toThrow("process.exit(1)");
|
|
198
|
+
expect(logs.join("\n")).toContain("模板 fake-tpl@beta 不存在");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("tarball 下载 404:以码 1 退出", async () => {
|
|
202
|
+
reset("tarball-404");
|
|
203
|
+
await expect(start()).rejects.toThrow("process.exit(1)");
|
|
204
|
+
expect(logs.join("\n")).toContain("请求失败:HTTP 404");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("tarball 内容损坏:解包错误原样抛出(非 HttpError 不吞)", async () => {
|
|
208
|
+
reset("corrupt");
|
|
209
|
+
await expect(start()).rejects.toThrow();
|
|
210
|
+
// 非 HttpError 路径不打印友好提示、不调用 process.exit
|
|
211
|
+
expect(logs.join("\n")).not.toContain("请求失败");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("非空目录确认清空:emptyDir 后重新解包(覆盖 prepare 清空分支)", async () => {
|
|
215
|
+
scenario = "ok";
|
|
216
|
+
logs.length = 0;
|
|
217
|
+
const dir = join(workspaceRoot, "myapp");
|
|
218
|
+
rmSync(dir, { recursive: true, force: true });
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
writeFileSync(join(dir, "occupier.txt"), "占位");
|
|
221
|
+
confirmAnswers.push(true);
|
|
222
|
+
await start();
|
|
223
|
+
expect(existsSync(join(dir, "occupier.txt"))).toBe(false);
|
|
224
|
+
expect(existsSync(join(dir, "index.js"))).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("registry 探测纯函数(补本实例的行覆盖)", () => {
|
|
228
|
+
const npmrc = join(workspaceRoot, ".npmrc");
|
|
229
|
+
writeFileSync(npmrc, "registry=https://registry.example.com/\n");
|
|
230
|
+
expect(readNpmrcRegistry(npmrc)).toBe("https://registry.example.com/");
|
|
231
|
+
expect(
|
|
232
|
+
readNpmrcRegistry(join(workspaceRoot, "absent.npmrc")),
|
|
233
|
+
).toBeUndefined();
|
|
234
|
+
expect(getLocalRegistry(workspaceRoot, workspaceRoot)).toBe(
|
|
235
|
+
"https://registry.example.com/",
|
|
236
|
+
);
|
|
237
|
+
rmSync(npmrc, { force: true });
|
|
238
|
+
expect(getLocalRegistry(workspaceRoot, workspaceRoot)).toBeUndefined();
|
|
239
|
+
// 临时目录内容核对后由 afterAll 统一清理
|
|
240
|
+
expect(readdirSync(workspaceRoot).length).toBeGreaterThan(0);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { renderManifest } from "../index.ts";
|
|
3
|
+
import { baseManifest, templateFiles } from "../template.ts";
|
|
4
|
+
|
|
5
|
+
test("内置模板依赖纯度:不含任何 @koishijs / koishi-plugin 官方生态包", () => {
|
|
6
|
+
const { dependencies, devDependencies } = baseManifest();
|
|
7
|
+
for (const key of Object.keys(dependencies ?? {})) {
|
|
8
|
+
// koishi 裸名是刻意的 npm alias 占位,其余必须是 @koishi-ce 作用域
|
|
9
|
+
expect(key === "koishi" || key.startsWith("@koishi-ce/")).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
const toolchain = new Set(["bun-types", "cross-env"]);
|
|
12
|
+
for (const key of Object.keys(devDependencies ?? {})) {
|
|
13
|
+
expect(key.startsWith("@koishi-ce/") || toolchain.has(key)).toBe(true);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("内置模板以 npm alias 钉住 koishi 裸名,版本保持 4.18.x 冻结线", () => {
|
|
18
|
+
const request = baseManifest().dependencies?.["koishi"];
|
|
19
|
+
expect(typeof request).toBe("string");
|
|
20
|
+
// alias 形态 npm:@koishi-ce/koishi-shim@^4.18.x——版本若漂出 ^4 区间,
|
|
21
|
+
// 上游插件的 peer `koishi ^4` 判定为不满足,market UI 会试图改写它
|
|
22
|
+
expect(request ?? "").toMatch(/^npm:@koishi-ce\/koishi-shim@\^4\./);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("内置模板静态文件齐备,koishi.yml 预配 market 镜像源", () => {
|
|
26
|
+
for (const file of [
|
|
27
|
+
".env",
|
|
28
|
+
".gitignore",
|
|
29
|
+
"koishi.yml",
|
|
30
|
+
"tsconfig.json",
|
|
31
|
+
"README.md",
|
|
32
|
+
]) {
|
|
33
|
+
expect(templateFiles[file]?.length ?? 0).toBeGreaterThan(0);
|
|
34
|
+
}
|
|
35
|
+
expect(templateFiles["koishi.yml"]).toContain(
|
|
36
|
+
"endpoint: https://registry.koishi.chat/index.json",
|
|
37
|
+
);
|
|
38
|
+
// 模板不预装数据库,依赖数据库的插件保持 ~ 禁用
|
|
39
|
+
expect(templateFiles["koishi.yml"]).toContain("~analytics");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("renderManifest 渲染内置模板:常规改写生效,prod 模式保留 koishi alias", () => {
|
|
43
|
+
const output = JSON.parse(renderManifest(baseManifest(), "my-app", false));
|
|
44
|
+
expect(output.name).toBe("my-app");
|
|
45
|
+
expect(output.private).toBe(true);
|
|
46
|
+
expect(output.version).toBe("0.0.0");
|
|
47
|
+
expect(output.scripts.start).toBe("koishi start");
|
|
48
|
+
expect(output.dependencies.koishi).toBe(
|
|
49
|
+
"npm:@koishi-ce/koishi-shim@^4.18.11",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const prod = JSON.parse(renderManifest(baseManifest(), "my-app", true));
|
|
53
|
+
expect("workspaces" in prod).toBe(false);
|
|
54
|
+
expect("devDependencies" in prod).toBe(false);
|
|
55
|
+
// koishi alias 是运行时依赖,prod 模式下必须原样保留
|
|
56
|
+
expect(prod.dependencies.koishi).toBe("npm:@koishi-ce/koishi-shim@^4.18.11");
|
|
57
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* apps/koishi-create,二者不一致是历史遗留,以目录名为准)。
|
|
4
4
|
*
|
|
5
5
|
* 通过 `bunx create-koishi-ce [name]`(npx 亦可)交互式创建 Koishi 机器人
|
|
6
|
-
* 应用项目:确定项目名 → 准备目标目录 →
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 应用项目:确定项目名 → 准备目标目录 → 写入内置 @koishi-ce 模板(默认,
|
|
7
|
+
* 见 src/template.ts;--template <包名> 可改用 npm registry 远程模板,如
|
|
8
|
+
* 上游官方 @koishijs/boilerplate)→ 按需初始化 git → 询问是否立即安装
|
|
9
|
+
* 依赖并启动。CLI 可执行入口在 src/bin.ts(构建产物 lib/bin.mjs,bin 字段
|
|
10
|
+
* 指向它);本文件只承载主流程与可单测的纯函数(范式对齐
|
|
11
|
+
* @koishi-ce/scripts)。
|
|
11
12
|
*/
|
|
12
13
|
import { spawnSync } from "node:child_process";
|
|
13
14
|
import {
|
|
@@ -27,6 +28,7 @@ import prompts from "prompts";
|
|
|
27
28
|
import { extract } from "tar";
|
|
28
29
|
import parse from "yargs-parser";
|
|
29
30
|
import pkg from "../package.json" with { type: "json" };
|
|
31
|
+
import { baseManifest, templateFiles } from "./template.ts";
|
|
30
32
|
|
|
31
33
|
const { version } = pkg;
|
|
32
34
|
|
|
@@ -52,7 +54,8 @@ export interface Manifest {
|
|
|
52
54
|
private?: boolean;
|
|
53
55
|
version?: string;
|
|
54
56
|
workspaces?: unknown;
|
|
55
|
-
|
|
57
|
+
dependencies?: Record<string, string>;
|
|
58
|
+
devDependencies?: Record<string, string>;
|
|
56
59
|
[key: string]: unknown;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -250,23 +253,28 @@ function writePackageJson() {
|
|
|
250
253
|
}
|
|
251
254
|
|
|
252
255
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* 2. 拉取模板包元数据,按 dist-tags 解析目标版本(--ref,默认 latest);
|
|
256
|
-
* 3. 流式下载 tarball 并解包到目标目录(strip: 1 去掉包根目录层级),
|
|
257
|
-
* 网络错误统一以 HttpError 提示后退出;
|
|
258
|
-
* 4. 最后改写 package.json。
|
|
256
|
+
* 写入内置模板(默认路径):静态文件(src/template.ts 的 templateFiles)
|
|
257
|
+
* 加上由 baseManifest() 渲染出的 package.json。纯本地写入,无网络请求。
|
|
259
258
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
function scaffoldBuiltin() {
|
|
260
|
+
for (const [file, content] of Object.entries(templateFiles)) {
|
|
261
|
+
writeFileSync(join(rootDir, file), content);
|
|
262
|
+
}
|
|
263
|
+
writeFileSync(
|
|
264
|
+
join(rootDir, "package.json"),
|
|
265
|
+
renderManifest(baseManifest(), project, argv.prod === true),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
262
268
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
/**
|
|
270
|
+
* 远程模板下载与解包(--template 指定时):
|
|
271
|
+
* 1. 拉取模板包元数据,按 dist-tags 解析目标版本(--ref,默认 latest);
|
|
272
|
+
* 2. 流式下载 tarball 并解包到目标目录(strip: 1 去掉包根目录层级),
|
|
273
|
+
* 网络错误统一以 HttpError 提示后退出;
|
|
274
|
+
* 3. 最后改写 package.json。
|
|
275
|
+
*/
|
|
276
|
+
async function scaffoldRemote(registry: string) {
|
|
277
|
+
const template = argv.template as string;
|
|
270
278
|
const ref = argv.ref || "latest";
|
|
271
279
|
|
|
272
280
|
try {
|
|
@@ -302,6 +310,27 @@ async function scaffold() {
|
|
|
302
310
|
}
|
|
303
311
|
|
|
304
312
|
writePackageJson();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 生成项目的主入口:默认写内置 @koishi-ce 模板(scaffoldBuiltin);
|
|
317
|
+
* --template <包名> 时改为从 npm registry 下载远程模板(scaffoldRemote),
|
|
318
|
+
* registry 取值 --registry 参数 > 本机 npm 配置 > 官方源。
|
|
319
|
+
*/
|
|
320
|
+
async function scaffold() {
|
|
321
|
+
console.log(kleur.dim(" 正在 ") + project + kleur.dim(" 中生成项目 ..."));
|
|
322
|
+
|
|
323
|
+
if (argv.template) {
|
|
324
|
+
const registry = (
|
|
325
|
+
argv.registry ||
|
|
326
|
+
getLocalRegistry() ||
|
|
327
|
+
"https://registry.npmjs.org"
|
|
328
|
+
).replace(/\/$/, "");
|
|
329
|
+
console.log(kleur.dim(` 使用 registry:${registry}\n`));
|
|
330
|
+
await scaffoldRemote(registry);
|
|
331
|
+
} else {
|
|
332
|
+
scaffoldBuiltin();
|
|
333
|
+
}
|
|
305
334
|
|
|
306
335
|
console.log(kleur.green(" 完成。\n"));
|
|
307
336
|
}
|
|
@@ -364,8 +393,8 @@ export async function start() {
|
|
|
364
393
|
用法:create-koishi-ce [名称] [选项]
|
|
365
394
|
|
|
366
395
|
选项:
|
|
367
|
-
-t, --template <名称>
|
|
368
|
-
-r, --ref <引用>
|
|
396
|
+
-t, --template <名称> 从 npm registry 下载指定模板包(默认使用内置 @koishi-ce 模板)
|
|
397
|
+
-r, --ref <引用> 远程模板版本引用(默认 latest)
|
|
369
398
|
-f, --forced 强制清空目标目录
|
|
370
399
|
-g, --git 初始化 git 仓库
|
|
371
400
|
--registry <地址> 指定 npm registry(如 https://registry.npmmirror.com)
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置项目模板(create-koishi-ce 的默认模板)。
|
|
3
|
+
*
|
|
4
|
+
* 历史包袱修正:本脚手架最初直接下载上游官方 @koishijs/boilerplate 解包
|
|
5
|
+
* 生成项目,产物依赖全是 npm 官方包(koishi / @koishijs/*),完全绕开了
|
|
6
|
+
* 本仓的 @koishi-ce 再分发生态(官方 market 还带 Bun 下必炸的 get-registry)。
|
|
7
|
+
* 默认模板改为内置的纯 @koishi-ce 依赖集;确需上游官方模板时用
|
|
8
|
+
* --template <包名> 走 npm 远程下载(见 index.ts 的 scaffoldRemote)。
|
|
9
|
+
*
|
|
10
|
+
* 模板要点:
|
|
11
|
+
* - 运行时 Bun:koishi CLI(@koishi-ce/koishi 的 bin)与 loader 插件加载链
|
|
12
|
+
* 均以 Bun 为准(ESM-only 产物,Bun 原生加载 TS / yml);
|
|
13
|
+
* - "koishi" 裸名用 npm alias 钉到 @koishi-ce/koishi-shim(4.18.x 冻结线,
|
|
14
|
+
* 见 packages/node/koishi-shim):上游官方 adapter / database 插件与社区
|
|
15
|
+
* koishi-plugin-* 的 peer `koishi ^4.x` 由此满足,不会拉入 npm 官方
|
|
16
|
+
* koishi 形成第二份框架副本;市场安装亦不改写该声明(installer 的
|
|
17
|
+
* isGuardedRequest 护栏将 npm:@koishi-ce alias 与 workspace: 同等保护);
|
|
18
|
+
* - 本仓没有 adapter / database 插件的再分发,模板不预装这两类;依赖数据库
|
|
19
|
+
* 的插件(如 analytics)在 koishi.yml 中保持 ~ 禁用,待用户从市场安装
|
|
20
|
+
* 数据库插件后再启用;
|
|
21
|
+
* - 依赖版本统一 ^1.0.0 区间(安装时取最新 1.x),shim 版本例外(冻结线)。
|
|
22
|
+
*/
|
|
23
|
+
import type { Manifest } from "./index.ts";
|
|
24
|
+
|
|
25
|
+
/** 模板静态文件(相对项目根的路径 → 文件内容),package.json 另行渲染 */
|
|
26
|
+
export const templateFiles: Record<string, string> = {
|
|
27
|
+
".env": [
|
|
28
|
+
"GITHUB_MIRROR = https://ghproxy.com/https://github.com",
|
|
29
|
+
"GITHUB_CONTENT_MIRROR = https://ghproxy.com/https://raw.githubusercontent.com",
|
|
30
|
+
"GRAVATAR_MIRROR = https://cravatar.cn",
|
|
31
|
+
].join("\n"),
|
|
32
|
+
".gitignore": ["node_modules/", "data/"].join("\n"),
|
|
33
|
+
"koishi.yml": [
|
|
34
|
+
"plugins:",
|
|
35
|
+
" group:server:",
|
|
36
|
+
" server:",
|
|
37
|
+
" port: 5140",
|
|
38
|
+
" maxPort: 5149",
|
|
39
|
+
" group:basic:",
|
|
40
|
+
" ~admin: {}",
|
|
41
|
+
" ~bind: {}",
|
|
42
|
+
" commands: {}",
|
|
43
|
+
" help: {}",
|
|
44
|
+
" http: {}",
|
|
45
|
+
" ~inspect: {}",
|
|
46
|
+
" locales: {}",
|
|
47
|
+
" proxy-agent: {}",
|
|
48
|
+
" group:console:",
|
|
49
|
+
" actions: {}",
|
|
50
|
+
" ~analytics: {}",
|
|
51
|
+
" ~auth: {}",
|
|
52
|
+
" config: {}",
|
|
53
|
+
" console:",
|
|
54
|
+
" open: true",
|
|
55
|
+
" explorer: {}",
|
|
56
|
+
" insight: {}",
|
|
57
|
+
" logger: {}",
|
|
58
|
+
" market:",
|
|
59
|
+
" search:",
|
|
60
|
+
" endpoint: https://registry.koishi.chat/index.json",
|
|
61
|
+
" notifier: {}",
|
|
62
|
+
" oobe: {}",
|
|
63
|
+
" sandbox: {}",
|
|
64
|
+
" status: {}",
|
|
65
|
+
" group:develop:",
|
|
66
|
+
" $if: env.NODE_ENV === 'development'",
|
|
67
|
+
" hmr:",
|
|
68
|
+
" root: .",
|
|
69
|
+
].join("\n"),
|
|
70
|
+
"tsconfig.json": [
|
|
71
|
+
"{",
|
|
72
|
+
' "compilerOptions": {',
|
|
73
|
+
' "target": "ESNext",',
|
|
74
|
+
' "module": "ESNext",',
|
|
75
|
+
' "moduleResolution": "bundler",',
|
|
76
|
+
' "lib": ["ESNext"],',
|
|
77
|
+
' "types": ["bun-types"],',
|
|
78
|
+
' "strict": true,',
|
|
79
|
+
' "skipLibCheck": true,',
|
|
80
|
+
' "noEmit": true,',
|
|
81
|
+
' "allowImportingTsExtensions": true,',
|
|
82
|
+
' "verbatimModuleSyntax": true,',
|
|
83
|
+
' "erasableSyntaxOnly": true',
|
|
84
|
+
" },",
|
|
85
|
+
' "include": ["plugins/*/src", "external/*/src"]',
|
|
86
|
+
"}",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
"README.md": `# Koishi 机器人项目
|
|
89
|
+
|
|
90
|
+
由 \`bun create koishi-ce\` 生成,运行于 [@koishi-ce 社区再分发版](https://github.com/Koishi-CE/koishi) 生态。
|
|
91
|
+
|
|
92
|
+
## 环境要求
|
|
93
|
+
|
|
94
|
+
- [Bun](https://bun.sh) ≥ 1.2(运行时:koishi CLI 与插件加载链以 Bun 为准)
|
|
95
|
+
|
|
96
|
+
## 快速开始
|
|
97
|
+
|
|
98
|
+
\`\`\`bash
|
|
99
|
+
bun install
|
|
100
|
+
bun run start # 启动(生产模式)
|
|
101
|
+
bun run dev # 启动(开发模式,启用 HMR 热更新)
|
|
102
|
+
\`\`\`
|
|
103
|
+
|
|
104
|
+
启动后访问控制台:<http://127.0.0.1:5140>
|
|
105
|
+
|
|
106
|
+
## 安装插件
|
|
107
|
+
|
|
108
|
+
推荐在控制台「插件市场」页安装(已预配 registry.koishi.chat 镜像源);也可以手动 \`bun add <包名>\` 后在 \`koishi.yml\` 中启用。
|
|
109
|
+
|
|
110
|
+
上游官方 adapter(如 adapter-discord / adapter-telegram)、数据库插件(如 database-sqlite)与社区 koishi-plugin-* 插件均可直接安装:根依赖中的 \`"koishi": "npm:@koishi-ce/koishi-shim@^4.18.11"\` 已把上游生态对 \`koishi\` 的依赖钉回 @koishi-ce 框架(**请勿删除或改写该行**),不会形成第二份框架副本。analytics 等依赖数据库的插件请先安装数据库插件,再去掉配置中对应的 \`~\` 前缀启用。
|
|
111
|
+
|
|
112
|
+
## 自定义插件
|
|
113
|
+
|
|
114
|
+
在 \`plugins/\` 目录下创建插件包(可用 \`bun run new <名称>\` 生成骨架),在 \`koishi.yml\` 中以相对路径引用(如 \`./plugins/my-plugin\`)即可启用;Bun 直接加载 TypeScript 源码,无需预编译。
|
|
115
|
+
`,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 内置模板的 package.json 基础内容(name/version 会被 renderManifest 覆写,
|
|
120
|
+
* prod 模式下 workspaces 与 devDependencies 会被移除)。
|
|
121
|
+
*/
|
|
122
|
+
export function baseManifest(): Manifest {
|
|
123
|
+
return {
|
|
124
|
+
type: "module",
|
|
125
|
+
workspaces: ["plugins/*", "external/*"],
|
|
126
|
+
scripts: {
|
|
127
|
+
start: "koishi start",
|
|
128
|
+
dev: "cross-env NODE_ENV=development koishi start",
|
|
129
|
+
new: "koishi-scripts setup",
|
|
130
|
+
},
|
|
131
|
+
dependencies: {
|
|
132
|
+
"@koishi-ce/koishi": "^1.0.0",
|
|
133
|
+
"@koishi-ce/plugin-actions": "^1.0.0",
|
|
134
|
+
"@koishi-ce/plugin-admin": "^1.0.0",
|
|
135
|
+
"@koishi-ce/plugin-analytics": "^1.0.0",
|
|
136
|
+
"@koishi-ce/plugin-auth": "^1.0.0",
|
|
137
|
+
"@koishi-ce/plugin-bind": "^1.0.0",
|
|
138
|
+
"@koishi-ce/plugin-commands": "^1.0.0",
|
|
139
|
+
"@koishi-ce/plugin-config": "^1.0.0",
|
|
140
|
+
"@koishi-ce/plugin-console": "^1.0.0",
|
|
141
|
+
"@koishi-ce/plugin-explorer": "^1.0.0",
|
|
142
|
+
"@koishi-ce/plugin-help": "^1.0.0",
|
|
143
|
+
"@koishi-ce/plugin-http": "^1.0.0",
|
|
144
|
+
"@koishi-ce/plugin-insight": "^1.0.0",
|
|
145
|
+
"@koishi-ce/plugin-inspect": "^1.0.0",
|
|
146
|
+
"@koishi-ce/plugin-locales": "^1.0.0",
|
|
147
|
+
"@koishi-ce/plugin-logger": "^1.0.0",
|
|
148
|
+
"@koishi-ce/plugin-market": "^1.0.0",
|
|
149
|
+
"@koishi-ce/plugin-notifier": "^1.0.0",
|
|
150
|
+
"@koishi-ce/plugin-oobe": "^1.0.0",
|
|
151
|
+
"@koishi-ce/plugin-proxy-agent": "^1.0.0",
|
|
152
|
+
"@koishi-ce/plugin-sandbox": "^1.0.0",
|
|
153
|
+
"@koishi-ce/plugin-server": "^1.0.0",
|
|
154
|
+
"@koishi-ce/plugin-status": "^1.0.0",
|
|
155
|
+
// 上游裸名占位:npm alias 钉到 @koishi-ce 的 koishi shim(勿删,
|
|
156
|
+
// 语义见文件头注释);版本必须保持 4.18.x 冻结线以满足 ^4 peer
|
|
157
|
+
koishi: "npm:@koishi-ce/koishi-shim@^4.18.11",
|
|
158
|
+
},
|
|
159
|
+
devDependencies: {
|
|
160
|
+
"@koishi-ce/client": "^1.0.0",
|
|
161
|
+
"@koishi-ce/plugin-hmr": "^1.0.0",
|
|
162
|
+
"@koishi-ce/scripts": "^1.0.0",
|
|
163
|
+
"bun-types": "^1.4.0",
|
|
164
|
+
"cross-env": "^7.0.3",
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|