@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.
- package/README.md +10 -1
- package/dist/index.js +150 -75
- 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.
|
|
10
|
-
oxfmt: "0.
|
|
11
|
-
knip: "6.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
})
|
|
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 =
|
|
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
|
-
})
|
|
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)
|
|
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
|
-
})
|
|
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)
|
|
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
|
-
})
|
|
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)
|
|
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
|
-
})
|
|
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)
|
|
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
|
-
})
|
|
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")
|
|
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
|
-
|
|
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
|
-
})
|
|
536
|
-
const ensureGitIgnore =
|
|
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
|
-
})
|
|
548
|
-
const askRecipe =
|
|
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
|
-
})
|
|
586
|
-
const installPackages = (pm, cwd, packages)
|
|
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
|
-
})
|
|
597
|
-
const applyToppings = (cwd, esm, pkg, toppings)
|
|
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
|
-
})
|
|
604
|
-
const persistScripts = (pkgPath, pkg, updated)
|
|
605
|
-
if (JSON.stringify(updated.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
|
-
}
|
|
609
|
-
})
|
|
610
|
-
|
|
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
|
-
})
|
|
619
|
-
const commitChanges =
|
|
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
|
-
})
|
|
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
|
-
|
|
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.
|
|
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
|
}
|