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 +86 -0
- package/dist/index.js +827 -0
- package/dist/templates/README.md.template +16 -0
- package/dist/templates/_gitignore.template +5 -0
- package/dist/templates/oxfmt.config.ts.template +1 -0
- package/dist/templates/oxlint.config.ts.template +1 -0
- package/dist/templates/src/app.test.tsx.template +8 -0
- package/dist/templates/src/app.tsx.template +8 -0
- package/dist/templates/src/index.css.template +11 -0
- package/dist/templates/src/main.tsx.template +14 -0
- package/dist/templates/src/test-setup.ts.template +1 -0
- package/dist/templates/src/vite-env.d.ts.template +1 -0
- package/dist/templates/vitest.config.ts.template +8 -0
- package/package.json +55 -0
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 @@
|
|
|
1
|
+
export { default } from '@chanom/dev-config/oxfmt/config';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@chanom/dev-config/oxlint/config';
|
|
@@ -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" />
|
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
|
+
}
|