@chanom/cli 0.1.0 → 0.2.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 +1 -1
  2. package/dist/index.js +123 -67
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -46,7 +46,7 @@ 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
package/dist/index.js CHANGED
@@ -102,20 +102,82 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
102
102
  "--quiet"
103
103
  ], cwd).pipe(Effect.map((clean) => !clean)),
104
104
  isRepo: (cwd) => fs.exists(path.join(cwd, ".git")),
105
- writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents)
105
+ writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents),
106
+ readGitignore: (cwd) => fs.readFileString(path.join(cwd, ".gitignore"))
106
107
  };
107
108
  }) }) {};
108
109
  //#endregion
110
+ //#region src/domain/package-manager.ts
111
+ /** How to run a locally installed binary with each package manager. */
112
+ const PM_EXEC = {
113
+ pnpm: ["pnpm", "exec"],
114
+ npm: [
115
+ "npx",
116
+ "--no",
117
+ "--"
118
+ ],
119
+ yarn: ["yarn"],
120
+ bun: ["bunx"]
121
+ };
122
+ const KNOWN_PMS = [
123
+ "pnpm",
124
+ "yarn",
125
+ "bun",
126
+ "npm"
127
+ ];
128
+ /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
129
+ function parsePmField(field) {
130
+ if (!field) return void 0;
131
+ const name = field.split("@")[0];
132
+ return KNOWN_PMS.find((pm) => pm === name);
133
+ }
134
+ /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
135
+ function parsePmUserAgent(agent) {
136
+ if (!agent) return void 0;
137
+ return KNOWN_PMS.find((pm) => agent.startsWith(pm));
138
+ }
139
+ /**
140
+ * Extra `add` flags needed when installing at a workspace root: pnpm and yarn
141
+ * classic refuse root installs without `-w` / `-W`. npm, bun, and yarn berry
142
+ * install to the root with no flag (npm's `-w` targets a child workspace instead).
143
+ */
144
+ function workspaceRootFlags(pm, hints) {
145
+ switch (pm) {
146
+ case "pnpm": return hints.hasPnpmWorkspaceFile ? ["-w"] : [];
147
+ case "yarn": return hints.hasWorkspacesField && !hints.hasYarnBerryConfig ? ["-W"] : [];
148
+ case "npm":
149
+ case "bun": return [];
150
+ }
151
+ }
152
+ /** The `packageManager` field wins over the user agent, which wins over the fallback. */
153
+ function resolvePm(hints) {
154
+ return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
155
+ }
156
+ //#endregion
109
157
  //#region src/services/package-installer.ts
110
158
  var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
111
- /** Installs npm packages with the detected package manager. */
159
+ /**
160
+ * Installs npm packages with the detected package manager, adding the
161
+ * workspace-root flag (pnpm `-w`, yarn classic `-W`) when the target
162
+ * directory is a workspace root.
163
+ */
112
164
  var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", { effect: Effect.gen(function* () {
113
165
  const runner = yield* CommandRunner;
114
- return { installDev: (pm, cwd, packages) => runner.execInherit(pm, [
166
+ const fs = yield* FileSystem.FileSystem;
167
+ const path = yield* Path.Path;
168
+ const has = (cwd, file) => fs.exists(path.join(cwd, file)).pipe(Effect.orElseSucceed(() => false));
169
+ 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));
170
+ const workspaceHints = (cwd) => Effect.all({
171
+ hasPnpmWorkspaceFile: has(cwd, "pnpm-workspace.yaml"),
172
+ hasWorkspacesField: hasWorkspacesField(cwd),
173
+ hasYarnBerryConfig: has(cwd, ".yarnrc.yml")
174
+ }, { concurrency: 3 });
175
+ return { installDev: (pm, cwd, packages) => workspaceHints(cwd).pipe(Effect.flatMap((hints) => runner.execInherit(pm, [
115
176
  "add",
116
177
  "-D",
178
+ ...workspaceRootFlags(pm, hints),
117
179
  ...packages
118
- ], cwd).pipe(Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
180
+ ], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
119
181
  }) }) {};
120
182
  //#endregion
121
183
  //#region src/services/prompter.ts
@@ -147,40 +209,6 @@ var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
147
209
  })
148
210
  } }) {};
149
211
  //#endregion
150
- //#region src/domain/package-manager.ts
151
- /** How to run a locally installed binary with each package manager. */
152
- const PM_EXEC = {
153
- pnpm: ["pnpm", "exec"],
154
- npm: [
155
- "npx",
156
- "--no",
157
- "--"
158
- ],
159
- yarn: ["yarn"],
160
- bun: ["bunx"]
161
- };
162
- const KNOWN_PMS = [
163
- "pnpm",
164
- "yarn",
165
- "bun",
166
- "npm"
167
- ];
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) {
176
- if (!agent) return void 0;
177
- return KNOWN_PMS.find((pm) => agent.startsWith(pm));
178
- }
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";
182
- }
183
- //#endregion
184
212
  //#region src/utils/detect-pm.ts
185
213
  const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
186
214
  const fs = yield* FileSystem.FileSystem;
@@ -285,7 +313,7 @@ function commitMsgHook(pm) {
285
313
  }
286
314
  //#endregion
287
315
  //#region src/commands/add-commitlint/index.ts
288
- const apply$5 = (cwd, pm) => Effect.gen(function* () {
316
+ const apply$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
289
317
  const fs = yield* FileSystem.FileSystem;
290
318
  const path = yield* Path.Path;
291
319
  const prompter = yield* Prompter;
@@ -298,7 +326,7 @@ const apply$5 = (cwd, pm) => Effect.gen(function* () {
298
326
  if (yield* fs.exists(hookPath)) yield* prompter.warn("`.husky/commit-msg` already exists - skipping");
299
327
  else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
300
328
  }
301
- }).pipe(Effect.withSpan("add-commitlint.apply"));
329
+ });
302
330
  //#endregion
303
331
  //#region src/commands/add-husky/logic.ts
304
332
  function getPackages$4(pkg) {
@@ -307,7 +335,7 @@ function getPackages$4(pkg) {
307
335
  //#endregion
308
336
  //#region src/commands/add-husky/index.ts
309
337
  var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
310
- const apply$4 = (cwd, pm) => Effect.gen(function* () {
338
+ const apply$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
311
339
  const fs = yield* FileSystem.FileSystem;
312
340
  const path = yield* Path.Path;
313
341
  const prompter = yield* Prompter;
@@ -324,7 +352,7 @@ const apply$4 = (cwd, pm) => Effect.gen(function* () {
324
352
  "init"
325
353
  ], cwd);
326
354
  if (exitCode !== 0) return yield* new HuskyInitFailed({ exitCode });
327
- }).pipe(Effect.withSpan("add-husky.apply"));
355
+ });
328
356
  //#endregion
329
357
  //#region src/domain/scripts.ts
330
358
  /**
@@ -363,7 +391,7 @@ function getScriptPlan$2(scripts) {
363
391
  //#endregion
364
392
  //#region src/commands/add-knip/index.ts
365
393
  /** 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* () {
394
+ const apply$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
367
395
  const fs = yield* FileSystem.FileSystem;
368
396
  const path = yield* Path.Path;
369
397
  const prompter = yield* Prompter;
@@ -379,7 +407,7 @@ const apply$3 = (cwd, esm, pkg) => Effect.gen(function* () {
379
407
  ...pkg,
380
408
  scripts: plan.scripts
381
409
  };
382
- }).pipe(Effect.withSpan("add-knip.apply"));
410
+ });
383
411
  //#endregion
384
412
  //#region src/commands/add-lint-staged/logic.ts
385
413
  function getPackages$2(pkg) {
@@ -400,7 +428,7 @@ function buildConfig(linters, formatters) {
400
428
  }
401
429
  //#endregion
402
430
  //#region src/commands/add-lint-staged/index.ts
403
- const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false) => Effect.gen(function* () {
431
+ const apply$2 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
404
432
  const fs = yield* FileSystem.FileSystem;
405
433
  const path = yield* Path.Path;
406
434
  const prompter = yield* Prompter;
@@ -413,7 +441,7 @@ const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false)
413
441
  if ((yield* fs.exists(hookPath)) && !overwritePreCommit) yield* prompter.warn("`.husky/pre-commit` already exists - skipping");
414
442
  else yield* fs.writeFileString(hookPath, "lint-staged\n");
415
443
  }
416
- }).pipe(Effect.withSpan("add-lint-staged.apply"));
444
+ });
417
445
  //#endregion
418
446
  //#region src/commands/add-oxfmt/logic.ts
419
447
  function getPackages$1(pkg, versions) {
@@ -434,7 +462,7 @@ function getScriptPlan$1(scripts) {
434
462
  //#endregion
435
463
  //#region src/commands/add-oxfmt/index.ts
436
464
  /** 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* () {
465
+ const apply$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
438
466
  const fs = yield* FileSystem.FileSystem;
439
467
  const path = yield* Path.Path;
440
468
  const prompter = yield* Prompter;
@@ -450,7 +478,7 @@ const apply$1 = (cwd, esm, pkg) => Effect.gen(function* () {
450
478
  ...pkg,
451
479
  scripts: plan.scripts
452
480
  };
453
- }).pipe(Effect.withSpan("add-oxfmt.apply"));
481
+ });
454
482
  //#endregion
455
483
  //#region src/commands/add-oxlint/logic.ts
456
484
  function getPackages(pkg, versions) {
@@ -471,7 +499,7 @@ function getScriptPlan(scripts) {
471
499
  //#endregion
472
500
  //#region src/commands/add-oxlint/index.ts
473
501
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
474
- const apply = (cwd, esm, pkg) => Effect.gen(function* () {
502
+ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
475
503
  const fs = yield* FileSystem.FileSystem;
476
504
  const path = yield* Path.Path;
477
505
  const prompter = yield* Prompter;
@@ -487,7 +515,7 @@ const apply = (cwd, esm, pkg) => Effect.gen(function* () {
487
515
  ...pkg,
488
516
  scripts: plan.scripts
489
517
  };
490
- }).pipe(Effect.withSpan("add-oxlint.apply"));
518
+ });
491
519
  //#endregion
492
520
  //#region src/commands/brew/logic.ts
493
521
  /** Deduplicated list of packages to install for the chosen toppings and sweetness. */
@@ -499,6 +527,21 @@ function planPackages(pkg, toppings, sweetness, versions) {
499
527
  if (sweetness === "medium") packages.push(...getPackages$4(pkg), ...getPackages$2(pkg), ...getPackages$5(pkg));
500
528
  return [...new Set(packages)];
501
529
  }
530
+ /** True when the .gitignore content already has the entry as its own line. */
531
+ function hasGitignoreEntry(content, entry) {
532
+ const forms = /* @__PURE__ */ new Set([
533
+ entry,
534
+ `${entry}/`,
535
+ `/${entry}`,
536
+ `/${entry}/`
537
+ ]);
538
+ return content.split(/\r?\n/).some((line) => forms.has(line.trim()));
539
+ }
540
+ /** .gitignore content with the entry appended, preserving existing lines and newline termination. */
541
+ function appendGitignoreEntry(content, entry) {
542
+ if (content === "") return `${entry}\n`;
543
+ return content.endsWith("\n") ? `${content}${entry}\n` : `${content}\n${entry}\n`;
544
+ }
502
545
  function selectedLinters(toppings) {
503
546
  return toppings.filter((t) => t === "oxlint");
504
547
  }
@@ -507,7 +550,7 @@ function selectedFormatters(toppings) {
507
550
  }
508
551
  //#endregion
509
552
  //#region src/commands/brew/index.ts
510
- const ensureGitRepo = (cwd) => Effect.gen(function* () {
553
+ const ensureGitRepo = Effect.fn("brew.ensureGitRepo")(function* (cwd) {
511
554
  const git = yield* Git;
512
555
  if (yield* git.isRepo(cwd)) {
513
556
  yield* Effect.logDebug("git repository already present");
@@ -515,10 +558,21 @@ const ensureGitRepo = (cwd) => Effect.gen(function* () {
515
558
  }
516
559
  const s = yield* (yield* Prompter).spinner("Git not found, initializing repository...");
517
560
  yield* git.init(cwd);
518
- yield* git.writeGitignore(cwd);
519
561
  yield* s.stop("Git initialized");
520
- }).pipe(Effect.withSpan("brew.ensureGitRepo"));
521
- const askRecipe = () => Effect.gen(function* () {
562
+ });
563
+ const ensureGitIgnore = Effect.fn("brew.ensureGitIgnore")(function* (cwd) {
564
+ const git = yield* Git;
565
+ const prompter = yield* Prompter;
566
+ const existing = yield* git.readGitignore(cwd).pipe(Effect.catchTag("SystemError", (e) => e.reason === "NotFound" ? Effect.succeed(void 0) : Effect.fail(e)));
567
+ if (existing !== void 0 && hasGitignoreEntry(existing, "node_modules")) {
568
+ yield* Effect.logDebug("node_modules already present in .gitignore");
569
+ return;
570
+ }
571
+ const s = yield* prompter.spinner(existing === void 0 ? "Creating .gitignore..." : "Adding node_modules to .gitignore...");
572
+ yield* git.writeGitignore(cwd, appendGitignoreEntry(existing ?? "", "node_modules")).pipe(Effect.tapError(() => s.stop(pc.red("Could not update .gitignore"))));
573
+ yield* s.stop(existing === void 0 ? ".gitignore created" : "node_modules added to .gitignore");
574
+ });
575
+ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
522
576
  const prompter = yield* Prompter;
523
577
  return {
524
578
  toppings: yield* prompter.multiselect({
@@ -555,8 +609,8 @@ const askRecipe = () => Effect.gen(function* () {
555
609
  }]
556
610
  })
557
611
  };
558
- }).pipe(Effect.withSpan("brew.askRecipe"));
559
- const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
612
+ });
613
+ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, packages) {
560
614
  if (packages.length === 0) {
561
615
  yield* Effect.logDebug("no packages to install");
562
616
  return;
@@ -566,21 +620,22 @@ const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
566
620
  const s = yield* prompter.spinner(`Installing ${packages.join(", ")}...`);
567
621
  yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
568
622
  yield* s.stop("Packages installed");
569
- }).pipe(Effect.withSpan("brew.installPackages"));
570
- const applyToppings = (cwd, esm, pkg, toppings) => Effect.gen(function* () {
623
+ });
624
+ const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
571
625
  let updated = pkg;
572
626
  if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
573
627
  if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
574
628
  if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
575
629
  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 ?? {})) {
630
+ });
631
+ const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
632
+ if (JSON.stringify(updated.scripts ?? {}) === JSON.stringify(pkg.scripts ?? {})) yield* Effect.logDebug("scripts unchanged, skipping package.json write");
633
+ else {
579
634
  yield* Effect.logDebug(`writing updated scripts to ${pkgPath}`);
580
635
  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* () {
636
+ }
637
+ });
638
+ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, toppings, sweetness) {
584
639
  if (sweetness !== "medium") return;
585
640
  const fs = yield* FileSystem.FileSystem;
586
641
  const path = yield* Path.Path;
@@ -588,8 +643,8 @@ const setupCommitGate = (cwd, pm, toppings, sweetness) => Effect.gen(function* (
588
643
  yield* apply$4(cwd, pm);
589
644
  yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
590
645
  yield* apply$5(cwd, pm);
591
- }).pipe(Effect.withSpan("brew.setupCommitGate"));
592
- const commitChanges = (cwd) => Effect.gen(function* () {
646
+ });
647
+ const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
593
648
  const git = yield* Git;
594
649
  const prompter = yield* Prompter;
595
650
  const s = yield* prompter.spinner("Committing changes...");
@@ -607,11 +662,12 @@ const commitChanges = (cwd) => Effect.gen(function* () {
607
662
  yield* s.stop(pc.yellow("Could not commit changes automatically"));
608
663
  const detail = result.stderr || result.stdout;
609
664
  yield* prompter.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
610
- }).pipe(Effect.withSpan("brew.commitChanges"));
665
+ });
611
666
  const brew = (cwd = process.cwd()) => Effect.gen(function* () {
612
667
  const prompter = yield* Prompter;
613
668
  yield* prompter.intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
614
669
  yield* ensureGitRepo(cwd);
670
+ yield* ensureGitIgnore(cwd);
615
671
  const pm = yield* detectPm(cwd);
616
672
  yield* Effect.logDebug(`detected package manager: ${pm}`);
617
673
  const { pkg, pkgPath } = yield* readPkg(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
  }