@wolfstar/create-http-framework 1.0.1 → 2.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/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # `@wolfstar/create-http-framework`
2
+
3
+ A CLI scaffolding tool for creating new [WolfStar HTTP Framework](https://github.com/wolfstar-project/stars-components/tree/main/packages/http-framework) bot projects.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npm create @wolfstar/http-framework@latest my-discord-bot
9
+ # or
10
+ pnpm create @wolfstar/http-framework my-discord-bot
11
+ # or
12
+ yarn create @wolfstar/http-framework my-discord-bot
13
+ # or
14
+ bun create @wolfstar/http-framework my-discord-bot
15
+ ```
16
+
17
+ The CLI will guide you through the following prompts:
18
+
19
+ - **Project name** — an npm-compatible name for your bot
20
+ - **Package manager** — npm, yarn, pnpm, or bun
21
+ - **Language** — TypeScript or JavaScript
22
+ - **Build tool** — tsdown, TypeScript 6, or the TypeScript 7 release candidate
23
+ - **Linter and formatter** — Oxlint / ESLint and Oxfmt / Prettier
24
+ - **Port** — the port the HTTP server will listen on (default: `3000`)
25
+ - **i18n support** — optionally add `@wolfstar/http-framework-i18n`
26
+ - **Auto-install** — install dependencies immediately after scaffolding
27
+
28
+ ## Options
29
+
30
+ | Flag | Alias | Description |
31
+ | ---------------------------- | ----- | ----------------------------------------------------------- |
32
+ | `--overwrite` | | Overwrite the target directory if it already exists |
33
+ | `--no-interactive` | | Skip all prompts and use defaults / flags |
34
+ | `--interactive` | `-i` | Force interactive prompts even when an AI agent is detected |
35
+ | `--package-manager <pm>` | | Choose npm, yarn, pnpm, or bun |
36
+ | `--language <lang>` | | Choose TypeScript (`ts`) or JavaScript (`js`) |
37
+ | `--build <tool>` | | Choose `tsc6`, `tsc7`, or `tsdown` for TypeScript |
38
+ | `--lint <linter>` | | Choose `none`, `eslint`, or `oxlint` |
39
+ | `--format <formatter>` | | Choose `none`, `prettier`, or `oxfmt` |
40
+ | `--port <number>` | | Set the HTTP port (default: `3000`) |
41
+ | `--i18n` / `--no-i18n` | | Enable or disable i18n support |
42
+ | `--install` / `--no-install` | | Enable or disable dependency installation |
43
+ | `--help` | `-h` | Print usage and exit |
44
+
45
+ ## Non-interactive / AI agent mode
46
+
47
+ When the CLI detects an AI agent is running it (via [`@vercel/detect-agent`](https://www.npmjs.com/package/@vercel/detect-agent)), it automatically enters non-interactive mode, equivalent to passing `--no-interactive`. A project name argument is required in this mode:
48
+
49
+ ```bash
50
+ create-http-framework my-discord-bot --no-interactive
51
+ ```
52
+
53
+ ## Generated project structure
54
+
55
+ ```
56
+ my-discord-bot/
57
+ ├── src/
58
+ │ ├── commands/
59
+ │ │ └── ping.ts # Example ping command
60
+ │ └── index.ts # Entry point — starts the HTTP server
61
+ ├── .env # Environment variables (DISCORD_TOKEN, DISCORD_PUBLIC_KEY)
62
+ ├── .gitignore
63
+ ├── package.json
64
+ └── tsconfig.json
65
+ ```
66
+
67
+ ## Requirements
68
+
69
+ - Node.js `>=20`
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { cancel, confirm, intro, isCancel, outro, spinner, text } from "@clack/prompts";
2
+ import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
3
+ import { determineAgent } from "@vercel/detect-agent";
4
+ import mri from "mri";
3
5
  import { dirname, join, relative, resolve } from "node:path";
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
- import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import spawn from "cross-spawn";
6
8
  import { resolveCommand } from "package-manager-detector/commands";
7
9
  import { detect } from "package-manager-detector/detect";
8
10
  import Handlebars from "handlebars";
@@ -10,64 +12,395 @@ import { fileURLToPath } from "node:url";
10
12
 
11
13
  //#region src/tools/fileSystem.ts
12
14
  function directoryExists(path) {
13
- return existsSync(path);
15
+ return existsSync(path) && statSync(path).isDirectory();
16
+ }
17
+ function isEmpty(path) {
18
+ try {
19
+ return readdirSync(path).length === 0;
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return true;
22
+ throw error;
23
+ }
24
+ }
25
+ function emptyDir(dir) {
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) rmSync(join(dir, entry.name), {
27
+ recursive: true,
28
+ force: true
29
+ });
14
30
  }
15
31
  function writeFile(filePath, content) {
16
32
  mkdirSync(dirname(filePath), { recursive: true });
17
33
  writeFileSync(filePath, content, "utf-8");
18
34
  }
19
35
 
36
+ //#endregion
37
+ //#region src/tools/options.ts
38
+ const PACKAGE_MANAGERS = [
39
+ "npm",
40
+ "yarn",
41
+ "pnpm",
42
+ "bun"
43
+ ];
44
+ const LANGUAGES = ["ts", "js"];
45
+ const BUILD_TOOLS = [
46
+ "tsc6",
47
+ "tsc7",
48
+ "tsdown"
49
+ ];
50
+ const LINTERS = [
51
+ "none",
52
+ "eslint",
53
+ "oxlint"
54
+ ];
55
+ const FORMATTERS = [
56
+ "none",
57
+ "prettier",
58
+ "oxfmt"
59
+ ];
60
+ /**
61
+ * TypeScript 7.0 (the native compiler) ships under the main `typescript` package on the `rc`
62
+ * dist-tag. We pin it exactly because it is a prerelease (the old separate `@typescript/native-preview`
63
+ * package only has dev builds).
64
+ */
65
+ const TYPESCRIPT_RC_VERSION = "7.0.1-rc";
66
+
20
67
  //#endregion
21
68
  //#region src/tools/npmHelpers.ts
22
- function fetchVersion(packageName) {
23
- try {
24
- return execFileSync("npm", [
25
- "show",
26
- packageName,
27
- "version"
28
- ], {
29
- encoding: "utf-8",
30
- stdio: "pipe"
31
- }).trim();
32
- } catch {
33
- return "latest";
34
- }
35
- }
36
- function fetchDependencyVersions() {
37
- return {
38
- httpFramework: fetchVersion("@wolfstar/http-framework"),
39
- httpFrameworkI18n: fetchVersion("@wolfstar/http-framework-i18n"),
40
- discordApiTypes: fetchVersion("discord-api-types"),
41
- typescript: fetchVersion("typescript"),
42
- tsNode: fetchVersion("ts-node"),
43
- typesNode: fetchVersion("@types/node"),
44
- discordJsBuilders: fetchVersion("@discordjs/builders")
45
- };
69
+ async function fetchVersion(packageName) {
70
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
71
+ if (!response.ok) throw new Error(`Failed to resolve latest version for ${packageName}: ${response.status} ${response.statusText}`);
72
+ return (await response.json()).version;
73
+ }
74
+ /**
75
+ * Resolves the latest versions of only the packages required by the chosen selections.
76
+ * `ts-node` is intentionally never included. TypeScript 7.0 (tsc7) is pinned to the rc instead
77
+ * of being fetched, because `typescript@latest` resolves to the 6.x line.
78
+ */
79
+ async function fetchDependencyVersions(selections) {
80
+ const names = /* @__PURE__ */ new Set([
81
+ "@wolfstar/http-framework",
82
+ "discord-api-types",
83
+ "@discordjs/builders"
84
+ ]);
85
+ if (selections.i18n) names.add("@wolfstar/http-framework-i18n");
86
+ if (selections.language === "ts") {
87
+ names.add("@types/node");
88
+ switch (selections.buildTool) {
89
+ case "tsc6":
90
+ names.add("typescript").add("tsc-watch");
91
+ break;
92
+ case "tsc7":
93
+ names.add("tsc-watch");
94
+ break;
95
+ case "tsdown":
96
+ names.add("tsdown").add("typescript");
97
+ break;
98
+ }
99
+ }
100
+ if (selections.linter === "eslint") {
101
+ names.add("eslint");
102
+ names.add(selections.language === "ts" ? "typescript-eslint" : "@eslint/js");
103
+ }
104
+ if (selections.linter === "oxlint") names.add("oxlint");
105
+ if (selections.formatter === "prettier") names.add("prettier");
106
+ if (selections.formatter === "oxfmt") names.add("oxfmt");
107
+ const list = [...names];
108
+ const resolved = await Promise.all(list.map(fetchVersion));
109
+ const versions = {};
110
+ list.forEach((name, index) => {
111
+ versions[name] = resolved[index];
112
+ });
113
+ if (selections.language === "ts" && selections.buildTool === "tsc7") versions["typescript"] = TYPESCRIPT_RC_VERSION;
114
+ return versions;
46
115
  }
47
116
 
48
117
  //#endregion
49
118
  //#region src/tools/packageManager.ts
50
- async function detectPackageManager(installIn) {
119
+ async function detectPackageManager() {
51
120
  const detected = await detect({ cwd: process.cwd() });
52
- const agent = detected?.agent ?? "npm";
53
- const name = detected?.name ?? "npm";
54
- if (installIn) {
55
- const resolved = resolveCommand(agent, "install", []);
56
- if (!resolved) throw new Error(`Could not resolve install command for ${agent}`);
57
- execFileSync(resolved.command, resolved.args, {
58
- cwd: installIn,
59
- stdio: process.env["DEBUG"] ? "inherit" : "pipe"
60
- });
121
+ if (detected?.name) return detected.name;
122
+ const userAgent = process.env["npm_config_user_agent"];
123
+ if (userAgent) {
124
+ const name = userAgent.split("/")[0];
125
+ if ([
126
+ "npm",
127
+ "yarn",
128
+ "pnpm",
129
+ "bun",
130
+ "deno"
131
+ ].includes(name)) return name;
61
132
  }
62
- return name;
133
+ return "npm";
134
+ }
135
+ function quoteToken(token) {
136
+ return /\s/.test(token) ? `"${token}"` : token;
137
+ }
138
+ /**
139
+ * Builds the correct "run a script" command string for the given package manager, e.g.
140
+ * `npm run build` / `pnpm run build`. Extra args are forwarded the way each manager expects
141
+ * (npm inserts the `--` separator automatically), and tokens containing spaces are quoted so
142
+ * nested commands (`--onSuccess "pnpm run start"`) survive being placed in a package.json script.
143
+ */
144
+ function getRunScript(pm, script, args = []) {
145
+ const resolved = resolveCommand(pm, "run", [script, ...args]);
146
+ if (!resolved) throw new Error(`Could not resolve run command for ${pm}`);
147
+ return [resolved.command, ...resolved.args].map(quoteToken).join(" ");
148
+ }
149
+ /** Returns the install command string for the given package manager, e.g. `pnpm install`. */
150
+ function getInstallScript(pm) {
151
+ const resolved = resolveCommand(pm, "install", []);
152
+ if (!resolved) throw new Error(`Could not resolve install command for ${pm}`);
153
+ return [resolved.command, ...resolved.args].map(quoteToken).join(" ");
154
+ }
155
+ async function installDependencies(pm, dir) {
156
+ const agent = (await detect({ cwd: process.cwd() }))?.agent ?? pm;
157
+ const resolved = resolveCommand(agent, "install", []);
158
+ if (!resolved) throw new Error(`Could not resolve install command for ${agent}`);
159
+ const result = spawn.sync(resolved.command, resolved.args, {
160
+ cwd: dir,
161
+ stdio: process.env["DEBUG"] ? "inherit" : "pipe"
162
+ });
163
+ if (result.error) throw result.error;
164
+ if (result.status !== 0) throw new Error(`Dependency installation failed with exit code ${result.status ?? "unknown"}`);
165
+ }
166
+ /** Runs an arbitrary command in `dir` via cross-spawn (used for the post-scaffold formatter pass). */
167
+ function runCommand(command, args, dir) {
168
+ const result = spawn.sync(command, args, {
169
+ cwd: dir,
170
+ stdio: process.env["DEBUG"] ? "inherit" : "pipe"
171
+ });
172
+ if (result.error) throw result.error;
173
+ if (result.status !== 0) throw new Error(`Command "${command}" failed with exit code ${result.status ?? "unknown"}`);
174
+ }
175
+
176
+ //#endregion
177
+ //#region src/tools/projectFiles.ts
178
+ const caret = (version) => `^${version}`;
179
+ function sortKeys(record) {
180
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
181
+ }
182
+ function json(value) {
183
+ return `${JSON.stringify(value, null, " ")}\n`;
184
+ }
185
+ function buildScripts(ctx) {
186
+ const { packageManager: pm, buildTool } = ctx;
187
+ const start = "node dist/index.js";
188
+ let scripts;
189
+ if (ctx.language === "js") scripts = {
190
+ start: "node src/index.js",
191
+ dev: "node --watch src/index.js"
192
+ };
193
+ else if (buildTool === "tsdown") scripts = {
194
+ build: "tsdown",
195
+ dev: getRunScript(pm, "build", ["--onSuccess", getRunScript(pm, "start")]),
196
+ watch: getRunScript(pm, "build", ["--watch"]),
197
+ "watch:start": getRunScript(pm, "build", [
198
+ "--watch",
199
+ "--onSuccess",
200
+ getRunScript(pm, "start")
201
+ ]),
202
+ start
203
+ };
204
+ else scripts = {
205
+ build: "tsc -b src",
206
+ dev: `${getRunScript(pm, "build")} && ${getRunScript(pm, "start")}`,
207
+ watch: "tsc -b src -w",
208
+ "watch:start": `tsc-watch -b src --onSuccess "${getRunScript(pm, "start")}"`,
209
+ start
210
+ };
211
+ if (ctx.linter === "oxlint") {
212
+ scripts["lint"] = "oxlint src";
213
+ scripts["lint:fix"] = "oxlint --fix src";
214
+ } else if (ctx.linter === "eslint") {
215
+ scripts["lint"] = "eslint src";
216
+ scripts["lint:fix"] = "eslint src --fix";
217
+ }
218
+ if (ctx.formatter === "oxfmt") {
219
+ scripts["format"] = "oxfmt --write src";
220
+ scripts["format:check"] = "oxfmt --check src";
221
+ } else if (ctx.formatter === "prettier") {
222
+ scripts["format"] = "prettier --write src";
223
+ scripts["format:check"] = "prettier --check src";
224
+ }
225
+ return scripts;
226
+ }
227
+ function buildDependencies(ctx) {
228
+ const v = ctx.versions;
229
+ const dependencies = {
230
+ "@discordjs/builders": caret(v["@discordjs/builders"]),
231
+ "@wolfstar/http-framework": caret(v["@wolfstar/http-framework"]),
232
+ "discord-api-types": caret(v["discord-api-types"])
233
+ };
234
+ if (ctx.i18n) dependencies["@wolfstar/http-framework-i18n"] = caret(v["@wolfstar/http-framework-i18n"]);
235
+ return sortKeys(dependencies);
236
+ }
237
+ function buildDevDependencies(ctx) {
238
+ const v = ctx.versions;
239
+ const dev = {};
240
+ if (ctx.language === "ts") {
241
+ dev["@types/node"] = caret(v["@types/node"]);
242
+ switch (ctx.buildTool) {
243
+ case "tsc6":
244
+ dev["typescript"] = caret(v["typescript"]);
245
+ dev["tsc-watch"] = caret(v["tsc-watch"]);
246
+ break;
247
+ case "tsc7":
248
+ dev["typescript"] = v["typescript"];
249
+ dev["tsc-watch"] = caret(v["tsc-watch"]);
250
+ break;
251
+ case "tsdown":
252
+ dev["tsdown"] = caret(v["tsdown"]);
253
+ dev["typescript"] = caret(v["typescript"]);
254
+ break;
255
+ }
256
+ }
257
+ if (ctx.linter === "eslint") {
258
+ dev["eslint"] = caret(v["eslint"]);
259
+ if (ctx.language === "ts") dev["typescript-eslint"] = caret(v["typescript-eslint"]);
260
+ else dev["@eslint/js"] = caret(v["@eslint/js"]);
261
+ } else if (ctx.linter === "oxlint") dev["oxlint"] = caret(v["oxlint"]);
262
+ if (ctx.formatter === "prettier") dev["prettier"] = caret(v["prettier"]);
263
+ else if (ctx.formatter === "oxfmt") dev["oxfmt"] = caret(v["oxfmt"]);
264
+ return sortKeys(dev);
265
+ }
266
+ function packageJson(ctx) {
267
+ const devDependencies = buildDevDependencies(ctx);
268
+ return json({
269
+ name: ctx.name,
270
+ version: "1.0.0",
271
+ description: "A Discord HTTP bot built with `@wolfstar/http-framework`",
272
+ type: "module",
273
+ scripts: buildScripts(ctx),
274
+ dependencies: buildDependencies(ctx),
275
+ ...Object.keys(devDependencies).length > 0 ? { devDependencies } : {},
276
+ engines: { node: ">=20" }
277
+ });
278
+ }
279
+ const sharedCompilerOptions = {
280
+ target: "ES2022",
281
+ module: "Node16",
282
+ moduleResolution: "Node16",
283
+ strict: true,
284
+ esModuleInterop: true,
285
+ skipLibCheck: true,
286
+ declaration: true,
287
+ declarationMap: true,
288
+ sourceMap: true,
289
+ experimentalDecorators: true,
290
+ emitDecoratorMetadata: true
291
+ };
292
+ /** Writes the tsconfig(s). The tsc branches use a composite build so `tsc -b src` resolves `src/tsconfig.json`. */
293
+ function writeTsconfig(targetDir, ctx) {
294
+ if (ctx.language === "js") return;
295
+ if (ctx.buildTool === "tsdown") {
296
+ writeFile(join(targetDir, "tsconfig.json"), json({
297
+ compilerOptions: {
298
+ ...sharedCompilerOptions,
299
+ outDir: "./dist",
300
+ rootDir: "./src"
301
+ },
302
+ include: ["src/**/*.ts"],
303
+ exclude: ["node_modules", "dist"]
304
+ }));
305
+ return;
306
+ }
307
+ writeFile(join(targetDir, "tsconfig.json"), json({
308
+ files: [],
309
+ references: [{ path: "./src" }]
310
+ }));
311
+ writeFile(join(targetDir, "src", "tsconfig.json"), json({
312
+ compilerOptions: {
313
+ ...sharedCompilerOptions,
314
+ rootDir: ".",
315
+ outDir: "../dist",
316
+ composite: true,
317
+ tsBuildInfoFile: "../dist/tsconfig.tsbuildinfo"
318
+ },
319
+ include: ["**/*.ts"]
320
+ }));
321
+ }
322
+ function writeBuildConfig(targetDir, ctx) {
323
+ if (ctx.language === "js" || ctx.buildTool !== "tsdown") return;
324
+ const content = [
325
+ "import { defineConfig } from 'tsdown';",
326
+ "",
327
+ "export default defineConfig({",
328
+ " entry: ['src/**/*.ts'],",
329
+ " format: ['esm'],",
330
+ " target: 'node20',",
331
+ " // Mirror the src/ structure so the framework can load command pieces from dist/commands at runtime.",
332
+ " unbundle: true,",
333
+ " // Emit dist/index.js so the shared `start` script (node dist/index.js) works.",
334
+ " outExtensions: () => ({ js: '.js' }),",
335
+ " clean: true,",
336
+ " sourcemap: true",
337
+ "});",
338
+ ""
339
+ ].join("\n");
340
+ writeFile(join(targetDir, "tsdown.config.ts"), content);
341
+ }
342
+ function writeLinterConfig(targetDir, ctx) {
343
+ if (ctx.linter === "oxlint") writeFile(join(targetDir, ".oxlintrc.json"), json({
344
+ $schema: "./node_modules/oxlint/configuration_schema.json",
345
+ ...ctx.language === "ts" ? { plugins: ["typescript"] } : {},
346
+ categories: {
347
+ correctness: "error",
348
+ suspicious: "warn"
349
+ },
350
+ ignorePatterns: ["dist/**", "node_modules/**"]
351
+ }));
352
+ else if (ctx.linter === "eslint") {
353
+ const content = ctx.language === "ts" ? [
354
+ "import tseslint from 'typescript-eslint';",
355
+ "",
356
+ "export default tseslint.config(",
357
+ " { ignores: ['dist/**'] },",
358
+ " ...tseslint.configs.recommended",
359
+ ");",
360
+ ""
361
+ ].join("\n") : [
362
+ "import js from '@eslint/js';",
363
+ "",
364
+ "export default [",
365
+ " { ignores: ['dist/**'] },",
366
+ " js.configs.recommended",
367
+ "];",
368
+ ""
369
+ ].join("\n");
370
+ writeFile(join(targetDir, "eslint.config.mjs"), content);
371
+ }
372
+ }
373
+ function writeFormatterConfig(targetDir, ctx) {
374
+ if (ctx.formatter === "oxfmt") writeFile(join(targetDir, ".oxfmtrc.json"), json({
375
+ $schema: "./node_modules/oxfmt/configuration_schema.json",
376
+ useTabs: true,
377
+ tabWidth: 4,
378
+ printWidth: 150,
379
+ singleQuote: true,
380
+ trailingComma: "none",
381
+ semi: true,
382
+ endOfLine: "lf"
383
+ }));
384
+ else if (ctx.formatter === "prettier") writeFile(join(targetDir, ".prettierrc.json"), json({
385
+ useTabs: true,
386
+ tabWidth: 4,
387
+ printWidth: 150,
388
+ singleQuote: true,
389
+ trailingComma: "none"
390
+ }));
391
+ }
392
+ /** Generates every config-style file in code so the output is always valid, formatted JSON/TS. */
393
+ function writeProjectFiles(targetDir, ctx) {
394
+ writeFile(join(targetDir, "package.json"), packageJson(ctx));
395
+ writeTsconfig(targetDir, ctx);
396
+ writeBuildConfig(targetDir, ctx);
397
+ writeLinterConfig(targetDir, ctx);
398
+ writeFormatterConfig(targetDir, ctx);
63
399
  }
64
400
 
65
401
  //#endregion
66
402
  //#region src/tools/templateProcessor.ts
67
- Handlebars.registerHelper("eq", (a, b) => a === b);
68
- Handlebars.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
69
- Handlebars.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
70
- const templateDir = join(fileURLToPath(import.meta.url), "../../..", "template");
403
+ const templateDir = join(fileURLToPath(import.meta.url), "../..", "template");
71
404
  function walkDir(dir) {
72
405
  const entries = readdirSync(dir);
73
406
  const files = [];
@@ -82,6 +415,8 @@ function processTemplate(outputDir, context) {
82
415
  const allFiles = walkDir(templateDir);
83
416
  for (const absoluteSource of allFiles) {
84
417
  const relativePath = relative(templateDir, absoluteSource);
418
+ if (context.language === "js" && relativePath.endsWith(".ts.hbs")) continue;
419
+ if (context.language === "ts" && relativePath.endsWith(".js.hbs")) continue;
85
420
  const rawContent = readFileSync(absoluteSource, "utf-8");
86
421
  const isHandlebars = relativePath.endsWith(".hbs");
87
422
  writeFile(join(outputDir, isHandlebars ? relativePath.slice(0, -4) : relativePath), isHandlebars ? Handlebars.compile(rawContent)(context) : rawContent);
@@ -90,75 +425,352 @@ function processTemplate(outputDir, context) {
90
425
 
91
426
  //#endregion
92
427
  //#region src/index.ts
93
- const NAME_PATTERN = /^[a-z0-9_-]+$/;
428
+ function isValidPackageName(name) {
429
+ return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
430
+ }
431
+ function toValidPackageName(name) {
432
+ return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/^[._]+/, "").replace(/[^a-z0-9-._~]/g, "-");
433
+ }
434
+ function printHelp() {
435
+ process.stdout.write(`
436
+ Usage: create-http-framework [project-name] [options]
437
+
438
+ Options:
439
+ --overwrite Overwrite target directory if it already exists
440
+ --no-interactive Skip all prompts and use defaults / flags
441
+ --interactive, -i Force interactive prompts even when an AI agent is detected
442
+ --package-manager <pm> npm | yarn | pnpm | bun (defaults to the detected one)
443
+ --language <lang> ts | js (default: ts)
444
+ --build <tool> tsc6 | tsc7 | tsdown — TypeScript only (default: tsdown)
445
+ --lint <linter> none | eslint | oxlint (default: oxlint)
446
+ --format <formatter> none | prettier | oxfmt (default: oxfmt)
447
+ --port <number> HTTP port (default: 3000)
448
+ --i18n / --no-i18n Toggle @wolfstar/http-framework-i18n (default: off)
449
+ --install / --no-install Toggle dependency installation (default: on)
450
+ --help, -h Print this message and exit
451
+
452
+ `);
453
+ process.exit(0);
454
+ }
455
+ function parseEnum(value, allowed, label) {
456
+ if (value === void 0) return void 0;
457
+ if (!allowed.includes(value)) {
458
+ cancel(`Invalid ${label} "${value}". Expected one of: ${allowed.join(", ")}.`);
459
+ process.exit(1);
460
+ }
461
+ return value;
462
+ }
94
463
  async function main() {
464
+ const argv = mri(process.argv.slice(2), {
465
+ boolean: ["overwrite", "help"],
466
+ string: [
467
+ "package-manager",
468
+ "language",
469
+ "build",
470
+ "lint",
471
+ "format",
472
+ "port"
473
+ ],
474
+ alias: {
475
+ h: "help",
476
+ i: "interactive"
477
+ },
478
+ default: { install: true }
479
+ });
480
+ if (argv["help"]) {
481
+ printHelp();
482
+ return;
483
+ }
484
+ const argProjectName = argv._[0];
485
+ const flagOverwrite = argv["overwrite"];
486
+ const flagInteractive = argv["interactive"];
487
+ const cliPackageManager = parseEnum(argv["package-manager"], PACKAGE_MANAGERS, "--package-manager");
488
+ const cliLanguage = parseEnum(argv["language"], LANGUAGES, "--language");
489
+ const cliBuild = parseEnum(argv["build"], BUILD_TOOLS, "--build");
490
+ const cliLint = parseEnum(argv["lint"], LINTERS, "--lint");
491
+ const cliFormat = parseEnum(argv["format"], FORMATTERS, "--format");
492
+ const cliPort = argv["port"];
493
+ const cliI18n = argv["i18n"];
494
+ const cliInstall = argv["install"];
495
+ const { isAgent, agent } = await determineAgent();
496
+ const agentMode = isAgent && flagInteractive !== true;
497
+ const nonInteractive = flagInteractive === false || agentMode;
95
498
  intro("Welcome to the WolfStar HTTP Framework!");
96
- const packageManager = await detectPackageManager();
97
- const projectNameArg = process.argv[2];
98
- const projectName = await text({
99
- message: "What is the name of your project?",
100
- placeholder: "my-discord-bot",
101
- initialValue: projectNameArg ?? "",
102
- validate(value) {
103
- if (!value) return "Project name cannot be empty.";
104
- if (!NAME_PATTERN.test(value)) return "Use only lowercase letters, numbers, dashes, and underscores.";
105
- if (directoryExists(resolve(value))) return `Directory "${value}" already exists.`;
499
+ if (agentMode && flagInteractive !== false) log.info(`AI agent detected (${agent.name}) — running in non-interactive mode`);
500
+ const detectedPackageManager = await detectPackageManager();
501
+ let projectName;
502
+ if (nonInteractive) {
503
+ if (!argProjectName) {
504
+ cancel("A project name argument is required in non-interactive mode.");
505
+ process.exit(1);
106
506
  }
107
- });
108
- if (isCancel(projectName)) {
109
- cancel("Operation cancelled.");
110
- process.exit(0);
111
- }
112
- const portInput = await text({
113
- message: "Which port should the HTTP server listen on?",
114
- placeholder: "3000",
115
- initialValue: "3000",
116
- validate(value) {
117
- const n = Number(value);
118
- if (!value || Number.isNaN(n) || !Number.isInteger(n) || n < 1 || n > 65535) return "Enter a valid port number (1–65535).";
507
+ const normalized = isValidPackageName(argProjectName) ? argProjectName : toValidPackageName(argProjectName);
508
+ if (!normalized || !isValidPackageName(normalized)) {
509
+ cancel("Provide a valid npm-compatible project name in non-interactive mode.");
510
+ process.exit(1);
119
511
  }
120
- });
121
- if (isCancel(portInput)) {
122
- cancel("Operation cancelled.");
123
- process.exit(0);
512
+ projectName = normalized;
513
+ } else {
514
+ const suggested = argProjectName ? toValidPackageName(argProjectName) : "";
515
+ const nameResult = await text({
516
+ message: "What is the name of your project?",
517
+ placeholder: "my-discord-bot",
518
+ initialValue: argProjectName && isValidPackageName(argProjectName) ? argProjectName : suggested,
519
+ validate(value) {
520
+ if (!value) return "Project name cannot be empty.";
521
+ if (!isValidPackageName(value)) return "Use lowercase letters, numbers, dashes, dots, or underscores (npm-compatible).";
522
+ }
523
+ });
524
+ if (isCancel(nameResult)) {
525
+ cancel("Operation cancelled.");
526
+ process.exit(0);
527
+ }
528
+ projectName = nameResult;
124
529
  }
125
- const wantsI18n = await confirm({
126
- message: "Would you like to add i18n support? (@wolfstar/http-framework-i18n)",
127
- initialValue: false
128
- });
129
- if (isCancel(wantsI18n)) {
130
- cancel("Operation cancelled.");
131
- process.exit(0);
530
+ let packageManager;
531
+ if (nonInteractive) packageManager = cliPackageManager ?? detectedPackageManager;
532
+ else {
533
+ const pmResult = await select({
534
+ message: "Which package manager would you like to use?",
535
+ options: PACKAGE_MANAGERS.map((pm) => ({
536
+ value: pm,
537
+ label: pm
538
+ })),
539
+ initialValue: PACKAGE_MANAGERS.includes(detectedPackageManager) ? detectedPackageManager : "npm"
540
+ });
541
+ if (isCancel(pmResult)) {
542
+ cancel("Operation cancelled.");
543
+ process.exit(0);
544
+ }
545
+ packageManager = pmResult;
132
546
  }
133
- const wantsInstall = await confirm({
134
- message: `Would you like to automatically install dependencies with ${packageManager}?`,
135
- initialValue: true
136
- });
137
- if (isCancel(wantsInstall)) {
138
- cancel("Operation cancelled.");
139
- process.exit(0);
547
+ const targetDir = resolve(projectName);
548
+ if (directoryExists(targetDir) && !isEmpty(targetDir)) if (flagOverwrite) emptyDir(targetDir);
549
+ else if (nonInteractive) {
550
+ cancel(`"${projectName}" already exists. Use --overwrite to overwrite it.`);
551
+ process.exit(1);
552
+ } else {
553
+ const choice = await select({
554
+ message: `"${projectName}" already exists. What would you like to do?`,
555
+ options: [{
556
+ value: "overwrite",
557
+ label: "Overwrite the directory"
558
+ }, {
559
+ value: "cancel",
560
+ label: "Cancel"
561
+ }]
562
+ });
563
+ if (isCancel(choice) || choice === "cancel") {
564
+ cancel("Operation cancelled.");
565
+ process.exit(0);
566
+ }
567
+ emptyDir(targetDir);
568
+ }
569
+ let port;
570
+ if (nonInteractive) {
571
+ port = cliPort === void 0 ? 3e3 : Number(cliPort);
572
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
573
+ cancel("Provide a valid --port between 1 and 65535.");
574
+ process.exit(1);
575
+ }
576
+ } else {
577
+ const portResult = await text({
578
+ message: "Which port should the HTTP server listen on?",
579
+ placeholder: "3000",
580
+ initialValue: "3000",
581
+ validate(value) {
582
+ const n = Number(value);
583
+ if (!value || Number.isNaN(n) || !Number.isInteger(n) || n < 1 || n > 65535) return "Enter a valid port number (1–65535).";
584
+ }
585
+ });
586
+ if (isCancel(portResult)) {
587
+ cancel("Operation cancelled.");
588
+ process.exit(0);
589
+ }
590
+ port = Number(portResult);
591
+ }
592
+ let language;
593
+ if (nonInteractive) language = cliLanguage ?? "ts";
594
+ else {
595
+ const languageResult = await select({
596
+ message: "Which language would you like to use?",
597
+ options: [{
598
+ value: "ts",
599
+ label: "TypeScript"
600
+ }, {
601
+ value: "js",
602
+ label: "JavaScript"
603
+ }],
604
+ initialValue: "ts"
605
+ });
606
+ if (isCancel(languageResult)) {
607
+ cancel("Operation cancelled.");
608
+ process.exit(0);
609
+ }
610
+ language = languageResult;
611
+ }
612
+ let buildTool = "tsdown";
613
+ if (cliBuild !== void 0 && language === "js") log.warn("--build only applies to TypeScript projects and will be ignored.");
614
+ if (language === "ts") if (nonInteractive) buildTool = cliBuild ?? "tsdown";
615
+ else {
616
+ const buildResult = await select({
617
+ message: "Which build tool would you like to use?",
618
+ options: [
619
+ {
620
+ value: "tsdown",
621
+ label: "tsdown (bundler)"
622
+ },
623
+ {
624
+ value: "tsc6",
625
+ label: "TypeScript 6.0 (tsc)"
626
+ },
627
+ {
628
+ value: "tsc7",
629
+ label: "TypeScript 7.0 rc (tsc)"
630
+ }
631
+ ],
632
+ initialValue: "tsdown"
633
+ });
634
+ if (isCancel(buildResult)) {
635
+ cancel("Operation cancelled.");
636
+ process.exit(0);
637
+ }
638
+ buildTool = buildResult;
639
+ }
640
+ let linter;
641
+ if (nonInteractive) linter = cliLint ?? "oxlint";
642
+ else {
643
+ const lintResult = await select({
644
+ message: "Which linter would you like to use?",
645
+ options: [
646
+ {
647
+ value: "oxlint",
648
+ label: "oxlint"
649
+ },
650
+ {
651
+ value: "eslint",
652
+ label: "ESLint"
653
+ },
654
+ {
655
+ value: "none",
656
+ label: "None"
657
+ }
658
+ ],
659
+ initialValue: "oxlint"
660
+ });
661
+ if (isCancel(lintResult)) {
662
+ cancel("Operation cancelled.");
663
+ process.exit(0);
664
+ }
665
+ linter = lintResult;
666
+ }
667
+ let formatter;
668
+ if (nonInteractive) formatter = cliFormat ?? "oxfmt";
669
+ else {
670
+ const formatResult = await select({
671
+ message: "Which formatter would you like to use?",
672
+ options: [
673
+ {
674
+ value: "oxfmt",
675
+ label: "oxfmt"
676
+ },
677
+ {
678
+ value: "prettier",
679
+ label: "Prettier"
680
+ },
681
+ {
682
+ value: "none",
683
+ label: "None"
684
+ }
685
+ ],
686
+ initialValue: "oxfmt"
687
+ });
688
+ if (isCancel(formatResult)) {
689
+ cancel("Operation cancelled.");
690
+ process.exit(0);
691
+ }
692
+ formatter = formatResult;
693
+ }
694
+ let wantsI18n;
695
+ if (nonInteractive) wantsI18n = cliI18n ?? false;
696
+ else {
697
+ const i18nResult = await confirm({
698
+ message: "Would you like to add i18n support? (@wolfstar/http-framework-i18n)",
699
+ initialValue: false
700
+ });
701
+ if (isCancel(i18nResult)) {
702
+ cancel("Operation cancelled.");
703
+ process.exit(0);
704
+ }
705
+ wantsI18n = Boolean(i18nResult);
706
+ }
707
+ let wantsInstall;
708
+ if (nonInteractive) wantsInstall = cliInstall;
709
+ else {
710
+ const installResult = await confirm({
711
+ message: `Would you like to automatically install dependencies with ${packageManager}?`,
712
+ initialValue: true
713
+ });
714
+ if (isCancel(installResult)) {
715
+ cancel("Operation cancelled.");
716
+ process.exit(0);
717
+ }
718
+ wantsInstall = Boolean(installResult);
140
719
  }
141
720
  const s = spinner();
142
721
  s.start("Fetching latest dependency versions...");
143
- const versions = fetchDependencyVersions();
722
+ const versions = await fetchDependencyVersions({
723
+ i18n: wantsI18n,
724
+ language,
725
+ buildTool,
726
+ linter,
727
+ formatter
728
+ });
144
729
  s.stop("Versions fetched.");
145
730
  s.start("Generating project files...");
146
- const outputDir = join(process.cwd(), projectName);
147
- processTemplate(outputDir, {
731
+ processTemplate(targetDir, {
732
+ name: projectName,
733
+ port,
734
+ language
735
+ });
736
+ writeProjectFiles(targetDir, {
148
737
  name: projectName,
149
- port: Number(portInput),
150
- i18n: Boolean(wantsI18n),
738
+ port,
739
+ i18n: wantsI18n,
151
740
  packageManager,
152
- todaysDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
741
+ language,
742
+ buildTool,
743
+ linter,
744
+ formatter,
153
745
  versions
154
746
  });
155
747
  s.stop("Project files generated.");
748
+ if (formatter !== "none") try {
749
+ s.start("Formatting project files...");
750
+ if (formatter === "oxfmt") runCommand("npx", [
751
+ "--yes",
752
+ "oxfmt",
753
+ "--write",
754
+ "."
755
+ ], targetDir);
756
+ else runCommand("npx", [
757
+ "--yes",
758
+ "prettier",
759
+ "--write",
760
+ ".",
761
+ "--log-level",
762
+ "warn"
763
+ ], targetDir);
764
+ s.stop("Project files formatted.");
765
+ } catch {
766
+ s.stop("Skipped formatting (formatter not available yet — it will run after install).");
767
+ }
156
768
  if (wantsInstall) {
157
769
  s.start(`Installing dependencies with ${packageManager}...`);
158
- await detectPackageManager(outputDir);
770
+ await installDependencies(packageManager, targetDir);
159
771
  s.stop("Dependencies installed.");
160
772
  }
161
- outro(`Done! To get started:\n\n cd ${projectName}\n${wantsInstall ? "" : ` ${packageManager} install\n`} ${packageManager} run dev`);
773
+ outro(`Done! To get started:\n\n cd ${projectName}\n${wantsInstall ? "" : ` ${getInstallScript(packageManager)}\n`} ${getRunScript(packageManager, "dev")}`);
162
774
  }
163
775
  main().catch((error) => {
164
776
  cancel(error instanceof Error ? error.message : String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolfstar/create-http-framework",
3
- "version": "1.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Create a new WolfStar HTTP Framework bot project",
5
5
  "keywords": [
6
6
  "cli",
@@ -33,12 +33,17 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@clack/prompts": "^1.6.0",
36
+ "@clack/prompts": "^1.7.0",
37
+ "@vercel/detect-agent": "^1.2.3",
38
+ "cross-spawn": "^7.0.6",
37
39
  "handlebars": "^4.7.9",
38
- "package-manager-detector": "^1.6.0"
40
+ "mri": "^1.2.0",
41
+ "package-manager-detector": "^1.7.0"
39
42
  },
40
43
  "devDependencies": {
44
+ "@types/cross-spawn": "^6.0.6",
41
45
  "@types/handlebars": "^4.1.0",
46
+ "@types/mri": "^1.2.0",
42
47
  "@types/node": "22.15.21",
43
48
  "tsdown": "^0.22.3",
44
49
  "typescript": "~5.8.3"
package/template/.env.hbs CHANGED
@@ -1 +1,4 @@
1
- DISCORD_TOKEN=your_bot_token_here DISCORD_PUBLIC_KEY=your_public_key_here DISCORD_CLIENT_ID=your_client_id_here HTTP_POST_PATH=/
1
+ DISCORD_TOKEN=your_bot_token_here
2
+ DISCORD_PUBLIC_KEY=your_public_key_here
3
+ DISCORD_CLIENT_ID=your_client_id_here
4
+ HTTP_POST_PATH=/
@@ -1 +1,4 @@
1
- node_modules/ dist/ .env *.js.map
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.js.map
@@ -0,0 +1,12 @@
1
+ import { Command } from '@wolfstar/http-framework';
2
+ import { MessageFlags } from 'discord-api-types/v10';
3
+
4
+ export class PingCommand extends Command {
5
+ chatInputRun(interaction) {
6
+ return interaction.reply({ content: 'Pong!', flags: MessageFlags.Ephemeral });
7
+ }
8
+
9
+ static builder = (builder) => builder
10
+ .setName('ping')
11
+ .setDescription('Replies with pong!')
12
+ }
@@ -1,5 +1,13 @@
1
- import { Command } from '@wolfstar/http-framework'; import { SlashCommandBuilder } from '@discordjs/builders'; import { MessageFlags } from
2
- 'discord-api-types/v10'; export class PingCommand extends Command { public constructor(context: Command.LoaderContext) { super(context, { name:
3
- 'ping', description: 'Replies with pong!' }); } public override chatInputRun(interaction: Command.ChatInputInteraction) { return interaction.reply({
4
- content: 'Pong!', flags: MessageFlags.Ephemeral }); } public static readonly builder = new SlashCommandBuilder() .setName('ping')
5
- .setDescription('Replies with pong!'); }
1
+ import { Command, RegisterCommand } from '@wolfstar/http-framework';
2
+ import { MessageFlags } from 'discord-api-types/v10';
3
+
4
+ @RegisterCommand((builder) =>
5
+ builder //
6
+ .setName('ping')
7
+ .setDescription('Replies with pong!')
8
+ )
9
+ export class PingCommand extends Command {
10
+ public override chatInputRun(interaction: Command.ChatInputInteraction) {
11
+ return interaction.reply({ content: 'Pong!', flags: MessageFlags.Ephemeral });
12
+ }
13
+ }
@@ -0,0 +1,10 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Client } from '@wolfstar/http-framework';
4
+
5
+ const client = new Client();
6
+
7
+ await client.load({ baseUserDirectory: join(dirname(fileURLToPath(import.meta.url)), 'commands') });
8
+ await client.listen({ port: {{port}} });
9
+
10
+ console.log('Bot is running on port {{port}}');
@@ -1,5 +1,10 @@
1
- import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Client } from '@wolfstar/http-framework'; const client =
2
- new Client(); await client.load({ baseUserDirectory: join(dirname(fileURLToPath(import.meta.url)), 'commands') }); await client.listen({ port:
3
- {{port}}
4
- }); console.log('Bot is running on port
5
- {{port}}');
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { Client } from '@wolfstar/http-framework';
4
+
5
+ const client = new Client();
6
+
7
+ await client.load({ baseUserDirectory: join(dirname(fileURLToPath(import.meta.url)), 'commands') });
8
+ await client.listen({ port: {{port}} });
9
+
10
+ console.log('Bot is running on port {{port}}');
@@ -1,7 +0,0 @@
1
- { "name": "{{name}}", "version": "1.0.0", "description": "A Discord HTTP bot built with `@wolfstar/http-framework`", "author": "`@wolfstar`",
2
- "repository": { "url": "https://github.com/yourusername/your-repo.git" }, "bugs": { "url": "https://github.com/yourusername/your-repo/issues" },
3
- "homepage": "https://github.com/yourusername/your-repo#readme", "keywords": ["discord", "bot", "http", "wolfstar"], "type": "module", "scripts": {
4
- "build": "tsc", "dev": "node --watch --loader ts-node/esm src/index.ts", "start": "node dist/index.js" }, "dependencies": { "@discordjs/builders": "^{{versions.discordJsBuilders}}",
5
- "@wolfstar/http-framework": "^{{versions.httpFramework}}"{{#if i18n}}, "@wolfstar/http-framework-i18n": "^{{versions.httpFrameworkI18n}}"{{/if}},
6
- "discord-api-types": "^{{versions.discordApiTypes}}" }, "devDependencies": { "@types/node": "^{{versions.typesNode}}", "ts-node": "^{{versions.tsNode}}",
7
- "typescript": "^{{versions.typescript}}" }, "engines": { "node": ">=20" } }
@@ -1,3 +0,0 @@
1
- { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true,
2
- "esModuleInterop": true, "skipLibCheck": true, "declaration": true, "declarationMap": true, "sourceMap": true, "experimentalDecorators": true,
3
- "emitDecoratorMetadata": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] }