create-chanom-app 0.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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # create-chanom-app
2
+
3
+ Scaffold a Vite + React + TypeScript app, wired to the [chanom](https://github.com/peeranat-dan/chanom) shared configs, with a couple of prompts.
4
+
5
+ ## Usage
6
+
7
+ Run it directly, no install needed:
8
+
9
+ ```sh
10
+ npm create chanom-app@latest
11
+ # or
12
+ pnpm create chanom-app
13
+ ```
14
+
15
+ Pass a directory to skip the name prompt:
16
+
17
+ ```sh
18
+ pnpm create chanom-app my-app
19
+ ```
20
+
21
+ Scaffold into the current directory with `.`:
22
+
23
+ ```sh
24
+ pnpm create chanom-app .
25
+ ```
26
+
27
+ ## Prompts
28
+
29
+ Run with no flags, the CLI walks you through:
30
+
31
+ 1. **Project name** - the target directory (kebab-cased). Defaults to `my-chanom-app`.
32
+ 2. **Initialize a git repository?** - `git init` plus an initial commit.
33
+ 3. **Add commit hooks?** - husky + lint-staged + commitlint (only asked when git is enabled).
34
+ 4. **Install dependencies now?** - runs `<pm> install` after scaffolding.
35
+
36
+ Every prompt has a matching flag so you can pre-answer any of them (or all, with `--yes`).
37
+
38
+ ## Options
39
+
40
+ | Flag | What it does |
41
+ | ----------------------------- | --------------------------------------------------------- |
42
+ | `[directory]` | Target directory / project name (prompted if omitted) |
43
+ | `--git` / `--no-git` | Initialize a git repository (default: yes) |
44
+ | `--commit-hooks` | Add husky + lint-staged + commitlint (requires git) |
45
+ | `--no-commit-hooks` | Skip the commit-hooks topping |
46
+ | `--install` / `--no-install` | Install dependencies after scaffolding (default: install) |
47
+ | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager to record (default: detected) |
48
+ | `-y`, `--yes` | Accept all defaults, no prompts (CI-friendly) |
49
+ | `--help` | Show help |
50
+ | `--version` | Show the version |
51
+
52
+ `--no-git --commit-hooks` is rejected: husky needs a repo to install into.
53
+
54
+ The package manager is detected from the invoking user agent (falling back to `pnpm`); `--pm` overrides it and is recorded in the generated `package.json` `packageManager` field.
55
+
56
+ ### Non-interactive
57
+
58
+ ```sh
59
+ pnpm create chanom-app my-app --yes --no-install
60
+ ```
61
+
62
+ `--yes` accepts every default without prompting, so scaffolding runs unattended in CI.
63
+
64
+ ## What gets generated
65
+
66
+ A minimal Vite + React + TS app wired to the chanom shared configs:
67
+
68
+ - `package.json` - dependencies pinned to the versions the scaffolder bundles, plus `dev` / `build` / `preview` / `lint` / `format` / `format:check` / `test` / `test:watch` scripts
69
+ - `vite.config.ts` - using `@vitejs/plugin-react` and [`@chanom/vite-config`](../vite-config)
70
+ - `tsconfig.json` - extending `@tsconfig/vite-react`
71
+ - `oxlint.config.ts` / `oxfmt.config.ts` - re-exporting the [`@chanom/dev-config`](../dev-config) presets
72
+ - `vitest.config.ts` and a sample `src/app.test.tsx`
73
+ - `src/` - `main.tsx`, `app.tsx`, `index.css`, and type declarations
74
+ - `index.html`, `.gitignore`, and a starter `README.md`
75
+
76
+ With the **commit-hooks** topping, it also adds husky, lint-staged, and commitlint as dev dependencies plus their config files (`.husky/pre-commit`, `.husky/commit-msg`, `.lintstagedrc.json`, `.commitlintrc.json`). The hooks materialize on the first install via the generated `prepare: "husky"` script.
77
+
78
+ ## Development
79
+
80
+ ```sh
81
+ pnpm build # bundle with tsdown (copies templates/ into dist/)
82
+ pnpm test # run vitest once
83
+ pnpm test:watch # run vitest in watch mode
84
+ ```
85
+
86
+ Most files in `templates/` are copied byte-for-byte into every generated app. The README, `src/app.tsx`, and `src/app.test.tsx` have their app-name placeholders replaced; files whose contents are fully computed (`package.json`, `vite.config.ts`, `tsconfig.json`, `index.html`) are rendered by the generators in `src/generate/`.
package/dist/index.js ADDED
@@ -0,0 +1,827 @@
1
+ #!/usr/bin/env node
2
+ import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
3
+ import { Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
4
+ import * as clack from "@clack/prompts";
5
+ import pc from "picocolors";
6
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
7
+ import { join } from "node:path";
8
+ //#region ../internal/src/domain/package-manager.ts
9
+ /** How to run a locally installed binary with each package manager. */
10
+ const PM_EXEC = {
11
+ pnpm: ["pnpm", "exec"],
12
+ npm: [
13
+ "npx",
14
+ "--no",
15
+ "--"
16
+ ],
17
+ yarn: ["yarn"],
18
+ bun: ["bunx"]
19
+ };
20
+ const KNOWN_PMS = [
21
+ "pnpm",
22
+ "yarn",
23
+ "bun",
24
+ "npm"
25
+ ];
26
+ /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
27
+ function parsePmField(field) {
28
+ if (!field) return void 0;
29
+ const name = field.split("@")[0];
30
+ return KNOWN_PMS.find((pm) => pm === name);
31
+ }
32
+ /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
33
+ function parsePmUserAgent(agent) {
34
+ if (!agent) return void 0;
35
+ return KNOWN_PMS.find((pm) => agent.startsWith(pm));
36
+ }
37
+ /** The `packageManager` field wins over the user agent, which wins over the fallback. */
38
+ function resolvePm(hints) {
39
+ return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
40
+ }
41
+ //#endregion
42
+ //#region ../internal/src/services/command-runner.ts
43
+ const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
44
+ /**
45
+ * Low-level process execution. Everything that shells out (git, package
46
+ * managers, husky) goes through this service so commands can be stubbed in tests.
47
+ */
48
+ var CommandRunner = class extends Effect.Service()("internal/CommandRunner", { effect: Effect.gen(function* () {
49
+ const executor = yield* CommandExecutor.CommandExecutor;
50
+ const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
51
+ const traced = (cmd, args, cwd, effect) => Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`).pipe(Effect.andThen(effect), Effect.tap((code) => Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${code}`)));
52
+ const capture = (cmd, args, cwd) => Effect.scoped(Effect.gen(function* () {
53
+ yield* Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`);
54
+ const proc = yield* executor.start(make(cmd, args, cwd));
55
+ const [exitCode, stdout, stderr] = yield* Effect.all([
56
+ proc.exitCode,
57
+ collectText(proc.stdout),
58
+ collectText(proc.stderr)
59
+ ], { concurrency: 3 });
60
+ yield* Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${exitCode}`);
61
+ return {
62
+ exitCode,
63
+ stdout: stdout.trim(),
64
+ stderr: stderr.trim()
65
+ };
66
+ }));
67
+ const exitCode = (cmd, args, cwd) => traced(cmd, args, cwd, make(cmd, args, cwd).pipe(Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, executor)));
68
+ const execInherit = (cmd, args, cwd) => traced(cmd, args, cwd, make(cmd, args, cwd).pipe(Command.stdout("inherit"), Command.stderr("inherit"), Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, executor)));
69
+ return {
70
+ /** Runs a command silently and collects its exit code and trimmed output. */
71
+ capture,
72
+ /** Runs a command silently and returns only its exit code. */
73
+ exitCode,
74
+ /** Runs a command with stdout/stderr forwarded to the user's terminal. */
75
+ execInherit
76
+ };
77
+ }) }) {};
78
+ //#endregion
79
+ //#region ../internal/src/services/git.ts
80
+ /** Git operations, built on top of {@link CommandRunner}. */
81
+ var Git = class extends Effect.Service()("internal/Git", { effect: Effect.gen(function* () {
82
+ const runner = yield* CommandRunner;
83
+ const fs = yield* FileSystem.FileSystem;
84
+ const path = yield* Path.Path;
85
+ const run = (args, cwd) => runner.capture("git", args, cwd).pipe(Effect.map(({ exitCode, stdout, stderr }) => ({
86
+ ok: exitCode === 0,
87
+ stdout,
88
+ stderr
89
+ })));
90
+ const exitOk = (args, cwd) => runner.exitCode("git", args, cwd).pipe(Effect.map((code) => code === 0));
91
+ return {
92
+ init: (cwd) => run(["init"], cwd),
93
+ stageAll: (cwd) => run(["add", "."], cwd),
94
+ commit: (cwd, message) => run([
95
+ "commit",
96
+ "-m",
97
+ message
98
+ ], cwd),
99
+ hasIdentity: (cwd) => exitOk(["var", "GIT_COMMITTER_IDENT"], cwd),
100
+ setLocalIdentity: (cwd, name, email) => run([
101
+ "config",
102
+ "user.name",
103
+ name
104
+ ], cwd).pipe(Effect.andThen(() => run([
105
+ "config",
106
+ "user.email",
107
+ email
108
+ ], cwd)), Effect.asVoid),
109
+ hasStagedChanges: (cwd) => exitOk([
110
+ "diff",
111
+ "--cached",
112
+ "--quiet"
113
+ ], cwd).pipe(Effect.map((clean) => !clean)),
114
+ /**
115
+ * True when `cwd` is anywhere inside a git work tree — a `.git` folder
116
+ * check would miss subdirectories (e.g. workspace packages) and lead to
117
+ * nested `git init` calls.
118
+ */
119
+ isRepo: (cwd) => exitOk(["rev-parse", "--is-inside-work-tree"], cwd),
120
+ /** Path of `cwd` relative to the work-tree root: empty at the root, `None` outside a repository. */
121
+ prefix: (cwd) => run(["rev-parse", "--show-prefix"], cwd).pipe(Effect.map((result) => result.ok ? Option.some(result.stdout) : Option.none())),
122
+ /** Absolute path of the work-tree root, or `None` outside a repository. */
123
+ root: (cwd) => run(["rev-parse", "--show-toplevel"], cwd).pipe(Effect.map((result) => result.ok ? Option.some(result.stdout) : Option.none())),
124
+ writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents),
125
+ readGitignore: (cwd) => fs.readFileString(path.join(cwd, ".gitignore"))
126
+ };
127
+ }) }) {};
128
+ //#endregion
129
+ //#region ../internal/src/services/prompter.ts
130
+ var Cancelled = class extends Data.TaggedError("Cancelled") {};
131
+ const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
132
+ /**
133
+ * All terminal interaction (prompts, spinners, log messages). Commands never
134
+ * touch @clack/prompts directly, so tests can script answers and capture output.
135
+ */
136
+ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
137
+ intro: (message) => Effect.sync(() => clack.intro(message)),
138
+ outro: (message) => Effect.sync(() => clack.outro(message)),
139
+ warn: (message) => Effect.sync(() => clack.log.warn(message)),
140
+ error: (message) => Effect.sync(() => clack.log.error(message)),
141
+ info: (message) => Effect.sync(() => clack.log.info(message)),
142
+ /** Prints a message with no icon or styling (help text, plan summaries). */
143
+ message: (message) => Effect.sync(() => clack.log.message(message)),
144
+ debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
145
+ text: (params) => guardCancel(() => clack.text({
146
+ message: params.message,
147
+ placeholder: params.placeholder,
148
+ defaultValue: params.defaultValue,
149
+ initialValue: params.initialValue,
150
+ validate: params.validate
151
+ })),
152
+ confirm: (params) => guardCancel(() => clack.confirm({
153
+ message: params.message,
154
+ initialValue: params.initialValue ?? true
155
+ })),
156
+ select: (params) => guardCancel(() => clack.select({
157
+ message: params.message,
158
+ options: [...params.options]
159
+ })),
160
+ multiselect: (params) => guardCancel(() => clack.multiselect({
161
+ message: params.message,
162
+ options: [...params.options],
163
+ required: params.required ?? false
164
+ })),
165
+ spinner: (message) => Effect.sync(() => {
166
+ const s = clack.spinner();
167
+ s.start(message);
168
+ return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
169
+ })
170
+ } }) {};
171
+ //#endregion
172
+ //#region src/bundled-versions.ts
173
+ const depVersions = {
174
+ "react": "19.2.8",
175
+ "react-dom": "19.2.8",
176
+ "@vitejs/plugin-react": "6.0.4",
177
+ "@tsconfig/vite-react": "8.1.1",
178
+ "@types/react": "19.2.17",
179
+ "@types/react-dom": "19.2.3",
180
+ "typescript": "6.0.3",
181
+ "vite": "8.1.0",
182
+ "vitest": "3.2.6",
183
+ "@vitest/coverage-v8": "3.2.6",
184
+ "jsdom": "30.0.0",
185
+ "@testing-library/react": "16.3.2",
186
+ "@testing-library/jest-dom": "7.0.0",
187
+ "oxlint": "1.73.0",
188
+ "oxlint-tsgolint": "0.24.0",
189
+ "oxfmt": "0.58.0",
190
+ "husky": "9.1.7",
191
+ "lint-staged": "17.0.8",
192
+ "@commitlint/cli": "21.2.0",
193
+ "@commitlint/config-conventional": "21.2.0",
194
+ "@chanom/dev-config": "0.0.3",
195
+ "@chanom/vite-config": "0.1.0"
196
+ };
197
+ /** This package's own version, for `--version`. */
198
+ const pkgVersion = "0.0.0";
199
+ //#endregion
200
+ //#region src/domain/args.ts
201
+ const PACKAGE_MANAGERS = [
202
+ "pnpm",
203
+ "npm",
204
+ "yarn",
205
+ "bun"
206
+ ];
207
+ const isPackageManager = (value) => PACKAGE_MANAGERS.includes(value);
208
+ /**
209
+ * Parses the CLI arguments, returning either the resolved flags or a
210
+ * user-facing error message. Every flag mirrors an interactive prompt; the
211
+ * contradictory `--no-git --commit-hooks` pair is rejected here rather than
212
+ * silently reconciled (husky needs a repo to install into).
213
+ */
214
+ function parseArgs(argv) {
215
+ const args = {
216
+ yes: false,
217
+ help: false,
218
+ version: false
219
+ };
220
+ for (let i = 0; i < argv.length; i++) {
221
+ const arg = argv[i];
222
+ switch (arg) {
223
+ case "-y":
224
+ case "--yes":
225
+ args.yes = true;
226
+ break;
227
+ case "-h":
228
+ case "--help":
229
+ args.help = true;
230
+ break;
231
+ case "--version":
232
+ args.version = true;
233
+ break;
234
+ case "--git":
235
+ args.git = true;
236
+ break;
237
+ case "--no-git":
238
+ args.git = false;
239
+ break;
240
+ case "--commit-hooks":
241
+ args.commitHooks = true;
242
+ break;
243
+ case "--no-commit-hooks":
244
+ args.commitHooks = false;
245
+ break;
246
+ case "--install":
247
+ args.install = true;
248
+ break;
249
+ case "--no-install":
250
+ args.install = false;
251
+ break;
252
+ case "--pm": {
253
+ const value = argv[++i];
254
+ if (value === void 0) return {
255
+ ok: false,
256
+ message: "--pm requires a value."
257
+ };
258
+ if (!isPackageManager(value)) return {
259
+ ok: false,
260
+ message: `Unknown package manager "${value}". Use one of: ${PACKAGE_MANAGERS.join(", ")}.`
261
+ };
262
+ args.pm = value;
263
+ break;
264
+ }
265
+ default:
266
+ if (arg.startsWith("-")) return {
267
+ ok: false,
268
+ message: `Unknown option: ${arg}`
269
+ };
270
+ if (args.directory !== void 0) return {
271
+ ok: false,
272
+ message: `Unexpected extra argument: ${arg}`
273
+ };
274
+ args.directory = arg;
275
+ }
276
+ }
277
+ if (args.git === false && args.commitHooks === true) return {
278
+ ok: false,
279
+ message: "--commit-hooks requires a git repo. Remove --no-git or --commit-hooks."
280
+ };
281
+ return {
282
+ ok: true,
283
+ args
284
+ };
285
+ }
286
+ /**
287
+ * Validates a project name / target directory. Returns an error message to
288
+ * reject it, or `undefined` when it is acceptable. `.` (scaffold into the
289
+ * current directory) is allowed.
290
+ */
291
+ function validateProjectName(name) {
292
+ if (name.trim() === "") return "Please enter a project name.";
293
+ if (name === ".") return void 0;
294
+ if (!/^[a-z0-9._-]+$/i.test(name)) return "Use letters, numbers, dots, dashes, and underscores only.";
295
+ if (name.startsWith(".")) return "Name cannot start with a dot.";
296
+ }
297
+ //#endregion
298
+ //#region src/domain/project-name.ts
299
+ /**
300
+ * Converts a project name to the lowercase kebab-case format expected for an
301
+ * unscoped npm package name.
302
+ */
303
+ function toKebabCase(name) {
304
+ return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[._\s-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
305
+ }
306
+ //#endregion
307
+ //#region src/domain/contribution.ts
308
+ const dedupe = (values) => [...new Set(values)];
309
+ /**
310
+ * Folds contributions in order into a single value. Package names, vite imports,
311
+ * and plugins are concatenated then de-duplicated (first occurrence wins its
312
+ * position); scripts merge with later contributions overriding earlier keys;
313
+ * files de-duplicate by path (first writer wins).
314
+ */
315
+ function mergeContributions(contributions) {
316
+ const filesByPath = /* @__PURE__ */ new Map();
317
+ for (const contribution of contributions) for (const file of contribution.files) if (!filesByPath.has(file.path)) filesByPath.set(file.path, file);
318
+ return {
319
+ dependencies: dedupe(contributions.flatMap((c) => c.dependencies)),
320
+ devDependencies: dedupe(contributions.flatMap((c) => c.devDependencies)),
321
+ scripts: contributions.reduce((acc, c) => ({
322
+ ...acc,
323
+ ...c.scripts
324
+ }), {}),
325
+ viteImports: dedupe(contributions.flatMap((c) => c.viteImports)),
326
+ vitePlugins: dedupe(contributions.flatMap((c) => c.vitePlugins)),
327
+ files: [...filesByPath.values()]
328
+ };
329
+ }
330
+ //#endregion
331
+ //#region src/generate/app-template.ts
332
+ /**
333
+ * Replaces the placeholder app name in the generated component and its test.
334
+ * The app name is normalized before it reaches this renderer.
335
+ */
336
+ function renderAppTemplate(template, appName) {
337
+ return template.replaceAll("my-app", appName);
338
+ }
339
+ //#endregion
340
+ //#region src/generate/index-html.ts
341
+ /**
342
+ * Renders `index.html` with the app name as its `<title>`. This is the one file
343
+ * whose content varies by project name, so it is generated rather than copied;
344
+ * the project name is validated to `[a-z0-9._-]`, so no HTML escaping is needed.
345
+ */
346
+ function renderIndexHtml(appName) {
347
+ return `<!doctype html>
348
+ <html lang="en">
349
+ <head>
350
+ <meta charset="UTF-8" />
351
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
352
+ <title>${appName}</title>
353
+ </head>
354
+ <body>
355
+ <div id="root"></div>
356
+ <script type="module" src="/src/main.tsx"><\/script>
357
+ </body>
358
+ </html>
359
+ `;
360
+ }
361
+ //#endregion
362
+ //#region src/domain/versions.ts
363
+ /**
364
+ * Pinned version for `name`, throwing when it is absent. Missing keys are a
365
+ * build/catalog bug (the strict build-time reader should have already caught
366
+ * them), never a reason to fall back to a range or `latest`.
367
+ */
368
+ function resolveVersion(versions, name) {
369
+ const version = versions[name];
370
+ if (version === void 0) throw new Error(`No pinned version for "${name}". The dependency version map is incomplete.`);
371
+ return version;
372
+ }
373
+ //#endregion
374
+ //#region src/generate/package-json.ts
375
+ /** Resolves each name to its exact pinned version, sorted for deterministic output. */
376
+ function toDependencyMap(names, versions) {
377
+ const map = {};
378
+ for (const name of [...names].sort()) map[name] = resolveVersion(versions, name);
379
+ return map;
380
+ }
381
+ /**
382
+ * Renders the generated `package.json` as a string. Every dependency is pinned
383
+ * exact from the injected catalog versions; a missing version throws rather
384
+ * than emitting a range (see {@link resolveVersion}).
385
+ *
386
+ * The pnpm build allow-list lives in `pnpm-workspace.yaml`, not here: pnpm no
387
+ * longer reads `pnpm.onlyBuiltDependencies` from `package.json` (see
388
+ * {@link buildPnpmWorkspace}).
389
+ */
390
+ function buildPackageJson(input) {
391
+ const pkg = {
392
+ name: toKebabCase(input.appName),
393
+ version: "0.0.0",
394
+ private: true,
395
+ type: "module",
396
+ ...input.packageManager === void 0 ? {} : { packageManager: input.packageManager },
397
+ scripts: input.contribution.scripts,
398
+ dependencies: toDependencyMap(input.contribution.dependencies, input.versions),
399
+ devDependencies: toDependencyMap(input.contribution.devDependencies, input.versions)
400
+ };
401
+ return JSON.stringify(pkg, null, 2) + "\n";
402
+ }
403
+ //#endregion
404
+ //#region src/generate/pnpm-workspace.ts
405
+ /**
406
+ * Build scripts pnpm must be told to run for the generated app. pnpm v10 blocks
407
+ * every dependency lifecycle script unless it is allow-listed; esbuild (pulled
408
+ * in by the Vite baseline) needs its postinstall to compile its native binary,
409
+ * so without this the very first `pnpm install` aborts with ERR_PNPM_IGNORED_BUILDS.
410
+ *
411
+ * This lives in `pnpm-workspace.yaml` rather than a `package.json` `pnpm` field:
412
+ * pnpm no longer reads `pnpm.onlyBuiltDependencies` from `package.json` and warns
413
+ * when it is present. A standalone (non-workspace) project still reads the
414
+ * setting from `pnpm-workspace.yaml`.
415
+ */
416
+ const onlyBuiltDependencies = ["esbuild"];
417
+ const allowBuilds = ["esbuild"];
418
+ /** Renders the generated `pnpm-workspace.yaml` as a string. */
419
+ function buildPnpmWorkspace() {
420
+ return [
421
+ "onlyBuiltDependencies:",
422
+ ...onlyBuiltDependencies.map((name) => ` - ${name}`),
423
+ "allowBuilds:",
424
+ ...allowBuilds.map((name) => ` ${name}: true`)
425
+ ].join("\n") + "\n";
426
+ }
427
+ //#endregion
428
+ //#region src/generate/readme.ts
429
+ /**
430
+ * Replaces the template's project heading with the generated package name.
431
+ * Keeping the rest of the template untouched makes README updates independent
432
+ * from the scaffolding logic.
433
+ */
434
+ function renderReadme(template, appName) {
435
+ return template.replace(/^# my-app$/m, `# ${appName}`);
436
+ }
437
+ //#endregion
438
+ //#region src/generate/tsconfig.ts
439
+ const TSCONFIG = {
440
+ extends: "@tsconfig/vite-react/tsconfig.json",
441
+ compilerOptions: { types: ["vite/client", "vitest/globals"] },
442
+ include: ["src"]
443
+ };
444
+ /** Renders the generated `tsconfig.json` as a string. */
445
+ function buildTsconfig() {
446
+ return JSON.stringify(TSCONFIG, null, 2) + "\n";
447
+ }
448
+ //#endregion
449
+ //#region src/generate/vite-config.ts
450
+ /**
451
+ * Renders `vite.config.ts` from the folded contribution's Vite imports and
452
+ * plugin list. Generated (not a static template) so a future topping can add
453
+ * plugins without a substitution engine — the baseline output is the invariant
454
+ * `createViteConfig({ plugins: [react()] })`.
455
+ */
456
+ function renderViteConfig(contribution) {
457
+ return `${["import { createViteConfig } from '@chanom/vite-config';", ...contribution.viteImports].join("\n")}\n\nexport default createViteConfig({ plugins: [${contribution.vitePlugins.join(", ")}] });\n`;
458
+ }
459
+ //#endregion
460
+ //#region src/templates.ts
461
+ /**
462
+ * Template files copied into every generated app. Source files use
463
+ * a `.template` suffix so repository tooling does not lint, format, or type-check
464
+ * them. `_gitignore` also uses a placeholder name because npm strips a real
465
+ * `.gitignore` from published packages. Files whose content is computed
466
+ * (package.json, vite.config.ts, tsconfig.json, index.html) are generated, not
467
+ * listed here. README.md, app.tsx, and app.test.tsx have their app-name
468
+ * placeholders replaced while copying.
469
+ */
470
+ const TEMPLATE_FILES = [
471
+ {
472
+ src: "_gitignore.template",
473
+ dest: ".gitignore"
474
+ },
475
+ {
476
+ src: "README.md.template",
477
+ dest: "README.md"
478
+ },
479
+ {
480
+ src: "oxlint.config.ts.template",
481
+ dest: "oxlint.config.ts"
482
+ },
483
+ {
484
+ src: "oxfmt.config.ts.template",
485
+ dest: "oxfmt.config.ts"
486
+ },
487
+ {
488
+ src: "vitest.config.ts.template",
489
+ dest: "vitest.config.ts"
490
+ },
491
+ {
492
+ src: "src/main.tsx.template",
493
+ dest: "src/main.tsx"
494
+ },
495
+ {
496
+ src: "src/app.tsx.template",
497
+ dest: "src/app.tsx"
498
+ },
499
+ {
500
+ src: "src/app.test.tsx.template",
501
+ dest: "src/app.test.tsx"
502
+ },
503
+ {
504
+ src: "src/index.css.template",
505
+ dest: "src/index.css"
506
+ },
507
+ {
508
+ src: "src/test-setup.ts.template",
509
+ dest: "src/test-setup.ts"
510
+ },
511
+ {
512
+ src: "src/vite-env.d.ts.template",
513
+ dest: "src/vite-env.d.ts"
514
+ }
515
+ ];
516
+ //#endregion
517
+ //#region src/toppings.ts
518
+ /**
519
+ * The always-present baseline: a minimal Vite + React + TS app wired to the
520
+ * chanom shared configs. Its static source files ship as verbatim templates;
521
+ * this contribution supplies only what the generator computes (deps, scripts,
522
+ * the Vite plugin list).
523
+ */
524
+ const baseline = {
525
+ dependencies: ["react", "react-dom"],
526
+ devDependencies: [
527
+ "@chanom/dev-config",
528
+ "@chanom/vite-config",
529
+ "@vitejs/plugin-react",
530
+ "@tsconfig/vite-react",
531
+ "@types/react",
532
+ "@types/react-dom",
533
+ "typescript",
534
+ "vite",
535
+ "vitest",
536
+ "@vitest/coverage-v8",
537
+ "jsdom",
538
+ "@testing-library/react",
539
+ "@testing-library/jest-dom",
540
+ "oxlint",
541
+ "oxlint-tsgolint",
542
+ "oxfmt"
543
+ ],
544
+ scripts: {
545
+ dev: "vite",
546
+ build: "tsc -b && vite build",
547
+ preview: "vite preview",
548
+ lint: "oxlint",
549
+ format: "oxfmt",
550
+ "format:check": "oxfmt --check",
551
+ test: "vitest run",
552
+ "test:watch": "vitest"
553
+ },
554
+ viteImports: ["import react from '@vitejs/plugin-react';"],
555
+ vitePlugins: ["react()"],
556
+ files: []
557
+ };
558
+ const LINT_STAGED_CONFIG = {
559
+ "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": ["oxlint --fix --no-error-on-unmatched-pattern"],
560
+ "*": ["oxfmt --no-error-on-unmatched-pattern"]
561
+ };
562
+ const COMMITLINT_CONFIG = { extends: ["@commitlint/config-conventional"] };
563
+ /**
564
+ * The `commit-hooks` topping: husky + lint-staged + commitlint as one unit.
565
+ * Config choices are copied verbatim from `chanom brew`'s add-* commands. No
566
+ * `husky init` runs at generation time (deps are not installed yet); husky's
567
+ * `.husky/_/` materializes on the user's first install via `prepare: "husky"`.
568
+ */
569
+ function commitHooks(pm) {
570
+ return {
571
+ dependencies: [],
572
+ devDependencies: [
573
+ "husky",
574
+ "lint-staged",
575
+ "@commitlint/cli",
576
+ "@commitlint/config-conventional"
577
+ ],
578
+ scripts: { prepare: "husky" },
579
+ viteImports: [],
580
+ vitePlugins: [],
581
+ files: [
582
+ {
583
+ path: ".husky/pre-commit",
584
+ contents: "lint-staged\n"
585
+ },
586
+ {
587
+ path: ".husky/commit-msg",
588
+ contents: [
589
+ ...PM_EXEC[pm],
590
+ "commitlint",
591
+ "--edit",
592
+ "$1"
593
+ ].join(" ") + "\n"
594
+ },
595
+ {
596
+ path: ".lintstagedrc.json",
597
+ contents: JSON.stringify(LINT_STAGED_CONFIG, null, 2) + "\n"
598
+ },
599
+ {
600
+ path: ".commitlintrc.json",
601
+ contents: JSON.stringify(COMMITLINT_CONFIG, null, 2) + "\n"
602
+ }
603
+ ]
604
+ };
605
+ }
606
+ /** The baseline plus whichever toppings the resolved options select, in fold order. */
607
+ function selectContributions(selection) {
608
+ return selection.commitHooks ? [baseline, commitHooks(selection.pm)] : [baseline];
609
+ }
610
+ //#endregion
611
+ //#region src/scaffold.ts
612
+ var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
613
+ const writeFile = Effect.fn("scaffold.writeFile")(function* (targetDir, relPath, contents) {
614
+ const fs = yield* FileSystem.FileSystem;
615
+ const path = yield* Path.Path;
616
+ const dest = path.join(targetDir, relPath);
617
+ yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
618
+ yield* fs.writeFileString(dest, contents);
619
+ });
620
+ const copyTemplates = Effect.fn("scaffold.copyTemplates")(function* (plan) {
621
+ const fs = yield* FileSystem.FileSystem;
622
+ const path = yield* Path.Path;
623
+ const appName = toKebabCase(plan.appName);
624
+ for (const file of TEMPLATE_FILES) {
625
+ const template = yield* fs.readFileString(path.join(plan.templatesRoot, file.src));
626
+ const contents = file.dest === "README.md" ? renderReadme(template, appName) : file.dest === "src/app.tsx" || file.dest === "src/app.test.tsx" ? renderAppTemplate(template, appName) : template;
627
+ yield* writeFile(plan.targetDir, file.dest, contents);
628
+ }
629
+ });
630
+ const writeGeneratedFiles = Effect.fn("scaffold.writeGeneratedFiles")(function* (plan, folded) {
631
+ const appName = toKebabCase(plan.appName);
632
+ yield* writeFile(plan.targetDir, "package.json", buildPackageJson({
633
+ appName,
634
+ packageManager: plan.packageManagerField,
635
+ contribution: folded,
636
+ versions: depVersions
637
+ }));
638
+ yield* writeFile(plan.targetDir, "pnpm-workspace.yaml", buildPnpmWorkspace());
639
+ yield* writeFile(plan.targetDir, "vite.config.ts", renderViteConfig(folded));
640
+ yield* writeFile(plan.targetDir, "tsconfig.json", buildTsconfig());
641
+ yield* writeFile(plan.targetDir, "index.html", renderIndexHtml(appName));
642
+ for (const file of folded.files) yield* writeFile(plan.targetDir, file.path, file.contents);
643
+ });
644
+ const initGit = Effect.fn("scaffold.initGit")(function* (plan) {
645
+ const git = yield* Git;
646
+ const s = yield* (yield* Prompter).spinner("Initializing git repository...");
647
+ yield* git.init(plan.targetDir);
648
+ if (!(yield* git.hasIdentity(plan.targetDir))) yield* git.setLocalIdentity(plan.targetDir, "create-chanom-app", "create-chanom-app@users.noreply.github.com");
649
+ yield* git.stageAll(plan.targetDir);
650
+ const result = yield* git.commit(plan.targetDir, "chore: scaffold with create-chanom-app");
651
+ yield* s.stop(result.ok ? "Git repository initialized" : pc.yellow("Could not create the initial commit"));
652
+ });
653
+ const installDependencies = Effect.fn("scaffold.installDependencies")(function* (plan) {
654
+ const prompter = yield* Prompter;
655
+ const runner = yield* CommandRunner;
656
+ const s = yield* prompter.spinner(`Installing dependencies with ${plan.pm}...`);
657
+ if ((yield* runner.execInherit(plan.pm, ["install"], plan.targetDir).pipe(Effect.tapError(() => s.stop(pc.red("Dependency installation failed"))))) !== 0) {
658
+ yield* s.stop(pc.red("Dependency installation failed"));
659
+ return yield* new InstallFailed({ pm: plan.pm });
660
+ }
661
+ yield* s.stop("Dependencies installed");
662
+ });
663
+ /** Writes the generated app, then optionally initializes git and installs deps. */
664
+ const scaffold = Effect.fn("scaffold")(function* (plan) {
665
+ const fs = yield* FileSystem.FileSystem;
666
+ const folded = mergeContributions(selectContributions({
667
+ pm: plan.pm,
668
+ commitHooks: plan.commitHooks
669
+ }));
670
+ yield* fs.makeDirectory(plan.targetDir, { recursive: true });
671
+ yield* copyTemplates(plan);
672
+ yield* writeGeneratedFiles(plan, folded);
673
+ if (plan.git) yield* initGit(plan);
674
+ if (plan.install) yield* installDependencies(plan);
675
+ });
676
+ //#endregion
677
+ //#region src/cli.ts
678
+ const DEFAULT_NAME = "my-chanom-app";
679
+ const HELP = `${pc.bold("create-chanom-app")} ${pc.dim("- scaffold a Vite + React + TS app")}
680
+
681
+ ${pc.bold("Usage")}
682
+ npm create chanom-app@latest ${pc.dim("[directory] [options]")}
683
+ pnpm create chanom-app ${pc.dim("[directory] [options]")}
684
+
685
+ ${pc.bold("Options")}
686
+ ${pc.cyan("[directory]")} Target directory / project name (prompted if omitted)
687
+ ${pc.cyan("--git / --no-git")} Initialize a git repository (default: yes)
688
+ ${pc.cyan("--commit-hooks")} Add husky + lint-staged + commitlint (requires git)
689
+ ${pc.cyan("--no-commit-hooks")} Skip the commit-hooks topping
690
+ ${pc.cyan("--install / --no-install")} Install dependencies after scaffolding (default: install)
691
+ ${pc.cyan("--pm <pnpm|npm|yarn|bun>")} Package manager to record (default: detected)
692
+ ${pc.cyan("-y, --yes")} Accept all defaults, no prompts (CI-friendly)
693
+ ${pc.cyan("--help")} Show this help
694
+ ${pc.cyan("--version")} Show the version`;
695
+ var TargetNotEmpty = class extends Data.TaggedError("TargetNotEmpty") {};
696
+ /**
697
+ * `pnpm@11.9.0`-style field from the create launcher's user agent. Only the
698
+ * leading token is the real launcher (`npm/?` placeholders trail it), so the
699
+ * field is written only when it matches the resolved PM and carries a version.
700
+ */
701
+ function packageManagerField(pm, userAgent) {
702
+ const [name, version] = (userAgent?.split(" ")[0] ?? "").split("/");
703
+ return name === pm && version && version !== "?" ? `${pm}@${version}` : void 0;
704
+ }
705
+ /** flag wins, then `--yes`/default, otherwise ask. Building the ask effect is side-effect-free. */
706
+ function resolveConfirm(flag, yes, ask) {
707
+ if (flag !== void 0) return Effect.succeed(flag);
708
+ return yes ? Effect.succeed(true) : ask;
709
+ }
710
+ const isEmptyDir = Effect.fn("cli.isEmptyDir")(function* (dir) {
711
+ const fs = yield* FileSystem.FileSystem;
712
+ if (!(yield* fs.exists(dir))) return true;
713
+ return (yield* fs.readDirectory(dir)).filter((entry) => entry !== ".git").length === 0;
714
+ });
715
+ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
716
+ const prompter = yield* Prompter;
717
+ const path = yield* Path.Path;
718
+ const parsed = parseArgs(argv);
719
+ if (!parsed.ok) {
720
+ yield* prompter.error(parsed.message);
721
+ return 1;
722
+ }
723
+ const args = parsed.args;
724
+ if (args.help) {
725
+ yield* prompter.message(HELP);
726
+ return 0;
727
+ }
728
+ if (args.version) {
729
+ yield* prompter.message(pkgVersion);
730
+ return 0;
731
+ }
732
+ if (args.directory !== void 0) {
733
+ const invalid = validateProjectName(args.directory);
734
+ if (invalid !== void 0) {
735
+ yield* prompter.error(invalid);
736
+ return 1;
737
+ }
738
+ }
739
+ yield* prompter.intro(pc.magenta("🧋 create-chanom-app"));
740
+ let directory;
741
+ if (args.directory !== void 0) directory = toKebabCase(args.directory);
742
+ else if (args.yes) directory = DEFAULT_NAME;
743
+ else directory = yield* prompter.text({
744
+ message: "Project name?",
745
+ placeholder: DEFAULT_NAME,
746
+ defaultValue: DEFAULT_NAME,
747
+ validate: validateProjectName
748
+ });
749
+ if (directory === void 0) return 1;
750
+ const targetDir = path.resolve(cwd, directory);
751
+ const appName = path.basename(targetDir);
752
+ if (!(yield* isEmptyDir(targetDir))) return yield* new TargetNotEmpty({ directory });
753
+ const git = yield* resolveConfirm(args.git, args.yes, prompter.confirm({
754
+ message: "Initialize a git repository?",
755
+ initialValue: true
756
+ }));
757
+ const commitHooks = git ? yield* resolveConfirm(args.commitHooks, args.yes, prompter.confirm({
758
+ message: "Add commit hooks (husky + lint-staged + commitlint)?",
759
+ initialValue: true
760
+ })) : false;
761
+ const install = yield* resolveConfirm(args.install, args.yes, prompter.confirm({
762
+ message: "Install dependencies now?",
763
+ initialValue: true
764
+ }));
765
+ const pm = args.pm ?? resolvePm({
766
+ userAgent: options.userAgent,
767
+ fallback: "pnpm"
768
+ });
769
+ yield* prompter.message([
770
+ `${pc.dim("directory")} ${directory}`,
771
+ `${pc.dim("package mgr")} ${pm}`,
772
+ `${pc.dim("git")} ${git ? "yes" : "no"}`,
773
+ `${pc.dim("commit hooks")} ${git ? commitHooks ? "yes" : "no" : pc.dim("n/a")}`,
774
+ `${pc.dim("install")} ${install ? "yes" : "no"}`
775
+ ].join("\n"));
776
+ yield* scaffold({
777
+ targetDir,
778
+ appName,
779
+ pm,
780
+ packageManagerField: packageManagerField(pm, options.userAgent),
781
+ git,
782
+ commitHooks,
783
+ install,
784
+ templatesRoot: options.templatesRoot
785
+ });
786
+ const steps = [
787
+ ...directory === "." ? [] : [`cd ${directory}`],
788
+ ...install ? [] : [`${pm} install`],
789
+ `${pm} run dev`
790
+ ];
791
+ yield* prompter.outro(pc.green(`Done! Next steps:\n ${steps.join("\n ")}`));
792
+ return 0;
793
+ });
794
+ const reportError = (message) => Effect.gen(function* () {
795
+ yield* (yield* Prompter).error(message);
796
+ return 1;
797
+ });
798
+ /** Runs the scaffolder and returns the process exit code. */
799
+ const run = (argv, cwd, options) => {
800
+ const debug = options.debug ?? false;
801
+ return program$1(argv, cwd, options).pipe(Effect.catchTags({
802
+ Cancelled: () => Effect.succeed(0),
803
+ TargetNotEmpty: (e) => reportError(`${e.directory} is not empty. Aborting.`),
804
+ InstallFailed: (e) => reportError(`Dependency installation with ${e.pm} failed.`)
805
+ }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
806
+ };
807
+ //#endregion
808
+ //#region src/index.ts
809
+ const debug = process.argv.includes("--debug");
810
+ const argv = process.argv.slice(2).filter((arg) => arg !== "--debug");
811
+ const templatesRoot = join(import.meta.dirname, "templates");
812
+ const ServicesLive = Layer.mergeAll(Prompter.Default, Git.Default).pipe(Layer.provideMerge(CommandRunner.Default), Layer.provideMerge(NodeContext.layer));
813
+ const DebugLoggerLive = Logger.replace(Logger.defaultLogger, Logger.prettyLogger({ stderr: true }));
814
+ const MainLive = debug ? Layer.merge(ServicesLive, DebugLoggerLive) : ServicesLive;
815
+ const program = run(argv, process.cwd(), {
816
+ userAgent: process.env["npm_config_user_agent"],
817
+ templatesRoot,
818
+ debug
819
+ }).pipe(Effect.flatMap((exitCode) => Effect.sync(() => {
820
+ if (exitCode !== 0) process.exitCode = exitCode;
821
+ })), Effect.provide(MainLive));
822
+ NodeRuntime.runMain(program, {
823
+ disableErrorReporting: true,
824
+ disablePrettyLogger: true
825
+ });
826
+ //#endregion
827
+ export {};
@@ -0,0 +1,16 @@
1
+ # my-app
2
+
3
+ Brewed with [create-chanom-app](https://github.com/peeranat-dan/chanom).
4
+
5
+ ## Scripts
6
+
7
+ | Command | What it does |
8
+ | ------------------- | ------------------------------------- |
9
+ | `pnpm dev` | Start the Vite dev server |
10
+ | `pnpm build` | Type-check, then build for production |
11
+ | `pnpm preview` | Preview the production build |
12
+ | `pnpm lint` | Lint with oxlint |
13
+ | `pnpm format` | Format with oxfmt |
14
+ | `pnpm format:check` | Check formatting without writing |
15
+ | `pnpm test` | Run the test suite once |
16
+ | `pnpm test:watch` | Run tests in watch mode |
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ *.local
5
+ .DS_Store
@@ -0,0 +1 @@
1
+ export { default } from '@chanom/dev-config/oxfmt/config';
@@ -0,0 +1 @@
1
+ export { default } from '@chanom/dev-config/oxlint/config';
@@ -0,0 +1,8 @@
1
+ import { render, screen } from '@testing-library/react';
2
+
3
+ import { App } from './app';
4
+
5
+ test('renders the app heading', () => {
6
+ render(<App />);
7
+ expect(screen.getByRole('heading', { name: 'my-app' })).toBeInTheDocument();
8
+ });
@@ -0,0 +1,8 @@
1
+ export function App() {
2
+ return (
3
+ <main>
4
+ <h1>my-app</h1>
5
+ <p>Brewed with create-chanom-app.</p>
6
+ </main>
7
+ );
8
+ }
@@ -0,0 +1,11 @@
1
+ :root {
2
+ font-family: system-ui, sans-serif;
3
+ line-height: 1.5;
4
+ }
5
+
6
+ body {
7
+ margin: 0;
8
+ display: grid;
9
+ place-items: center;
10
+ min-height: 100vh;
11
+ }
@@ -0,0 +1,14 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ import { App } from './app';
5
+ import './index.css';
6
+
7
+ const rootElement = document.getElementById('root');
8
+ if (!rootElement) throw new Error('Root element #root not found');
9
+
10
+ createRoot(rootElement).render(
11
+ <StrictMode>
12
+ <App />
13
+ </StrictMode>,
14
+ );
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,8 @@
1
+ import { createReactAppVitestConfig } from '@chanom/vite-config/vitest';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default createReactAppVitestConfig({
5
+ plugins: [react()],
6
+ setupFiles: ['./src/test-setup.ts'],
7
+ coverage: true,
8
+ });
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "create-chanom-app",
3
+ "version": "0.0.0",
4
+ "description": "Scaffold a Vite + React + TypeScript app, wired to the chanom shared configs.",
5
+ "keywords": [
6
+ "create",
7
+ "react",
8
+ "scaffold",
9
+ "starter",
10
+ "typescript",
11
+ "vite"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/peeranat-dan/chanom.git",
16
+ "directory": "packages/create-chanom-app"
17
+ },
18
+ "bin": {
19
+ "create-chanom-app": "./dist/index.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "type": "module",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@clack/prompts": "1.6.0",
30
+ "@effect/platform": "0.96.2",
31
+ "@effect/platform-node": "0.107.0",
32
+ "effect": "3.21.4",
33
+ "picocolors": "1.1.1"
34
+ },
35
+ "devDependencies": {
36
+ "@effect/language-service": "0.86.4",
37
+ "@effect/vitest": "0.29.0",
38
+ "@tsconfig/node-lts": "24.0.0",
39
+ "@tsconfig/node-ts": "23.6.4",
40
+ "@types/node": "26.0.1",
41
+ "@vitest/coverage-v8": "3.2.6",
42
+ "tsdown": "0.22.3",
43
+ "typescript": "6.0.3",
44
+ "vitest": "3.2.6",
45
+ "@chanom/dev-config": "0.0.3",
46
+ "@chanom/internal": "0.0.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "dev": "tsdown --watch",
51
+ "test": "vitest run",
52
+ "test:coverage": "vitest run --coverage",
53
+ "test:watch": "vitest"
54
+ }
55
+ }