frontpl 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kingsword
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,11 +1,89 @@
1
1
  # frontpl
2
2
 
3
- Interactive CLI to scaffold standardized frontend project templates.
3
+ Interactive CLI to scaffold standardized frontend project templates (with optional CI/Release workflows).
4
+
5
+ > Node.js >= 22
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ # If published on npm:
11
+ npm i -g frontpl
12
+ # or
13
+ pnpm add -g frontpl
14
+
15
+ # Or run once via npx:
16
+ npx frontpl --help
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```sh
22
+ frontpl my-frontend
23
+ # or
24
+ frontpl init my-frontend
25
+ ```
26
+
27
+ Follow the prompts to choose:
28
+
29
+ - Package manager (`npm`/`pnpm`/`yarn`/`bun`/`deno`)
30
+ - Optional tooling: `oxlint`, `oxfmt`, `vitest`, `tsdown`
31
+ - Git init
32
+ - GitHub Actions workflows:
33
+ - CI only
34
+ - CI + release (release supports tag/commit/both)
35
+
36
+ ## Commands
37
+
38
+ ### `frontpl [name]` / `frontpl init [name]`
39
+
40
+ Scaffold a new project into `./<name>` (or prompt for a name when omitted).
41
+
42
+ Generated output includes (based on options):
43
+
44
+ - `.editorconfig`, `.gitignore`, `.gitattributes`
45
+ - `package.json` (+ scripts like `typecheck`, optional `lint`, `format:check`, `test`, `build`)
46
+ - `tsconfig.json`, `src/index.ts`
47
+ - Optional configs: `.oxlintrc.json`, `.oxfmtrc.json`, `tsdown.config.ts`
48
+ - Optional GitHub Actions workflows in `.github/workflows/`
49
+
50
+ ### `frontpl ci`
51
+
52
+ Add or update CI/Release workflows for an existing project (run it in your repo root).
53
+
54
+ What it does:
55
+
56
+ - Detects the package manager via `package.json#packageManager` or lockfiles
57
+ - Suggests a `workingDirectory` (supports monorepo layouts like `packages/*` / `apps/*`)
58
+ - Detects Node.js major version from `.nvmrc`, `.node-version`, or `package.json#engines.node` (defaults to `22`)
59
+ - Generates `.github/workflows/ci.yml`
60
+ - Optionally generates `.github/workflows/release.yml` (tag/commit/both)
61
+
62
+ ## GitHub Actions (CI + Release)
63
+
64
+ frontpl generates workflows that call reusable workflows from `kingsword09/workflows` (pinned to `@v1` by default):
65
+
66
+ - CI: `cli-ci.yml`
67
+ - Release (tag, recommended): `cli-release-tag.yml`
68
+ - Release (commit, legacy): `cli-release.yml`
69
+
70
+ ### Release modes
71
+
72
+ - **Tag (recommended)**: trigger on tag push (`vX.Y.Z`), validate `package.json#version` matches the tag.
73
+ - **Commit (legacy)**: trigger on `main` push, publish only when the commit message matches `chore(release): vX.Y.Z` (also supports `chore: release vX.Y.Z`), and the workflow will create/push the tag.
74
+ - **Both**: a single `release.yml` listens to both `main` and `tags`, and routes to the corresponding reusable workflow.
75
+
76
+ ### Publishing auth
77
+
78
+ - **Trusted publishing (OIDC)**: enable `trustedPublishing: true` (no `NPM_TOKEN` required). Your repo must be configured on npm as a trusted publisher for the calling workflow.
79
+ - **NPM token**: set `trustedPublishing: false` and provide `NPM_TOKEN` in GitHub secrets.
4
80
 
5
81
  ## Development
6
82
 
7
83
  ```sh
8
84
  pnpm install
85
+ pnpm run typecheck
9
86
  pnpm run build
10
87
  node dist/cli.mjs --help
88
+ node dist/cli.mjs ci
11
89
  ```
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as runInit } from "./init-Cva-s-yN.mjs";
2
+ import { n as runCi, t as runInit } from "./init-DXlH6jJs.mjs";
3
3
  import bin from "tiny-bin";
4
4
 
5
5
  //#region src/cli.ts
@@ -8,6 +8,8 @@ async function main() {
8
8
  await runInit({ nameArg: args[0] });
9
9
  }).command("init", "Scaffold a new project").argument("[name]", "Project name (directory name)").action(async (_options, args) => {
10
10
  await runInit({ nameArg: args[0] });
11
+ }).command("ci", "Add CI/release workflows to an existing project").action(async () => {
12
+ await runCi();
11
13
  }).run();
12
14
  }
13
15
  main();
package/dist/index.d.mts CHANGED
@@ -1,3 +1,6 @@
1
+ //#region src/commands/ci.d.ts
2
+ declare function runCi(): Promise<undefined>;
3
+ //#endregion
1
4
  //#region src/commands/init.d.ts
2
5
  declare function runInit({
3
6
  nameArg
@@ -5,4 +8,4 @@ declare function runInit({
5
8
  nameArg?: string;
6
9
  }): Promise<void>;
7
10
  //#endregion
8
- export { runInit };
11
+ export { runCi, runInit };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as runInit } from "./init-Cva-s-yN.mjs";
1
+ import { n as runCi, t as runInit } from "./init-DXlH6jJs.mjs";
2
2
 
3
- export { runInit };
3
+ export { runCi, runInit };
@@ -0,0 +1,846 @@
1
+ import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
2
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { spawn } from "node:child_process";
6
+ import os from "node:os";
7
+
8
+ //#region src/lib/fs.ts
9
+ async function writeText(filePath, contents) {
10
+ await mkdir(path.dirname(filePath), { recursive: true });
11
+ await writeFile(filePath, contents, "utf8");
12
+ }
13
+
14
+ //#endregion
15
+ //#region src/lib/templates.ts
16
+ function editorconfigTemplate() {
17
+ return [
18
+ "root = true",
19
+ "",
20
+ "[*]",
21
+ "charset = utf-8",
22
+ "end_of_line = lf",
23
+ "indent_style = space",
24
+ "indent_size = 2",
25
+ "insert_final_newline = true",
26
+ "trim_trailing_whitespace = true",
27
+ "",
28
+ "[*.md]",
29
+ "trim_trailing_whitespace = false",
30
+ ""
31
+ ].join("\n");
32
+ }
33
+ function gitignoreTemplate() {
34
+ return [
35
+ "node_modules",
36
+ "dist",
37
+ "coverage",
38
+ "*.log",
39
+ ".DS_Store",
40
+ ".env",
41
+ ".env.*",
42
+ ""
43
+ ].join("\n");
44
+ }
45
+ function gitattributesTemplate() {
46
+ return ["* text=auto eol=lf", ""].join("\n");
47
+ }
48
+ function tsconfigTemplate() {
49
+ return JSON.stringify({
50
+ compilerOptions: {
51
+ target: "ES2022",
52
+ module: "ESNext",
53
+ moduleResolution: "Bundler",
54
+ strict: true,
55
+ skipLibCheck: true,
56
+ noEmit: true
57
+ },
58
+ include: ["src"]
59
+ }, null, 2) + "\n";
60
+ }
61
+ function srcIndexTemplate() {
62
+ return [
63
+ "export function hello(name: string) {",
64
+ " return `Hello, ${name}`;",
65
+ "}",
66
+ ""
67
+ ].join("\n");
68
+ }
69
+ function srcVitestTemplate() {
70
+ return [
71
+ "import { describe, expect, it } from \"vitest\";",
72
+ "import { hello } from \"./index.js\";",
73
+ "",
74
+ "describe(\"hello\", () => {",
75
+ " it(\"greets\", () => {",
76
+ " expect(hello(\"world\")).toBe(\"Hello, world\");",
77
+ " });",
78
+ "});",
79
+ ""
80
+ ].join("\n");
81
+ }
82
+ function readmeTemplate(projectName) {
83
+ return [
84
+ `# ${projectName}`,
85
+ "",
86
+ "Generated by `frontpl`.",
87
+ ""
88
+ ].join("\n");
89
+ }
90
+ function oxlintConfigTemplate({ useVitest }) {
91
+ return JSON.stringify({
92
+ $schema: "https://json.schemastore.org/oxlintrc.json",
93
+ env: {
94
+ browser: true,
95
+ es2022: true
96
+ }
97
+ }, null, 2) + "\n";
98
+ }
99
+ function oxfmtConfigTemplate() {
100
+ return JSON.stringify({ $schema: "https://json.schemastore.org/oxfmtrc.json" }, null, 2) + "\n";
101
+ }
102
+ function tsdownConfigTemplate() {
103
+ return [
104
+ "import { defineConfig } from \"tsdown\";",
105
+ "",
106
+ "export default defineConfig({",
107
+ " entry: [\"src/index.ts\"],",
108
+ " platform: \"browser\"",
109
+ "});",
110
+ ""
111
+ ].join("\n");
112
+ }
113
+ function packageJsonTemplate(opts) {
114
+ const scripts = { typecheck: "tsc --noEmit" };
115
+ if (opts.useOxlint) {
116
+ const oxlintCmd = [
117
+ "oxlint",
118
+ opts.useVitest ? "--vitest-plugin" : void 0,
119
+ "--type-aware",
120
+ "--type-check"
121
+ ].filter(Boolean).join(" ");
122
+ scripts.lint = oxlintCmd;
123
+ scripts["lint:fix"] = `${oxlintCmd} --fix`;
124
+ }
125
+ if (opts.useOxfmt) {
126
+ scripts.format = "oxfmt";
127
+ scripts["format:check"] = "oxfmt --check";
128
+ scripts.fmt = "oxfmt";
129
+ scripts["fmt:check"] = "oxfmt --check";
130
+ }
131
+ if (opts.useVitest) scripts.test = "vitest";
132
+ if (opts.useTsdown) scripts.build = "tsdown";
133
+ const devDependencies = { typescript: opts.typescriptVersion };
134
+ if (opts.useOxlint) {
135
+ if (opts.oxlintVersion) devDependencies.oxlint = opts.oxlintVersion;
136
+ if (opts.oxlintTsgolintVersion) devDependencies["oxlint-tsgolint"] = opts.oxlintTsgolintVersion;
137
+ }
138
+ if (opts.useOxfmt && opts.oxfmtVersion) devDependencies.oxfmt = opts.oxfmtVersion;
139
+ if (opts.useVitest && opts.vitestVersion) devDependencies.vitest = opts.vitestVersion;
140
+ if (opts.useTsdown && opts.tsdownVersion) devDependencies.tsdown = opts.tsdownVersion;
141
+ return JSON.stringify({
142
+ name: opts.name,
143
+ version: "0.0.0",
144
+ private: true,
145
+ type: "module",
146
+ scripts,
147
+ devDependencies,
148
+ packageManager: opts.packageManager
149
+ }, null, 2) + "\n";
150
+ }
151
+ function githubCliCiWorkflowTemplate(opts) {
152
+ const ref = opts.workflowsRef ?? "v1";
153
+ const installCommand = opts.installCommand?.trim();
154
+ const lintCommand = opts.lintCommand?.trim();
155
+ const formatCheckCommand = opts.formatCheckCommand?.trim();
156
+ const testCommand = opts.testCommand?.trim();
157
+ return [
158
+ "name: CI",
159
+ "",
160
+ "on:",
161
+ " push:",
162
+ " branches: [main]",
163
+ " pull_request:",
164
+ " branches: [main]",
165
+ "",
166
+ "jobs:",
167
+ " ci:",
168
+ ` uses: kingsword09/workflows/.github/workflows/cli-ci.yml@${ref}`,
169
+ " with:",
170
+ ` packageManager: ${opts.packageManager}`,
171
+ ` nodeVersion: ${opts.nodeVersion}`,
172
+ ` workingDirectory: ${opts.workingDirectory}`,
173
+ ` runLint: ${opts.runLint}`,
174
+ ` runFormatCheck: ${opts.runFormatCheck}`,
175
+ ` runTests: ${opts.runTests}`,
176
+ ...installCommand ? [` installCommand: ${yamlString(installCommand)}`] : [],
177
+ ...lintCommand ? [` lintCommand: ${yamlString(lintCommand)}`] : [],
178
+ ...formatCheckCommand ? [` formatCheckCommand: ${yamlString(formatCheckCommand)}`] : [],
179
+ ...testCommand ? [` testCommand: ${yamlString(testCommand)}`] : [],
180
+ ""
181
+ ].join("\n");
182
+ }
183
+ function githubCliReleaseWorkflowTemplate(opts) {
184
+ const ref = opts.workflowsRef ?? "v1";
185
+ const trustedPublishing = opts.trustedPublishing;
186
+ const needsNpmToken = opts.packageManager !== "deno" && trustedPublishing === false;
187
+ return [
188
+ "name: Release",
189
+ "",
190
+ "on:",
191
+ " push:",
192
+ " branches: [main]",
193
+ "",
194
+ "jobs:",
195
+ " release:",
196
+ " permissions:",
197
+ " contents: write",
198
+ " id-token: write",
199
+ ` uses: kingsword09/workflows/.github/workflows/cli-release.yml@${ref}`,
200
+ " with:",
201
+ ` packageManager: ${opts.packageManager}`,
202
+ ` nodeVersion: ${opts.nodeVersion}`,
203
+ ` workingDirectory: ${opts.workingDirectory}`,
204
+ ...trustedPublishing === void 0 ? [] : [` trustedPublishing: ${trustedPublishing}`],
205
+ ...needsNpmToken ? [" secrets:", " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"] : [],
206
+ ""
207
+ ].join("\n");
208
+ }
209
+ function githubCliReleaseTagWorkflowTemplate(opts) {
210
+ const ref = opts.workflowsRef ?? "v1";
211
+ const trustedPublishing = opts.trustedPublishing;
212
+ const needsNpmToken = opts.packageManager !== "deno" && trustedPublishing === false;
213
+ return [
214
+ "name: Release",
215
+ "",
216
+ "on:",
217
+ " push:",
218
+ " tags: [v*.*.*]",
219
+ "",
220
+ "jobs:",
221
+ " release:",
222
+ " permissions:",
223
+ " contents: write",
224
+ " id-token: write",
225
+ ` uses: kingsword09/workflows/.github/workflows/cli-release-tag.yml@${ref}`,
226
+ " with:",
227
+ ` packageManager: ${opts.packageManager}`,
228
+ ` nodeVersion: ${opts.nodeVersion}`,
229
+ ` workingDirectory: ${opts.workingDirectory}`,
230
+ ...trustedPublishing === void 0 ? [] : [` trustedPublishing: ${trustedPublishing}`],
231
+ ...needsNpmToken ? [" secrets:", " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"] : [],
232
+ ""
233
+ ].join("\n");
234
+ }
235
+ function githubCliReleaseBothWorkflowTemplate(opts) {
236
+ const ref = opts.workflowsRef ?? "v1";
237
+ const trustedPublishing = opts.trustedPublishing;
238
+ const needsNpmToken = opts.packageManager !== "deno" && trustedPublishing === false;
239
+ return [
240
+ "name: Release",
241
+ "",
242
+ "on:",
243
+ " push:",
244
+ " branches: [main]",
245
+ " tags: [v*.*.*]",
246
+ "",
247
+ "jobs:",
248
+ " release_tag:",
249
+ " if: startsWith(github.ref, 'refs/tags/')",
250
+ " permissions:",
251
+ " contents: write",
252
+ " id-token: write",
253
+ ` uses: kingsword09/workflows/.github/workflows/cli-release-tag.yml@${ref}`,
254
+ " with:",
255
+ ` packageManager: ${opts.packageManager}`,
256
+ ` nodeVersion: ${opts.nodeVersion}`,
257
+ ` workingDirectory: ${opts.workingDirectory}`,
258
+ ...trustedPublishing === void 0 ? [] : [` trustedPublishing: ${trustedPublishing}`],
259
+ ...needsNpmToken ? [" secrets:", " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"] : [],
260
+ "",
261
+ " release_commit:",
262
+ " if: startsWith(github.ref, 'refs/heads/')",
263
+ " permissions:",
264
+ " contents: write",
265
+ " id-token: write",
266
+ ` uses: kingsword09/workflows/.github/workflows/cli-release.yml@${ref}`,
267
+ " with:",
268
+ ` packageManager: ${opts.packageManager}`,
269
+ ` nodeVersion: ${opts.nodeVersion}`,
270
+ ` workingDirectory: ${opts.workingDirectory}`,
271
+ ...trustedPublishing === void 0 ? [] : [` trustedPublishing: ${trustedPublishing}`],
272
+ ...needsNpmToken ? [" secrets:", " NPM_TOKEN: ${{ secrets.NPM_TOKEN }}"] : [],
273
+ ""
274
+ ].join("\n");
275
+ }
276
+ function yamlString(value) {
277
+ return JSON.stringify(value);
278
+ }
279
+
280
+ //#endregion
281
+ //#region src/lib/utils.ts
282
+ async function pathExists(pathname) {
283
+ try {
284
+ await access(pathname);
285
+ return true;
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ //#endregion
292
+ //#region src/commands/ci.ts
293
+ async function runCi() {
294
+ try {
295
+ intro("frontpl (ci)");
296
+ const rootDir = process.cwd();
297
+ const detectedPackageManager = await detectPackageManager(rootDir);
298
+ const packageManager = await select({
299
+ message: detectedPackageManager ? `Package manager (detected: ${detectedPackageManager})` : "Package manager",
300
+ initialValue: detectedPackageManager ?? "pnpm",
301
+ options: [
302
+ {
303
+ value: "npm",
304
+ label: "npm"
305
+ },
306
+ {
307
+ value: "yarn",
308
+ label: "yarn"
309
+ },
310
+ {
311
+ value: "pnpm",
312
+ label: "pnpm"
313
+ },
314
+ {
315
+ value: "bun",
316
+ label: "bun"
317
+ },
318
+ {
319
+ value: "deno",
320
+ label: "deno"
321
+ }
322
+ ]
323
+ });
324
+ if (isCancel(packageManager)) return abort();
325
+ const candidates = await listPackageCandidates(rootDir, packageManager);
326
+ if (candidates.length === 0) {
327
+ cancel("No package found. Run this command in a project root (with package.json or deno.json).");
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ const initialWorkingDirectory = await detectWorkingDirectory(rootDir, candidates);
332
+ const workingDirectory = candidates.length === 1 ? candidates[0] : await select({
333
+ message: "Working directory (package folder)",
334
+ initialValue: initialWorkingDirectory,
335
+ options: candidates.map((c) => ({
336
+ value: c,
337
+ label: c
338
+ }))
339
+ });
340
+ if (isCancel(workingDirectory)) return abort();
341
+ const nodeVersionDefault = await detectNodeMajorVersion(rootDir) ?? 22;
342
+ const nodeVersionText = await text({
343
+ message: "Node.js major version (for GitHub Actions)",
344
+ initialValue: String(nodeVersionDefault),
345
+ validate: (value) => {
346
+ const major = Number.parseInt(value.trim(), 10);
347
+ if (!Number.isFinite(major) || major <= 0) return "Enter a valid major version (e.g. 22)";
348
+ }
349
+ });
350
+ if (isCancel(nodeVersionText)) return abort();
351
+ const nodeVersion = Number.parseInt(String(nodeVersionText).trim(), 10);
352
+ const { runLint, runFormatCheck, runTests, lintCommand, formatCheckCommand, testCommand } = await resolveCiCommands(rootDir, workingDirectory, packageManager);
353
+ const addRelease = await confirm({
354
+ message: "Add release workflow too?",
355
+ initialValue: true
356
+ });
357
+ if (isCancel(addRelease)) return abort();
358
+ const releaseMode = addRelease ? await select({
359
+ message: "Release workflows",
360
+ initialValue: "tag",
361
+ options: [
362
+ {
363
+ value: "tag",
364
+ label: "Tag push (vX.Y.Z) — recommended"
365
+ },
366
+ {
367
+ value: "commit",
368
+ label: "Release commit (chore(release): vX.Y.Z) — legacy"
369
+ },
370
+ {
371
+ value: "both",
372
+ label: "Both (tag + commit)"
373
+ }
374
+ ]
375
+ }) : void 0;
376
+ if (isCancel(releaseMode)) return abort();
377
+ const trustedPublishing = addRelease && packageManager !== "deno" ? await confirm({
378
+ message: "Release: npm trusted publishing (OIDC)?",
379
+ initialValue: true
380
+ }) : void 0;
381
+ if (isCancel(trustedPublishing)) return abort();
382
+ const ciWorkflowPath = path.join(rootDir, ".github/workflows/ci.yml");
383
+ const releaseWorkflowPath = path.join(rootDir, ".github/workflows/release.yml");
384
+ if (!await confirmOverwriteIfExists(ciWorkflowPath, ".github/workflows/ci.yml")) {
385
+ cancel("Skipped CI workflow");
386
+ process.exitCode = 0;
387
+ return;
388
+ }
389
+ await writeText(ciWorkflowPath, githubCliCiWorkflowTemplate({
390
+ packageManager,
391
+ nodeVersion,
392
+ workingDirectory,
393
+ runLint,
394
+ runFormatCheck,
395
+ runTests,
396
+ lintCommand,
397
+ formatCheckCommand,
398
+ testCommand
399
+ }));
400
+ if (addRelease) {
401
+ if (await confirmOverwriteIfExists(releaseWorkflowPath, ".github/workflows/release.yml")) await writeText(releaseWorkflowPath, (releaseMode === "both" ? githubCliReleaseBothWorkflowTemplate : releaseMode === "commit" ? githubCliReleaseWorkflowTemplate : githubCliReleaseTagWorkflowTemplate)({
402
+ packageManager,
403
+ nodeVersion,
404
+ workingDirectory,
405
+ trustedPublishing
406
+ }));
407
+ }
408
+ outro(addRelease ? "Done. Generated CI + release workflows in .github/workflows/." : "Done. Generated CI workflow in .github/workflows/.");
409
+ } catch (err) {
410
+ if (err instanceof CancelledError) return;
411
+ throw err;
412
+ }
413
+ }
414
+ var CancelledError = class extends Error {
415
+ constructor() {
416
+ super("Cancelled");
417
+ }
418
+ };
419
+ function abort(opts = {}) {
420
+ cancel(opts.message ?? "Cancelled");
421
+ process.exitCode = opts.exitCode ?? 0;
422
+ throw new CancelledError();
423
+ }
424
+ async function confirmOverwriteIfExists(absPath, label) {
425
+ if (!await pathExists(absPath)) return true;
426
+ const overwrite = await confirm({
427
+ message: `Overwrite existing ${label}?`,
428
+ initialValue: true
429
+ });
430
+ if (isCancel(overwrite)) return abort();
431
+ return overwrite;
432
+ }
433
+ function isPackageManager(value) {
434
+ return value === "npm" || value === "pnpm" || value === "yarn" || value === "bun" || value === "deno";
435
+ }
436
+ async function detectPackageManager(rootDir) {
437
+ const pmField = (await readPackageJson(path.join(rootDir, "package.json")))?.packageManager;
438
+ if (pmField) {
439
+ const pm = pmField.split("@")[0] ?? "";
440
+ if (isPackageManager(pm)) return pm;
441
+ }
442
+ const candidates = [];
443
+ if (await pathExists(path.join(rootDir, "pnpm-lock.yaml"))) candidates.push("pnpm");
444
+ if (await pathExists(path.join(rootDir, "yarn.lock"))) candidates.push("yarn");
445
+ if (await pathExists(path.join(rootDir, "package-lock.json"))) candidates.push("npm");
446
+ if (await pathExists(path.join(rootDir, "bun.lockb"))) candidates.push("bun");
447
+ if (await pathExists(path.join(rootDir, "deno.json")) || await pathExists(path.join(rootDir, "deno.jsonc"))) candidates.push("deno");
448
+ return candidates.length === 1 ? candidates[0] : void 0;
449
+ }
450
+ async function listPackageCandidates(rootDir, packageManager) {
451
+ const candidates = /* @__PURE__ */ new Set();
452
+ if (await pathExists(path.join(rootDir, "package.json"))) candidates.add(".");
453
+ if (packageManager === "deno" && (await pathExists(path.join(rootDir, "deno.json")) || await pathExists(path.join(rootDir, "deno.jsonc")))) candidates.add(".");
454
+ for (const base of ["packages", "apps"]) {
455
+ const baseDir = path.join(rootDir, base);
456
+ if (!await pathExists(baseDir)) continue;
457
+ const entries = await readdir(baseDir, { withFileTypes: true });
458
+ for (const entry of entries) {
459
+ if (!entry.isDirectory()) continue;
460
+ if (await pathExists(path.join(baseDir, entry.name, "package.json"))) candidates.add(path.posix.join(base, entry.name));
461
+ }
462
+ }
463
+ return [...candidates];
464
+ }
465
+ async function detectWorkingDirectory(rootDir, candidates) {
466
+ if (candidates.length === 1) return candidates[0];
467
+ const rootScripts = (await readPackageJson(path.join(rootDir, "package.json")))?.scripts ?? {};
468
+ const rootHasScripts = Object.keys(rootScripts).length > 0;
469
+ const nonRoot = candidates.filter((c) => c !== ".");
470
+ if (!rootHasScripts && nonRoot.length === 1) return nonRoot[0];
471
+ return ".";
472
+ }
473
+ async function detectNodeMajorVersion(rootDir) {
474
+ for (const file of [".nvmrc", ".node-version"]) {
475
+ const filePath = path.join(rootDir, file);
476
+ if (!await pathExists(filePath)) continue;
477
+ const major = parseMajorVersion((await readFile(filePath, "utf8")).split("\n")[0]?.trim() ?? "");
478
+ if (major) return major;
479
+ }
480
+ const engine = (await readPackageJson(path.join(rootDir, "package.json")))?.engines?.node;
481
+ if (!engine) return;
482
+ const match = engine.match(/([0-9]{2,})/);
483
+ if (!match) return;
484
+ return Number.parseInt(match[1], 10);
485
+ }
486
+ function parseMajorVersion(input) {
487
+ const trimmed = input.trim().replace(/^v/, "");
488
+ const major = Number.parseInt(trimmed.split(".")[0] ?? "", 10);
489
+ if (!Number.isFinite(major) || major <= 0) return;
490
+ return major;
491
+ }
492
+ async function resolveCiCommands(rootDir, workingDirectory, packageManager) {
493
+ if (packageManager === "deno") return {
494
+ runLint: true,
495
+ runFormatCheck: true,
496
+ runTests: true
497
+ };
498
+ const pkg = await readPackageJson(path.join(rootDir, workingDirectory, "package.json"));
499
+ if (!pkg) return abort({
500
+ message: `Missing package.json in ${workingDirectory}`,
501
+ exitCode: 1
502
+ });
503
+ const scripts = pkg.scripts ?? {};
504
+ const hasLint = typeof scripts.lint === "string";
505
+ const hasTest = typeof scripts.test === "string";
506
+ const hasFormatCheck = typeof scripts["format:check"] === "string";
507
+ const hasFmtCheck = typeof scripts["fmt:check"] === "string";
508
+ const runLintDefault = hasLint;
509
+ const runFormatCheckDefault = hasFormatCheck || hasFmtCheck;
510
+ const runTestsDefault = hasTest;
511
+ const runLint = await confirm({
512
+ message: `CI: run lint${hasLint ? "" : " (no lint script detected)"}`,
513
+ initialValue: runLintDefault
514
+ });
515
+ if (isCancel(runLint)) return abort();
516
+ const runFormatCheck = await confirm({
517
+ message: `CI: run format check${runFormatCheckDefault ? "" : " (no format check script detected)"}`,
518
+ initialValue: runFormatCheckDefault
519
+ });
520
+ if (isCancel(runFormatCheck)) return abort();
521
+ const runTests = await confirm({
522
+ message: `CI: run tests${hasTest ? "" : " (no test script detected)"}`,
523
+ initialValue: runTestsDefault
524
+ });
525
+ if (isCancel(runTests)) return abort();
526
+ return {
527
+ runLint,
528
+ runFormatCheck,
529
+ runTests,
530
+ lintCommand: runLint && !hasLint ? await promptCommand("Lint command", pmRun(packageManager, "lint")) : void 0,
531
+ formatCheckCommand: runFormatCheck && !hasFormatCheck ? hasFmtCheck ? pmRun(packageManager, "fmt:check") : await promptCommand("Format check command", pmRun(packageManager, "format:check")) : void 0,
532
+ testCommand: runTests && !hasTest ? await promptCommand("Test command", pmRun(packageManager, "test")) : void 0
533
+ };
534
+ }
535
+ async function promptCommand(message, initialValue) {
536
+ const value = await text({
537
+ message,
538
+ initialValue,
539
+ validate: (v) => !v.trim() ? "Command is required" : void 0
540
+ });
541
+ if (isCancel(value)) return abort();
542
+ return String(value).trim();
543
+ }
544
+ function pmRun(pm, script) {
545
+ switch (pm) {
546
+ case "npm": return `npm run ${script}`;
547
+ case "pnpm": return `pnpm run ${script}`;
548
+ case "yarn": return `yarn ${script}`;
549
+ case "bun": return `bun run ${script}`;
550
+ case "deno": return script;
551
+ }
552
+ }
553
+ async function readPackageJson(filePath) {
554
+ try {
555
+ return JSON.parse(await readFile(filePath, "utf8"));
556
+ } catch {
557
+ return;
558
+ }
559
+ }
560
+
561
+ //#endregion
562
+ //#region src/lib/exec.ts
563
+ async function exec(command, args, opts = {}) {
564
+ const resolved = resolveCommand$1(command);
565
+ return new Promise((resolve) => {
566
+ const child = spawn(resolved, args, {
567
+ cwd: opts.cwd,
568
+ stdio: "inherit",
569
+ shell: false,
570
+ env: process.env
571
+ });
572
+ child.on("close", (code) => resolve({ ok: code === 0 }));
573
+ child.on("error", () => resolve({ ok: false }));
574
+ });
575
+ }
576
+ function resolveCommand$1(command) {
577
+ if (process.platform !== "win32") return command;
578
+ if (command === "npm") return "npm.cmd";
579
+ if (command === "pnpm") return "pnpm.cmd";
580
+ if (command === "yarn") return "yarn.cmd";
581
+ return command;
582
+ }
583
+
584
+ //#endregion
585
+ //#region src/lib/versions.ts
586
+ async function detectPackageManagerVersion(pm) {
587
+ switch (pm) {
588
+ case "npm": return (await execCapture("npm", ["--version"])).stdout.trim() || void 0;
589
+ case "pnpm": return (await execCapture("pnpm", ["--version"])).stdout.trim() || void 0;
590
+ case "yarn": return (await execCapture("yarn", ["--version"])).stdout.trim() || void 0;
591
+ case "bun": return (await execCapture("bun", ["--version"])).stdout.trim() || void 0;
592
+ case "deno": return ((await execCapture("deno", ["--version"])).stdout.trim().split("\n")[0] ?? "").match(/deno\\s+([0-9]+\\.[0-9]+\\.[0-9]+)/)?.[1];
593
+ }
594
+ }
595
+ async function execCapture(command, args) {
596
+ const resolved = resolveCommand(command);
597
+ return new Promise((resolve) => {
598
+ const child = spawn(resolved, args, {
599
+ cwd: os.tmpdir(),
600
+ stdio: [
601
+ "ignore",
602
+ "pipe",
603
+ "ignore"
604
+ ],
605
+ shell: false,
606
+ env: process.env
607
+ });
608
+ const chunks = [];
609
+ child.stdout.on("data", (d) => chunks.push(Buffer.from(d)));
610
+ child.on("close", (code) => {
611
+ resolve({
612
+ ok: code === 0,
613
+ stdout: Buffer.concat(chunks).toString("utf8")
614
+ });
615
+ });
616
+ child.on("error", () => resolve({
617
+ ok: false,
618
+ stdout: ""
619
+ }));
620
+ });
621
+ }
622
+ function resolveCommand(command) {
623
+ if (process.platform !== "win32") return command;
624
+ if (command === "npm") return "npm.cmd";
625
+ if (command === "pnpm") return "pnpm.cmd";
626
+ if (command === "yarn") return "yarn.cmd";
627
+ return command;
628
+ }
629
+
630
+ //#endregion
631
+ //#region src/commands/init.ts
632
+ async function runInit({ nameArg }) {
633
+ intro("frontpl");
634
+ const projectName = await text({
635
+ message: "Project name",
636
+ initialValue: nameArg ?? "my-frontend",
637
+ validate: validateProjectName
638
+ });
639
+ if (isCancel(projectName)) return onCancel();
640
+ const packageManager = await select({
641
+ message: "Package manager",
642
+ initialValue: "pnpm",
643
+ options: [
644
+ {
645
+ value: "npm",
646
+ label: "npm"
647
+ },
648
+ {
649
+ value: "yarn",
650
+ label: "yarn"
651
+ },
652
+ {
653
+ value: "pnpm",
654
+ label: "pnpm"
655
+ },
656
+ {
657
+ value: "bun",
658
+ label: "bun"
659
+ },
660
+ {
661
+ value: "deno",
662
+ label: "deno"
663
+ }
664
+ ]
665
+ });
666
+ if (isCancel(packageManager)) return onCancel();
667
+ const pnpmWorkspace = packageManager === "pnpm" ? await confirm({
668
+ message: "pnpm workspace mode (monorepo skeleton)?",
669
+ initialValue: false
670
+ }) : false;
671
+ if (isCancel(pnpmWorkspace)) return onCancel();
672
+ const useOxlint = await confirm({
673
+ message: "Enable oxlint (type-aware + type-check via tsgolint)?",
674
+ initialValue: true
675
+ });
676
+ if (isCancel(useOxlint)) return onCancel();
677
+ const useOxfmt = await confirm({
678
+ message: "Enable oxfmt (code formatting)?",
679
+ initialValue: true
680
+ });
681
+ if (isCancel(useOxfmt)) return onCancel();
682
+ const useVitest = await confirm({
683
+ message: "Add Vitest?",
684
+ initialValue: false
685
+ });
686
+ if (isCancel(useVitest)) return onCancel();
687
+ const useTsdown = await confirm({
688
+ message: "Add tsdown build?",
689
+ initialValue: true
690
+ });
691
+ if (isCancel(useTsdown)) return onCancel();
692
+ const initGit = await confirm({
693
+ message: "Initialize a git repository?",
694
+ initialValue: true
695
+ });
696
+ if (isCancel(initGit)) return onCancel();
697
+ const githubActions = await select({
698
+ message: "GitHub Actions workflows",
699
+ initialValue: "ci",
700
+ options: [
701
+ {
702
+ value: "none",
703
+ label: "None"
704
+ },
705
+ {
706
+ value: "ci",
707
+ label: "CI only"
708
+ },
709
+ {
710
+ value: "ci+release",
711
+ label: "CI + release"
712
+ }
713
+ ]
714
+ });
715
+ if (isCancel(githubActions)) return onCancel();
716
+ const releaseMode = githubActions === "ci+release" ? await select({
717
+ message: "Release workflows",
718
+ initialValue: "tag",
719
+ options: [
720
+ {
721
+ value: "tag",
722
+ label: "Tag push (vX.Y.Z) — recommended"
723
+ },
724
+ {
725
+ value: "commit",
726
+ label: "Release commit (chore(release): vX.Y.Z) — legacy"
727
+ },
728
+ {
729
+ value: "both",
730
+ label: "Both (tag + commit)"
731
+ }
732
+ ]
733
+ }) : void 0;
734
+ if (isCancel(releaseMode)) return onCancel();
735
+ const trustedPublishing = githubActions === "ci+release" && packageManager !== "deno" ? await confirm({
736
+ message: "Release: npm trusted publishing (OIDC)?",
737
+ initialValue: true
738
+ }) : void 0;
739
+ if (isCancel(trustedPublishing)) return onCancel();
740
+ const rootDir = path.resolve(process.cwd(), projectName);
741
+ if (await pathExists(rootDir)) {
742
+ cancel(`Directory already exists: ${rootDir}`);
743
+ process.exitCode = 1;
744
+ return;
745
+ }
746
+ const pkgDir = pnpmWorkspace ? path.join(rootDir, "packages", projectName) : rootDir;
747
+ const pmVersion = await detectPackageManagerVersion(packageManager);
748
+ const packageManagerField = pmVersion ? `${packageManager}@${pmVersion}` : `${packageManager}@latest`;
749
+ await mkdir(path.join(pkgDir, "src"), { recursive: true });
750
+ await Promise.all([
751
+ writeText(path.join(rootDir, ".editorconfig"), editorconfigTemplate()),
752
+ writeText(path.join(rootDir, ".gitignore"), gitignoreTemplate()),
753
+ writeText(path.join(rootDir, ".gitattributes"), gitattributesTemplate())
754
+ ]);
755
+ if (pnpmWorkspace) {
756
+ await writeText(path.join(rootDir, "pnpm-workspace.yaml"), [
757
+ "packages:",
758
+ " - \"packages/*\"",
759
+ ""
760
+ ].join("\n"));
761
+ await writeText(path.join(rootDir, "package.json"), JSON.stringify({
762
+ name: projectName,
763
+ private: true,
764
+ packageManager: packageManagerField
765
+ }, null, 2) + "\n");
766
+ }
767
+ await Promise.all([
768
+ writeText(path.join(pkgDir, "README.md"), readmeTemplate(projectName)),
769
+ writeText(path.join(pkgDir, "src/index.ts"), srcIndexTemplate()),
770
+ writeText(path.join(pkgDir, "tsconfig.json"), tsconfigTemplate()),
771
+ writeText(path.join(pkgDir, "package.json"), packageJsonTemplate({
772
+ name: projectName,
773
+ packageManager: packageManagerField,
774
+ typescriptVersion: "latest",
775
+ useOxlint,
776
+ oxlintVersion: "latest",
777
+ oxlintTsgolintVersion: "latest",
778
+ useOxfmt,
779
+ oxfmtVersion: "latest",
780
+ useVitest,
781
+ vitestVersion: "latest",
782
+ useTsdown,
783
+ tsdownVersion: "latest"
784
+ }))
785
+ ]);
786
+ if (useOxlint) await writeText(path.join(pkgDir, ".oxlintrc.json"), oxlintConfigTemplate({ useVitest }));
787
+ if (useOxfmt) await writeText(path.join(pkgDir, ".oxfmtrc.json"), oxfmtConfigTemplate());
788
+ if (useVitest) await writeText(path.join(pkgDir, "src/index.test.ts"), srcVitestTemplate());
789
+ if (useTsdown) await writeText(path.join(pkgDir, "tsdown.config.ts"), tsdownConfigTemplate());
790
+ if (packageManager === "deno") await writeText(path.join(rootDir, "deno.json"), JSON.stringify({ nodeModulesDir: "auto" }, null, 2) + "\n");
791
+ if (githubActions !== "none") {
792
+ const workingDirectory = pnpmWorkspace ? path.posix.join("packages", projectName) : ".";
793
+ await writeText(path.join(rootDir, ".github/workflows/ci.yml"), githubCliCiWorkflowTemplate({
794
+ packageManager,
795
+ nodeVersion: 22,
796
+ workingDirectory,
797
+ runLint: useOxlint,
798
+ runFormatCheck: useOxfmt,
799
+ runTests: useVitest
800
+ }));
801
+ }
802
+ if (githubActions === "ci+release") {
803
+ const workingDirectory = pnpmWorkspace ? path.posix.join("packages", projectName) : ".";
804
+ await writeText(path.join(rootDir, ".github/workflows/release.yml"), (releaseMode === "both" ? githubCliReleaseBothWorkflowTemplate : releaseMode === "commit" ? githubCliReleaseWorkflowTemplate : githubCliReleaseTagWorkflowTemplate)({
805
+ packageManager,
806
+ nodeVersion: 22,
807
+ workingDirectory,
808
+ trustedPublishing
809
+ }));
810
+ }
811
+ const canInstall = Boolean(pmVersion);
812
+ let installOk = false;
813
+ if (canInstall) {
814
+ const installSpinner = spinner();
815
+ installSpinner.start(`Installing dependencies with ${packageManager}`);
816
+ installOk = (await exec(packageManager, ["install"], { cwd: rootDir })).ok;
817
+ installSpinner.stop(installOk ? "Dependencies installed" : "Install failed (skipped)");
818
+ }
819
+ if (initGit) await exec("git", ["init"], { cwd: rootDir });
820
+ outro(`Done. Next:\n cd ${projectName}${!canInstall ? `\n (${packageManager} not found, run install manually)` : !installOk ? `\n (${packageManager} install failed, run install manually)` : ""}\n ${nextStepHint(packageManager)}`);
821
+ }
822
+ function validateProjectName(value) {
823
+ const name = value.trim();
824
+ if (!name) return "Project name is required";
825
+ if (name.length > 214) return "Project name is too long";
826
+ if (name.startsWith(".")) return "Project name cannot start with '.'";
827
+ if (name.startsWith("_")) return "Project name cannot start with '_'";
828
+ if (/[A-Z]/.test(name)) return "Use lowercase letters only";
829
+ if (!/^[a-z0-9._-]+$/.test(name)) return "Use letters, numbers, '.', '_' or '-'";
830
+ }
831
+ function onCancel() {
832
+ cancel("Cancelled");
833
+ process.exitCode = 0;
834
+ }
835
+ function nextStepHint(pm) {
836
+ switch (pm) {
837
+ case "npm": return "npm run typecheck";
838
+ case "pnpm": return "pnpm run typecheck";
839
+ case "yarn": return "yarn typecheck";
840
+ case "bun": return "bun run typecheck";
841
+ case "deno": return "deno task typecheck # (or run the package.json scripts with your preferred runner)";
842
+ }
843
+ }
844
+
845
+ //#endregion
846
+ export { runCi as n, runInit as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontpl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive CLI to scaffold standardized frontend project templates.",
5
5
  "keywords": [
6
6
  "cli",
@@ -26,8 +26,15 @@
26
26
  "dist"
27
27
  ],
28
28
  "type": "module",
29
- "main": "dist/index.mjs",
30
- "types": "dist/index.d.mts",
29
+ "main": "./dist/index.mjs",
30
+ "types": "./dist/index.d.mts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.mts",
34
+ "import": "./dist/index.mjs",
35
+ "require": "./dist/index.mjs"
36
+ }
37
+ },
31
38
  "scripts": {
32
39
  "build": "tsdown",
33
40
  "dev": "tsdown --watch",
@@ -1,380 +0,0 @@
1
- import { cancel, confirm, intro, isCancel, outro, select, spinner, text } from "@clack/prompts";
2
- import { access, mkdir, writeFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import process from "node:process";
5
- import { spawn } from "node:child_process";
6
- import os from "node:os";
7
-
8
- //#region src/lib/exec.ts
9
- async function exec(command, args, opts = {}) {
10
- const resolved = resolveCommand$1(command);
11
- return new Promise((resolve) => {
12
- const child = spawn(resolved, args, {
13
- cwd: opts.cwd,
14
- stdio: "inherit",
15
- shell: false,
16
- env: process.env
17
- });
18
- child.on("close", (code) => resolve({ ok: code === 0 }));
19
- child.on("error", () => resolve({ ok: false }));
20
- });
21
- }
22
- function resolveCommand$1(command) {
23
- if (process.platform !== "win32") return command;
24
- if (command === "npm") return "npm.cmd";
25
- if (command === "pnpm") return "pnpm.cmd";
26
- if (command === "yarn") return "yarn.cmd";
27
- return command;
28
- }
29
-
30
- //#endregion
31
- //#region src/lib/versions.ts
32
- async function detectPackageManagerVersion(pm) {
33
- switch (pm) {
34
- case "npm": return (await execCapture("npm", ["--version"])).stdout.trim() || void 0;
35
- case "pnpm": return (await execCapture("pnpm", ["--version"])).stdout.trim() || void 0;
36
- case "yarn": return (await execCapture("yarn", ["--version"])).stdout.trim() || void 0;
37
- case "bun": return (await execCapture("bun", ["--version"])).stdout.trim() || void 0;
38
- case "deno": return ((await execCapture("deno", ["--version"])).stdout.trim().split("\n")[0] ?? "").match(/deno\\s+([0-9]+\\.[0-9]+\\.[0-9]+)/)?.[1];
39
- }
40
- }
41
- async function execCapture(command, args) {
42
- const resolved = resolveCommand(command);
43
- return new Promise((resolve) => {
44
- const child = spawn(resolved, args, {
45
- cwd: os.tmpdir(),
46
- stdio: [
47
- "ignore",
48
- "pipe",
49
- "ignore"
50
- ],
51
- shell: false,
52
- env: process.env
53
- });
54
- const chunks = [];
55
- child.stdout.on("data", (d) => chunks.push(Buffer.from(d)));
56
- child.on("close", (code) => {
57
- resolve({
58
- ok: code === 0,
59
- stdout: Buffer.concat(chunks).toString("utf8")
60
- });
61
- });
62
- child.on("error", () => resolve({
63
- ok: false,
64
- stdout: ""
65
- }));
66
- });
67
- }
68
- function resolveCommand(command) {
69
- if (process.platform !== "win32") return command;
70
- if (command === "npm") return "npm.cmd";
71
- if (command === "pnpm") return "pnpm.cmd";
72
- if (command === "yarn") return "yarn.cmd";
73
- return command;
74
- }
75
-
76
- //#endregion
77
- //#region src/lib/templates.ts
78
- function editorconfigTemplate() {
79
- return [
80
- "root = true",
81
- "",
82
- "[*]",
83
- "charset = utf-8",
84
- "end_of_line = lf",
85
- "indent_style = space",
86
- "indent_size = 2",
87
- "insert_final_newline = true",
88
- "trim_trailing_whitespace = true",
89
- "",
90
- "[*.md]",
91
- "trim_trailing_whitespace = false",
92
- ""
93
- ].join("\n");
94
- }
95
- function gitignoreTemplate() {
96
- return [
97
- "node_modules",
98
- "dist",
99
- "coverage",
100
- "*.log",
101
- ".DS_Store",
102
- ".env",
103
- ".env.*",
104
- ""
105
- ].join("\n");
106
- }
107
- function gitattributesTemplate() {
108
- return ["* text=auto eol=lf", ""].join("\n");
109
- }
110
- function tsconfigTemplate() {
111
- return JSON.stringify({
112
- compilerOptions: {
113
- target: "ES2022",
114
- module: "ESNext",
115
- moduleResolution: "Bundler",
116
- strict: true,
117
- skipLibCheck: true,
118
- noEmit: true
119
- },
120
- include: ["src"]
121
- }, null, 2) + "\n";
122
- }
123
- function srcIndexTemplate() {
124
- return [
125
- "export function hello(name: string) {",
126
- " return `Hello, ${name}`;",
127
- "}",
128
- ""
129
- ].join("\n");
130
- }
131
- function srcVitestTemplate() {
132
- return [
133
- "import { describe, expect, it } from \"vitest\";",
134
- "import { hello } from \"./index.js\";",
135
- "",
136
- "describe(\"hello\", () => {",
137
- " it(\"greets\", () => {",
138
- " expect(hello(\"world\")).toBe(\"Hello, world\");",
139
- " });",
140
- "});",
141
- ""
142
- ].join("\n");
143
- }
144
- function readmeTemplate(projectName) {
145
- return [
146
- `# ${projectName}`,
147
- "",
148
- "Generated by `frontpl`.",
149
- ""
150
- ].join("\n");
151
- }
152
- function oxlintConfigTemplate({ useVitest }) {
153
- return JSON.stringify({
154
- $schema: "https://json.schemastore.org/oxlintrc.json",
155
- env: {
156
- browser: true,
157
- es2022: true
158
- }
159
- }, null, 2) + "\n";
160
- }
161
- function oxfmtConfigTemplate() {
162
- return JSON.stringify({ $schema: "https://json.schemastore.org/oxfmtrc.json" }, null, 2) + "\n";
163
- }
164
- function tsdownConfigTemplate() {
165
- return [
166
- "import { defineConfig } from \"tsdown\";",
167
- "",
168
- "export default defineConfig({",
169
- " entry: [\"src/index.ts\"],",
170
- " platform: \"browser\"",
171
- "});",
172
- ""
173
- ].join("\n");
174
- }
175
- function packageJsonTemplate(opts) {
176
- const scripts = { typecheck: "tsc --noEmit" };
177
- if (opts.useOxlint) {
178
- const oxlintCmd = [
179
- "oxlint",
180
- opts.useVitest ? "--vitest-plugin" : void 0,
181
- "--type-aware",
182
- "--type-check"
183
- ].filter(Boolean).join(" ");
184
- scripts.lint = oxlintCmd;
185
- scripts["lint:fix"] = `${oxlintCmd} --fix`;
186
- }
187
- if (opts.useOxfmt) {
188
- scripts.fmt = "oxfmt";
189
- scripts["fmt:check"] = "oxfmt --check";
190
- }
191
- if (opts.useVitest) scripts.test = "vitest";
192
- if (opts.useTsdown) scripts.build = "tsdown";
193
- const devDependencies = { typescript: opts.typescriptVersion };
194
- if (opts.useOxlint) {
195
- if (opts.oxlintVersion) devDependencies.oxlint = opts.oxlintVersion;
196
- if (opts.oxlintTsgolintVersion) devDependencies["oxlint-tsgolint"] = opts.oxlintTsgolintVersion;
197
- }
198
- if (opts.useOxfmt && opts.oxfmtVersion) devDependencies.oxfmt = opts.oxfmtVersion;
199
- if (opts.useVitest && opts.vitestVersion) devDependencies.vitest = opts.vitestVersion;
200
- if (opts.useTsdown && opts.tsdownVersion) devDependencies.tsdown = opts.tsdownVersion;
201
- return JSON.stringify({
202
- name: opts.name,
203
- version: "0.0.0",
204
- private: true,
205
- type: "module",
206
- scripts,
207
- devDependencies,
208
- packageManager: opts.packageManager
209
- }, null, 2) + "\n";
210
- }
211
-
212
- //#endregion
213
- //#region src/lib/utils.ts
214
- async function pathExists(pathname) {
215
- try {
216
- await access(pathname);
217
- return true;
218
- } catch {
219
- return false;
220
- }
221
- }
222
-
223
- //#endregion
224
- //#region src/commands/init.ts
225
- async function runInit({ nameArg }) {
226
- intro("frontpl");
227
- const projectName = await text({
228
- message: "Project name",
229
- initialValue: nameArg ?? "my-frontend",
230
- validate: validateProjectName
231
- });
232
- if (isCancel(projectName)) return onCancel();
233
- const packageManager = await select({
234
- message: "Package manager",
235
- initialValue: "pnpm",
236
- options: [
237
- {
238
- value: "npm",
239
- label: "npm"
240
- },
241
- {
242
- value: "yarn",
243
- label: "yarn"
244
- },
245
- {
246
- value: "pnpm",
247
- label: "pnpm"
248
- },
249
- {
250
- value: "bun",
251
- label: "bun"
252
- },
253
- {
254
- value: "deno",
255
- label: "deno"
256
- }
257
- ]
258
- });
259
- if (isCancel(packageManager)) return onCancel();
260
- const pnpmWorkspace = packageManager === "pnpm" ? await confirm({
261
- message: "pnpm workspace mode (monorepo skeleton)?",
262
- initialValue: false
263
- }) : false;
264
- if (isCancel(pnpmWorkspace)) return onCancel();
265
- const useOxlint = await confirm({
266
- message: "Enable oxlint (type-aware + type-check via tsgolint)?",
267
- initialValue: true
268
- });
269
- if (isCancel(useOxlint)) return onCancel();
270
- const useOxfmt = await confirm({
271
- message: "Enable oxfmt (code formatting)?",
272
- initialValue: true
273
- });
274
- if (isCancel(useOxfmt)) return onCancel();
275
- const useVitest = await confirm({
276
- message: "Add Vitest?",
277
- initialValue: false
278
- });
279
- if (isCancel(useVitest)) return onCancel();
280
- const useTsdown = await confirm({
281
- message: "Add tsdown build?",
282
- initialValue: true
283
- });
284
- if (isCancel(useTsdown)) return onCancel();
285
- const initGit = await confirm({
286
- message: "Initialize a git repository?",
287
- initialValue: true
288
- });
289
- if (isCancel(initGit)) return onCancel();
290
- const rootDir = path.resolve(process.cwd(), projectName);
291
- if (await pathExists(rootDir)) {
292
- cancel(`Directory already exists: ${rootDir}`);
293
- process.exitCode = 1;
294
- return;
295
- }
296
- const pkgDir = pnpmWorkspace ? path.join(rootDir, "packages", projectName) : rootDir;
297
- const pmVersion = await detectPackageManagerVersion(packageManager);
298
- const packageManagerField = pmVersion ? `${packageManager}@${pmVersion}` : `${packageManager}@latest`;
299
- await mkdir(path.join(pkgDir, "src"), { recursive: true });
300
- await Promise.all([
301
- writeText(path.join(rootDir, ".editorconfig"), editorconfigTemplate()),
302
- writeText(path.join(rootDir, ".gitignore"), gitignoreTemplate()),
303
- writeText(path.join(rootDir, ".gitattributes"), gitattributesTemplate())
304
- ]);
305
- if (pnpmWorkspace) {
306
- await writeText(path.join(rootDir, "pnpm-workspace.yaml"), [
307
- "packages:",
308
- " - \"packages/*\"",
309
- ""
310
- ].join("\n"));
311
- await writeText(path.join(rootDir, "package.json"), JSON.stringify({
312
- name: projectName,
313
- private: true,
314
- packageManager: packageManagerField
315
- }, null, 2) + "\n");
316
- }
317
- await Promise.all([
318
- writeText(path.join(pkgDir, "README.md"), readmeTemplate(projectName)),
319
- writeText(path.join(pkgDir, "src/index.ts"), srcIndexTemplate()),
320
- writeText(path.join(pkgDir, "tsconfig.json"), tsconfigTemplate()),
321
- writeText(path.join(pkgDir, "package.json"), packageJsonTemplate({
322
- name: projectName,
323
- packageManager: packageManagerField,
324
- typescriptVersion: "latest",
325
- useOxlint,
326
- oxlintVersion: "latest",
327
- oxlintTsgolintVersion: "latest",
328
- useOxfmt,
329
- oxfmtVersion: "latest",
330
- useVitest,
331
- vitestVersion: "latest",
332
- useTsdown,
333
- tsdownVersion: "latest"
334
- }))
335
- ]);
336
- if (useOxlint) await writeText(path.join(pkgDir, ".oxlintrc.json"), oxlintConfigTemplate({ useVitest }));
337
- if (useOxfmt) await writeText(path.join(pkgDir, ".oxfmtrc.json"), oxfmtConfigTemplate());
338
- if (useVitest) await writeText(path.join(pkgDir, "src/index.test.ts"), srcVitestTemplate());
339
- if (useTsdown) await writeText(path.join(pkgDir, "tsdown.config.ts"), tsdownConfigTemplate());
340
- if (packageManager === "deno") await writeText(path.join(rootDir, "deno.json"), JSON.stringify({ nodeModulesDir: "auto" }, null, 2) + "\n");
341
- const canInstall = Boolean(pmVersion);
342
- let installOk = false;
343
- if (canInstall) {
344
- const installSpinner = spinner();
345
- installSpinner.start(`Installing dependencies with ${packageManager}`);
346
- installOk = (await exec(packageManager, ["install"], { cwd: rootDir })).ok;
347
- installSpinner.stop(installOk ? "Dependencies installed" : "Install failed (skipped)");
348
- }
349
- if (initGit) await exec("git", ["init"], { cwd: rootDir });
350
- outro(`Done. Next:\n cd ${projectName}${!canInstall ? `\n (${packageManager} not found, run install manually)` : !installOk ? `\n (${packageManager} install failed, run install manually)` : ""}\n ${nextStepHint(packageManager)}`);
351
- }
352
- function validateProjectName(value) {
353
- const name = value.trim();
354
- if (!name) return "Project name is required";
355
- if (name.length > 214) return "Project name is too long";
356
- if (name.startsWith(".")) return "Project name cannot start with '.'";
357
- if (name.startsWith("_")) return "Project name cannot start with '_'";
358
- if (/[A-Z]/.test(name)) return "Use lowercase letters only";
359
- if (!/^[a-z0-9._-]+$/.test(name)) return "Use letters, numbers, '.', '_' or '-'";
360
- }
361
- function onCancel() {
362
- cancel("Cancelled");
363
- process.exitCode = 0;
364
- }
365
- async function writeText(filePath, contents) {
366
- await mkdir(path.dirname(filePath), { recursive: true });
367
- await writeFile(filePath, contents, "utf8");
368
- }
369
- function nextStepHint(pm) {
370
- switch (pm) {
371
- case "npm": return "npm run typecheck";
372
- case "pnpm": return "pnpm run typecheck";
373
- case "yarn": return "yarn typecheck";
374
- case "bun": return "bun run typecheck";
375
- case "deno": return "deno task typecheck # (or run the package.json scripts with your preferred runner)";
376
- }
377
- }
378
-
379
- //#endregion
380
- export { runInit as t };