@chanom/cli 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +45 -11
  2. package/dist/index.js +548 -304
  3. package/package.json +15 -2
package/README.md CHANGED
@@ -24,9 +24,17 @@ pnpm add -D @chanom/cli
24
24
  chanom brew
25
25
  ```
26
26
 
27
+ Add `--debug` to trace what the CLI is doing and see exactly where it failed:
28
+
29
+ ```sh
30
+ chanom brew --debug
31
+ ```
32
+
33
+ Debug mode logs each step (detected package manager, planned packages, every command it spawns and its exit code) to stderr, and on failure prints the full error trace including which step failed.
34
+
27
35
  `brew` walks you through two prompts:
28
36
 
29
- 1. **Toppings** - which tools to add, any combination of:
37
+ 1. **Toppings** - which tools to add, any combination (or none):
30
38
  - `oxlint` - fast Rust-based linter
31
39
  - `oxfmt` - fast Rust-based formatter
32
40
  - `knip` - dead code remover
@@ -37,26 +45,52 @@ chanom brew
37
45
  Based on your answers, `brew` will:
38
46
 
39
47
  - Initialize a git repo if one doesn't exist yet (with a `.gitignore`)
40
- - Detect your package manager (`pnpm`, `npm`, `yarn`, or `bun`) from `package.json`/`packageManager` or the invoking user agent
41
- - Install the packages needed for your selected toppings, skipping any already installed
42
- - Write config files (`oxlint.config.ts`, `oxfmt.config.ts`, `knip.config.ts`, `.commitlintrc.json`, `.lintstagedrc.json`) and wire up `package.json` scripts
48
+ - Detect your package manager (`pnpm`, `npm`, `yarn`, or `bun`) - the `packageManager` field in `package.json` wins over the invoking user agent, falling back to `pnpm`
49
+ - Install the packages needed for your selected toppings as dev dependencies
50
+ - Write config files and wire up `package.json` scripts
43
51
  - If `medium` sweetness is selected, run `husky init` and add `pre-commit` / `commit-msg` hooks
44
52
  - Stage and commit the changes it made
45
53
 
54
+ Cancelling a prompt (Ctrl+C) exits cleanly without touching anything further.
55
+
46
56
  ## What gets added
47
57
 
48
- | Topping | Config file | Scripts added |
49
- | -------- | ------------------ | ------------------------ |
50
- | `oxlint` | `oxlint.config.ts` | `lint`, `lint:fix` |
51
- | `oxfmt` | `oxfmt.config.ts` | `format`, `format:check` |
52
- | `knip` | `knip.config.ts` | `knip` |
58
+ | Topping | Config file | Scripts added |
59
+ | -------- | -------------------- | ------------------------ |
60
+ | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
61
+ | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
62
+ | `knip` | `knip.config.ts`\* | `knip` |
63
+
64
+ \* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
53
65
 
54
66
  Config files re-export presets from [`@chanom/dev-config`](../dev-config).
55
67
 
56
68
  `medium` sweetness additionally adds:
57
69
 
58
70
  - `husky` - installed via `husky init`
59
- - `lint-staged` - `.lintstagedrc.json` running the selected linter/formatter on staged files
71
+ - `lint-staged` - `.lintstagedrc.json` running the selected linter/formatter on staged files, wired into the `pre-commit` hook
60
72
  - `commitlint` - `.commitlintrc.json` extending `@commitlint/config-conventional`, enforced in the `commit-msg` hook
61
73
 
62
- Existing scripts and config are never overwritten - if a script name already exists in `package.json`, `brew` logs a warning and leaves it alone.
74
+ ## Version pinning
75
+
76
+ The CLI bundles the tool versions its generated configs are known to work with (baked in at build time). `oxlint`, `oxfmt`, and `@chanom/dev-config` are (re)installed whenever the installed version doesn't match the pinned one exactly - in either direction, since the generated config must match. `knip`, `husky`, `lint-staged`, and the commitlint packages are only installed when missing.
77
+
78
+ ## Safety
79
+
80
+ Existing setup is never overwritten:
81
+
82
+ - If a config file for a tool already exists (any common variant, e.g. `.oxlintrc.json` or `oxlint.config.mjs`), `brew` warns and skips writing it.
83
+ - If a script name already exists in `package.json`, `brew` warns and leaves it alone.
84
+ - Existing `.husky/pre-commit` and `.husky/commit-msg` hooks are kept.
85
+
86
+ The final commit uses your git identity; if none is configured, a local `Chanom <chanom@local>` identity is set. If committing fails (e.g. a commit hook rejects it), your files stay staged and `brew` tells you to commit manually.
87
+
88
+ ## Development
89
+
90
+ See [ARCHITECTURE.md](./ARCHITECTURE.md) for how the code is organized.
91
+
92
+ ```sh
93
+ pnpm build # bundle with tsdown
94
+ pnpm test # run vitest once
95
+ pnpm test:watch # run vitest in watch mode
96
+ ```
package/dist/index.js CHANGED
@@ -1,10 +1,153 @@
1
1
  #!/usr/bin/env node
2
- import { intro, log, multiselect, outro, select, spinner } from "@clack/prompts";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
- import { basename, join } from "node:path";
2
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
+ import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
4
+ import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
5
5
  import pc from "picocolors";
6
- import { spawnSync } from "node:child_process";
7
- //#region src/utils/detect-pm.ts
6
+ import * as clack from "@clack/prompts";
7
+ //#region src/bundled-versions.ts
8
+ const bundledVersions = {
9
+ oxlint: "1.71.0",
10
+ oxfmt: "0.56.0",
11
+ knip: "6.23.0",
12
+ devConfig: "0.0.3"
13
+ };
14
+ //#endregion
15
+ //#region src/domain/pkg.ts
16
+ var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
17
+ var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
18
+ function isEsm(pkg) {
19
+ return pkg.type === "module";
20
+ }
21
+ function isPackageInstalled(pkg, name) {
22
+ return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
23
+ }
24
+ function stripRangePrefix(version) {
25
+ return version.replace(/^[\^~]/, "");
26
+ }
27
+ function getMismatchedPackage(pkg, name, version) {
28
+ const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
29
+ return installed !== void 0 && stripRangePrefix(installed) === version ? void 0 : `${name}@${version}`;
30
+ }
31
+ //#endregion
32
+ //#region src/services/command-runner.ts
33
+ const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
34
+ /**
35
+ * Low-level process execution. Everything that shells out (git, package
36
+ * managers, husky) goes through this service so commands can be stubbed in tests.
37
+ */
38
+ var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect: Effect.gen(function* () {
39
+ const executor = yield* CommandExecutor.CommandExecutor;
40
+ const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
41
+ 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}`)));
42
+ const capture = (cmd, args, cwd) => Effect.scoped(Effect.gen(function* () {
43
+ yield* Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`);
44
+ const proc = yield* executor.start(make(cmd, args, cwd));
45
+ const [exitCode, stdout, stderr] = yield* Effect.all([
46
+ proc.exitCode,
47
+ collectText(proc.stdout),
48
+ collectText(proc.stderr)
49
+ ], { concurrency: 3 });
50
+ yield* Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${exitCode}`);
51
+ return {
52
+ exitCode,
53
+ stdout: stdout.trim(),
54
+ stderr: stderr.trim()
55
+ };
56
+ }));
57
+ const exitCode = (cmd, args, cwd) => traced(cmd, args, cwd, make(cmd, args, cwd).pipe(Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, executor)));
58
+ 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)));
59
+ return {
60
+ /** Runs a command silently and collects its exit code and trimmed output. */
61
+ capture,
62
+ /** Runs a command silently and returns only its exit code. */
63
+ exitCode,
64
+ /** Runs a command with stdout/stderr forwarded to the user's terminal. */
65
+ execInherit
66
+ };
67
+ }) }) {};
68
+ //#endregion
69
+ //#region src/services/git.ts
70
+ /** Git operations, built on top of {@link CommandRunner}. */
71
+ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(function* () {
72
+ const runner = yield* CommandRunner;
73
+ const fs = yield* FileSystem.FileSystem;
74
+ const path = yield* Path.Path;
75
+ const run = (args, cwd) => runner.capture("git", args, cwd).pipe(Effect.map(({ exitCode, stdout, stderr }) => ({
76
+ ok: exitCode === 0,
77
+ stdout,
78
+ stderr
79
+ })));
80
+ const exitOk = (args, cwd) => runner.exitCode("git", args, cwd).pipe(Effect.map((code) => code === 0));
81
+ return {
82
+ init: (cwd) => run(["init"], cwd),
83
+ stageAll: (cwd) => run(["add", "."], cwd),
84
+ commit: (cwd, message) => run([
85
+ "commit",
86
+ "-m",
87
+ message
88
+ ], cwd),
89
+ hasIdentity: (cwd) => exitOk(["var", "GIT_COMMITTER_IDENT"], cwd),
90
+ setLocalIdentity: (cwd, name, email) => run([
91
+ "config",
92
+ "user.name",
93
+ name
94
+ ], cwd).pipe(Effect.andThen(() => run([
95
+ "config",
96
+ "user.email",
97
+ email
98
+ ], cwd)), Effect.asVoid),
99
+ hasStagedChanges: (cwd) => exitOk([
100
+ "diff",
101
+ "--cached",
102
+ "--quiet"
103
+ ], cwd).pipe(Effect.map((clean) => !clean)),
104
+ isRepo: (cwd) => fs.exists(path.join(cwd, ".git")),
105
+ writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents)
106
+ };
107
+ }) }) {};
108
+ //#endregion
109
+ //#region src/services/package-installer.ts
110
+ var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
111
+ /** Installs npm packages with the detected package manager. */
112
+ var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", { effect: Effect.gen(function* () {
113
+ const runner = yield* CommandRunner;
114
+ return { installDev: (pm, cwd, packages) => runner.execInherit(pm, [
115
+ "add",
116
+ "-D",
117
+ ...packages
118
+ ], cwd).pipe(Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
119
+ }) }) {};
120
+ //#endregion
121
+ //#region src/services/prompter.ts
122
+ var Cancelled = class extends Data.TaggedError("Cancelled") {};
123
+ const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
124
+ /**
125
+ * All terminal interaction (prompts, spinners, log messages). Commands never
126
+ * touch @clack/prompts directly, so tests can script answers and capture output.
127
+ */
128
+ var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
129
+ intro: (message) => Effect.sync(() => clack.intro(message)),
130
+ outro: (message) => Effect.sync(() => clack.outro(message)),
131
+ warn: (message) => Effect.sync(() => clack.log.warn(message)),
132
+ error: (message) => Effect.sync(() => clack.log.error(message)),
133
+ debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
134
+ select: (params) => guardCancel(() => clack.select({
135
+ message: params.message,
136
+ options: [...params.options]
137
+ })),
138
+ multiselect: (params) => guardCancel(() => clack.multiselect({
139
+ message: params.message,
140
+ options: [...params.options],
141
+ required: params.required ?? false
142
+ })),
143
+ spinner: (message) => Effect.sync(() => {
144
+ const s = clack.spinner();
145
+ s.start(message);
146
+ return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
147
+ })
148
+ } }) {};
149
+ //#endregion
150
+ //#region src/domain/package-manager.ts
8
151
  /** How to run a locally installed binary with each package manager. */
9
152
  const PM_EXEC = {
10
153
  pnpm: ["pnpm", "exec"],
@@ -22,115 +165,53 @@ const KNOWN_PMS = [
22
165
  "bun",
23
166
  "npm"
24
167
  ];
25
- function fromPkgJson(cwd) {
26
- const pkgPath = join(cwd, "package.json");
27
- if (!existsSync(pkgPath)) return void 0;
28
- try {
29
- const { packageManager } = JSON.parse(readFileSync(pkgPath, "utf-8"));
30
- if (!packageManager) return void 0;
31
- const name = packageManager.split("@")[0];
32
- return KNOWN_PMS.includes(name) ? name : void 0;
33
- } catch {
34
- return;
35
- }
36
- }
37
- function fromUserAgent() {
38
- const agent = process.env["npm_config_user_agent"];
168
+ /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
169
+ function parsePmField(field) {
170
+ if (!field) return void 0;
171
+ const name = field.split("@")[0];
172
+ return KNOWN_PMS.find((pm) => pm === name);
173
+ }
174
+ /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
175
+ function parsePmUserAgent(agent) {
39
176
  if (!agent) return void 0;
40
177
  return KNOWN_PMS.find((pm) => agent.startsWith(pm));
41
178
  }
42
- function detectPm(cwd = process.cwd(), defaultPm = "pnpm") {
43
- return fromPkgJson(cwd) ?? fromUserAgent() ?? defaultPm;
179
+ /** The `packageManager` field wins over the user agent, which wins over the fallback. */
180
+ function resolvePm(hints) {
181
+ return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
44
182
  }
45
183
  //#endregion
46
- //#region src/utils/git.ts
47
- function run(args, cwd) {
48
- const { status, stdout, stderr } = spawnSync("git", args, {
49
- stdio: "pipe",
50
- cwd,
51
- encoding: "utf-8"
184
+ //#region src/utils/detect-pm.ts
185
+ const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
186
+ const fs = yield* FileSystem.FileSystem;
187
+ const pkgPath = (yield* Path.Path).join(cwd, "package.json");
188
+ return resolvePm({
189
+ packageManagerField: yield* fs.readFileString(pkgPath).pipe(Effect.flatMap((contents) => Effect.try(() => JSON.parse(contents).packageManager)), Effect.orElseSucceed(() => void 0)),
190
+ userAgent,
191
+ fallback
52
192
  });
53
- return {
54
- ok: status === 0,
55
- stdout: (stdout ?? "").trim(),
56
- stderr: (stderr ?? "").trim()
57
- };
58
- }
59
- function isGitRepo(cwd = process.cwd()) {
60
- return existsSync(join(cwd, ".git"));
61
- }
62
- function init(cwd) {
63
- return run(["init"], cwd);
64
- }
65
- /** True if git has a usable author identity (committer.name + committer.email). */
66
- function hasIdentity(cwd) {
67
- return run(["var", "GIT_COMMITTER_IDENT"], cwd).ok;
68
- }
69
- /** Set a repo-local identity so commits succeed on a fresh repo without global config. */
70
- function setLocalIdentity(cwd, name, email) {
71
- run([
72
- "config",
73
- "user.name",
74
- name
75
- ], cwd);
76
- run([
77
- "config",
78
- "user.email",
79
- email
80
- ], cwd);
81
- }
82
- function stageAll(cwd) {
83
- return run(["add", "."], cwd);
84
- }
85
- /** True if there is anything staged to commit. */
86
- function hasStagedChanges(cwd) {
87
- return !run([
88
- "diff",
89
- "--cached",
90
- "--quiet"
91
- ], cwd).ok;
92
- }
93
- function commit(cwd, message) {
94
- return run([
95
- "commit",
96
- "-m",
97
- message
98
- ], cwd);
99
- }
100
- function writeGitignore(cwd, contents = "node_modules\n") {
101
- writeFileSync(join(cwd, ".gitignore"), contents, "utf-8");
102
- }
103
- //#endregion
104
- //#region src/utils/install.ts
105
- function installDev(pm, cwd, ...packages) {
106
- return spawnSync(pm, [
107
- "add",
108
- "-D",
109
- ...packages
110
- ], {
111
- stdio: "inherit",
112
- cwd
113
- }).status === 0;
114
- }
193
+ });
115
194
  //#endregion
116
- //#region src/utils/pkg.ts
117
- function isEsm(pkg) {
118
- return pkg.type === "module";
119
- }
120
- function readPkg(cwd) {
121
- const pkgPath = join(cwd, "package.json");
122
- if (!existsSync(pkgPath)) throw new Error(`No package.json found in ${cwd}`);
195
+ //#region src/utils/pkg-file.ts
196
+ const readPkg = (cwd) => Effect.gen(function* () {
197
+ const fs = yield* FileSystem.FileSystem;
198
+ const pkgPath = (yield* Path.Path).join(cwd, "package.json");
199
+ if (!(yield* fs.exists(pkgPath))) return yield* new PkgNotFound({ cwd });
200
+ const contents = yield* fs.readFileString(pkgPath);
123
201
  return {
124
- pkg: JSON.parse(readFileSync(pkgPath, "utf-8")),
202
+ pkg: yield* Effect.try({
203
+ try: () => JSON.parse(contents),
204
+ catch: () => new PkgInvalid({ pkgPath })
205
+ }),
125
206
  pkgPath
126
207
  };
127
- }
128
- function isPackageInstalled(pkg, name) {
129
- return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
130
- }
208
+ });
209
+ const writePkg = (pkgPath, pkg) => Effect.gen(function* () {
210
+ yield* (yield* FileSystem.FileSystem).writeFileString(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
211
+ });
131
212
  //#endregion
132
- //#region src/utils/detect-setup.ts
133
- const CONFIG_FILES = {
213
+ //#region src/domain/setup.ts
214
+ const SETUP_FILE_CANDIDATES = {
134
215
  oxlint: [
135
216
  "oxlint.config.js",
136
217
  "oxlint.config.cjs",
@@ -173,258 +254,421 @@ const CONFIG_FILES = {
173
254
  "vite.config.mjs"
174
255
  ]
175
256
  };
176
- function detectSetupFile(type, cwd = process.cwd()) {
177
- for (const filename of CONFIG_FILES[type]) {
178
- const filepath = join(cwd, filename);
179
- if (existsSync(filepath)) return filepath;
257
+ //#endregion
258
+ //#region src/utils/detect-setup.ts
259
+ /** Finds an existing config file for the given tool, if any. */
260
+ const detectSetupFile = (type, cwd) => Effect.gen(function* () {
261
+ const fs = yield* FileSystem.FileSystem;
262
+ const path = yield* Path.Path;
263
+ for (const candidate of SETUP_FILE_CANDIDATES[type]) {
264
+ const filepath = path.join(cwd, candidate);
265
+ if (yield* fs.exists(filepath)) return Option.some(filepath);
180
266
  }
181
- return null;
182
- }
267
+ return Option.none();
268
+ });
183
269
  //#endregion
184
- //#region src/commands/add-commitlint/index.ts
270
+ //#region src/commands/add-commitlint/logic.ts
185
271
  function getPackages$5(pkg) {
186
272
  const missing = [];
187
273
  if (!isPackageInstalled(pkg, "@commitlint/cli")) missing.push("@commitlint/cli");
188
274
  if (!isPackageInstalled(pkg, "@commitlint/config-conventional")) missing.push("@commitlint/config-conventional");
189
275
  return missing;
190
276
  }
191
- function apply$5(cwd, pm) {
192
- const existing = detectSetupFile("commitlint", cwd);
193
- if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping commitlint config`);
194
- else writeFileSync(join(cwd, ".commitlintrc.json"), JSON.stringify({ extends: ["@commitlint/config-conventional"] }, null, 2) + "\n");
195
- if (existsSync(join(cwd, ".husky"))) {
196
- const hookPath = join(cwd, ".husky", "commit-msg");
197
- if (existsSync(hookPath)) log.warn("`.husky/commit-msg` already exists - skipping");
198
- else writeFileSync(hookPath, [
199
- ...PM_EXEC[pm],
200
- "commitlint",
201
- "--edit",
202
- "$1"
203
- ].join(" ") + "\n");
204
- }
277
+ const CONFIG_CONTENTS = JSON.stringify({ extends: ["@commitlint/config-conventional"] }, null, 2) + "\n";
278
+ function commitMsgHook(pm) {
279
+ return [
280
+ ...PM_EXEC[pm],
281
+ "commitlint",
282
+ "--edit",
283
+ "$1"
284
+ ].join(" ") + "\n";
205
285
  }
206
286
  //#endregion
207
- //#region src/commands/add-husky/index.ts
287
+ //#region src/commands/add-commitlint/index.ts
288
+ const apply$5 = (cwd, pm) => Effect.gen(function* () {
289
+ const fs = yield* FileSystem.FileSystem;
290
+ const path = yield* Path.Path;
291
+ const prompter = yield* Prompter;
292
+ const existing = yield* detectSetupFile("commitlint", cwd);
293
+ if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping commitlint config`);
294
+ else yield* fs.writeFileString(path.join(cwd, ".commitlintrc.json"), CONFIG_CONTENTS);
295
+ const huskyDir = path.join(cwd, ".husky");
296
+ if (yield* fs.exists(huskyDir)) {
297
+ const hookPath = path.join(huskyDir, "commit-msg");
298
+ if (yield* fs.exists(hookPath)) yield* prompter.warn("`.husky/commit-msg` already exists - skipping");
299
+ else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
300
+ }
301
+ }).pipe(Effect.withSpan("add-commitlint.apply"));
302
+ //#endregion
303
+ //#region src/commands/add-husky/logic.ts
208
304
  function getPackages$4(pkg) {
209
305
  return isPackageInstalled(pkg, "husky") ? [] : ["husky"];
210
306
  }
211
- function apply$4(cwd, pm) {
212
- if (existsSync(join(cwd, ".husky"))) {
213
- log.warn("`.husky` already exists - skipping husky init");
307
+ //#endregion
308
+ //#region src/commands/add-husky/index.ts
309
+ var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
310
+ const apply$4 = (cwd, pm) => Effect.gen(function* () {
311
+ const fs = yield* FileSystem.FileSystem;
312
+ const path = yield* Path.Path;
313
+ const prompter = yield* Prompter;
314
+ const runner = yield* CommandRunner;
315
+ const huskyDir = path.join(cwd, ".husky");
316
+ if (yield* fs.exists(huskyDir)) {
317
+ yield* prompter.warn("`.husky` already exists - skipping husky init");
214
318
  return;
215
319
  }
216
320
  const [cmd, ...args] = PM_EXEC[pm];
217
- spawnSync(cmd, [
321
+ const exitCode = yield* runner.execInherit(cmd, [
218
322
  ...args,
219
323
  "husky",
220
324
  "init"
221
- ], {
222
- stdio: "inherit",
223
- cwd
224
- });
325
+ ], cwd);
326
+ if (exitCode !== 0) return yield* new HuskyInitFailed({ exitCode });
327
+ }).pipe(Effect.withSpan("add-husky.apply"));
328
+ //#endregion
329
+ //#region src/domain/scripts.ts
330
+ /**
331
+ * Merges `wanted` scripts into `current` without overwriting anything the user
332
+ * already has. Returns the merged scripts plus which keys were added or skipped.
333
+ */
334
+ function planScripts(current, wanted) {
335
+ const scripts = { ...current };
336
+ const added = [];
337
+ const skipped = [];
338
+ for (const [key, value] of Object.entries(wanted)) if (scripts[key]) skipped.push(key);
339
+ else {
340
+ scripts[key] = value;
341
+ added.push(key);
342
+ }
343
+ return {
344
+ scripts,
345
+ added,
346
+ skipped
347
+ };
225
348
  }
226
349
  //#endregion
227
350
  //#region src/commands/add-knip/logic.ts
228
- function getMissingScripts$2(scripts) {
229
- const missing = {};
230
- if (!scripts["knip"]) missing["knip"] = "knip";
231
- return missing;
351
+ function getPackages$3(pkg, versions) {
352
+ return isPackageInstalled(pkg, "knip") ? [] : [`knip@${versions.knip}`, `@chanom/dev-config@${versions.devConfig}`];
232
353
  }
233
- //#endregion
234
- //#region src/commands/add-knip/index.ts
235
- function getPackages$3(pkg) {
236
- return isPackageInstalled(pkg, "knip") ? [] : [`knip@6.23.0`, `@chanom/dev-config@0.0.3`];
354
+ function configFile$2(esm) {
355
+ return {
356
+ fileName: `knip.config.${esm ? "ts" : "mts"}`,
357
+ contents: `export { default } from '@chanom/dev-config/knip/config';\n`
358
+ };
237
359
  }
238
- function apply$3(cwd, esm) {
239
- const existing = detectSetupFile("knip", cwd);
240
- if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping knip config`);
241
- else writeFileSync(join(cwd, `knip.config.${esm ? "ts" : "mts"}`), `export { default } from '@chanom/dev-config/knip/config';\n`);
242
- const { pkg, pkgPath } = readPkg(cwd);
243
- pkg.scripts ??= {};
244
- const missing = getMissingScripts$2(pkg.scripts);
245
- for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
246
- const skipped = ["knip"].filter((k) => !(k in missing));
247
- for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
248
- if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
360
+ function getScriptPlan$2(scripts) {
361
+ return planScripts(scripts, { knip: "knip" });
249
362
  }
250
363
  //#endregion
251
- //#region src/commands/add-lint-staged/index.ts
364
+ //#region src/commands/add-knip/index.ts
365
+ /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
366
+ const apply$3 = (cwd, esm, pkg) => Effect.gen(function* () {
367
+ const fs = yield* FileSystem.FileSystem;
368
+ const path = yield* Path.Path;
369
+ const prompter = yield* Prompter;
370
+ const existing = yield* detectSetupFile("knip", cwd);
371
+ if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping knip config`);
372
+ else {
373
+ const config = configFile$2(esm);
374
+ yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
375
+ }
376
+ const plan = getScriptPlan$2(pkg.scripts);
377
+ for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
378
+ return {
379
+ ...pkg,
380
+ scripts: plan.scripts
381
+ };
382
+ }).pipe(Effect.withSpan("add-knip.apply"));
383
+ //#endregion
384
+ //#region src/commands/add-lint-staged/logic.ts
252
385
  function getPackages$2(pkg) {
253
386
  return isPackageInstalled(pkg, "lint-staged") ? [] : ["lint-staged"];
254
387
  }
255
- function apply$2(cwd, linters = [], formatters = [], overwritePreCommit = false) {
256
- const existing = detectSetupFile("lint-staged", cwd);
257
- if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping lint-staged config`);
258
- else {
259
- const config = {};
260
- const jsGlob = "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
261
- if (linters.includes("oxlint")) {
262
- config[jsGlob] ??= [];
263
- config[jsGlob].push("oxlint --fix");
264
- }
265
- if (formatters.includes("oxfmt")) {
266
- config["*"] ??= [];
267
- config["*"].push("oxfmt");
268
- }
269
- writeFileSync(join(cwd, ".lintstagedrc.json"), JSON.stringify(config, null, 2) + "\n");
388
+ function buildConfig(linters, formatters) {
389
+ const config = {};
390
+ const jsGlob = "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
391
+ if (linters.includes("oxlint")) {
392
+ config[jsGlob] ??= [];
393
+ config[jsGlob].push("oxlint --fix --no-error-on-unmatched-pattern");
270
394
  }
271
- if (existsSync(join(cwd, ".husky"))) {
272
- const hookPath = join(cwd, ".husky", "pre-commit");
273
- if (existsSync(hookPath) && !overwritePreCommit) log.warn("`.husky/pre-commit` already exists - skipping");
274
- else writeFileSync(hookPath, "lint-staged\n");
395
+ if (formatters.includes("oxfmt")) {
396
+ config["*"] ??= [];
397
+ config["*"].push("oxfmt --no-error-on-unmatched-pattern");
275
398
  }
399
+ return config;
276
400
  }
277
401
  //#endregion
402
+ //#region src/commands/add-lint-staged/index.ts
403
+ const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false) => Effect.gen(function* () {
404
+ const fs = yield* FileSystem.FileSystem;
405
+ const path = yield* Path.Path;
406
+ const prompter = yield* Prompter;
407
+ const existing = yield* detectSetupFile("lint-staged", cwd);
408
+ if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping lint-staged config`);
409
+ else yield* fs.writeFileString(path.join(cwd, ".lintstagedrc.json"), JSON.stringify(buildConfig(linters, formatters), null, 2) + "\n");
410
+ const huskyDir = path.join(cwd, ".husky");
411
+ if (yield* fs.exists(huskyDir)) {
412
+ const hookPath = path.join(huskyDir, "pre-commit");
413
+ if ((yield* fs.exists(hookPath)) && !overwritePreCommit) yield* prompter.warn("`.husky/pre-commit` already exists - skipping");
414
+ else yield* fs.writeFileString(hookPath, "lint-staged\n");
415
+ }
416
+ }).pipe(Effect.withSpan("add-lint-staged.apply"));
417
+ //#endregion
278
418
  //#region src/commands/add-oxfmt/logic.ts
279
- function getMissingScripts$1(scripts) {
280
- const missing = {};
281
- if (!scripts["format"]) missing["format"] = "oxfmt";
282
- if (!scripts["format:check"]) missing["format:check"] = "oxfmt --check";
283
- return missing;
419
+ function getPackages$1(pkg, versions) {
420
+ return [getMismatchedPackage(pkg, "oxfmt", versions.oxfmt), getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)].filter((p) => p !== void 0);
284
421
  }
285
- //#endregion
286
- //#region src/commands/add-oxfmt/index.ts
287
- function getPackages$1(pkg) {
288
- return isPackageInstalled(pkg, "oxfmt") ? [] : [`oxfmt@0.56.0`, `@chanom/dev-config@0.0.3`];
422
+ function configFile$1(esm) {
423
+ return {
424
+ fileName: `oxfmt.config.${esm ? "ts" : "mts"}`,
425
+ contents: `export { default } from '@chanom/dev-config/oxfmt/config';\n`
426
+ };
289
427
  }
290
- function apply$1(cwd, esm) {
291
- const existing = detectSetupFile("oxfmt", cwd);
292
- if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping oxfmt config`);
293
- else writeFileSync(join(cwd, `oxfmt.config.${esm ? "ts" : "mts"}`), `export { default } from '@chanom/dev-config/oxfmt/config';\n`);
294
- const { pkg, pkgPath } = readPkg(cwd);
295
- pkg.scripts ??= {};
296
- const missing = getMissingScripts$1(pkg.scripts);
297
- for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
298
- const skipped = ["format", "format:check"].filter((k) => !(k in missing));
299
- for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
300
- if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
428
+ function getScriptPlan$1(scripts) {
429
+ return planScripts(scripts, {
430
+ format: "oxfmt",
431
+ "format:check": "oxfmt --check"
432
+ });
301
433
  }
302
434
  //#endregion
435
+ //#region src/commands/add-oxfmt/index.ts
436
+ /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
437
+ const apply$1 = (cwd, esm, pkg) => Effect.gen(function* () {
438
+ const fs = yield* FileSystem.FileSystem;
439
+ const path = yield* Path.Path;
440
+ const prompter = yield* Prompter;
441
+ const existing = yield* detectSetupFile("oxfmt", cwd);
442
+ if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping oxfmt config`);
443
+ else {
444
+ const config = configFile$1(esm);
445
+ yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
446
+ }
447
+ const plan = getScriptPlan$1(pkg.scripts);
448
+ for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
449
+ return {
450
+ ...pkg,
451
+ scripts: plan.scripts
452
+ };
453
+ }).pipe(Effect.withSpan("add-oxfmt.apply"));
454
+ //#endregion
303
455
  //#region src/commands/add-oxlint/logic.ts
304
- function getMissingScripts(scripts) {
305
- const missing = {};
306
- if (!scripts["lint"]) missing["lint"] = "oxlint";
307
- if (!scripts["lint:fix"]) missing["lint:fix"] = "oxlint --fix";
308
- return missing;
456
+ function getPackages(pkg, versions) {
457
+ return [getMismatchedPackage(pkg, "oxlint", versions.oxlint), getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)].filter((p) => p !== void 0);
458
+ }
459
+ function configFile(esm) {
460
+ return {
461
+ fileName: `oxlint.config.${esm ? "ts" : "mts"}`,
462
+ contents: `export { default } from '@chanom/dev-config/oxlint/config';\n`
463
+ };
464
+ }
465
+ function getScriptPlan(scripts) {
466
+ return planScripts(scripts, {
467
+ lint: "oxlint",
468
+ "lint:fix": "oxlint --fix"
469
+ });
309
470
  }
310
471
  //#endregion
311
472
  //#region src/commands/add-oxlint/index.ts
312
- function getPackages(pkg) {
313
- return isPackageInstalled(pkg, "oxlint") ? [] : [`oxlint@1.71.0`, `@chanom/dev-config@0.0.3`];
473
+ /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
474
+ const apply = (cwd, esm, pkg) => Effect.gen(function* () {
475
+ const fs = yield* FileSystem.FileSystem;
476
+ const path = yield* Path.Path;
477
+ const prompter = yield* Prompter;
478
+ const existing = yield* detectSetupFile("oxlint", cwd);
479
+ if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping oxlint config`);
480
+ else {
481
+ const config = configFile(esm);
482
+ yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
483
+ }
484
+ const plan = getScriptPlan(pkg.scripts);
485
+ for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
486
+ return {
487
+ ...pkg,
488
+ scripts: plan.scripts
489
+ };
490
+ }).pipe(Effect.withSpan("add-oxlint.apply"));
491
+ //#endregion
492
+ //#region src/commands/brew/logic.ts
493
+ /** Deduplicated list of packages to install for the chosen toppings and sweetness. */
494
+ function planPackages(pkg, toppings, sweetness, versions) {
495
+ const packages = [];
496
+ if (toppings.includes("oxlint")) packages.push(...getPackages(pkg, versions));
497
+ if (toppings.includes("oxfmt")) packages.push(...getPackages$1(pkg, versions));
498
+ if (toppings.includes("knip")) packages.push(...getPackages$3(pkg, versions));
499
+ if (sweetness === "medium") packages.push(...getPackages$4(pkg), ...getPackages$2(pkg), ...getPackages$5(pkg));
500
+ return [...new Set(packages)];
501
+ }
502
+ function selectedLinters(toppings) {
503
+ return toppings.filter((t) => t === "oxlint");
314
504
  }
315
- function apply(cwd, esm) {
316
- const existing = detectSetupFile("oxlint", cwd);
317
- if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping oxlint config`);
318
- else writeFileSync(join(cwd, `oxlint.config.${esm ? "ts" : "mts"}`), `export { default } from '@chanom/dev-config/oxlint/config';\n`);
319
- const { pkg, pkgPath } = readPkg(cwd);
320
- pkg.scripts ??= {};
321
- const missing = getMissingScripts(pkg.scripts);
322
- for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
323
- const skipped = ["lint", "lint:fix"].filter((k) => !(k in missing));
324
- for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
325
- if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
505
+ function selectedFormatters(toppings) {
506
+ return toppings.filter((t) => t === "oxfmt");
326
507
  }
327
508
  //#endregion
328
509
  //#region src/commands/brew/index.ts
329
- async function brew(cwd = process.cwd()) {
330
- intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
331
- if (!isGitRepo(cwd)) {
332
- const s = spinner();
333
- s.start("Git not found, initializing repository...");
334
- init(cwd);
335
- writeGitignore(cwd);
336
- s.stop("Git initialized");
510
+ const ensureGitRepo = (cwd) => Effect.gen(function* () {
511
+ const git = yield* Git;
512
+ if (yield* git.isRepo(cwd)) {
513
+ yield* Effect.logDebug("git repository already present");
514
+ return;
337
515
  }
338
- const pm = detectPm(cwd);
339
- if (!existsSync(join(cwd, "package.json"))) {
340
- log.error(`No package.json found. Run \`${pm} init\` first, then re-run \`chanom brew\`.`);
341
- process.exit(1);
516
+ const s = yield* (yield* Prompter).spinner("Git not found, initializing repository...");
517
+ yield* git.init(cwd);
518
+ yield* git.writeGitignore(cwd);
519
+ yield* s.stop("Git initialized");
520
+ }).pipe(Effect.withSpan("brew.ensureGitRepo"));
521
+ const askRecipe = () => Effect.gen(function* () {
522
+ const prompter = yield* Prompter;
523
+ return {
524
+ toppings: yield* prompter.multiselect({
525
+ message: "Which toppings would you like?",
526
+ options: [
527
+ {
528
+ value: "oxlint",
529
+ label: "oxlint",
530
+ hint: "fast Rust-based linter"
531
+ },
532
+ {
533
+ value: "oxfmt",
534
+ label: "oxfmt",
535
+ hint: "fast Rust-based formatter"
536
+ },
537
+ {
538
+ value: "knip",
539
+ label: "knip",
540
+ hint: "dead code remover"
541
+ }
542
+ ],
543
+ required: false
544
+ }),
545
+ sweetness: yield* prompter.select({
546
+ message: "How sweet would you like it?",
547
+ options: [{
548
+ value: "light",
549
+ label: "light",
550
+ hint: "warn only, no blocking"
551
+ }, {
552
+ value: "medium",
553
+ label: "medium",
554
+ hint: "block on commit (husky + lint-staged + commitlint)"
555
+ }]
556
+ })
557
+ };
558
+ }).pipe(Effect.withSpan("brew.askRecipe"));
559
+ const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
560
+ if (packages.length === 0) {
561
+ yield* Effect.logDebug("no packages to install");
562
+ return;
342
563
  }
343
- const toppings = await multiselect({
344
- message: "Which toppings would you like?",
345
- options: [
346
- {
347
- value: "oxlint",
348
- label: "oxlint",
349
- hint: "fast Rust-based linter"
350
- },
351
- {
352
- value: "oxfmt",
353
- label: "oxfmt",
354
- hint: "fast Rust-based formatter"
355
- },
356
- {
357
- value: "knip",
358
- label: "knip",
359
- hint: "dead code remover"
360
- }
361
- ],
362
- required: false
363
- });
364
- if (typeof toppings === "symbol") process.exit(0);
365
- const sweetness = await select({
366
- message: "How sweet would you like it?",
367
- options: [{
368
- value: "light",
369
- label: "light",
370
- hint: "warn only, no blocking"
371
- }, {
372
- value: "medium",
373
- label: "medium",
374
- hint: "block on commit (husky + lint-staged + commitlint)"
375
- }]
376
- });
377
- if (typeof sweetness === "symbol") process.exit(0);
378
- const { pkg } = readPkg(cwd);
379
- const esm = isEsm(pkg);
380
- const packages = [];
381
- if (toppings.includes("oxlint")) packages.push(...getPackages(pkg));
382
- if (toppings.includes("oxfmt")) packages.push(...getPackages$1(pkg));
383
- if (toppings.includes("knip")) packages.push(...getPackages$3(pkg));
384
- if (sweetness === "medium") packages.push(...getPackages$4(pkg), ...getPackages$2(pkg), ...getPackages$5(pkg));
385
- if (packages.length > 0) {
386
- const s = spinner();
387
- const uniquePackages = Array.from(new Set(packages));
388
- s.start(`Installing ${uniquePackages.join(", ")}...`);
389
- if (!installDev(pm, cwd, ...uniquePackages)) {
390
- s.stop(pc.red("Package installation failed"));
391
- log.error(`\`${pm} add -D\` failed. Fix the error above and re-run \`chanom brew\`.`);
392
- process.exit(1);
393
- }
394
- s.stop("Packages installed");
564
+ const prompter = yield* Prompter;
565
+ const installer = yield* PackageInstaller;
566
+ const s = yield* prompter.spinner(`Installing ${packages.join(", ")}...`);
567
+ yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
568
+ yield* s.stop("Packages installed");
569
+ }).pipe(Effect.withSpan("brew.installPackages"));
570
+ const applyToppings = (cwd, esm, pkg, toppings) => Effect.gen(function* () {
571
+ let updated = pkg;
572
+ if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
573
+ if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
574
+ if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
575
+ return updated;
576
+ }).pipe(Effect.withSpan("brew.applyToppings"));
577
+ const persistScripts = (pkgPath, pkg, updated) => Effect.gen(function* () {
578
+ if (JSON.stringify(updated.scripts ?? {}) !== JSON.stringify(pkg.scripts ?? {})) {
579
+ yield* Effect.logDebug(`writing updated scripts to ${pkgPath}`);
580
+ yield* writePkg(pkgPath, updated);
581
+ } else yield* Effect.logDebug("scripts unchanged, skipping package.json write");
582
+ }).pipe(Effect.withSpan("brew.persistScripts"));
583
+ const setupCommitGate = (cwd, pm, toppings, sweetness) => Effect.gen(function* () {
584
+ if (sweetness !== "medium") return;
585
+ const fs = yield* FileSystem.FileSystem;
586
+ const path = yield* Path.Path;
587
+ const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
588
+ yield* apply$4(cwd, pm);
589
+ yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
590
+ yield* apply$5(cwd, pm);
591
+ }).pipe(Effect.withSpan("brew.setupCommitGate"));
592
+ const commitChanges = (cwd) => Effect.gen(function* () {
593
+ const git = yield* Git;
594
+ const prompter = yield* Prompter;
595
+ const s = yield* prompter.spinner("Committing changes...");
596
+ if (!(yield* git.hasIdentity(cwd))) yield* git.setLocalIdentity(cwd, "Chanom", "chanom@local");
597
+ yield* git.stageAll(cwd);
598
+ if (!(yield* git.hasStagedChanges(cwd))) {
599
+ yield* s.stop("Nothing to commit, skipping");
600
+ return;
395
601
  }
396
- if (toppings.includes("oxlint")) apply(cwd, esm);
397
- if (toppings.includes("oxfmt")) apply$1(cwd, esm);
398
- if (toppings.includes("knip")) apply$3(cwd, esm);
399
- if (sweetness === "medium") {
400
- const huskyExisted = existsSync(join(cwd, ".husky"));
401
- apply$4(cwd, pm);
402
- apply$2(cwd, toppings.filter((t) => ["oxlint"].includes(t)), toppings.filter((t) => ["oxfmt"].includes(t)), !huskyExisted);
403
- apply$5(cwd, pm);
602
+ const result = yield* git.commit(cwd, "chore: setup chanom configuration");
603
+ if (result.ok) {
604
+ yield* s.stop("Changes committed");
605
+ return;
404
606
  }
405
- const s2 = spinner();
406
- s2.start("Committing changes...");
407
- if (!hasIdentity(cwd)) setLocalIdentity(cwd, "Chanom", "chanom@local");
408
- stageAll(cwd);
409
- if (hasStagedChanges(cwd)) {
410
- const result = commit(cwd, "chore: setup chanom configuration");
411
- if (result.ok) s2.stop("Changes committed");
412
- else {
413
- s2.stop(pc.yellow("Could not commit changes automatically"));
414
- const detail = result.stderr || result.stdout;
415
- log.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
416
- }
417
- } else s2.stop("Nothing to commit, skipping");
418
- outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
419
- }
607
+ yield* s.stop(pc.yellow("Could not commit changes automatically"));
608
+ const detail = result.stderr || result.stdout;
609
+ yield* prompter.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
610
+ }).pipe(Effect.withSpan("brew.commitChanges"));
611
+ const brew = (cwd = process.cwd()) => Effect.gen(function* () {
612
+ const prompter = yield* Prompter;
613
+ yield* prompter.intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
614
+ yield* ensureGitRepo(cwd);
615
+ const pm = yield* detectPm(cwd);
616
+ yield* Effect.logDebug(`detected package manager: ${pm}`);
617
+ const { pkg, pkgPath } = yield* readPkg(cwd);
618
+ const { toppings, sweetness } = yield* askRecipe();
619
+ yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
620
+ const packages = planPackages(pkg, toppings, sweetness, bundledVersions);
621
+ yield* Effect.logDebug(`planned packages: ${packages.length > 0 ? packages.join(", ") : "(none)"}`);
622
+ yield* installPackages(pm, cwd, packages);
623
+ yield* persistScripts(pkgPath, pkg, yield* applyToppings(cwd, isEsm(pkg), pkg, toppings));
624
+ yield* setupCommitGate(cwd, pm, toppings, sweetness);
625
+ yield* commitChanges(cwd);
626
+ yield* prompter.outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
627
+ });
628
+ //#endregion
629
+ //#region src/cli.ts
630
+ var UnknownCommand = class extends Data.TaggedError("UnknownCommand") {};
631
+ const commands = { brew };
632
+ const dispatch = (name, cwd) => Effect.gen(function* () {
633
+ if (name !== void 0 && Object.hasOwn(commands, name)) return yield* commands[name](cwd).pipe(Effect.withSpan(name));
634
+ return yield* new UnknownCommand({ command: name });
635
+ });
636
+ const reportError = (message) => Effect.gen(function* () {
637
+ yield* (yield* Prompter).error(message);
638
+ return 1;
639
+ });
640
+ const isCancellation = (cause) => Chunk.isEmpty(Cause.defects(cause)) && Chunk.every(Cause.failures(cause), (failure) => failure instanceof Cancelled);
641
+ /** Prints the fully rendered cause, including the span trace of the failing step. */
642
+ const traceFailure = (cause) => Effect.gen(function* () {
643
+ if (isCancellation(cause)) return;
644
+ yield* (yield* Prompter).debug(Cause.pretty(cause));
645
+ });
646
+ /** Runs the named command and returns the process exit code. */
647
+ const run = (name, cwd = process.cwd(), options = {}) => {
648
+ const debug = options.debug ?? false;
649
+ return dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
650
+ Cancelled: () => Effect.succeed(0),
651
+ UnknownCommand: (e) => reportError(`Unknown command: ${e.command ?? "(none)"}\nAvailable commands: ${Object.keys(commands).join(", ")}`),
652
+ PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
653
+ PkgInvalid: (e) => reportError(`Could not parse ${e.pkgPath}. Is it valid JSON?`),
654
+ InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
655
+ HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`)
656
+ }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
657
+ };
420
658
  //#endregion
421
659
  //#region src/index.ts
422
- const command = process.argv[2];
423
- if (command === "brew") await brew();
424
- else {
425
- console.error(`Unknown command: ${command ?? "(none)"}`);
426
- console.error("Usage: chanom brew");
427
- process.exit(1);
428
- }
660
+ const args = process.argv.slice(2);
661
+ const debug = args.includes("--debug");
662
+ const command = args.find((arg) => !arg.startsWith("--"));
663
+ const ServicesLive = Layer.mergeAll(Prompter.Default, Git.Default, PackageInstaller.Default).pipe(Layer.provideMerge(CommandRunner.Default), Layer.provideMerge(NodeContext.layer));
664
+ const DebugLoggerLive = Logger.replace(Logger.defaultLogger, Logger.prettyLogger({ stderr: true }));
665
+ const MainLive = debug ? Layer.merge(ServicesLive, DebugLoggerLive) : ServicesLive;
666
+ const program = run(command, process.cwd(), { debug }).pipe(Effect.flatMap((exitCode) => Effect.sync(() => {
667
+ if (exitCode !== 0) process.exitCode = exitCode;
668
+ })), Effect.provide(MainLive));
669
+ NodeRuntime.runMain(program, {
670
+ disableErrorReporting: true,
671
+ disablePrettyLogger: true
672
+ });
429
673
  //#endregion
430
674
  export {};
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/peeranat-dan/chanom.git",
9
+ "directory": "packages/cli"
10
+ },
6
11
  "bin": {
7
12
  "chanom": "./dist/index.js"
8
13
  },
@@ -12,18 +17,26 @@
12
17
  "type": "module",
13
18
  "dependencies": {
14
19
  "@clack/prompts": "1.6.0",
20
+ "@effect/platform": "0.96.2",
21
+ "@effect/platform-node": "0.107.0",
22
+ "effect": "3.21.4",
15
23
  "picocolors": "1.1.1"
16
24
  },
17
25
  "devDependencies": {
26
+ "@effect/language-service": "0.86.4",
27
+ "@effect/vitest": "0.29.0",
18
28
  "@tsconfig/node-lts": "24.0.0",
19
29
  "@tsconfig/node-ts": "23.6.4",
20
30
  "@types/node": "26.0.1",
21
31
  "tsdown": "0.22.3",
22
32
  "typescript": "6.0.3",
33
+ "vitest": "3.2.6",
23
34
  "@chanom/dev-config": "0.0.3"
24
35
  },
25
36
  "scripts": {
26
37
  "build": "tsdown",
27
- "dev": "tsdown --watch"
38
+ "dev": "tsdown --watch",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest"
28
41
  }
29
42
  }