@chanom/cli 0.1.1 → 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 +95 -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
@@ -107,16 +107,77 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
107
107
  };
108
108
  }) }) {};
109
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
110
157
  //#region src/services/package-installer.ts
111
158
  var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
112
- /** 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
+ */
113
164
  var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", { effect: Effect.gen(function* () {
114
165
  const runner = yield* CommandRunner;
115
- 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, [
116
176
  "add",
117
177
  "-D",
178
+ ...workspaceRootFlags(pm, hints),
118
179
  ...packages
119
- ], cwd).pipe(Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
180
+ ], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
120
181
  }) }) {};
121
182
  //#endregion
122
183
  //#region src/services/prompter.ts
@@ -148,40 +209,6 @@ var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
148
209
  })
149
210
  } }) {};
150
211
  //#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
212
  //#region src/utils/detect-pm.ts
186
213
  const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
187
214
  const fs = yield* FileSystem.FileSystem;
@@ -286,7 +313,7 @@ function commitMsgHook(pm) {
286
313
  }
287
314
  //#endregion
288
315
  //#region src/commands/add-commitlint/index.ts
289
- const apply$5 = (cwd, pm) => Effect.gen(function* () {
316
+ const apply$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
290
317
  const fs = yield* FileSystem.FileSystem;
291
318
  const path = yield* Path.Path;
292
319
  const prompter = yield* Prompter;
@@ -299,7 +326,7 @@ const apply$5 = (cwd, pm) => Effect.gen(function* () {
299
326
  if (yield* fs.exists(hookPath)) yield* prompter.warn("`.husky/commit-msg` already exists - skipping");
300
327
  else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
301
328
  }
302
- }).pipe(Effect.withSpan("add-commitlint.apply"));
329
+ });
303
330
  //#endregion
304
331
  //#region src/commands/add-husky/logic.ts
305
332
  function getPackages$4(pkg) {
@@ -308,7 +335,7 @@ function getPackages$4(pkg) {
308
335
  //#endregion
309
336
  //#region src/commands/add-husky/index.ts
310
337
  var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
311
- const apply$4 = (cwd, pm) => Effect.gen(function* () {
338
+ const apply$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
312
339
  const fs = yield* FileSystem.FileSystem;
313
340
  const path = yield* Path.Path;
314
341
  const prompter = yield* Prompter;
@@ -325,7 +352,7 @@ const apply$4 = (cwd, pm) => Effect.gen(function* () {
325
352
  "init"
326
353
  ], cwd);
327
354
  if (exitCode !== 0) return yield* new HuskyInitFailed({ exitCode });
328
- }).pipe(Effect.withSpan("add-husky.apply"));
355
+ });
329
356
  //#endregion
330
357
  //#region src/domain/scripts.ts
331
358
  /**
@@ -364,7 +391,7 @@ function getScriptPlan$2(scripts) {
364
391
  //#endregion
365
392
  //#region src/commands/add-knip/index.ts
366
393
  /** 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* () {
394
+ const apply$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
368
395
  const fs = yield* FileSystem.FileSystem;
369
396
  const path = yield* Path.Path;
370
397
  const prompter = yield* Prompter;
@@ -380,7 +407,7 @@ const apply$3 = (cwd, esm, pkg) => Effect.gen(function* () {
380
407
  ...pkg,
381
408
  scripts: plan.scripts
382
409
  };
383
- }).pipe(Effect.withSpan("add-knip.apply"));
410
+ });
384
411
  //#endregion
385
412
  //#region src/commands/add-lint-staged/logic.ts
386
413
  function getPackages$2(pkg) {
@@ -401,7 +428,7 @@ function buildConfig(linters, formatters) {
401
428
  }
402
429
  //#endregion
403
430
  //#region src/commands/add-lint-staged/index.ts
404
- 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) {
405
432
  const fs = yield* FileSystem.FileSystem;
406
433
  const path = yield* Path.Path;
407
434
  const prompter = yield* Prompter;
@@ -414,7 +441,7 @@ const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false)
414
441
  if ((yield* fs.exists(hookPath)) && !overwritePreCommit) yield* prompter.warn("`.husky/pre-commit` already exists - skipping");
415
442
  else yield* fs.writeFileString(hookPath, "lint-staged\n");
416
443
  }
417
- }).pipe(Effect.withSpan("add-lint-staged.apply"));
444
+ });
418
445
  //#endregion
419
446
  //#region src/commands/add-oxfmt/logic.ts
420
447
  function getPackages$1(pkg, versions) {
@@ -435,7 +462,7 @@ function getScriptPlan$1(scripts) {
435
462
  //#endregion
436
463
  //#region src/commands/add-oxfmt/index.ts
437
464
  /** 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* () {
465
+ const apply$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
439
466
  const fs = yield* FileSystem.FileSystem;
440
467
  const path = yield* Path.Path;
441
468
  const prompter = yield* Prompter;
@@ -451,7 +478,7 @@ const apply$1 = (cwd, esm, pkg) => Effect.gen(function* () {
451
478
  ...pkg,
452
479
  scripts: plan.scripts
453
480
  };
454
- }).pipe(Effect.withSpan("add-oxfmt.apply"));
481
+ });
455
482
  //#endregion
456
483
  //#region src/commands/add-oxlint/logic.ts
457
484
  function getPackages(pkg, versions) {
@@ -472,7 +499,7 @@ function getScriptPlan(scripts) {
472
499
  //#endregion
473
500
  //#region src/commands/add-oxlint/index.ts
474
501
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
475
- const apply = (cwd, esm, pkg) => Effect.gen(function* () {
502
+ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
476
503
  const fs = yield* FileSystem.FileSystem;
477
504
  const path = yield* Path.Path;
478
505
  const prompter = yield* Prompter;
@@ -488,7 +515,7 @@ const apply = (cwd, esm, pkg) => Effect.gen(function* () {
488
515
  ...pkg,
489
516
  scripts: plan.scripts
490
517
  };
491
- }).pipe(Effect.withSpan("add-oxlint.apply"));
518
+ });
492
519
  //#endregion
493
520
  //#region src/commands/brew/logic.ts
494
521
  /** Deduplicated list of packages to install for the chosen toppings and sweetness. */
@@ -523,7 +550,7 @@ function selectedFormatters(toppings) {
523
550
  }
524
551
  //#endregion
525
552
  //#region src/commands/brew/index.ts
526
- const ensureGitRepo = (cwd) => Effect.gen(function* () {
553
+ const ensureGitRepo = Effect.fn("brew.ensureGitRepo")(function* (cwd) {
527
554
  const git = yield* Git;
528
555
  if (yield* git.isRepo(cwd)) {
529
556
  yield* Effect.logDebug("git repository already present");
@@ -532,8 +559,8 @@ const ensureGitRepo = (cwd) => Effect.gen(function* () {
532
559
  const s = yield* (yield* Prompter).spinner("Git not found, initializing repository...");
533
560
  yield* git.init(cwd);
534
561
  yield* s.stop("Git initialized");
535
- }).pipe(Effect.withSpan("brew.ensureGitRepo"));
536
- const ensureGitIgnore = (cwd) => Effect.gen(function* () {
562
+ });
563
+ const ensureGitIgnore = Effect.fn("brew.ensureGitIgnore")(function* (cwd) {
537
564
  const git = yield* Git;
538
565
  const prompter = yield* Prompter;
539
566
  const existing = yield* git.readGitignore(cwd).pipe(Effect.catchTag("SystemError", (e) => e.reason === "NotFound" ? Effect.succeed(void 0) : Effect.fail(e)));
@@ -544,8 +571,8 @@ const ensureGitIgnore = (cwd) => Effect.gen(function* () {
544
571
  const s = yield* prompter.spinner(existing === void 0 ? "Creating .gitignore..." : "Adding node_modules to .gitignore...");
545
572
  yield* git.writeGitignore(cwd, appendGitignoreEntry(existing ?? "", "node_modules")).pipe(Effect.tapError(() => s.stop(pc.red("Could not update .gitignore"))));
546
573
  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* () {
574
+ });
575
+ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
549
576
  const prompter = yield* Prompter;
550
577
  return {
551
578
  toppings: yield* prompter.multiselect({
@@ -582,8 +609,8 @@ const askRecipe = () => Effect.gen(function* () {
582
609
  }]
583
610
  })
584
611
  };
585
- }).pipe(Effect.withSpan("brew.askRecipe"));
586
- const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
612
+ });
613
+ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, packages) {
587
614
  if (packages.length === 0) {
588
615
  yield* Effect.logDebug("no packages to install");
589
616
  return;
@@ -593,21 +620,22 @@ const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
593
620
  const s = yield* prompter.spinner(`Installing ${packages.join(", ")}...`);
594
621
  yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
595
622
  yield* s.stop("Packages installed");
596
- }).pipe(Effect.withSpan("brew.installPackages"));
597
- const applyToppings = (cwd, esm, pkg, toppings) => Effect.gen(function* () {
623
+ });
624
+ const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
598
625
  let updated = pkg;
599
626
  if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
600
627
  if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
601
628
  if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
602
629
  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 ?? {})) {
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 {
606
634
  yield* Effect.logDebug(`writing updated scripts to ${pkgPath}`);
607
635
  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* () {
636
+ }
637
+ });
638
+ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, toppings, sweetness) {
611
639
  if (sweetness !== "medium") return;
612
640
  const fs = yield* FileSystem.FileSystem;
613
641
  const path = yield* Path.Path;
@@ -615,8 +643,8 @@ const setupCommitGate = (cwd, pm, toppings, sweetness) => Effect.gen(function* (
615
643
  yield* apply$4(cwd, pm);
616
644
  yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
617
645
  yield* apply$5(cwd, pm);
618
- }).pipe(Effect.withSpan("brew.setupCommitGate"));
619
- const commitChanges = (cwd) => Effect.gen(function* () {
646
+ });
647
+ const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
620
648
  const git = yield* Git;
621
649
  const prompter = yield* Prompter;
622
650
  const s = yield* prompter.spinner("Committing changes...");
@@ -634,7 +662,7 @@ const commitChanges = (cwd) => Effect.gen(function* () {
634
662
  yield* s.stop(pc.yellow("Could not commit changes automatically"));
635
663
  const detail = result.stderr || result.stdout;
636
664
  yield* prompter.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
637
- }).pipe(Effect.withSpan("brew.commitChanges"));
665
+ });
638
666
  const brew = (cwd = process.cwd()) => Effect.gen(function* () {
639
667
  const prompter = yield* Prompter;
640
668
  yield* prompter.intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.1.1",
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
  }