@chanom/cli 0.2.0 → 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.
package/README.md CHANGED
@@ -53,6 +53,11 @@ Based on your answers, `brew` will:
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,7 +101,16 @@ 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
  };
@@ -429,6 +438,10 @@ function buildConfig(linters, formatters) {
429
438
  //#endregion
430
439
  //#region src/commands/add-lint-staged/index.ts
431
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
+ }
432
445
  const fs = yield* FileSystem.FileSystem;
433
446
  const path = yield* Path.Path;
434
447
  const prompter = yield* Prompter;
@@ -524,9 +537,17 @@ function planPackages(pkg, toppings, sweetness, versions) {
524
537
  if (toppings.includes("oxlint")) packages.push(...getPackages(pkg, versions));
525
538
  if (toppings.includes("oxfmt")) packages.push(...getPackages$1(pkg, versions));
526
539
  if (toppings.includes("knip")) packages.push(...getPackages$3(pkg, versions));
527
- 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
+ }
528
545
  return [...new Set(packages)];
529
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
+ }
530
551
  /** True when the .gitignore content already has the entry as its own line. */
531
552
  function hasGitignoreEntry(content, entry) {
532
553
  const forms = /* @__PURE__ */ new Set([
@@ -550,6 +571,7 @@ function selectedFormatters(toppings) {
550
571
  }
551
572
  //#endregion
552
573
  //#region src/commands/brew/index.ts
574
+ var NotAtRepoRoot = class extends Data.TaggedError("NotAtRepoRoot") {};
553
575
  const ensureGitRepo = Effect.fn("brew.ensureGitRepo")(function* (cwd) {
554
576
  const git = yield* Git;
555
577
  if (yield* git.isRepo(cwd)) {
@@ -635,13 +657,35 @@ const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg,
635
657
  yield* writePkg(pkgPath, updated);
636
658
  }
637
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
+ });
638
676
  const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, toppings, sweetness) {
639
677
  if (sweetness !== "medium") return;
640
678
  const fs = yield* FileSystem.FileSystem;
641
679
  const path = yield* Path.Path;
680
+ const prompter = yield* Prompter;
642
681
  const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
643
682
  yield* apply$4(cwd, pm);
644
- 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
+ }
645
689
  yield* apply$5(cwd, pm);
646
690
  });
647
691
  const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
@@ -673,10 +717,12 @@ const brew = (cwd = process.cwd()) => Effect.gen(function* () {
673
717
  const { pkg, pkgPath } = yield* readPkg(cwd);
674
718
  const { toppings, sweetness } = yield* askRecipe();
675
719
  yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
720
+ yield* ensureRepoRootForHooks(cwd, sweetness);
676
721
  const packages = planPackages(pkg, toppings, sweetness, bundledVersions);
677
722
  yield* Effect.logDebug(`planned packages: ${packages.length > 0 ? packages.join(", ") : "(none)"}`);
678
723
  yield* installPackages(pm, cwd, packages);
679
- 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));
680
726
  yield* setupCommitGate(cwd, pm, toppings, sweetness);
681
727
  yield* commitChanges(cwd);
682
728
  yield* prompter.outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
@@ -708,7 +754,8 @@ const run = (name, cwd = process.cwd(), options = {}) => {
708
754
  PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
709
755
  PkgInvalid: (e) => reportError(`Could not parse ${e.pkgPath}. Is it valid JSON?`),
710
756
  InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
711
- 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.`)
712
759
  }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
713
760
  };
714
761
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {