create-koishi-ce 1.0.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.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/lib/bin.mjs ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bun
2
+ import { r as start } from "./src-D9XdTOrE.mjs";
3
+ //#region src/bin.ts
4
+ /**
5
+ * create-koishi-ce 的 CLI 可执行入口:shebang 由 rolldown 原样保留到
6
+ * lib/bin.mjs,package.json 的 bin 字段指向它(范式同 @koishi-ce/scripts)。
7
+ */
8
+ start().catch((err) => {
9
+ console.error(err);
10
+ process.exitCode = 1;
11
+ });
12
+ //#endregion
13
+ export {};
package/lib/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * 模板项目的 package.json(改写目标):只需要类型化本流程触碰的字段,
4
+ * 其余字段经 index signature 原样保留。
5
+ */
6
+ interface Manifest {
7
+ name?: string;
8
+ private?: boolean;
9
+ version?: string;
10
+ workspaces?: unknown;
11
+ devDependencies?: unknown;
12
+ [key: string]: unknown;
13
+ }
14
+ /**
15
+ * 探测后续安装/启动使用的包管理器(Bun-first):yarn / pnpm 用户跟随其
16
+ * 生态习惯;其余场景(npm、bun 及探测不到 user-agent)一律走 bun——
17
+ * 本 CLI 自身以 bun 为运行时(bin shebang),能执行即已具备 bun 环境。
18
+ */
19
+ declare function detectAgent(): string;
20
+ /**
21
+ * 改写模板的 package.json(纯函数,导出供单测):替换项目名、标记
22
+ * private、版本归零。
23
+ */
24
+ declare function renderManifest(source: Manifest, project: string, prod: boolean): string;
25
+ /**
26
+ * CLI 主流程:--help 打印用法后即返回;否则依次执行
27
+ * 项目名询问 → prepare(目录准备)→ scaffold(模板解包)→ initGit → install。
28
+ */
29
+ declare function start(): Promise<void>;
30
+ //#endregion
31
+ export { Manifest, detectAgent, renderManifest, start };
package/lib/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { n as renderManifest, r as start, t as detectAgent } from "./src-D9XdTOrE.mjs";
2
+ export { detectAgent, renderManifest, start };
@@ -0,0 +1,312 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, join, relative } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import getRegistry from "get-registry";
6
+ import kleur from "kleur";
7
+ import prompts from "prompts";
8
+ import { extract } from "tar";
9
+ import parse from "yargs-parser";
10
+ //#endregion
11
+ //#region src/index.ts
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 } = {
24
+ name: "create-koishi-ce",
25
+ description: "Setup a Koishi application",
26
+ version: "1.0.0",
27
+ type: "module",
28
+ main: "./lib/index.mjs",
29
+ module: "./lib/index.mjs",
30
+ types: "./lib/index.d.ts",
31
+ bin: { "create-koishi-ce": "./lib/bin.mjs" },
32
+ files: ["lib", "src"],
33
+ contributors: ["Shigma <shigma10826@gmail.com>", "Oppenheymu <oppenheymu@gmail.com>"],
34
+ license: "MIT",
35
+ repository: {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Koishi-CE/koishi.git",
38
+ "directory": "apps/koishi-create"
39
+ },
40
+ bugs: { "url": "https://github.com/Koishi-CE/koishi/issues" },
41
+ homepage: "https://koishi.chat",
42
+ scripts: { "build": "tsdown" },
43
+ keywords: [
44
+ "bot",
45
+ "chatbot",
46
+ "koishi",
47
+ "discord",
48
+ "telegram",
49
+ "create",
50
+ "scaffold",
51
+ "template",
52
+ "generator",
53
+ "boilerplate"
54
+ ],
55
+ devDependencies: {
56
+ "@types/prompts": "^2.4.9",
57
+ "@types/yargs-parser": "^21.0.3"
58
+ },
59
+ dependencies: {
60
+ "get-registry": "^1.1.0",
61
+ "kleur": "^4.1.5",
62
+ "prompts": "^2.4.2",
63
+ "tar": "^7.5.22",
64
+ "yargs-parser": "^22.0.0"
65
+ },
66
+ exports: {
67
+ ".": {
68
+ "source": "./src/index.ts",
69
+ "types": "./lib/index.d.ts",
70
+ "import": "./lib/index.mjs",
71
+ "default": "./lib/index.mjs"
72
+ },
73
+ "./package.json": "./package.json"
74
+ }
75
+ };
76
+ /** fetch 请求非 2xx 时抛出的错误,携带 HTTP 状态码与状态文本 */
77
+ var HttpError = class extends Error {
78
+ status;
79
+ statusText;
80
+ constructor(status, statusText) {
81
+ super(`HTTP ${status} ${statusText}`);
82
+ this.status = status;
83
+ this.statusText = statusText;
84
+ }
85
+ };
86
+ const argv = parse(process.argv.slice(2), { alias: {
87
+ ref: ["r"],
88
+ forced: ["f"],
89
+ git: ["g"],
90
+ prod: ["p"],
91
+ template: ["t"],
92
+ yes: ["y"],
93
+ help: ["h"]
94
+ } });
95
+ /** 项目目录名(rootDir 的最后一段,写入生成项目的 package.json 的 name) */
96
+ let project;
97
+ /** 目标目录的绝对路径(由用户输入的项目名拼接 cwd 得到) */
98
+ let rootDir;
99
+ const cwd = process.cwd();
100
+ /**
101
+ * 探测后续安装/启动使用的包管理器(Bun-first):yarn / pnpm 用户跟随其
102
+ * 生态习惯;其余场景(npm、bun 及探测不到 user-agent)一律走 bun——
103
+ * 本 CLI 自身以 bun 为运行时(bin shebang),能执行即已具备 bun 环境。
104
+ */
105
+ function detectAgent() {
106
+ const ua = process.env["npm_config_user_agent"] ?? "";
107
+ if (ua.startsWith("yarn")) return "yarn";
108
+ if (ua.startsWith("pnpm")) return "pnpm";
109
+ return "bun";
110
+ }
111
+ /** 静默执行命令探测其是否可用(如 git --version),失败即视为不可用 */
112
+ function supports(command) {
113
+ return spawnSync(command[0] ?? "", command.slice(1), { stdio: "ignore" }).status === 0;
114
+ }
115
+ /** 读 git 全局配置单项(读不到 → 空串) */
116
+ function gitConfig(key) {
117
+ const res = spawnSync("git", [
118
+ "config",
119
+ "--get",
120
+ key
121
+ ], { encoding: "utf8" });
122
+ return res.status === 0 ? res.stdout?.trim() ?? "" : "";
123
+ }
124
+ /**
125
+ * 获取项目名:优先取第一个位置参数,否则交互式询问(默认 koishi-app)。
126
+ * 用户取消或输入为空时直接退出(不强行兜底默认值)。
127
+ */
128
+ async function getName() {
129
+ if (argv._[0]) return `${argv._[0]}`;
130
+ const trimmed = (await prompts({
131
+ type: "text",
132
+ name: "name",
133
+ message: "项目名:",
134
+ initial: "koishi-app"
135
+ })).name?.trim();
136
+ if (!trimmed) process.exit(0);
137
+ return trimmed;
138
+ }
139
+ /** 递归清空目录内容(目录本身保留)。 */
140
+ function emptyDir(root) {
141
+ for (const file of readdirSync(root)) rmSync(join(root, file), {
142
+ recursive: true,
143
+ force: true
144
+ });
145
+ }
146
+ /** 交互式确认框:返回用户是否选择了「是」(取消视为否) */
147
+ async function confirm(message) {
148
+ return (await prompts({
149
+ type: "confirm",
150
+ name: "yes",
151
+ initial: true,
152
+ message
153
+ })).yes === true;
154
+ }
155
+ /**
156
+ * 准备目标目录:不存在则创建;已存在且非空时,未指定 --forced / --yes
157
+ * 会先提示目录非空并询问是否清空后继续,用户拒绝则直接退出。
158
+ */
159
+ async function prepare() {
160
+ if (!existsSync(rootDir)) {
161
+ mkdirSync(rootDir, { recursive: true });
162
+ return;
163
+ }
164
+ if (!readdirSync(rootDir).length) return;
165
+ if (!argv.forced && !argv.yes) {
166
+ console.log(kleur.yellow(` 目标目录 "${project}" 非空。`));
167
+ if (!await confirm("清空现有文件并继续?")) process.exit(0);
168
+ }
169
+ emptyDir(rootDir);
170
+ }
171
+ /**
172
+ * 改写模板的 package.json(纯函数,导出供单测):替换项目名、标记
173
+ * private、版本归零。
174
+ */
175
+ function renderManifest(source, project, prod) {
176
+ const meta = { ...source };
177
+ meta["name"] = project;
178
+ meta["private"] = true;
179
+ meta["version"] = "0.0.0";
180
+ if (prod) {
181
+ delete meta["workspaces"];
182
+ delete meta["devDependencies"];
183
+ }
184
+ return `${JSON.stringify(meta, null, 2)}\n`;
185
+ }
186
+ /** 把改写结果写回生成项目的 package.json */
187
+ function writePackageJson() {
188
+ const filename = join(rootDir, "package.json");
189
+ const meta = JSON.parse(readFileSync(filename, "utf8"));
190
+ writeFileSync(filename, renderManifest(meta, project, argv.prod === true));
191
+ }
192
+ /**
193
+ * 模板下载与解包的主流程:
194
+ * 1. 确定 npm registry(--registry 参数 > 本机 npm 配置 > 官方源);
195
+ * 2. 拉取模板包元数据,按 dist-tags 解析目标版本(--ref,默认 latest);
196
+ * 3. 流式下载 tarball 并解包到目标目录(strip: 1 去掉包根目录层级),
197
+ * 网络错误统一以 HttpError 提示后退出;
198
+ * 4. 最后改写 package.json。
199
+ */
200
+ async function scaffold() {
201
+ console.log(kleur.dim(" 正在 ") + project + kleur.dim(" 中生成项目 ..."));
202
+ const registry = (argv.registry || await getRegistry() || "https://registry.npmjs.org").replace(/\/$/, "");
203
+ console.log(kleur.dim(` 使用 registry:${registry}\n`));
204
+ const template = argv.template || "@koishijs/boilerplate";
205
+ const ref = argv.ref || "latest";
206
+ try {
207
+ const metaRes = await fetch(`${registry}/${template}`);
208
+ if (!metaRes.ok) throw new HttpError(metaRes.status, metaRes.statusText);
209
+ const remote = await metaRes.json();
210
+ const version = remote["dist-tags"][ref];
211
+ const url = version === void 0 ? void 0 : remote.versions[version]?.dist?.tarball;
212
+ if (url === void 0) throw new HttpError(404, `模板 ${template}@${ref} 不存在`);
213
+ const tarballRes = await fetch(url);
214
+ const body = tarballRes.body;
215
+ if (!tarballRes.ok || !body) throw new HttpError(tarballRes.status, tarballRes.statusText);
216
+ await new Promise((resolve, reject) => {
217
+ Readable.fromWeb(body).pipe(extract({
218
+ cwd: rootDir,
219
+ newer: true,
220
+ strip: 1
221
+ })).on("finish", resolve).on("error", reject);
222
+ });
223
+ } catch (err) {
224
+ if (!(err instanceof HttpError)) throw err;
225
+ console.log(`${kleur.red("error")} 请求失败:HTTP ${err.status} ${err.statusText}`);
226
+ process.exit(1);
227
+ }
228
+ writePackageJson();
229
+ console.log(kleur.green(" 完成。\n"));
230
+ }
231
+ /**
232
+ * 初始化 git 仓库:仅在显式传入 --git 且本机装有 git 时执行,分支名取
233
+ * git 的 init.defaultBranch(未配置则 main)。
234
+ */
235
+ async function initGit() {
236
+ if (!argv.git || !supports(["git", "--version"])) return;
237
+ const branch = gitConfig("init.defaultBranch") || "main";
238
+ spawnSync("git", [
239
+ "init",
240
+ "-b",
241
+ branch
242
+ ], {
243
+ stdio: "ignore",
244
+ cwd: rootDir
245
+ });
246
+ console.log(kleur.green(` 已初始化 git 仓库(分支 ${branch})。\n`));
247
+ }
248
+ /**
249
+ * 收尾交互:询问是否立即安装依赖并启动,包管理器由 detectAgent()
250
+ * Bun-first 探测;用户拒绝时打印后续手动安装与启动的命令。
251
+ */
252
+ async function install() {
253
+ if (argv.yes) return;
254
+ const agent = detectAgent();
255
+ const startArgs = agent === "yarn" ? ["start"] : ["run", "start"];
256
+ if (await confirm("现在安装依赖并启动吗?")) {
257
+ if (spawnSync(agent, ["install"], {
258
+ stdio: "inherit",
259
+ cwd: rootDir
260
+ }).status !== 0) {
261
+ console.log(kleur.red(" 依赖安装失败,请检查上方日志。"));
262
+ return;
263
+ }
264
+ spawnSync(agent, startArgs, {
265
+ stdio: "inherit",
266
+ cwd: rootDir
267
+ });
268
+ } else {
269
+ console.log(kleur.dim(" 稍后可以这样启动:\n"));
270
+ if (rootDir !== cwd) {
271
+ const related = relative(cwd, rootDir);
272
+ console.log(kleur.blue(` cd ${kleur.bold(related)}`));
273
+ }
274
+ console.log(kleur.blue(` ${agent === "yarn" ? "yarn" : `${agent} install`}`));
275
+ console.log(kleur.blue(` ${agent === "yarn" ? "yarn" : `${agent} run`} start`));
276
+ console.log();
277
+ }
278
+ }
279
+ /**
280
+ * CLI 主流程:--help 打印用法后即返回;否则依次执行
281
+ * 项目名询问 → prepare(目录准备)→ scaffold(模板解包)→ initGit → install。
282
+ */
283
+ async function start() {
284
+ if (argv.help) {
285
+ console.log(`
286
+ 用法:create-koishi-ce [名称] [选项]
287
+
288
+ 选项:
289
+ -t, --template <名称> 模板包名(默认 @koishijs/boilerplate)
290
+ -r, --ref <引用> 模板版本引用(默认 latest)
291
+ -f, --forced 强制清空目标目录
292
+ -g, --git 初始化 git 仓库
293
+ --registry <地址> 指定 npm registry(如 https://registry.npmmirror.com)
294
+ -p, --prod 生产模式(移除 devDependencies 与 workspaces)
295
+ -y, --yes 跳过全部询问
296
+ -h, --help 显示本帮助
297
+ `);
298
+ return;
299
+ }
300
+ console.log();
301
+ console.log(` ${kleur.bold("Create Koishi")} ${kleur.blue(`v${version}`)}`);
302
+ console.log();
303
+ const name = await getName();
304
+ rootDir = join(cwd, name);
305
+ project = basename(rootDir);
306
+ await prepare();
307
+ await scaffold();
308
+ await initGit();
309
+ await install();
310
+ }
311
+ //#endregion
312
+ export { renderManifest as n, start as r, detectAgent as t };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "create-koishi-ce",
3
+ "description": "Setup a Koishi application",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "main": "./lib/index.mjs",
7
+ "module": "./lib/index.mjs",
8
+ "types": "./lib/index.d.ts",
9
+ "bin": {
10
+ "create-koishi-ce": "./lib/bin.mjs"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "src"
15
+ ],
16
+ "contributors": [
17
+ "Shigma <shigma10826@gmail.com>",
18
+ "Oppenheymu <oppenheymu@gmail.com>"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Koishi-CE/koishi.git",
24
+ "directory": "apps/koishi-create"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/Koishi-CE/koishi/issues"
28
+ },
29
+ "homepage": "https://koishi.chat",
30
+ "scripts": {
31
+ "build": "tsdown"
32
+ },
33
+ "keywords": [
34
+ "bot",
35
+ "chatbot",
36
+ "koishi",
37
+ "discord",
38
+ "telegram",
39
+ "create",
40
+ "scaffold",
41
+ "template",
42
+ "generator",
43
+ "boilerplate"
44
+ ],
45
+ "devDependencies": {
46
+ "@types/prompts": "^2.4.9",
47
+ "@types/yargs-parser": "^21.0.3"
48
+ },
49
+ "dependencies": {
50
+ "get-registry": "^1.1.0",
51
+ "kleur": "^4.1.5",
52
+ "prompts": "^2.4.2",
53
+ "tar": "^7.5.22",
54
+ "yargs-parser": "^22.0.0"
55
+ },
56
+ "exports": {
57
+ ".": {
58
+ "source": "./src/index.ts",
59
+ "types": "./lib/index.d.ts",
60
+ "import": "./lib/index.mjs",
61
+ "default": "./lib/index.mjs"
62
+ },
63
+ "./package.json": "./package.json"
64
+ }
65
+ }
@@ -0,0 +1,44 @@
1
+ import { expect, test } from "bun:test";
2
+ import { detectAgent, type Manifest, renderManifest } from "../index.ts";
3
+
4
+ test("renderManifest 基础改写:替换项目名、标记 private、版本归零", () => {
5
+ const source: Manifest = {
6
+ name: "@koishijs/boilerplate",
7
+ version: "1.0.0",
8
+ workspaces: ["koishi-app/*"],
9
+ scripts: { start: "koishi start" },
10
+ };
11
+ const output = JSON.parse(renderManifest(source, "my-app", false));
12
+ expect(output.name).toBe("my-app");
13
+ expect(output.private).toBe(true);
14
+ expect(output.version).toBe("0.0.0");
15
+ // 未触碰的字段原样保留
16
+ expect(output.scripts).toEqual({ start: "koishi start" });
17
+ expect(output.workspaces).toEqual(["koishi-app/*"]);
18
+ // 与模板一致的两空格缩进 + 结尾换行
19
+ expect(renderManifest(source, "my-app", false).endsWith("}\n")).toBe(true);
20
+ });
21
+
22
+ test("renderManifest prod 模式:删除 workspaces 与 devDependencies", () => {
23
+ const source: Manifest = {
24
+ name: "@koishijs/boilerplate",
25
+ workspaces: ["koishi-app/*"],
26
+ devDependencies: { koishi: "^4.18.11" },
27
+ };
28
+ const output = JSON.parse(renderManifest(source, "my-app", true));
29
+ expect(output.name).toBe("my-app");
30
+ expect("workspaces" in output).toBe(false);
31
+ expect("devDependencies" in output).toBe(false);
32
+ });
33
+
34
+ test("detectAgent:yarn / pnpm 跟随探测,其余一律 bun", () => {
35
+ const key = "npm_config_user_agent";
36
+ process.env[key] = "npm/10.9.2 node/v22.14.0 x64 workspaces/false";
37
+ expect(detectAgent()).toBe("bun");
38
+ process.env[key] = "yarn/1.22.22 npm/? node/v22.14.0 x64";
39
+ expect(detectAgent()).toBe("yarn");
40
+ process.env[key] = "pnpm/10.12.1 npm/? node/v22.14.0 x64";
41
+ expect(detectAgent()).toBe("pnpm");
42
+ delete process.env[key];
43
+ expect(detectAgent()).toBe("bun");
44
+ });
package/src/bin.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * create-koishi-ce 的 CLI 可执行入口:shebang 由 rolldown 原样保留到
4
+ * lib/bin.mjs,package.json 的 bin 字段指向它(范式同 @koishi-ce/scripts)。
5
+ */
6
+ import { start } from "./index.ts";
7
+
8
+ start().catch((err) => {
9
+ console.error(err);
10
+ process.exitCode = 1;
11
+ });
package/src/index.ts ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * create-koishi-ce 脚手架(npm 包名 create-koishi-ce,目录名为
3
+ * apps/koishi-create,二者不一致是历史遗留,以目录名为准)。
4
+ *
5
+ * 通过 `bunx create-koishi-ce [name]`(npx 亦可)交互式创建 Koishi 机器人
6
+ * 应用项目:确定项目名 → 准备目标目录 → 从 npm registry 下载模板包(默认
7
+ * @koishijs/boilerplate,刻意沿用上游官方模板以保持与上游插件生态一致)
8
+ * 并解包 → 改写 package.json → 按需初始化 git → 询问是否立即安装依赖并
9
+ * 启动。CLI 可执行入口在 src/bin.ts(构建产物 lib/bin.mjs,bin 字段指向
10
+ * 它);本文件只承载主流程与可单测的纯函数(范式对齐 @koishi-ce/scripts)。
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { basename, join, relative } from "node:path";
22
+ import { Readable } from "node:stream";
23
+ import type { ReadableStream as NodeWebReadableStream } from "node:stream/web";
24
+ import getRegistry from "get-registry";
25
+ import kleur from "kleur";
26
+ import prompts from "prompts";
27
+ import { extract } from "tar";
28
+ import parse from "yargs-parser";
29
+ import pkg from "../package.json" with { type: "json" };
30
+
31
+ const { version } = pkg;
32
+
33
+ /** CLI 参数(yargs-parser 解析,别名映射见 bin 帮助文本) */
34
+ interface Args {
35
+ _: Array<string | number>;
36
+ registry?: string;
37
+ ref?: string;
38
+ forced?: boolean;
39
+ git?: boolean;
40
+ prod?: boolean;
41
+ template?: string;
42
+ yes?: boolean;
43
+ help?: boolean;
44
+ }
45
+
46
+ /**
47
+ * 模板项目的 package.json(改写目标):只需要类型化本流程触碰的字段,
48
+ * 其余字段经 index signature 原样保留。
49
+ */
50
+ export interface Manifest {
51
+ name?: string;
52
+ private?: boolean;
53
+ version?: string;
54
+ workspaces?: unknown;
55
+ devDependencies?: unknown;
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ /** registry 包元数据中本流程消费的字段 */
60
+ interface RegistryMeta {
61
+ "dist-tags": Record<string, string>;
62
+ versions: Record<string, { dist?: { tarball?: string } }>;
63
+ }
64
+
65
+ /** fetch 请求非 2xx 时抛出的错误,携带 HTTP 状态码与状态文本 */
66
+ class HttpError extends Error {
67
+ status: number;
68
+ statusText: string;
69
+ constructor(status: number, statusText: string) {
70
+ super(`HTTP ${status} ${statusText}`);
71
+ this.status = status;
72
+ this.statusText = statusText;
73
+ }
74
+ }
75
+
76
+ // 命令行参数(顶层解析;无副作用,单测导入本文件不会触发主流程)
77
+ const argv = parse(process.argv.slice(2), {
78
+ alias: {
79
+ ref: ["r"],
80
+ forced: ["f"],
81
+ git: ["g"],
82
+ prod: ["p"],
83
+ template: ["t"],
84
+ yes: ["y"],
85
+ help: ["h"],
86
+ },
87
+ }) as Args;
88
+
89
+ /** 项目目录名(rootDir 的最后一段,写入生成项目的 package.json 的 name) */
90
+ let project: string;
91
+ /** 目标目录的绝对路径(由用户输入的项目名拼接 cwd 得到) */
92
+ let rootDir: string;
93
+
94
+ // 执行脚手架时所在的工作目录,作为项目目录的基准
95
+ const cwd = process.cwd();
96
+
97
+ /**
98
+ * 探测后续安装/启动使用的包管理器(Bun-first):yarn / pnpm 用户跟随其
99
+ * 生态习惯;其余场景(npm、bun 及探测不到 user-agent)一律走 bun——
100
+ * 本 CLI 自身以 bun 为运行时(bin shebang),能执行即已具备 bun 环境。
101
+ */
102
+ export function detectAgent(): string {
103
+ const ua = process.env["npm_config_user_agent"] ?? "";
104
+ if (ua.startsWith("yarn")) return "yarn";
105
+ if (ua.startsWith("pnpm")) return "pnpm";
106
+ return "bun";
107
+ }
108
+
109
+ /** 静默执行命令探测其是否可用(如 git --version),失败即视为不可用 */
110
+ function supports(command: readonly string[]) {
111
+ return (
112
+ spawnSync(command[0] ?? "", command.slice(1), { stdio: "ignore" })
113
+ .status === 0
114
+ );
115
+ }
116
+
117
+ /** 读 git 全局配置单项(读不到 → 空串) */
118
+ function gitConfig(key: string): string {
119
+ const res = spawnSync("git", ["config", "--get", key], { encoding: "utf8" });
120
+ return res.status === 0 ? (res.stdout?.trim() ?? "") : "";
121
+ }
122
+
123
+ /**
124
+ * 获取项目名:优先取第一个位置参数,否则交互式询问(默认 koishi-app)。
125
+ * 用户取消或输入为空时直接退出(不强行兜底默认值)。
126
+ */
127
+ async function getName(): Promise<string> {
128
+ if (argv._[0]) return `${argv._[0]}`;
129
+ const answer = (await prompts({
130
+ type: "text",
131
+ name: "name",
132
+ message: "项目名:",
133
+ initial: "koishi-app",
134
+ })) as { name?: string };
135
+ const trimmed = answer.name?.trim();
136
+ if (!trimmed) process.exit(0);
137
+ return trimmed;
138
+ }
139
+
140
+ /** 递归清空目录内容(目录本身保留)。 */
141
+ function emptyDir(root: string) {
142
+ for (const file of readdirSync(root)) {
143
+ rmSync(join(root, file), { recursive: true, force: true });
144
+ }
145
+ }
146
+
147
+ /** 交互式确认框:返回用户是否选择了「是」(取消视为否) */
148
+ async function confirm(message: string) {
149
+ const answer = (await prompts({
150
+ type: "confirm",
151
+ name: "yes",
152
+ initial: true,
153
+ message,
154
+ })) as { yes?: boolean };
155
+ return answer.yes === true;
156
+ }
157
+
158
+ /**
159
+ * 准备目标目录:不存在则创建;已存在且非空时,未指定 --forced / --yes
160
+ * 会先提示目录非空并询问是否清空后继续,用户拒绝则直接退出。
161
+ */
162
+ async function prepare() {
163
+ if (!existsSync(rootDir)) {
164
+ mkdirSync(rootDir, { recursive: true });
165
+ return;
166
+ }
167
+
168
+ const files = readdirSync(rootDir);
169
+ if (!files.length) return;
170
+
171
+ if (!argv.forced && !argv.yes) {
172
+ console.log(kleur.yellow(` 目标目录 "${project}" 非空。`));
173
+ const yes = await confirm("清空现有文件并继续?");
174
+ if (!yes) process.exit(0);
175
+ }
176
+
177
+ emptyDir(rootDir);
178
+ }
179
+
180
+ /**
181
+ * 改写模板的 package.json(纯函数,导出供单测):替换项目名、标记
182
+ * private、版本归零。
183
+ */
184
+ export function renderManifest(
185
+ source: Manifest,
186
+ project: string,
187
+ prod: boolean,
188
+ ): string {
189
+ const meta: Manifest = { ...source };
190
+ meta["name"] = project;
191
+ meta["private"] = true;
192
+ meta["version"] = "0.0.0";
193
+ if (prod) {
194
+ // https://github.com/koishijs/koishi/issues/994
195
+ // 生产模式不借助 NODE_ENV 或 --production 标志,
196
+ // 而是直接删掉 devDependencies 与 workspaces 字段。
197
+ delete meta["workspaces"];
198
+ delete meta["devDependencies"];
199
+ }
200
+ return `${JSON.stringify(meta, null, 2)}\n`;
201
+ }
202
+
203
+ /** 把改写结果写回生成项目的 package.json */
204
+ function writePackageJson() {
205
+ const filename = join(rootDir, "package.json");
206
+ const meta = JSON.parse(readFileSync(filename, "utf8")) as Manifest;
207
+ writeFileSync(filename, renderManifest(meta, project, argv.prod === true));
208
+ }
209
+
210
+ /**
211
+ * 模板下载与解包的主流程:
212
+ * 1. 确定 npm registry(--registry 参数 > 本机 npm 配置 > 官方源);
213
+ * 2. 拉取模板包元数据,按 dist-tags 解析目标版本(--ref,默认 latest);
214
+ * 3. 流式下载 tarball 并解包到目标目录(strip: 1 去掉包根目录层级),
215
+ * 网络错误统一以 HttpError 提示后退出;
216
+ * 4. 最后改写 package.json。
217
+ */
218
+ async function scaffold() {
219
+ console.log(kleur.dim(" 正在 ") + project + kleur.dim(" 中生成项目 ..."));
220
+
221
+ const registry = (
222
+ argv.registry ||
223
+ (await getRegistry()) ||
224
+ "https://registry.npmjs.org"
225
+ ).replace(/\/$/, "");
226
+ console.log(kleur.dim(` 使用 registry:${registry}\n`));
227
+ const template = argv.template || "@koishijs/boilerplate";
228
+ const ref = argv.ref || "latest";
229
+
230
+ try {
231
+ const metaRes = await fetch(`${registry}/${template}`);
232
+ if (!metaRes.ok) throw new HttpError(metaRes.status, metaRes.statusText);
233
+ const remote = (await metaRes.json()) as RegistryMeta;
234
+ const version = remote["dist-tags"][ref];
235
+ const url =
236
+ version === undefined
237
+ ? undefined
238
+ : remote.versions[version]?.dist?.tarball;
239
+ if (url === undefined) {
240
+ throw new HttpError(404, `模板 ${template}@${ref} 不存在`);
241
+ }
242
+ const tarballRes = await fetch(url);
243
+ const body = tarballRes.body;
244
+ if (!tarballRes.ok || !body) {
245
+ throw new HttpError(tarballRes.status, tarballRes.statusText);
246
+ }
247
+
248
+ await new Promise<void>((resolve, reject) => {
249
+ Readable.fromWeb(body as unknown as NodeWebReadableStream)
250
+ .pipe(extract({ cwd: rootDir, newer: true, strip: 1 }))
251
+ .on("finish", resolve)
252
+ .on("error", reject);
253
+ });
254
+ } catch (err) {
255
+ if (!(err instanceof HttpError)) throw err;
256
+ console.log(
257
+ `${kleur.red("error")} 请求失败:HTTP ${err.status} ${err.statusText}`,
258
+ );
259
+ process.exit(1);
260
+ }
261
+
262
+ writePackageJson();
263
+
264
+ console.log(kleur.green(" 完成。\n"));
265
+ }
266
+
267
+ /**
268
+ * 初始化 git 仓库:仅在显式传入 --git 且本机装有 git 时执行,分支名取
269
+ * git 的 init.defaultBranch(未配置则 main)。
270
+ */
271
+ async function initGit() {
272
+ if (!argv.git || !supports(["git", "--version"])) return;
273
+ const branch = gitConfig("init.defaultBranch") || "main";
274
+ spawnSync("git", ["init", "-b", branch], { stdio: "ignore", cwd: rootDir });
275
+ console.log(kleur.green(` 已初始化 git 仓库(分支 ${branch})。\n`));
276
+ }
277
+
278
+ /**
279
+ * 收尾交互:询问是否立即安装依赖并启动,包管理器由 detectAgent()
280
+ * Bun-first 探测;用户拒绝时打印后续手动安装与启动的命令。
281
+ */
282
+ async function install() {
283
+ // 指定 -y 时跳过依赖安装(供 CI 等需要静默生成的场景)
284
+ if (argv.yes) return;
285
+
286
+ const agent = detectAgent();
287
+ const startArgs = agent === "yarn" ? ["start"] : ["run", "start"];
288
+ const yes = await confirm("现在安装依赖并启动吗?");
289
+ if (yes) {
290
+ const installed = spawnSync(agent, ["install"], {
291
+ stdio: "inherit",
292
+ cwd: rootDir,
293
+ });
294
+ if (installed.status !== 0) {
295
+ console.log(kleur.red(" 依赖安装失败,请检查上方日志。"));
296
+ return;
297
+ }
298
+ spawnSync(agent, startArgs, { stdio: "inherit", cwd: rootDir });
299
+ } else {
300
+ console.log(kleur.dim(" 稍后可以这样启动:\n"));
301
+ if (rootDir !== cwd) {
302
+ const related = relative(cwd, rootDir);
303
+ console.log(kleur.blue(` cd ${kleur.bold(related)}`));
304
+ }
305
+ console.log(
306
+ kleur.blue(` ${agent === "yarn" ? "yarn" : `${agent} install`}`),
307
+ );
308
+ console.log(
309
+ kleur.blue(` ${agent === "yarn" ? "yarn" : `${agent} run`} start`),
310
+ );
311
+ console.log();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * CLI 主流程:--help 打印用法后即返回;否则依次执行
317
+ * 项目名询问 → prepare(目录准备)→ scaffold(模板解包)→ initGit → install。
318
+ */
319
+ export async function start() {
320
+ if (argv.help) {
321
+ console.log(`
322
+ 用法:create-koishi-ce [名称] [选项]
323
+
324
+ 选项:
325
+ -t, --template <名称> 模板包名(默认 @koishijs/boilerplate)
326
+ -r, --ref <引用> 模板版本引用(默认 latest)
327
+ -f, --forced 强制清空目标目录
328
+ -g, --git 初始化 git 仓库
329
+ --registry <地址> 指定 npm registry(如 https://registry.npmmirror.com)
330
+ -p, --prod 生产模式(移除 devDependencies 与 workspaces)
331
+ -y, --yes 跳过全部询问
332
+ -h, --help 显示本帮助
333
+ `);
334
+ return;
335
+ }
336
+
337
+ console.log();
338
+ console.log(` ${kleur.bold("Create Koishi")} ${kleur.blue(`v${version}`)}`);
339
+ console.log();
340
+
341
+ const name = await getName();
342
+ rootDir = join(cwd, name);
343
+ project = basename(rootDir);
344
+
345
+ await prepare();
346
+ await scaffold();
347
+ await initGit();
348
+ await install();
349
+ }