@chanom/cli 0.0.3 → 0.1.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 +45 -11
- package/dist/index.js +576 -304
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -24,9 +24,17 @@ pnpm add -D @chanom/cli
|
|
|
24
24
|
chanom brew
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Add `--debug` to trace what the CLI is doing and see exactly where it failed:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
chanom brew --debug
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Debug mode logs each step (detected package manager, planned packages, every command it spawns and its exit code) to stderr, and on failure prints the full error trace including which step failed.
|
|
34
|
+
|
|
27
35
|
`brew` walks you through two prompts:
|
|
28
36
|
|
|
29
|
-
1. **Toppings** - which tools to add, any combination
|
|
37
|
+
1. **Toppings** - which tools to add, any combination (or none):
|
|
30
38
|
- `oxlint` - fast Rust-based linter
|
|
31
39
|
- `oxfmt` - fast Rust-based formatter
|
|
32
40
|
- `knip` - dead code remover
|
|
@@ -37,26 +45,52 @@ chanom brew
|
|
|
37
45
|
Based on your answers, `brew` will:
|
|
38
46
|
|
|
39
47
|
- Initialize a git repo if one doesn't exist yet (with a `.gitignore`)
|
|
40
|
-
- Detect your package manager (`pnpm`, `npm`, `yarn`, or `bun`)
|
|
41
|
-
- Install the packages needed for your selected toppings
|
|
42
|
-
- Write config files
|
|
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
|
|
50
|
+
- Write config files and wire up `package.json` scripts
|
|
43
51
|
- If `medium` sweetness is selected, run `husky init` and add `pre-commit` / `commit-msg` hooks
|
|
44
52
|
- Stage and commit the changes it made
|
|
45
53
|
|
|
54
|
+
Cancelling a prompt (Ctrl+C) exits cleanly without touching anything further.
|
|
55
|
+
|
|
46
56
|
## What gets added
|
|
47
57
|
|
|
48
|
-
| Topping | Config file
|
|
49
|
-
| -------- |
|
|
50
|
-
| `oxlint` | `oxlint.config.ts
|
|
51
|
-
| `oxfmt` | `oxfmt.config.ts
|
|
52
|
-
| `knip` | `knip.config.ts
|
|
58
|
+
| Topping | Config file | Scripts added |
|
|
59
|
+
| -------- | -------------------- | ------------------------ |
|
|
60
|
+
| `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
|
|
61
|
+
| `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
|
|
62
|
+
| `knip` | `knip.config.ts`\* | `knip` |
|
|
63
|
+
|
|
64
|
+
\* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
|
|
53
65
|
|
|
54
66
|
Config files re-export presets from [`@chanom/dev-config`](../dev-config).
|
|
55
67
|
|
|
56
68
|
`medium` sweetness additionally adds:
|
|
57
69
|
|
|
58
70
|
- `husky` - installed via `husky init`
|
|
59
|
-
- `lint-staged` - `.lintstagedrc.json` running the selected linter/formatter on staged files
|
|
71
|
+
- `lint-staged` - `.lintstagedrc.json` running the selected linter/formatter on staged files, wired into the `pre-commit` hook
|
|
60
72
|
- `commitlint` - `.commitlintrc.json` extending `@commitlint/config-conventional`, enforced in the `commit-msg` hook
|
|
61
73
|
|
|
62
|
-
|
|
74
|
+
## Version pinning
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
## Safety
|
|
79
|
+
|
|
80
|
+
Existing setup is never overwritten:
|
|
81
|
+
|
|
82
|
+
- If a config file for a tool already exists (any common variant, e.g. `.oxlintrc.json` or `oxlint.config.mjs`), `brew` warns and skips writing it.
|
|
83
|
+
- If a script name already exists in `package.json`, `brew` warns and leaves it alone.
|
|
84
|
+
- Existing `.husky/pre-commit` and `.husky/commit-msg` hooks are kept.
|
|
85
|
+
|
|
86
|
+
The final commit uses your git identity; if none is configured, a local `Chanom <chanom@local>` identity is set. If committing fails (e.g. a commit hook rejects it), your files stay staged and `brew` tells you to commit manually.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
See [ARCHITECTURE.md](./ARCHITECTURE.md) for how the code is organized.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
pnpm build # bundle with tsdown
|
|
94
|
+
pnpm test # run vitest once
|
|
95
|
+
pnpm test:watch # run vitest in watch mode
|
|
96
|
+
```
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,154 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
|
|
4
|
+
import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
|
|
5
5
|
import pc from "picocolors";
|
|
6
|
-
import
|
|
7
|
-
//#region src/
|
|
6
|
+
import * as clack from "@clack/prompts";
|
|
7
|
+
//#region src/bundled-versions.ts
|
|
8
|
+
const bundledVersions = {
|
|
9
|
+
oxlint: "1.71.0",
|
|
10
|
+
oxfmt: "0.56.0",
|
|
11
|
+
knip: "6.23.0",
|
|
12
|
+
devConfig: "0.0.3"
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/domain/pkg.ts
|
|
16
|
+
var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
|
|
17
|
+
var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
|
|
18
|
+
function isEsm(pkg) {
|
|
19
|
+
return pkg.type === "module";
|
|
20
|
+
}
|
|
21
|
+
function isPackageInstalled(pkg, name) {
|
|
22
|
+
return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
|
|
23
|
+
}
|
|
24
|
+
function stripRangePrefix(version) {
|
|
25
|
+
return version.replace(/^[\^~]/, "");
|
|
26
|
+
}
|
|
27
|
+
function getMismatchedPackage(pkg, name, version) {
|
|
28
|
+
const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
|
|
29
|
+
return installed !== void 0 && stripRangePrefix(installed) === version ? void 0 : `${name}@${version}`;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/services/command-runner.ts
|
|
33
|
+
const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
|
|
34
|
+
/**
|
|
35
|
+
* Low-level process execution. Everything that shells out (git, package
|
|
36
|
+
* managers, husky) goes through this service so commands can be stubbed in tests.
|
|
37
|
+
*/
|
|
38
|
+
var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect: Effect.gen(function* () {
|
|
39
|
+
const executor = yield* CommandExecutor.CommandExecutor;
|
|
40
|
+
const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
|
|
41
|
+
const traced = (cmd, args, cwd, effect) => Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`).pipe(Effect.andThen(effect), Effect.tap((code) => Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${code}`)));
|
|
42
|
+
const capture = (cmd, args, cwd) => Effect.scoped(Effect.gen(function* () {
|
|
43
|
+
yield* Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`);
|
|
44
|
+
const proc = yield* executor.start(make(cmd, args, cwd));
|
|
45
|
+
const [exitCode, stdout, stderr] = yield* Effect.all([
|
|
46
|
+
proc.exitCode,
|
|
47
|
+
collectText(proc.stdout),
|
|
48
|
+
collectText(proc.stderr)
|
|
49
|
+
], { concurrency: 3 });
|
|
50
|
+
yield* Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${exitCode}`);
|
|
51
|
+
return {
|
|
52
|
+
exitCode,
|
|
53
|
+
stdout: stdout.trim(),
|
|
54
|
+
stderr: stderr.trim()
|
|
55
|
+
};
|
|
56
|
+
}));
|
|
57
|
+
const exitCode = (cmd, args, cwd) => traced(cmd, args, cwd, make(cmd, args, cwd).pipe(Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, executor)));
|
|
58
|
+
const execInherit = (cmd, args, cwd) => traced(cmd, args, cwd, make(cmd, args, cwd).pipe(Command.stdout("inherit"), Command.stderr("inherit"), Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, executor)));
|
|
59
|
+
return {
|
|
60
|
+
/** Runs a command silently and collects its exit code and trimmed output. */
|
|
61
|
+
capture,
|
|
62
|
+
/** Runs a command silently and returns only its exit code. */
|
|
63
|
+
exitCode,
|
|
64
|
+
/** Runs a command with stdout/stderr forwarded to the user's terminal. */
|
|
65
|
+
execInherit
|
|
66
|
+
};
|
|
67
|
+
}) }) {};
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/services/git.ts
|
|
70
|
+
/** Git operations, built on top of {@link CommandRunner}. */
|
|
71
|
+
var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(function* () {
|
|
72
|
+
const runner = yield* CommandRunner;
|
|
73
|
+
const fs = yield* FileSystem.FileSystem;
|
|
74
|
+
const path = yield* Path.Path;
|
|
75
|
+
const run = (args, cwd) => runner.capture("git", args, cwd).pipe(Effect.map(({ exitCode, stdout, stderr }) => ({
|
|
76
|
+
ok: exitCode === 0,
|
|
77
|
+
stdout,
|
|
78
|
+
stderr
|
|
79
|
+
})));
|
|
80
|
+
const exitOk = (args, cwd) => runner.exitCode("git", args, cwd).pipe(Effect.map((code) => code === 0));
|
|
81
|
+
return {
|
|
82
|
+
init: (cwd) => run(["init"], cwd),
|
|
83
|
+
stageAll: (cwd) => run(["add", "."], cwd),
|
|
84
|
+
commit: (cwd, message) => run([
|
|
85
|
+
"commit",
|
|
86
|
+
"-m",
|
|
87
|
+
message
|
|
88
|
+
], cwd),
|
|
89
|
+
hasIdentity: (cwd) => exitOk(["var", "GIT_COMMITTER_IDENT"], cwd),
|
|
90
|
+
setLocalIdentity: (cwd, name, email) => run([
|
|
91
|
+
"config",
|
|
92
|
+
"user.name",
|
|
93
|
+
name
|
|
94
|
+
], cwd).pipe(Effect.andThen(() => run([
|
|
95
|
+
"config",
|
|
96
|
+
"user.email",
|
|
97
|
+
email
|
|
98
|
+
], cwd)), Effect.asVoid),
|
|
99
|
+
hasStagedChanges: (cwd) => exitOk([
|
|
100
|
+
"diff",
|
|
101
|
+
"--cached",
|
|
102
|
+
"--quiet"
|
|
103
|
+
], cwd).pipe(Effect.map((clean) => !clean)),
|
|
104
|
+
isRepo: (cwd) => fs.exists(path.join(cwd, ".git")),
|
|
105
|
+
writeGitignore: (cwd, contents = "node_modules\n") => fs.writeFileString(path.join(cwd, ".gitignore"), contents),
|
|
106
|
+
readGitignore: (cwd) => fs.readFileString(path.join(cwd, ".gitignore"))
|
|
107
|
+
};
|
|
108
|
+
}) }) {};
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/services/package-installer.ts
|
|
111
|
+
var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
|
|
112
|
+
/** Installs npm packages with the detected package manager. */
|
|
113
|
+
var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", { effect: Effect.gen(function* () {
|
|
114
|
+
const runner = yield* CommandRunner;
|
|
115
|
+
return { installDev: (pm, cwd, packages) => runner.execInherit(pm, [
|
|
116
|
+
"add",
|
|
117
|
+
"-D",
|
|
118
|
+
...packages
|
|
119
|
+
], cwd).pipe(Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
|
|
120
|
+
}) }) {};
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/services/prompter.ts
|
|
123
|
+
var Cancelled = class extends Data.TaggedError("Cancelled") {};
|
|
124
|
+
const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
|
|
125
|
+
/**
|
|
126
|
+
* All terminal interaction (prompts, spinners, log messages). Commands never
|
|
127
|
+
* touch @clack/prompts directly, so tests can script answers and capture output.
|
|
128
|
+
*/
|
|
129
|
+
var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
|
|
130
|
+
intro: (message) => Effect.sync(() => clack.intro(message)),
|
|
131
|
+
outro: (message) => Effect.sync(() => clack.outro(message)),
|
|
132
|
+
warn: (message) => Effect.sync(() => clack.log.warn(message)),
|
|
133
|
+
error: (message) => Effect.sync(() => clack.log.error(message)),
|
|
134
|
+
debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
|
|
135
|
+
select: (params) => guardCancel(() => clack.select({
|
|
136
|
+
message: params.message,
|
|
137
|
+
options: [...params.options]
|
|
138
|
+
})),
|
|
139
|
+
multiselect: (params) => guardCancel(() => clack.multiselect({
|
|
140
|
+
message: params.message,
|
|
141
|
+
options: [...params.options],
|
|
142
|
+
required: params.required ?? false
|
|
143
|
+
})),
|
|
144
|
+
spinner: (message) => Effect.sync(() => {
|
|
145
|
+
const s = clack.spinner();
|
|
146
|
+
s.start(message);
|
|
147
|
+
return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
|
|
148
|
+
})
|
|
149
|
+
} }) {};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/domain/package-manager.ts
|
|
8
152
|
/** How to run a locally installed binary with each package manager. */
|
|
9
153
|
const PM_EXEC = {
|
|
10
154
|
pnpm: ["pnpm", "exec"],
|
|
@@ -22,115 +166,53 @@ const KNOWN_PMS = [
|
|
|
22
166
|
"bun",
|
|
23
167
|
"npm"
|
|
24
168
|
];
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} catch {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function fromUserAgent() {
|
|
38
|
-
const agent = process.env["npm_config_user_agent"];
|
|
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) {
|
|
39
177
|
if (!agent) return void 0;
|
|
40
178
|
return KNOWN_PMS.find((pm) => agent.startsWith(pm));
|
|
41
179
|
}
|
|
42
|
-
|
|
43
|
-
|
|
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";
|
|
44
183
|
}
|
|
45
184
|
//#endregion
|
|
46
|
-
//#region src/utils/
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
185
|
+
//#region src/utils/detect-pm.ts
|
|
186
|
+
const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
|
|
187
|
+
const fs = yield* FileSystem.FileSystem;
|
|
188
|
+
const pkgPath = (yield* Path.Path).join(cwd, "package.json");
|
|
189
|
+
return resolvePm({
|
|
190
|
+
packageManagerField: yield* fs.readFileString(pkgPath).pipe(Effect.flatMap((contents) => Effect.try(() => JSON.parse(contents).packageManager)), Effect.orElseSucceed(() => void 0)),
|
|
191
|
+
userAgent,
|
|
192
|
+
fallback
|
|
52
193
|
});
|
|
53
|
-
|
|
54
|
-
ok: status === 0,
|
|
55
|
-
stdout: (stdout ?? "").trim(),
|
|
56
|
-
stderr: (stderr ?? "").trim()
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
function isGitRepo(cwd = process.cwd()) {
|
|
60
|
-
return existsSync(join(cwd, ".git"));
|
|
61
|
-
}
|
|
62
|
-
function init(cwd) {
|
|
63
|
-
return run(["init"], cwd);
|
|
64
|
-
}
|
|
65
|
-
/** True if git has a usable author identity (committer.name + committer.email). */
|
|
66
|
-
function hasIdentity(cwd) {
|
|
67
|
-
return run(["var", "GIT_COMMITTER_IDENT"], cwd).ok;
|
|
68
|
-
}
|
|
69
|
-
/** Set a repo-local identity so commits succeed on a fresh repo without global config. */
|
|
70
|
-
function setLocalIdentity(cwd, name, email) {
|
|
71
|
-
run([
|
|
72
|
-
"config",
|
|
73
|
-
"user.name",
|
|
74
|
-
name
|
|
75
|
-
], cwd);
|
|
76
|
-
run([
|
|
77
|
-
"config",
|
|
78
|
-
"user.email",
|
|
79
|
-
email
|
|
80
|
-
], cwd);
|
|
81
|
-
}
|
|
82
|
-
function stageAll(cwd) {
|
|
83
|
-
return run(["add", "."], cwd);
|
|
84
|
-
}
|
|
85
|
-
/** True if there is anything staged to commit. */
|
|
86
|
-
function hasStagedChanges(cwd) {
|
|
87
|
-
return !run([
|
|
88
|
-
"diff",
|
|
89
|
-
"--cached",
|
|
90
|
-
"--quiet"
|
|
91
|
-
], cwd).ok;
|
|
92
|
-
}
|
|
93
|
-
function commit(cwd, message) {
|
|
94
|
-
return run([
|
|
95
|
-
"commit",
|
|
96
|
-
"-m",
|
|
97
|
-
message
|
|
98
|
-
], cwd);
|
|
99
|
-
}
|
|
100
|
-
function writeGitignore(cwd, contents = "node_modules\n") {
|
|
101
|
-
writeFileSync(join(cwd, ".gitignore"), contents, "utf-8");
|
|
102
|
-
}
|
|
194
|
+
});
|
|
103
195
|
//#endregion
|
|
104
|
-
//#region src/utils/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
], {
|
|
111
|
-
stdio: "inherit",
|
|
112
|
-
cwd
|
|
113
|
-
}).status === 0;
|
|
114
|
-
}
|
|
115
|
-
//#endregion
|
|
116
|
-
//#region src/utils/pkg.ts
|
|
117
|
-
function isEsm(pkg) {
|
|
118
|
-
return pkg.type === "module";
|
|
119
|
-
}
|
|
120
|
-
function readPkg(cwd) {
|
|
121
|
-
const pkgPath = join(cwd, "package.json");
|
|
122
|
-
if (!existsSync(pkgPath)) throw new Error(`No package.json found in ${cwd}`);
|
|
196
|
+
//#region src/utils/pkg-file.ts
|
|
197
|
+
const readPkg = (cwd) => Effect.gen(function* () {
|
|
198
|
+
const fs = yield* FileSystem.FileSystem;
|
|
199
|
+
const pkgPath = (yield* Path.Path).join(cwd, "package.json");
|
|
200
|
+
if (!(yield* fs.exists(pkgPath))) return yield* new PkgNotFound({ cwd });
|
|
201
|
+
const contents = yield* fs.readFileString(pkgPath);
|
|
123
202
|
return {
|
|
124
|
-
pkg:
|
|
203
|
+
pkg: yield* Effect.try({
|
|
204
|
+
try: () => JSON.parse(contents),
|
|
205
|
+
catch: () => new PkgInvalid({ pkgPath })
|
|
206
|
+
}),
|
|
125
207
|
pkgPath
|
|
126
208
|
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
209
|
+
});
|
|
210
|
+
const writePkg = (pkgPath, pkg) => Effect.gen(function* () {
|
|
211
|
+
yield* (yield* FileSystem.FileSystem).writeFileString(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
212
|
+
});
|
|
131
213
|
//#endregion
|
|
132
|
-
//#region src/
|
|
133
|
-
const
|
|
214
|
+
//#region src/domain/setup.ts
|
|
215
|
+
const SETUP_FILE_CANDIDATES = {
|
|
134
216
|
oxlint: [
|
|
135
217
|
"oxlint.config.js",
|
|
136
218
|
"oxlint.config.cjs",
|
|
@@ -173,258 +255,448 @@ const CONFIG_FILES = {
|
|
|
173
255
|
"vite.config.mjs"
|
|
174
256
|
]
|
|
175
257
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/utils/detect-setup.ts
|
|
260
|
+
/** Finds an existing config file for the given tool, if any. */
|
|
261
|
+
const detectSetupFile = (type, cwd) => Effect.gen(function* () {
|
|
262
|
+
const fs = yield* FileSystem.FileSystem;
|
|
263
|
+
const path = yield* Path.Path;
|
|
264
|
+
for (const candidate of SETUP_FILE_CANDIDATES[type]) {
|
|
265
|
+
const filepath = path.join(cwd, candidate);
|
|
266
|
+
if (yield* fs.exists(filepath)) return Option.some(filepath);
|
|
180
267
|
}
|
|
181
|
-
return
|
|
182
|
-
}
|
|
268
|
+
return Option.none();
|
|
269
|
+
});
|
|
183
270
|
//#endregion
|
|
184
|
-
//#region src/commands/add-commitlint/
|
|
271
|
+
//#region src/commands/add-commitlint/logic.ts
|
|
185
272
|
function getPackages$5(pkg) {
|
|
186
273
|
const missing = [];
|
|
187
274
|
if (!isPackageInstalled(pkg, "@commitlint/cli")) missing.push("@commitlint/cli");
|
|
188
275
|
if (!isPackageInstalled(pkg, "@commitlint/config-conventional")) missing.push("@commitlint/config-conventional");
|
|
189
276
|
return missing;
|
|
190
277
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
...PM_EXEC[pm],
|
|
200
|
-
"commitlint",
|
|
201
|
-
"--edit",
|
|
202
|
-
"$1"
|
|
203
|
-
].join(" ") + "\n");
|
|
204
|
-
}
|
|
278
|
+
const CONFIG_CONTENTS = JSON.stringify({ extends: ["@commitlint/config-conventional"] }, null, 2) + "\n";
|
|
279
|
+
function commitMsgHook(pm) {
|
|
280
|
+
return [
|
|
281
|
+
...PM_EXEC[pm],
|
|
282
|
+
"commitlint",
|
|
283
|
+
"--edit",
|
|
284
|
+
"$1"
|
|
285
|
+
].join(" ") + "\n";
|
|
205
286
|
}
|
|
206
287
|
//#endregion
|
|
207
|
-
//#region src/commands/add-
|
|
288
|
+
//#region src/commands/add-commitlint/index.ts
|
|
289
|
+
const apply$5 = (cwd, pm) => Effect.gen(function* () {
|
|
290
|
+
const fs = yield* FileSystem.FileSystem;
|
|
291
|
+
const path = yield* Path.Path;
|
|
292
|
+
const prompter = yield* Prompter;
|
|
293
|
+
const existing = yield* detectSetupFile("commitlint", cwd);
|
|
294
|
+
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping commitlint config`);
|
|
295
|
+
else yield* fs.writeFileString(path.join(cwd, ".commitlintrc.json"), CONFIG_CONTENTS);
|
|
296
|
+
const huskyDir = path.join(cwd, ".husky");
|
|
297
|
+
if (yield* fs.exists(huskyDir)) {
|
|
298
|
+
const hookPath = path.join(huskyDir, "commit-msg");
|
|
299
|
+
if (yield* fs.exists(hookPath)) yield* prompter.warn("`.husky/commit-msg` already exists - skipping");
|
|
300
|
+
else yield* fs.writeFileString(hookPath, commitMsgHook(pm));
|
|
301
|
+
}
|
|
302
|
+
}).pipe(Effect.withSpan("add-commitlint.apply"));
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/commands/add-husky/logic.ts
|
|
208
305
|
function getPackages$4(pkg) {
|
|
209
306
|
return isPackageInstalled(pkg, "husky") ? [] : ["husky"];
|
|
210
307
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/commands/add-husky/index.ts
|
|
310
|
+
var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
|
|
311
|
+
const apply$4 = (cwd, pm) => Effect.gen(function* () {
|
|
312
|
+
const fs = yield* FileSystem.FileSystem;
|
|
313
|
+
const path = yield* Path.Path;
|
|
314
|
+
const prompter = yield* Prompter;
|
|
315
|
+
const runner = yield* CommandRunner;
|
|
316
|
+
const huskyDir = path.join(cwd, ".husky");
|
|
317
|
+
if (yield* fs.exists(huskyDir)) {
|
|
318
|
+
yield* prompter.warn("`.husky` already exists - skipping husky init");
|
|
214
319
|
return;
|
|
215
320
|
}
|
|
216
321
|
const [cmd, ...args] = PM_EXEC[pm];
|
|
217
|
-
|
|
322
|
+
const exitCode = yield* runner.execInherit(cmd, [
|
|
218
323
|
...args,
|
|
219
324
|
"husky",
|
|
220
325
|
"init"
|
|
221
|
-
],
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
326
|
+
], cwd);
|
|
327
|
+
if (exitCode !== 0) return yield* new HuskyInitFailed({ exitCode });
|
|
328
|
+
}).pipe(Effect.withSpan("add-husky.apply"));
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/domain/scripts.ts
|
|
331
|
+
/**
|
|
332
|
+
* Merges `wanted` scripts into `current` without overwriting anything the user
|
|
333
|
+
* already has. Returns the merged scripts plus which keys were added or skipped.
|
|
334
|
+
*/
|
|
335
|
+
function planScripts(current, wanted) {
|
|
336
|
+
const scripts = { ...current };
|
|
337
|
+
const added = [];
|
|
338
|
+
const skipped = [];
|
|
339
|
+
for (const [key, value] of Object.entries(wanted)) if (scripts[key]) skipped.push(key);
|
|
340
|
+
else {
|
|
341
|
+
scripts[key] = value;
|
|
342
|
+
added.push(key);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
scripts,
|
|
346
|
+
added,
|
|
347
|
+
skipped
|
|
348
|
+
};
|
|
225
349
|
}
|
|
226
350
|
//#endregion
|
|
227
351
|
//#region src/commands/add-knip/logic.ts
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
if (!scripts["knip"]) missing["knip"] = "knip";
|
|
231
|
-
return missing;
|
|
352
|
+
function getPackages$3(pkg, versions) {
|
|
353
|
+
return isPackageInstalled(pkg, "knip") ? [] : [`knip@${versions.knip}`, `@chanom/dev-config@${versions.devConfig}`];
|
|
232
354
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
355
|
+
function configFile$2(esm) {
|
|
356
|
+
return {
|
|
357
|
+
fileName: `knip.config.${esm ? "ts" : "mts"}`,
|
|
358
|
+
contents: `export { default } from '@chanom/dev-config/knip/config';\n`
|
|
359
|
+
};
|
|
237
360
|
}
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping knip config`);
|
|
241
|
-
else writeFileSync(join(cwd, `knip.config.${esm ? "ts" : "mts"}`), `export { default } from '@chanom/dev-config/knip/config';\n`);
|
|
242
|
-
const { pkg, pkgPath } = readPkg(cwd);
|
|
243
|
-
pkg.scripts ??= {};
|
|
244
|
-
const missing = getMissingScripts$2(pkg.scripts);
|
|
245
|
-
for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
|
|
246
|
-
const skipped = ["knip"].filter((k) => !(k in missing));
|
|
247
|
-
for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
248
|
-
if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
361
|
+
function getScriptPlan$2(scripts) {
|
|
362
|
+
return planScripts(scripts, { knip: "knip" });
|
|
249
363
|
}
|
|
250
364
|
//#endregion
|
|
251
|
-
//#region src/commands/add-
|
|
365
|
+
//#region src/commands/add-knip/index.ts
|
|
366
|
+
/** 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* () {
|
|
368
|
+
const fs = yield* FileSystem.FileSystem;
|
|
369
|
+
const path = yield* Path.Path;
|
|
370
|
+
const prompter = yield* Prompter;
|
|
371
|
+
const existing = yield* detectSetupFile("knip", cwd);
|
|
372
|
+
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping knip config`);
|
|
373
|
+
else {
|
|
374
|
+
const config = configFile$2(esm);
|
|
375
|
+
yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
|
|
376
|
+
}
|
|
377
|
+
const plan = getScriptPlan$2(pkg.scripts);
|
|
378
|
+
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
379
|
+
return {
|
|
380
|
+
...pkg,
|
|
381
|
+
scripts: plan.scripts
|
|
382
|
+
};
|
|
383
|
+
}).pipe(Effect.withSpan("add-knip.apply"));
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/commands/add-lint-staged/logic.ts
|
|
252
386
|
function getPackages$2(pkg) {
|
|
253
387
|
return isPackageInstalled(pkg, "lint-staged") ? [] : ["lint-staged"];
|
|
254
388
|
}
|
|
255
|
-
function
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (linters.includes("oxlint")) {
|
|
262
|
-
config[jsGlob] ??= [];
|
|
263
|
-
config[jsGlob].push("oxlint --fix");
|
|
264
|
-
}
|
|
265
|
-
if (formatters.includes("oxfmt")) {
|
|
266
|
-
config["*"] ??= [];
|
|
267
|
-
config["*"].push("oxfmt");
|
|
268
|
-
}
|
|
269
|
-
writeFileSync(join(cwd, ".lintstagedrc.json"), JSON.stringify(config, null, 2) + "\n");
|
|
389
|
+
function buildConfig(linters, formatters) {
|
|
390
|
+
const config = {};
|
|
391
|
+
const jsGlob = "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
|
|
392
|
+
if (linters.includes("oxlint")) {
|
|
393
|
+
config[jsGlob] ??= [];
|
|
394
|
+
config[jsGlob].push("oxlint --fix --no-error-on-unmatched-pattern");
|
|
270
395
|
}
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
else writeFileSync(hookPath, "lint-staged\n");
|
|
396
|
+
if (formatters.includes("oxfmt")) {
|
|
397
|
+
config["*"] ??= [];
|
|
398
|
+
config["*"].push("oxfmt --no-error-on-unmatched-pattern");
|
|
275
399
|
}
|
|
400
|
+
return config;
|
|
276
401
|
}
|
|
277
402
|
//#endregion
|
|
403
|
+
//#region src/commands/add-lint-staged/index.ts
|
|
404
|
+
const apply$2 = (cwd, linters = [], formatters = [], overwritePreCommit = false) => Effect.gen(function* () {
|
|
405
|
+
const fs = yield* FileSystem.FileSystem;
|
|
406
|
+
const path = yield* Path.Path;
|
|
407
|
+
const prompter = yield* Prompter;
|
|
408
|
+
const existing = yield* detectSetupFile("lint-staged", cwd);
|
|
409
|
+
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping lint-staged config`);
|
|
410
|
+
else yield* fs.writeFileString(path.join(cwd, ".lintstagedrc.json"), JSON.stringify(buildConfig(linters, formatters), null, 2) + "\n");
|
|
411
|
+
const huskyDir = path.join(cwd, ".husky");
|
|
412
|
+
if (yield* fs.exists(huskyDir)) {
|
|
413
|
+
const hookPath = path.join(huskyDir, "pre-commit");
|
|
414
|
+
if ((yield* fs.exists(hookPath)) && !overwritePreCommit) yield* prompter.warn("`.husky/pre-commit` already exists - skipping");
|
|
415
|
+
else yield* fs.writeFileString(hookPath, "lint-staged\n");
|
|
416
|
+
}
|
|
417
|
+
}).pipe(Effect.withSpan("add-lint-staged.apply"));
|
|
418
|
+
//#endregion
|
|
278
419
|
//#region src/commands/add-oxfmt/logic.ts
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
if (!scripts["format"]) missing["format"] = "oxfmt";
|
|
282
|
-
if (!scripts["format:check"]) missing["format:check"] = "oxfmt --check";
|
|
283
|
-
return missing;
|
|
420
|
+
function getPackages$1(pkg, versions) {
|
|
421
|
+
return [getMismatchedPackage(pkg, "oxfmt", versions.oxfmt), getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)].filter((p) => p !== void 0);
|
|
284
422
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
423
|
+
function configFile$1(esm) {
|
|
424
|
+
return {
|
|
425
|
+
fileName: `oxfmt.config.${esm ? "ts" : "mts"}`,
|
|
426
|
+
contents: `export { default } from '@chanom/dev-config/oxfmt/config';\n`
|
|
427
|
+
};
|
|
289
428
|
}
|
|
290
|
-
function
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
pkg.scripts ??= {};
|
|
296
|
-
const missing = getMissingScripts$1(pkg.scripts);
|
|
297
|
-
for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
|
|
298
|
-
const skipped = ["format", "format:check"].filter((k) => !(k in missing));
|
|
299
|
-
for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
300
|
-
if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
429
|
+
function getScriptPlan$1(scripts) {
|
|
430
|
+
return planScripts(scripts, {
|
|
431
|
+
format: "oxfmt",
|
|
432
|
+
"format:check": "oxfmt --check"
|
|
433
|
+
});
|
|
301
434
|
}
|
|
302
435
|
//#endregion
|
|
436
|
+
//#region src/commands/add-oxfmt/index.ts
|
|
437
|
+
/** 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* () {
|
|
439
|
+
const fs = yield* FileSystem.FileSystem;
|
|
440
|
+
const path = yield* Path.Path;
|
|
441
|
+
const prompter = yield* Prompter;
|
|
442
|
+
const existing = yield* detectSetupFile("oxfmt", cwd);
|
|
443
|
+
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping oxfmt config`);
|
|
444
|
+
else {
|
|
445
|
+
const config = configFile$1(esm);
|
|
446
|
+
yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
|
|
447
|
+
}
|
|
448
|
+
const plan = getScriptPlan$1(pkg.scripts);
|
|
449
|
+
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
450
|
+
return {
|
|
451
|
+
...pkg,
|
|
452
|
+
scripts: plan.scripts
|
|
453
|
+
};
|
|
454
|
+
}).pipe(Effect.withSpan("add-oxfmt.apply"));
|
|
455
|
+
//#endregion
|
|
303
456
|
//#region src/commands/add-oxlint/logic.ts
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return
|
|
457
|
+
function getPackages(pkg, versions) {
|
|
458
|
+
return [getMismatchedPackage(pkg, "oxlint", versions.oxlint), getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)].filter((p) => p !== void 0);
|
|
459
|
+
}
|
|
460
|
+
function configFile(esm) {
|
|
461
|
+
return {
|
|
462
|
+
fileName: `oxlint.config.${esm ? "ts" : "mts"}`,
|
|
463
|
+
contents: `export { default } from '@chanom/dev-config/oxlint/config';\n`
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function getScriptPlan(scripts) {
|
|
467
|
+
return planScripts(scripts, {
|
|
468
|
+
lint: "oxlint",
|
|
469
|
+
"lint:fix": "oxlint --fix"
|
|
470
|
+
});
|
|
309
471
|
}
|
|
310
472
|
//#endregion
|
|
311
473
|
//#region src/commands/add-oxlint/index.ts
|
|
312
|
-
|
|
313
|
-
|
|
474
|
+
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
475
|
+
const apply = (cwd, esm, pkg) => Effect.gen(function* () {
|
|
476
|
+
const fs = yield* FileSystem.FileSystem;
|
|
477
|
+
const path = yield* Path.Path;
|
|
478
|
+
const prompter = yield* Prompter;
|
|
479
|
+
const existing = yield* detectSetupFile("oxlint", cwd);
|
|
480
|
+
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping oxlint config`);
|
|
481
|
+
else {
|
|
482
|
+
const config = configFile(esm);
|
|
483
|
+
yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
|
|
484
|
+
}
|
|
485
|
+
const plan = getScriptPlan(pkg.scripts);
|
|
486
|
+
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
487
|
+
return {
|
|
488
|
+
...pkg,
|
|
489
|
+
scripts: plan.scripts
|
|
490
|
+
};
|
|
491
|
+
}).pipe(Effect.withSpan("add-oxlint.apply"));
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/commands/brew/logic.ts
|
|
494
|
+
/** Deduplicated list of packages to install for the chosen toppings and sweetness. */
|
|
495
|
+
function planPackages(pkg, toppings, sweetness, versions) {
|
|
496
|
+
const packages = [];
|
|
497
|
+
if (toppings.includes("oxlint")) packages.push(...getPackages(pkg, versions));
|
|
498
|
+
if (toppings.includes("oxfmt")) packages.push(...getPackages$1(pkg, versions));
|
|
499
|
+
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));
|
|
501
|
+
return [...new Set(packages)];
|
|
502
|
+
}
|
|
503
|
+
/** True when the .gitignore content already has the entry as its own line. */
|
|
504
|
+
function hasGitignoreEntry(content, entry) {
|
|
505
|
+
const forms = /* @__PURE__ */ new Set([
|
|
506
|
+
entry,
|
|
507
|
+
`${entry}/`,
|
|
508
|
+
`/${entry}`,
|
|
509
|
+
`/${entry}/`
|
|
510
|
+
]);
|
|
511
|
+
return content.split(/\r?\n/).some((line) => forms.has(line.trim()));
|
|
512
|
+
}
|
|
513
|
+
/** .gitignore content with the entry appended, preserving existing lines and newline termination. */
|
|
514
|
+
function appendGitignoreEntry(content, entry) {
|
|
515
|
+
if (content === "") return `${entry}\n`;
|
|
516
|
+
return content.endsWith("\n") ? `${content}${entry}\n` : `${content}\n${entry}\n`;
|
|
517
|
+
}
|
|
518
|
+
function selectedLinters(toppings) {
|
|
519
|
+
return toppings.filter((t) => t === "oxlint");
|
|
314
520
|
}
|
|
315
|
-
function
|
|
316
|
-
|
|
317
|
-
if (existing) log.warn(`\`${basename(existing)}\` already exists - skipping oxlint config`);
|
|
318
|
-
else writeFileSync(join(cwd, `oxlint.config.${esm ? "ts" : "mts"}`), `export { default } from '@chanom/dev-config/oxlint/config';\n`);
|
|
319
|
-
const { pkg, pkgPath } = readPkg(cwd);
|
|
320
|
-
pkg.scripts ??= {};
|
|
321
|
-
const missing = getMissingScripts(pkg.scripts);
|
|
322
|
-
for (const [key, value] of Object.entries(missing)) pkg.scripts[key] = value;
|
|
323
|
-
const skipped = ["lint", "lint:fix"].filter((k) => !(k in missing));
|
|
324
|
-
for (const key of skipped) log.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
325
|
-
if (Object.keys(missing).length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
521
|
+
function selectedFormatters(toppings) {
|
|
522
|
+
return toppings.filter((t) => t === "oxfmt");
|
|
326
523
|
}
|
|
327
524
|
//#endregion
|
|
328
525
|
//#region src/commands/brew/index.ts
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
init(cwd);
|
|
335
|
-
writeGitignore(cwd);
|
|
336
|
-
s.stop("Git initialized");
|
|
526
|
+
const ensureGitRepo = (cwd) => Effect.gen(function* () {
|
|
527
|
+
const git = yield* Git;
|
|
528
|
+
if (yield* git.isRepo(cwd)) {
|
|
529
|
+
yield* Effect.logDebug("git repository already present");
|
|
530
|
+
return;
|
|
337
531
|
}
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
532
|
+
const s = yield* (yield* Prompter).spinner("Git not found, initializing repository...");
|
|
533
|
+
yield* git.init(cwd);
|
|
534
|
+
yield* s.stop("Git initialized");
|
|
535
|
+
}).pipe(Effect.withSpan("brew.ensureGitRepo"));
|
|
536
|
+
const ensureGitIgnore = (cwd) => Effect.gen(function* () {
|
|
537
|
+
const git = yield* Git;
|
|
538
|
+
const prompter = yield* Prompter;
|
|
539
|
+
const existing = yield* git.readGitignore(cwd).pipe(Effect.catchTag("SystemError", (e) => e.reason === "NotFound" ? Effect.succeed(void 0) : Effect.fail(e)));
|
|
540
|
+
if (existing !== void 0 && hasGitignoreEntry(existing, "node_modules")) {
|
|
541
|
+
yield* Effect.logDebug("node_modules already present in .gitignore");
|
|
542
|
+
return;
|
|
342
543
|
}
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (!installDev(pm, cwd, ...uniquePackages)) {
|
|
390
|
-
s.stop(pc.red("Package installation failed"));
|
|
391
|
-
log.error(`\`${pm} add -D\` failed. Fix the error above and re-run \`chanom brew\`.`);
|
|
392
|
-
process.exit(1);
|
|
393
|
-
}
|
|
394
|
-
s.stop("Packages installed");
|
|
544
|
+
const s = yield* prompter.spinner(existing === void 0 ? "Creating .gitignore..." : "Adding node_modules to .gitignore...");
|
|
545
|
+
yield* git.writeGitignore(cwd, appendGitignoreEntry(existing ?? "", "node_modules")).pipe(Effect.tapError(() => s.stop(pc.red("Could not update .gitignore"))));
|
|
546
|
+
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* () {
|
|
549
|
+
const prompter = yield* Prompter;
|
|
550
|
+
return {
|
|
551
|
+
toppings: yield* prompter.multiselect({
|
|
552
|
+
message: "Which toppings would you like?",
|
|
553
|
+
options: [
|
|
554
|
+
{
|
|
555
|
+
value: "oxlint",
|
|
556
|
+
label: "oxlint",
|
|
557
|
+
hint: "fast Rust-based linter"
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
value: "oxfmt",
|
|
561
|
+
label: "oxfmt",
|
|
562
|
+
hint: "fast Rust-based formatter"
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
value: "knip",
|
|
566
|
+
label: "knip",
|
|
567
|
+
hint: "dead code remover"
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
required: false
|
|
571
|
+
}),
|
|
572
|
+
sweetness: yield* prompter.select({
|
|
573
|
+
message: "How sweet would you like it?",
|
|
574
|
+
options: [{
|
|
575
|
+
value: "light",
|
|
576
|
+
label: "light",
|
|
577
|
+
hint: "warn only, no blocking"
|
|
578
|
+
}, {
|
|
579
|
+
value: "medium",
|
|
580
|
+
label: "medium",
|
|
581
|
+
hint: "block on commit (husky + lint-staged + commitlint)"
|
|
582
|
+
}]
|
|
583
|
+
})
|
|
584
|
+
};
|
|
585
|
+
}).pipe(Effect.withSpan("brew.askRecipe"));
|
|
586
|
+
const installPackages = (pm, cwd, packages) => Effect.gen(function* () {
|
|
587
|
+
if (packages.length === 0) {
|
|
588
|
+
yield* Effect.logDebug("no packages to install");
|
|
589
|
+
return;
|
|
395
590
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
591
|
+
const prompter = yield* Prompter;
|
|
592
|
+
const installer = yield* PackageInstaller;
|
|
593
|
+
const s = yield* prompter.spinner(`Installing ${packages.join(", ")}...`);
|
|
594
|
+
yield* installer.installDev(pm, cwd, packages).pipe(Effect.tapError(() => s.stop(pc.red("Package installation failed"))));
|
|
595
|
+
yield* s.stop("Packages installed");
|
|
596
|
+
}).pipe(Effect.withSpan("brew.installPackages"));
|
|
597
|
+
const applyToppings = (cwd, esm, pkg, toppings) => Effect.gen(function* () {
|
|
598
|
+
let updated = pkg;
|
|
599
|
+
if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
|
|
600
|
+
if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
|
|
601
|
+
if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
|
|
602
|
+
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 ?? {})) {
|
|
606
|
+
yield* Effect.logDebug(`writing updated scripts to ${pkgPath}`);
|
|
607
|
+
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* () {
|
|
611
|
+
if (sweetness !== "medium") return;
|
|
612
|
+
const fs = yield* FileSystem.FileSystem;
|
|
613
|
+
const path = yield* Path.Path;
|
|
614
|
+
const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
|
|
615
|
+
yield* apply$4(cwd, pm);
|
|
616
|
+
yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
|
|
617
|
+
yield* apply$5(cwd, pm);
|
|
618
|
+
}).pipe(Effect.withSpan("brew.setupCommitGate"));
|
|
619
|
+
const commitChanges = (cwd) => Effect.gen(function* () {
|
|
620
|
+
const git = yield* Git;
|
|
621
|
+
const prompter = yield* Prompter;
|
|
622
|
+
const s = yield* prompter.spinner("Committing changes...");
|
|
623
|
+
if (!(yield* git.hasIdentity(cwd))) yield* git.setLocalIdentity(cwd, "Chanom", "chanom@local");
|
|
624
|
+
yield* git.stageAll(cwd);
|
|
625
|
+
if (!(yield* git.hasStagedChanges(cwd))) {
|
|
626
|
+
yield* s.stop("Nothing to commit, skipping");
|
|
627
|
+
return;
|
|
404
628
|
}
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
629
|
+
const result = yield* git.commit(cwd, "chore: setup chanom configuration");
|
|
630
|
+
if (result.ok) {
|
|
631
|
+
yield* s.stop("Changes committed");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
yield* s.stop(pc.yellow("Could not commit changes automatically"));
|
|
635
|
+
const detail = result.stderr || result.stdout;
|
|
636
|
+
yield* prompter.warn(`${detail ? detail + "\n" : ""}Your files are staged - commit them manually when ready.`);
|
|
637
|
+
}).pipe(Effect.withSpan("brew.commitChanges"));
|
|
638
|
+
const brew = (cwd = process.cwd()) => Effect.gen(function* () {
|
|
639
|
+
const prompter = yield* Prompter;
|
|
640
|
+
yield* prompter.intro(pc.magenta("🧋 Chanom - Let's brew your project!"));
|
|
641
|
+
yield* ensureGitRepo(cwd);
|
|
642
|
+
yield* ensureGitIgnore(cwd);
|
|
643
|
+
const pm = yield* detectPm(cwd);
|
|
644
|
+
yield* Effect.logDebug(`detected package manager: ${pm}`);
|
|
645
|
+
const { pkg, pkgPath } = yield* readPkg(cwd);
|
|
646
|
+
const { toppings, sweetness } = yield* askRecipe();
|
|
647
|
+
yield* Effect.logDebug(`recipe: toppings=[${toppings.join(", ")}] sweetness=${sweetness} esm=${isEsm(pkg)}`);
|
|
648
|
+
const packages = planPackages(pkg, toppings, sweetness, bundledVersions);
|
|
649
|
+
yield* Effect.logDebug(`planned packages: ${packages.length > 0 ? packages.join(", ") : "(none)"}`);
|
|
650
|
+
yield* installPackages(pm, cwd, packages);
|
|
651
|
+
yield* persistScripts(pkgPath, pkg, yield* applyToppings(cwd, isEsm(pkg), pkg, toppings));
|
|
652
|
+
yield* setupCommitGate(cwd, pm, toppings, sweetness);
|
|
653
|
+
yield* commitChanges(cwd);
|
|
654
|
+
yield* prompter.outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
|
|
655
|
+
});
|
|
656
|
+
//#endregion
|
|
657
|
+
//#region src/cli.ts
|
|
658
|
+
var UnknownCommand = class extends Data.TaggedError("UnknownCommand") {};
|
|
659
|
+
const commands = { brew };
|
|
660
|
+
const dispatch = (name, cwd) => Effect.gen(function* () {
|
|
661
|
+
if (name !== void 0 && Object.hasOwn(commands, name)) return yield* commands[name](cwd).pipe(Effect.withSpan(name));
|
|
662
|
+
return yield* new UnknownCommand({ command: name });
|
|
663
|
+
});
|
|
664
|
+
const reportError = (message) => Effect.gen(function* () {
|
|
665
|
+
yield* (yield* Prompter).error(message);
|
|
666
|
+
return 1;
|
|
667
|
+
});
|
|
668
|
+
const isCancellation = (cause) => Chunk.isEmpty(Cause.defects(cause)) && Chunk.every(Cause.failures(cause), (failure) => failure instanceof Cancelled);
|
|
669
|
+
/** Prints the fully rendered cause, including the span trace of the failing step. */
|
|
670
|
+
const traceFailure = (cause) => Effect.gen(function* () {
|
|
671
|
+
if (isCancellation(cause)) return;
|
|
672
|
+
yield* (yield* Prompter).debug(Cause.pretty(cause));
|
|
673
|
+
});
|
|
674
|
+
/** Runs the named command and returns the process exit code. */
|
|
675
|
+
const run = (name, cwd = process.cwd(), options = {}) => {
|
|
676
|
+
const debug = options.debug ?? false;
|
|
677
|
+
return dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
|
|
678
|
+
Cancelled: () => Effect.succeed(0),
|
|
679
|
+
UnknownCommand: (e) => reportError(`Unknown command: ${e.command ?? "(none)"}\nAvailable commands: ${Object.keys(commands).join(", ")}`),
|
|
680
|
+
PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
|
|
681
|
+
PkgInvalid: (e) => reportError(`Could not parse ${e.pkgPath}. Is it valid JSON?`),
|
|
682
|
+
InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
|
|
683
|
+
HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`)
|
|
684
|
+
}), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
|
|
685
|
+
};
|
|
420
686
|
//#endregion
|
|
421
687
|
//#region src/index.ts
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
688
|
+
const args = process.argv.slice(2);
|
|
689
|
+
const debug = args.includes("--debug");
|
|
690
|
+
const command = args.find((arg) => !arg.startsWith("--"));
|
|
691
|
+
const ServicesLive = Layer.mergeAll(Prompter.Default, Git.Default, PackageInstaller.Default).pipe(Layer.provideMerge(CommandRunner.Default), Layer.provideMerge(NodeContext.layer));
|
|
692
|
+
const DebugLoggerLive = Logger.replace(Logger.defaultLogger, Logger.prettyLogger({ stderr: true }));
|
|
693
|
+
const MainLive = debug ? Layer.merge(ServicesLive, DebugLoggerLive) : ServicesLive;
|
|
694
|
+
const program = run(command, process.cwd(), { debug }).pipe(Effect.flatMap((exitCode) => Effect.sync(() => {
|
|
695
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
696
|
+
})), Effect.provide(MainLive));
|
|
697
|
+
NodeRuntime.runMain(program, {
|
|
698
|
+
disableErrorReporting: true,
|
|
699
|
+
disablePrettyLogger: true
|
|
700
|
+
});
|
|
429
701
|
//#endregion
|
|
430
702
|
export {};
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chanom/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"keywords": [],
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/peeranat-dan/chanom.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
6
11
|
"bin": {
|
|
7
12
|
"chanom": "./dist/index.js"
|
|
8
13
|
},
|
|
@@ -12,18 +17,26 @@
|
|
|
12
17
|
"type": "module",
|
|
13
18
|
"dependencies": {
|
|
14
19
|
"@clack/prompts": "1.6.0",
|
|
20
|
+
"@effect/platform": "0.96.2",
|
|
21
|
+
"@effect/platform-node": "0.107.0",
|
|
22
|
+
"effect": "3.21.4",
|
|
15
23
|
"picocolors": "1.1.1"
|
|
16
24
|
},
|
|
17
25
|
"devDependencies": {
|
|
26
|
+
"@effect/language-service": "0.86.4",
|
|
27
|
+
"@effect/vitest": "0.29.0",
|
|
18
28
|
"@tsconfig/node-lts": "24.0.0",
|
|
19
29
|
"@tsconfig/node-ts": "23.6.4",
|
|
20
30
|
"@types/node": "26.0.1",
|
|
21
31
|
"tsdown": "0.22.3",
|
|
22
32
|
"typescript": "6.0.3",
|
|
33
|
+
"vitest": "3.2.6",
|
|
23
34
|
"@chanom/dev-config": "0.0.3"
|
|
24
35
|
},
|
|
25
36
|
"scripts": {
|
|
26
37
|
"build": "tsdown",
|
|
27
|
-
"dev": "tsdown --watch"
|
|
38
|
+
"dev": "tsdown --watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest"
|
|
28
41
|
}
|
|
29
42
|
}
|