@chanom/cli 0.1.1 → 0.2.1

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 +10 -1
  2. package/dist/index.js +150 -75
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -46,13 +46,18 @@ Based on your answers, `brew` will:
46
46
 
47
47
  - Initialize a git repo if one doesn't exist yet (with a `.gitignore`)
48
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
49
+ - Install the packages needed for your selected toppings as dev dependencies - when run at a workspace root (a `pnpm-workspace.yaml` or a `workspaces` field in `package.json`), the install gets the flag your package manager needs: `-w` for pnpm, `-W` for yarn classic, none for npm, bun, and yarn berry
50
50
  - Write config files and wire up `package.json` scripts
51
51
  - If `medium` sweetness is selected, run `husky init` and add `pre-commit` / `commit-msg` hooks
52
52
  - Stage and commit the changes it made
53
53
 
54
54
  Cancelling a prompt (Ctrl+C) exits cleanly without touching anything further.
55
55
 
56
+ `medium` sweetness must be brewed at the git repository root - husky can only
57
+ install hooks from the directory that holds `.git`. Run inside a subdirectory
58
+ (e.g. a workspace package), `brew` stops with an error naming the root before
59
+ installing anything. `light` sweetness works anywhere in the repo.
60
+
56
61
  ## What gets added
57
62
 
58
63
  | Topping | Config file | Scripts added |
@@ -71,6 +76,10 @@ Config files re-export presets from [`@chanom/dev-config`](../dev-config).
71
76
  - `lint-staged` - `.lintstagedrc.json` running the selected linter/formatter on staged files, wired into the `pre-commit` hook
72
77
  - `commitlint` - `.commitlintrc.json` extending `@commitlint/config-conventional`, enforced in the `commit-msg` hook
73
78
 
79
+ When neither `oxlint` nor `oxfmt` is selected, lint-staged is skipped entirely
80
+ (an empty config would fail every commit) and the `npm test` pre-commit hook
81
+ seeded by `husky init` is removed - only the `commit-msg` gate remains.
82
+
74
83
  ## Version pinning
75
84
 
76
85
  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.
package/dist/index.js CHANGED
@@ -6,9 +6,9 @@ import pc from "picocolors";
6
6
  import * as clack from "@clack/prompts";
7
7
  //#region src/bundled-versions.ts
8
8
  const bundledVersions = {
9
- oxlint: "1.71.0",
10
- oxfmt: "0.56.0",
11
- knip: "6.23.0",
9
+ oxlint: "1.73.0",
10
+ oxfmt: "0.58.0",
11
+ knip: "6.26.0",
12
12
  devConfig: "0.0.3"
13
13
  };
14
14
  //#endregion
@@ -101,22 +101,92 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
101
101
  "--cached",
102
102
  "--quiet"
103
103
  ], cwd).pipe(Effect.map((clean) => !clean)),
104
- isRepo: (cwd) => fs.exists(path.join(cwd, ".git")),
104
+ /**
105
+ * True when `cwd` is anywhere inside a git work tree — a `.git` folder
106
+ * check would miss subdirectories (e.g. workspace packages) and lead to
107
+ * nested `git init` calls.
108
+ */
109
+ isRepo: (cwd) => exitOk(["rev-parse", "--is-inside-work-tree"], cwd),
110
+ /** Path of `cwd` relative to the work-tree root: empty at the root, `None` outside a repository. */
111
+ prefix: (cwd) => run(["rev-parse", "--show-prefix"], cwd).pipe(Effect.map((result) => result.ok ? Option.some(result.stdout) : Option.none())),
112
+ /** Absolute path of the work-tree root, or `None` outside a repository. */
113
+ root: (cwd) => run(["rev-parse", "--show-toplevel"], cwd).pipe(Effect.map((result) => result.ok ? Option.some(result.stdout) : Option.none())),
105
114
  writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents),
106
115
  readGitignore: (cwd) => fs.readFileString(path.join(cwd, ".gitignore"))
107
116
  };
108
117
  }) }) {};
109
118
  //#endregion
119
+ //#region src/domain/package-manager.ts
120
+ /** How to run a locally installed binary with each package manager. */
121
+ const PM_EXEC = {
122
+ pnpm: ["pnpm", "exec"],
123
+ npm: [
124
+ "npx",
125
+ "--no",
126
+ "--"
127
+ ],
128
+ yarn: ["yarn"],
129
+ bun: ["bunx"]
130
+ };
131
+ const KNOWN_PMS = [
132
+ "pnpm",
133
+ "yarn",
134
+ "bun",
135
+ "npm"
136
+ ];
137
+ /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
138
+ function parsePmField(field) {
139
+ if (!field) return void 0;
140
+ const name = field.split("@")[0];
141
+ return KNOWN_PMS.find((pm) => pm === name);
142
+ }
143
+ /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
144
+ function parsePmUserAgent(agent) {
145
+ if (!agent) return void 0;
146
+ return KNOWN_PMS.find((pm) => agent.startsWith(pm));
147
+ }
148
+ /**
149
+ * Extra `add` flags needed when installing at a workspace root: pnpm and yarn
150
+ * classic refuse root installs without `-w` / `-W`. npm, bun, and yarn berry
151
+ * install to the root with no flag (npm's `-w` targets a child workspace instead).
152
+ */
153
+ function workspaceRootFlags(pm, hints) {
154
+ switch (pm) {
155
+ case "pnpm": return hints.hasPnpmWorkspaceFile ? ["-w"] : [];
156
+ case "yarn": return hints.hasWorkspacesField && !hints.hasYarnBerryConfig ? ["-W"] : [];
157
+ case "npm":
158
+ case "bun": return [];
159
+ }
160
+ }
161
+ /** The `packageManager` field wins over the user agent, which wins over the fallback. */
162
+ function resolvePm(hints) {
163
+ return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
164
+ }
165
+ //#endregion
110
166
  //#region src/services/package-installer.ts
111
167
  var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
112
- /** Installs npm packages with the detected package manager. */
168
+ /**
169
+ * Installs npm packages with the detected package manager, adding the
170
+ * workspace-root flag (pnpm `-w`, yarn classic `-W`) when the target
171
+ * directory is a workspace root.
172
+ */
113
173
  var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", { effect: Effect.gen(function* () {
114
174
  const runner = yield* CommandRunner;
115
- return { installDev: (pm, cwd, packages) => runner.execInherit(pm, [
175
+ const fs = yield* FileSystem.FileSystem;
176
+ const path = yield* Path.Path;
177
+ const has = (cwd, file) => fs.exists(path.join(cwd, file)).pipe(Effect.orElseSucceed(() => false));
178
+ const hasWorkspacesField = (cwd) => fs.readFileString(path.join(cwd, "package.json")).pipe(Effect.flatMap((contents) => Effect.try(() => JSON.parse(contents).workspaces !== void 0)), Effect.orElseSucceed(() => false));
179
+ const workspaceHints = (cwd) => Effect.all({
180
+ hasPnpmWorkspaceFile: has(cwd, "pnpm-workspace.yaml"),
181
+ hasWorkspacesField: hasWorkspacesField(cwd),
182
+ hasYarnBerryConfig: has(cwd, ".yarnrc.yml")
183
+ }, { concurrency: 3 });
184
+ return { installDev: (pm, cwd, packages) => workspaceHints(cwd).pipe(Effect.flatMap((hints) => runner.execInherit(pm, [
116
185
  "add",
117
186
  "-D",
187
+ ...workspaceRootFlags(pm, hints),
118
188
  ...packages
119
- ], cwd).pipe(Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
189
+ ], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
120
190
  }) }) {};
121
191
  //#endregion
122
192
  //#region src/services/prompter.ts
@@ -148,40 +218,6 @@ var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
148
218
  })
149
219
  } }) {};
150
220
  //#endregion
151
- //#region src/domain/package-manager.ts
152
- /** How to run a locally installed binary with each package manager. */
153
- const PM_EXEC = {
154
- pnpm: ["pnpm", "exec"],
155
- npm: [
156
- "npx",
157
- "--no",
158
- "--"
159
- ],
160
- yarn: ["yarn"],
161
- bun: ["bunx"]
162
- };
163
- const KNOWN_PMS = [
164
- "pnpm",
165
- "yarn",
166
- "bun",
167
- "npm"
168
- ];
169
- /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
170
- function parsePmField(field) {
171
- if (!field) return void 0;
172
- const name = field.split("@")[0];
173
- return KNOWN_PMS.find((pm) => pm === name);
174
- }
175
- /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
176
- function parsePmUserAgent(agent) {
177
- if (!agent) return void 0;
178
- return KNOWN_PMS.find((pm) => agent.startsWith(pm));
179
- }
180
- /** The `packageManager` field wins over the user agent, which wins over the fallback. */
181
- function resolvePm(hints) {
182
- return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
183
- }
184
- //#endregion
185
221
  //#region src/utils/detect-pm.ts
186
222
  const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
187
223
  const fs = yield* FileSystem.FileSystem;
@@ -286,7 +322,7 @@ function commitMsgHook(pm) {
286
322
  }
287
323
  //#endregion
288
324
  //#region src/commands/add-commitlint/index.ts
289
- const apply$5 = (cwd, pm) => Effect.gen(function* () {
325
+ const apply$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
290
326
  const fs = yield* FileSystem.FileSystem;
291
327
  const path = yield* Path.Path;
292
328
  const prompter = yield* Prompter;
@@ -299,7 +335,7 @@ const apply$5 = (cwd, pm) => Effect.gen(function* () {
299
335
  if (yield* fs.exists(hookPath)) yield* prompter.warn("`.husky/commit-msg` already exists - skipping");
300
336
  else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
301
337
  }
302
- }).pipe(Effect.withSpan("add-commitlint.apply"));
338
+ });
303
339
  //#endregion
304
340
  //#region src/commands/add-husky/logic.ts
305
341
  function getPackages$4(pkg) {
@@ -308,7 +344,7 @@ function getPackages$4(pkg) {
308
344
  //#endregion
309
345
  //#region src/commands/add-husky/index.ts
310
346
  var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
311
- const apply$4 = (cwd, pm) => Effect.gen(function* () {
347
+ const apply$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
312
348
  const fs = yield* FileSystem.FileSystem;
313
349
  const path = yield* Path.Path;
314
350
  const prompter = yield* Prompter;
@@ -325,7 +361,7 @@ const apply$4 = (cwd, pm) => Effect.gen(function* () {
325
361
  "init"
326
362
  ], cwd);
327
363
  if (exitCode !== 0) return yield* new HuskyInitFailed({ exitCode });
328
- }).pipe(Effect.withSpan("add-husky.apply"));
364
+ });
329
365
  //#endregion
330
366
  //#region src/domain/scripts.ts
331
367
  /**
@@ -364,7 +400,7 @@ function getScriptPlan$2(scripts) {
364
400
  //#endregion
365
401
  //#region src/commands/add-knip/index.ts
366
402
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
367
- const apply$3 = (cwd, esm, pkg) => Effect.gen(function* () {
403
+ const apply$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
368
404
  const fs = yield* FileSystem.FileSystem;
369
405
  const path = yield* Path.Path;
370
406
  const prompter = yield* Prompter;
@@ -380,7 +416,7 @@ const apply$3 = (cwd, esm, pkg) => Effect.gen(function* () {
380
416
  ...pkg,
381
417
  scripts: plan.scripts
382
418
  };
383
- }).pipe(Effect.withSpan("add-knip.apply"));
419
+ });
384
420
  //#endregion
385
421
  //#region src/commands/add-lint-staged/logic.ts
386
422
  function getPackages$2(pkg) {
@@ -401,7 +437,11 @@ function buildConfig(linters, formatters) {
401
437
  }
402
438
  //#endregion
403
439
  //#region src/commands/add-lint-staged/index.ts
404
- const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false) => Effect.gen(function* () {
440
+ const apply$2 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
441
+ if (linters.length === 0 && formatters.length === 0) {
442
+ yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
443
+ return;
444
+ }
405
445
  const fs = yield* FileSystem.FileSystem;
406
446
  const path = yield* Path.Path;
407
447
  const prompter = yield* Prompter;
@@ -414,7 +454,7 @@ const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false)
414
454
  if ((yield* fs.exists(hookPath)) && !overwritePreCommit) yield* prompter.warn("`.husky/pre-commit` already exists - skipping");
415
455
  else yield* fs.writeFileString(hookPath, "lint-staged\n");
416
456
  }
417
- }).pipe(Effect.withSpan("add-lint-staged.apply"));
457
+ });
418
458
  //#endregion
419
459
  //#region src/commands/add-oxfmt/logic.ts
420
460
  function getPackages$1(pkg, versions) {
@@ -435,7 +475,7 @@ function getScriptPlan$1(scripts) {
435
475
  //#endregion
436
476
  //#region src/commands/add-oxfmt/index.ts
437
477
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
438
- const apply$1 = (cwd, esm, pkg) => Effect.gen(function* () {
478
+ const apply$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
439
479
  const fs = yield* FileSystem.FileSystem;
440
480
  const path = yield* Path.Path;
441
481
  const prompter = yield* Prompter;
@@ -451,7 +491,7 @@ const apply$1 = (cwd, esm, pkg) => Effect.gen(function* () {
451
491
  ...pkg,
452
492
  scripts: plan.scripts
453
493
  };
454
- }).pipe(Effect.withSpan("add-oxfmt.apply"));
494
+ });
455
495
  //#endregion
456
496
  //#region src/commands/add-oxlint/logic.ts
457
497
  function getPackages(pkg, versions) {
@@ -472,7 +512,7 @@ function getScriptPlan(scripts) {
472
512
  //#endregion
473
513
  //#region src/commands/add-oxlint/index.ts
474
514
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
475
- const apply = (cwd, esm, pkg) => Effect.gen(function* () {
515
+ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
476
516
  const fs = yield* FileSystem.FileSystem;
477
517
  const path = yield* Path.Path;
478
518
  const prompter = yield* Prompter;
@@ -488,7 +528,7 @@ const apply = (cwd, esm, pkg) => Effect.gen(function* () {
488
528
  ...pkg,
489
529
  scripts: plan.scripts
490
530
  };
491
- }).pipe(Effect.withSpan("add-oxlint.apply"));
531
+ });
492
532
  //#endregion
493
533
  //#region src/commands/brew/logic.ts
494
534
  /** Deduplicated list of packages to install for the chosen toppings and sweetness. */
@@ -497,9 +537,17 @@ function planPackages(pkg, toppings, sweetness, versions) {
497
537
  if (toppings.includes("oxlint")) packages.push(...getPackages(pkg, versions));
498
538
  if (toppings.includes("oxfmt")) packages.push(...getPackages$1(pkg, versions));
499
539
  if (toppings.includes("knip")) packages.push(...getPackages$3(pkg, versions));
500
- if (sweetness === "medium") packages.push(...getPackages$4(pkg), ...getPackages$2(pkg), ...getPackages$5(pkg));
540
+ if (sweetness === "medium") {
541
+ packages.push(...getPackages$4(pkg));
542
+ if (wantsLintStaged(toppings)) packages.push(...getPackages$2(pkg));
543
+ packages.push(...getPackages$5(pkg));
544
+ }
501
545
  return [...new Set(packages)];
502
546
  }
547
+ /** True when at least one topping gives lint-staged something to run. */
548
+ function wantsLintStaged(toppings) {
549
+ return selectedLinters(toppings).length > 0 || selectedFormatters(toppings).length > 0;
550
+ }
503
551
  /** True when the .gitignore content already has the entry as its own line. */
504
552
  function hasGitignoreEntry(content, entry) {
505
553
  const forms = /* @__PURE__ */ new Set([
@@ -523,7 +571,8 @@ function selectedFormatters(toppings) {
523
571
  }
524
572
  //#endregion
525
573
  //#region src/commands/brew/index.ts
526
- const ensureGitRepo = (cwd) => Effect.gen(function* () {
574
+ var NotAtRepoRoot = class extends Data.TaggedError("NotAtRepoRoot") {};
575
+ const ensureGitRepo = Effect.fn("brew.ensureGitRepo")(function* (cwd) {
527
576
  const git = yield* Git;
528
577
  if (yield* git.isRepo(cwd)) {
529
578
  yield* Effect.logDebug("git repository already present");
@@ -532,8 +581,8 @@ const ensureGitRepo = (cwd) => Effect.gen(function* () {
532
581
  const s = yield* (yield* Prompter).spinner("Git not found, initializing repository...");
533
582
  yield* git.init(cwd);
534
583
  yield* s.stop("Git initialized");
535
- }).pipe(Effect.withSpan("brew.ensureGitRepo"));
536
- const ensureGitIgnore = (cwd) => Effect.gen(function* () {
584
+ });
585
+ const ensureGitIgnore = Effect.fn("brew.ensureGitIgnore")(function* (cwd) {
537
586
  const git = yield* Git;
538
587
  const prompter = yield* Prompter;
539
588
  const existing = yield* git.readGitignore(cwd).pipe(Effect.catchTag("SystemError", (e) => e.reason === "NotFound" ? Effect.succeed(void 0) : Effect.fail(e)));
@@ -544,8 +593,8 @@ const ensureGitIgnore = (cwd) => Effect.gen(function* () {
544
593
  const s = yield* prompter.spinner(existing === void 0 ? "Creating .gitignore..." : "Adding node_modules to .gitignore...");
545
594
  yield* git.writeGitignore(cwd, appendGitignoreEntry(existing ?? "", "node_modules")).pipe(Effect.tapError(() => s.stop(pc.red("Could not update .gitignore"))));
546
595
  yield* s.stop(existing === void 0 ? ".gitignore created" : "node_modules added to .gitignore");
547
- }).pipe(Effect.withSpan("brew.ensureGitIgnore"));
548
- const askRecipe = () => Effect.gen(function* () {
596
+ });
597
+ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
549
598
  const prompter = yield* Prompter;
550
599
  return {
551
600
  toppings: yield* prompter.multiselect({
@@ -582,8 +631,8 @@ const askRecipe = () => Effect.gen(function* () {
582
631
  }]
583
632
  })
584
633
  };
585
- }).pipe(Effect.withSpan("brew.askRecipe"));
586
- const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
634
+ });
635
+ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, packages) {
587
636
  if (packages.length === 0) {
588
637
  yield* Effect.logDebug("no packages to install");
589
638
  return;
@@ -593,30 +642,53 @@ const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
593
642
  const s = yield* prompter.spinner(`Installing ${packages.join(", ")}...`);
594
643
  yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
595
644
  yield* s.stop("Packages installed");
596
- }).pipe(Effect.withSpan("brew.installPackages"));
597
- const applyToppings = (cwd, esm, pkg, toppings) => Effect.gen(function* () {
645
+ });
646
+ const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
598
647
  let updated = pkg;
599
648
  if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
600
649
  if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
601
650
  if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
602
651
  return updated;
603
- }).pipe(Effect.withSpan("brew.applyToppings"));
604
- const persistScripts = (pkgPath, pkg, updated) => Effect.gen(function* () {
605
- if (JSON.stringify(updated.scripts ?? {}) !== JSON.stringify(pkg.scripts ?? {})) {
652
+ });
653
+ const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
654
+ if (JSON.stringify(updated.scripts ?? {}) === JSON.stringify(pkg.scripts ?? {})) yield* Effect.logDebug("scripts unchanged, skipping package.json write");
655
+ else {
606
656
  yield* Effect.logDebug(`writing updated scripts to ${pkgPath}`);
607
657
  yield* writePkg(pkgPath, updated);
608
- } else yield* Effect.logDebug("scripts unchanged, skipping package.json write");
609
- }).pipe(Effect.withSpan("brew.persistScripts"));
610
- const setupCommitGate = (cwd, pm, toppings, sweetness) => Effect.gen(function* () {
658
+ }
659
+ });
660
+ /**
661
+ * Fails when medium sweetness is chosen away from the git work-tree root:
662
+ * husky can only install hooks from the directory that holds `.git`, so
663
+ * running it in e.g. a workspace package would leave hooks that never fire.
664
+ */
665
+ const ensureRepoRootForHooks = Effect.fn("brew.ensureRepoRootForHooks")(function* (cwd, sweetness) {
666
+ if (sweetness !== "medium") return;
667
+ const git = yield* Git;
668
+ const prefix = yield* git.prefix(cwd);
669
+ if (Option.isNone(prefix) || prefix.value === "") return;
670
+ const root = yield* git.root(cwd);
671
+ return yield* new NotAtRepoRoot({
672
+ cwd,
673
+ root: Option.getOrElse(root, () => cwd)
674
+ });
675
+ });
676
+ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, toppings, sweetness) {
611
677
  if (sweetness !== "medium") return;
612
678
  const fs = yield* FileSystem.FileSystem;
613
679
  const path = yield* Path.Path;
680
+ const prompter = yield* Prompter;
614
681
  const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
615
682
  yield* apply$4(cwd, pm);
616
- yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
683
+ if (wantsLintStaged(toppings)) yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
684
+ else {
685
+ yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
686
+ const seededHook = path.join(cwd, ".husky", "pre-commit");
687
+ if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
688
+ }
617
689
  yield* apply$5(cwd, pm);
618
- }).pipe(Effect.withSpan("brew.setupCommitGate"));
619
- const commitChanges = (cwd) => Effect.gen(function* () {
690
+ });
691
+ const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
620
692
  const git = yield* Git;
621
693
  const prompter = yield* Prompter;
622
694
  const s = yield* prompter.spinner("Committing changes...");
@@ -634,7 +706,7 @@ const commitChanges = (cwd) => Effect.gen(function* () {
634
706
  yield* s.stop(pc.yellow("Could not commit changes automatically"));
635
707
  const detail = result.stderr || result.stdout;
636
708
  yield* prompter.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
637
- }).pipe(Effect.withSpan("brew.commitChanges"));
709
+ });
638
710
  const brew = (cwd = process.cwd()) => Effect.gen(function* () {
639
711
  const prompter = yield* Prompter;
640
712
  yield* prompter.intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
@@ -645,10 +717,12 @@ const brew = (cwd = process.cwd()) => Effect.gen(function* () {
645
717
  const { pkg, pkgPath } = yield* readPkg(cwd);
646
718
  const { toppings, sweetness } = yield* askRecipe();
647
719
  yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
720
+ yield* ensureRepoRootForHooks(cwd, sweetness);
648
721
  const packages = planPackages(pkg, toppings, sweetness, bundledVersions);
649
722
  yield* Effect.logDebug(`planned packages: ${packages.length > 0 ? packages.join(", ") : "(none)"}`);
650
723
  yield* installPackages(pm, cwd, packages);
651
- yield* persistScripts(pkgPath, pkg, yield* applyToppings(cwd, isEsm(pkg), pkg, toppings));
724
+ const { pkg: installedPkg } = yield* readPkg(cwd);
725
+ yield* persistScripts(pkgPath, installedPkg, yield* applyToppings(cwd, isEsm(installedPkg), installedPkg, toppings));
652
726
  yield* setupCommitGate(cwd, pm, toppings, sweetness);
653
727
  yield* commitChanges(cwd);
654
728
  yield* prompter.outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
@@ -680,7 +754,8 @@ const run = (name, cwd = process.cwd(), options = {}) => {
680
754
  PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
681
755
  PkgInvalid: (e) => reportError(`Could not parse ${e.pkgPath}. Is it valid JSON?`),
682
756
  InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
683
- HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`)
757
+ HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`),
758
+ NotAtRepoRoot: (e) => reportError(`Git hooks must be set up at the repository root: ${e.root}\nRun \`chanom brew\` there, or choose light sweetness.`)
684
759
  }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
685
760
  };
686
761
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "@tsconfig/node-lts": "24.0.0",
29
29
  "@tsconfig/node-ts": "23.6.4",
30
30
  "@types/node": "26.0.1",
31
+ "@vitest/coverage-v8": "3.2.6",
31
32
  "tsdown": "0.22.3",
32
33
  "typescript": "6.0.3",
33
34
  "vitest": "3.2.6",
@@ -37,6 +38,7 @@
37
38
  "build": "tsdown",
38
39
  "dev": "tsdown --watch",
39
40
  "test": "vitest run",
41
+ "test:coverage": "vitest run --coverage",
40
42
  "test:watch": "vitest"
41
43
  }
42
44
  }