@chanom/cli 0.2.1 → 0.3.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.
- package/README.md +20 -6
- package/dist/index.js +281 -100
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @chanom/cli
|
|
2
2
|
|
|
3
|
-
Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, and commit hooks, picked with a couple of prompts.
|
|
3
|
+
Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, VS Code settings, and commit hooks, picked with a couple of prompts.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -38,6 +38,7 @@ Debug mode logs each step (detected package manager, planned packages, every com
|
|
|
38
38
|
- `oxlint` - fast Rust-based linter
|
|
39
39
|
- `oxfmt` - fast Rust-based formatter
|
|
40
40
|
- `knip` - dead code remover
|
|
41
|
+
- `vscode` - editor settings + extension recommendations
|
|
41
42
|
2. **Sweetness** - how strict to be:
|
|
42
43
|
- `light` - warn only, no blocking
|
|
43
44
|
- `medium` - block on commit via husky + lint-staged + commitlint
|
|
@@ -60,16 +61,25 @@ installing anything. `light` sweetness works anywhere in the repo.
|
|
|
60
61
|
|
|
61
62
|
## What gets added
|
|
62
63
|
|
|
63
|
-
| Topping | Config file
|
|
64
|
-
| -------- |
|
|
65
|
-
| `oxlint` | `oxlint.config.ts`\*
|
|
66
|
-
| `oxfmt` | `oxfmt.config.ts`\*
|
|
67
|
-
| `knip` | `knip.config.ts`\*
|
|
64
|
+
| Topping | Config file | Scripts added |
|
|
65
|
+
| -------- | -------------------------------------------------- | ------------------------ |
|
|
66
|
+
| `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
|
|
67
|
+
| `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
|
|
68
|
+
| `knip` | `knip.config.ts`\* | `knip` |
|
|
69
|
+
| `vscode` | `.vscode/settings.json`, `.vscode/extensions.json` | none |
|
|
68
70
|
|
|
69
71
|
\* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
|
|
70
72
|
|
|
71
73
|
Config files re-export presets from [`@chanom/dev-config`](../dev-config).
|
|
72
74
|
|
|
75
|
+
The `vscode` topping installs no packages and adds no scripts. It writes editor
|
|
76
|
+
settings that route formatting and lint fixes through the
|
|
77
|
+
[oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode)
|
|
78
|
+
on save (`editor.formatOnSave` stays off so both run in one pass), points
|
|
79
|
+
`oxc.fmt.configPath` at the oxfmt config, and recommends the extension so
|
|
80
|
+
teammates are prompted to install it. Only portable settings are written - no
|
|
81
|
+
`cSpell.words` or other repo-specific entries.
|
|
82
|
+
|
|
73
83
|
`medium` sweetness additionally adds:
|
|
74
84
|
|
|
75
85
|
- `husky` - installed via `husky init`
|
|
@@ -91,6 +101,10 @@ Existing setup is never overwritten:
|
|
|
91
101
|
- 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.
|
|
92
102
|
- If a script name already exists in `package.json`, `brew` warns and leaves it alone.
|
|
93
103
|
- Existing `.husky/pre-commit` and `.husky/commit-msg` hooks are kept.
|
|
104
|
+
- `.vscode/settings.json` and `.vscode/extensions.json` are merged, not replaced: a
|
|
105
|
+
setting you already have wins (and is warned about), and existing extension
|
|
106
|
+
recommendations are kept. A `.vscode` file that can't be parsed as JSON (e.g. it
|
|
107
|
+
has comments) is left untouched with a warning.
|
|
94
108
|
|
|
95
109
|
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.
|
|
96
110
|
|
package/dist/index.js
CHANGED
|
@@ -1,41 +1,122 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
3
|
-
import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
|
|
4
2
|
import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
|
|
5
|
-
import
|
|
3
|
+
import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
|
|
6
4
|
import * as clack from "@clack/prompts";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
7
|
+
//#region ../internal/src/domain/package-manager.ts
|
|
8
|
+
/** How to run a locally installed binary with each package manager. */
|
|
9
|
+
const PM_EXEC = {
|
|
10
|
+
pnpm: ["pnpm", "exec"],
|
|
11
|
+
npm: [
|
|
12
|
+
"npx",
|
|
13
|
+
"--no",
|
|
14
|
+
"--"
|
|
15
|
+
],
|
|
16
|
+
yarn: ["yarn"],
|
|
17
|
+
bun: ["bunx"]
|
|
13
18
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const KNOWN_PMS = [
|
|
20
|
+
"pnpm",
|
|
21
|
+
"yarn",
|
|
22
|
+
"bun",
|
|
23
|
+
"npm"
|
|
24
|
+
];
|
|
25
|
+
/** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
|
|
26
|
+
function parsePmField(field) {
|
|
27
|
+
if (!field) return void 0;
|
|
28
|
+
const name = field.split("@")[0];
|
|
29
|
+
return KNOWN_PMS.find((pm) => pm === name);
|
|
20
30
|
}
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
/** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
|
|
32
|
+
function parsePmUserAgent(agent) {
|
|
33
|
+
if (!agent) return void 0;
|
|
34
|
+
return KNOWN_PMS.find((pm) => agent.startsWith(pm));
|
|
23
35
|
}
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
/** The `packageManager` field wins over the user agent, which wins over the fallback. */
|
|
37
|
+
function resolvePm(hints) {
|
|
38
|
+
return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
|
|
26
39
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region ../internal/src/domain/vscode.ts
|
|
42
|
+
/** Keys VS Code reads as a per-language override, e.g. `[typescript]`. */
|
|
43
|
+
const LANGUAGES = [
|
|
44
|
+
"markdown",
|
|
45
|
+
"json",
|
|
46
|
+
"typescriptreact",
|
|
47
|
+
"typescript",
|
|
48
|
+
"javascript"
|
|
49
|
+
];
|
|
50
|
+
const OXC_FORMATTER = "oxc.oxc-vscode";
|
|
51
|
+
/** The extensions a chanom project recommends; unioned into an existing list. */
|
|
52
|
+
const RECOMMENDED_EXTENSIONS = [OXC_FORMATTER];
|
|
53
|
+
/**
|
|
54
|
+
* The VS Code settings chanom contributes. Every value is portable across
|
|
55
|
+
* projects - repo-specific entries (`cSpell.words`, `git.*`) are deliberately
|
|
56
|
+
* excluded so this can be dropped into any project without carrying chanom's
|
|
57
|
+
* own choices.
|
|
58
|
+
*
|
|
59
|
+
* `editor.formatOnSave` is false on purpose: formatting runs through the oxc
|
|
60
|
+
* code action instead, so formatting and lint fixes apply in one pass rather
|
|
61
|
+
* than racing each other.
|
|
62
|
+
*/
|
|
63
|
+
function chanomSettings(oxfmtConfigPath) {
|
|
64
|
+
return {
|
|
65
|
+
"editor.defaultFormatter": OXC_FORMATTER,
|
|
66
|
+
"editor.formatOnSave": false,
|
|
67
|
+
"editor.codeActionsOnSave": {
|
|
68
|
+
"source.format.oxc": "always",
|
|
69
|
+
"source.fixAll.oxc": "always",
|
|
70
|
+
"source.removeUnusedImports": "always"
|
|
71
|
+
},
|
|
72
|
+
"oxc.fmt.configPath": oxfmtConfigPath,
|
|
73
|
+
"oxc.typeAware": true,
|
|
74
|
+
...Object.fromEntries(LANGUAGES.map((language) => [`[${language}]`, { "editor.defaultFormatter": OXC_FORMATTER }]))
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Merges `wanted` settings into `current` without overwriting anything already
|
|
79
|
+
* set, mirroring `planScripts`. The merge is one level deep by key: a key the
|
|
80
|
+
* user already has is left untouched even when its value is an object, because
|
|
81
|
+
* a partially merged `editor.codeActionsOnSave` is more surprising than one the
|
|
82
|
+
* user fully owns.
|
|
83
|
+
*/
|
|
84
|
+
function planSettings(current, wanted) {
|
|
85
|
+
const settings = { ...current };
|
|
86
|
+
const added = [];
|
|
87
|
+
const skipped = [];
|
|
88
|
+
for (const [key, value] of Object.entries(wanted)) if (Object.hasOwn(settings, key)) skipped.push(key);
|
|
89
|
+
else {
|
|
90
|
+
settings[key] = value;
|
|
91
|
+
added.push(key);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
settings,
|
|
95
|
+
added,
|
|
96
|
+
skipped
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Unions `wanted` into the existing recommendations, preserving existing order. */
|
|
100
|
+
function planExtensions(current, wanted) {
|
|
101
|
+
const recommendations = [...current ?? []];
|
|
102
|
+
const added = [];
|
|
103
|
+
for (const extension of wanted) if (!recommendations.includes(extension)) {
|
|
104
|
+
recommendations.push(extension);
|
|
105
|
+
added.push(extension);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
recommendations,
|
|
109
|
+
added
|
|
110
|
+
};
|
|
30
111
|
}
|
|
31
112
|
//#endregion
|
|
32
|
-
//#region src/services/command-runner.ts
|
|
113
|
+
//#region ../internal/src/services/command-runner.ts
|
|
33
114
|
const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
|
|
34
115
|
/**
|
|
35
116
|
* Low-level process execution. Everything that shells out (git, package
|
|
36
117
|
* managers, husky) goes through this service so commands can be stubbed in tests.
|
|
37
118
|
*/
|
|
38
|
-
var CommandRunner = class extends Effect.Service()("
|
|
119
|
+
var CommandRunner = class extends Effect.Service()("internal/CommandRunner", { effect: Effect.gen(function* () {
|
|
39
120
|
const executor = yield* CommandExecutor.CommandExecutor;
|
|
40
121
|
const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
|
|
41
122
|
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}`)));
|
|
@@ -66,9 +147,9 @@ var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect
|
|
|
66
147
|
};
|
|
67
148
|
}) }) {};
|
|
68
149
|
//#endregion
|
|
69
|
-
//#region src/services/git.ts
|
|
150
|
+
//#region ../internal/src/services/git.ts
|
|
70
151
|
/** Git operations, built on top of {@link CommandRunner}. */
|
|
71
|
-
var Git = class extends Effect.Service()("
|
|
152
|
+
var Git = class extends Effect.Service()("internal/Git", { effect: Effect.gen(function* () {
|
|
72
153
|
const runner = yield* CommandRunner;
|
|
73
154
|
const fs = yield* FileSystem.FileSystem;
|
|
74
155
|
const path = yield* Path.Path;
|
|
@@ -116,35 +197,76 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
|
|
|
116
197
|
};
|
|
117
198
|
}) }) {};
|
|
118
199
|
//#endregion
|
|
119
|
-
//#region src/
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
200
|
+
//#region ../internal/src/services/prompter.ts
|
|
201
|
+
var Cancelled = class extends Data.TaggedError("Cancelled") {};
|
|
202
|
+
const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
|
|
203
|
+
/**
|
|
204
|
+
* All terminal interaction (prompts, spinners, log messages). Commands never
|
|
205
|
+
* touch @clack/prompts directly, so tests can script answers and capture output.
|
|
206
|
+
*/
|
|
207
|
+
var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
|
|
208
|
+
intro: (message) => Effect.sync(() => clack.intro(message)),
|
|
209
|
+
outro: (message) => Effect.sync(() => clack.outro(message)),
|
|
210
|
+
warn: (message) => Effect.sync(() => clack.log.warn(message)),
|
|
211
|
+
error: (message) => Effect.sync(() => clack.log.error(message)),
|
|
212
|
+
info: (message) => Effect.sync(() => clack.log.info(message)),
|
|
213
|
+
/** Prints a message with no icon or styling (help text, plan summaries). */
|
|
214
|
+
message: (message) => Effect.sync(() => clack.log.message(message)),
|
|
215
|
+
debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
|
|
216
|
+
text: (params) => guardCancel(() => clack.text({
|
|
217
|
+
message: params.message,
|
|
218
|
+
placeholder: params.placeholder,
|
|
219
|
+
defaultValue: params.defaultValue,
|
|
220
|
+
initialValue: params.initialValue,
|
|
221
|
+
validate: params.validate
|
|
222
|
+
})),
|
|
223
|
+
confirm: (params) => guardCancel(() => clack.confirm({
|
|
224
|
+
message: params.message,
|
|
225
|
+
initialValue: params.initialValue ?? true
|
|
226
|
+
})),
|
|
227
|
+
select: (params) => guardCancel(() => clack.select({
|
|
228
|
+
message: params.message,
|
|
229
|
+
options: [...params.options]
|
|
230
|
+
})),
|
|
231
|
+
multiselect: (params) => guardCancel(() => clack.multiselect({
|
|
232
|
+
message: params.message,
|
|
233
|
+
options: [...params.options],
|
|
234
|
+
required: params.required ?? false
|
|
235
|
+
})),
|
|
236
|
+
spinner: (message) => Effect.sync(() => {
|
|
237
|
+
const s = clack.spinner();
|
|
238
|
+
s.start(message);
|
|
239
|
+
return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
|
|
240
|
+
})
|
|
241
|
+
} }) {};
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/bundled-versions.ts
|
|
244
|
+
const bundledVersions = {
|
|
245
|
+
oxlint: "1.73.0",
|
|
246
|
+
oxlintTsgolint: "0.24.0",
|
|
247
|
+
oxfmt: "0.58.0",
|
|
248
|
+
knip: "6.26.0",
|
|
249
|
+
devConfig: "0.0.3"
|
|
130
250
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/domain/pkg.ts
|
|
253
|
+
var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
|
|
254
|
+
var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
|
|
255
|
+
function isEsm(pkg) {
|
|
256
|
+
return pkg.type === "module";
|
|
142
257
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!agent) return void 0;
|
|
146
|
-
return KNOWN_PMS.find((pm) => agent.startsWith(pm));
|
|
258
|
+
function isPackageInstalled(pkg, name) {
|
|
259
|
+
return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
|
|
147
260
|
}
|
|
261
|
+
function stripRangePrefix(version) {
|
|
262
|
+
return version.replace(/^[\^~]/, "");
|
|
263
|
+
}
|
|
264
|
+
function getMismatchedPackage(pkg, name, version) {
|
|
265
|
+
const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
|
|
266
|
+
return installed !== void 0 && stripRangePrefix(installed) === version ? void 0 : `${name}@${version}`;
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/domain/package-manager.ts
|
|
148
270
|
/**
|
|
149
271
|
* Extra `add` flags needed when installing at a workspace root: pnpm and yarn
|
|
150
272
|
* classic refuse root installs without `-w` / `-W`. npm, bun, and yarn berry
|
|
@@ -158,10 +280,6 @@ function workspaceRootFlags(pm, hints) {
|
|
|
158
280
|
case "bun": return [];
|
|
159
281
|
}
|
|
160
282
|
}
|
|
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
283
|
//#endregion
|
|
166
284
|
//#region src/services/package-installer.ts
|
|
167
285
|
var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
|
|
@@ -189,35 +307,6 @@ var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", {
|
|
|
189
307
|
], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
|
|
190
308
|
}) }) {};
|
|
191
309
|
//#endregion
|
|
192
|
-
//#region src/services/prompter.ts
|
|
193
|
-
var Cancelled = class extends Data.TaggedError("Cancelled") {};
|
|
194
|
-
const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
|
|
195
|
-
/**
|
|
196
|
-
* All terminal interaction (prompts, spinners, log messages). Commands never
|
|
197
|
-
* touch @clack/prompts directly, so tests can script answers and capture output.
|
|
198
|
-
*/
|
|
199
|
-
var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
|
|
200
|
-
intro: (message) => Effect.sync(() => clack.intro(message)),
|
|
201
|
-
outro: (message) => Effect.sync(() => clack.outro(message)),
|
|
202
|
-
warn: (message) => Effect.sync(() => clack.log.warn(message)),
|
|
203
|
-
error: (message) => Effect.sync(() => clack.log.error(message)),
|
|
204
|
-
debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
|
|
205
|
-
select: (params) => guardCancel(() => clack.select({
|
|
206
|
-
message: params.message,
|
|
207
|
-
options: [...params.options]
|
|
208
|
-
})),
|
|
209
|
-
multiselect: (params) => guardCancel(() => clack.multiselect({
|
|
210
|
-
message: params.message,
|
|
211
|
-
options: [...params.options],
|
|
212
|
-
required: params.required ?? false
|
|
213
|
-
})),
|
|
214
|
-
spinner: (message) => Effect.sync(() => {
|
|
215
|
-
const s = clack.spinner();
|
|
216
|
-
s.start(message);
|
|
217
|
-
return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
|
|
218
|
-
})
|
|
219
|
-
} }) {};
|
|
220
|
-
//#endregion
|
|
221
310
|
//#region src/utils/detect-pm.ts
|
|
222
311
|
const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
|
|
223
312
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -322,7 +411,7 @@ function commitMsgHook(pm) {
|
|
|
322
411
|
}
|
|
323
412
|
//#endregion
|
|
324
413
|
//#region src/commands/add-commitlint/index.ts
|
|
325
|
-
const apply$
|
|
414
|
+
const apply$6 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
|
|
326
415
|
const fs = yield* FileSystem.FileSystem;
|
|
327
416
|
const path = yield* Path.Path;
|
|
328
417
|
const prompter = yield* Prompter;
|
|
@@ -344,7 +433,7 @@ function getPackages$4(pkg) {
|
|
|
344
433
|
//#endregion
|
|
345
434
|
//#region src/commands/add-husky/index.ts
|
|
346
435
|
var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
|
|
347
|
-
const apply$
|
|
436
|
+
const apply$5 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
|
|
348
437
|
const fs = yield* FileSystem.FileSystem;
|
|
349
438
|
const path = yield* Path.Path;
|
|
350
439
|
const prompter = yield* Prompter;
|
|
@@ -400,7 +489,7 @@ function getScriptPlan$2(scripts) {
|
|
|
400
489
|
//#endregion
|
|
401
490
|
//#region src/commands/add-knip/index.ts
|
|
402
491
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
403
|
-
const apply$
|
|
492
|
+
const apply$4 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
|
|
404
493
|
const fs = yield* FileSystem.FileSystem;
|
|
405
494
|
const path = yield* Path.Path;
|
|
406
495
|
const prompter = yield* Prompter;
|
|
@@ -437,7 +526,7 @@ function buildConfig(linters, formatters) {
|
|
|
437
526
|
}
|
|
438
527
|
//#endregion
|
|
439
528
|
//#region src/commands/add-lint-staged/index.ts
|
|
440
|
-
const apply$
|
|
529
|
+
const apply$3 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
|
|
441
530
|
if (linters.length === 0 && formatters.length === 0) {
|
|
442
531
|
yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
|
|
443
532
|
return;
|
|
@@ -475,7 +564,7 @@ function getScriptPlan$1(scripts) {
|
|
|
475
564
|
//#endregion
|
|
476
565
|
//#region src/commands/add-oxfmt/index.ts
|
|
477
566
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
478
|
-
const apply$
|
|
567
|
+
const apply$2 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
|
|
479
568
|
const fs = yield* FileSystem.FileSystem;
|
|
480
569
|
const path = yield* Path.Path;
|
|
481
570
|
const prompter = yield* Prompter;
|
|
@@ -495,7 +584,11 @@ const apply$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
|
|
|
495
584
|
//#endregion
|
|
496
585
|
//#region src/commands/add-oxlint/logic.ts
|
|
497
586
|
function getPackages(pkg, versions) {
|
|
498
|
-
return [
|
|
587
|
+
return [
|
|
588
|
+
getMismatchedPackage(pkg, "oxlint", versions.oxlint),
|
|
589
|
+
getMismatchedPackage(pkg, "oxlint-tsgolint", versions.oxlintTsgolint),
|
|
590
|
+
getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)
|
|
591
|
+
].filter((p) => p !== void 0);
|
|
499
592
|
}
|
|
500
593
|
function configFile(esm) {
|
|
501
594
|
return {
|
|
@@ -512,7 +605,7 @@ function getScriptPlan(scripts) {
|
|
|
512
605
|
//#endregion
|
|
513
606
|
//#region src/commands/add-oxlint/index.ts
|
|
514
607
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
515
|
-
const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
608
|
+
const apply$1 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
516
609
|
const fs = yield* FileSystem.FileSystem;
|
|
517
610
|
const path = yield* Path.Path;
|
|
518
611
|
const prompter = yield* Prompter;
|
|
@@ -530,6 +623,70 @@ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
|
530
623
|
};
|
|
531
624
|
});
|
|
532
625
|
//#endregion
|
|
626
|
+
//#region src/commands/add-vscode/index.ts
|
|
627
|
+
const writeJson = Effect.fn("add-vscode.writeJson")(function* (filePath, value) {
|
|
628
|
+
yield* (yield* FileSystem.FileSystem).writeFileString(filePath, JSON.stringify(value, null, 2) + "\n");
|
|
629
|
+
});
|
|
630
|
+
/**
|
|
631
|
+
* Reads and parses an existing `.vscode` file. `.vscode` files are JSONC by
|
|
632
|
+
* convention, so a file we cannot parse is not an error - it is a file the user
|
|
633
|
+
* hand-wrote with comments. Returns `None` for both "absent" and "unparseable";
|
|
634
|
+
* the caller distinguishes them via `fs.exists` to warn appropriately.
|
|
635
|
+
*/
|
|
636
|
+
const readJson = Effect.fn("add-vscode.readJson")(function* (filePath) {
|
|
637
|
+
const fs = yield* FileSystem.FileSystem;
|
|
638
|
+
if (!(yield* fs.exists(filePath))) return Option.none();
|
|
639
|
+
const contents = yield* fs.readFileString(filePath);
|
|
640
|
+
return yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
|
|
641
|
+
});
|
|
642
|
+
const applySettings = Effect.fn("add-vscode.applySettings")(function* (vscodeDir, oxfmtConfigPath) {
|
|
643
|
+
const fs = yield* FileSystem.FileSystem;
|
|
644
|
+
const path = yield* Path.Path;
|
|
645
|
+
const prompter = yield* Prompter;
|
|
646
|
+
const filePath = path.join(vscodeDir, "settings.json");
|
|
647
|
+
const existed = yield* fs.exists(filePath);
|
|
648
|
+
const existing = yield* readJson(filePath);
|
|
649
|
+
if (existed && Option.isNone(existing)) {
|
|
650
|
+
yield* prompter.warn("`.vscode/settings.json` could not be parsed (comments?) - skipping settings");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const plan = planSettings(Option.getOrUndefined(existing), chanomSettings(oxfmtConfigPath));
|
|
654
|
+
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` is already set in .vscode/settings.json - skipping`);
|
|
655
|
+
if (plan.added.length > 0) yield* writeJson(filePath, plan.settings);
|
|
656
|
+
});
|
|
657
|
+
const applyExtensions = Effect.fn("add-vscode.applyExtensions")(function* (vscodeDir) {
|
|
658
|
+
const fs = yield* FileSystem.FileSystem;
|
|
659
|
+
const path = yield* Path.Path;
|
|
660
|
+
const prompter = yield* Prompter;
|
|
661
|
+
const filePath = path.join(vscodeDir, "extensions.json");
|
|
662
|
+
const existed = yield* fs.exists(filePath);
|
|
663
|
+
const existing = yield* readJson(filePath);
|
|
664
|
+
if (existed && Option.isNone(existing)) {
|
|
665
|
+
yield* prompter.warn("`.vscode/extensions.json` could not be parsed (comments?) - skipping recommendations");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const plan = planExtensions(Option.getOrUndefined(existing)?.recommendations, RECOMMENDED_EXTENSIONS);
|
|
669
|
+
if (plan.added.length > 0) yield* writeJson(filePath, {
|
|
670
|
+
...Option.getOrUndefined(existing),
|
|
671
|
+
recommendations: plan.recommendations
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
/**
|
|
675
|
+
* Writes `.vscode/settings.json` and `.vscode/extensions.json`, merging into
|
|
676
|
+
* whatever is already there. Existing settings keys and recommendations always
|
|
677
|
+
* win, so re-running is safe and never clobbers the user's editor config.
|
|
678
|
+
*
|
|
679
|
+
* Unlike the other add-* commands this contributes no packages and no scripts,
|
|
680
|
+
* so it returns nothing - there is no `Pkg` for the caller to persist.
|
|
681
|
+
*/
|
|
682
|
+
const apply = Effect.fn("add-vscode.apply")(function* (cwd, oxfmtConfigPath) {
|
|
683
|
+
const fs = yield* FileSystem.FileSystem;
|
|
684
|
+
const vscodeDir = (yield* Path.Path).join(cwd, ".vscode");
|
|
685
|
+
yield* fs.makeDirectory(vscodeDir, { recursive: true });
|
|
686
|
+
yield* applySettings(vscodeDir, oxfmtConfigPath);
|
|
687
|
+
yield* applyExtensions(vscodeDir);
|
|
688
|
+
});
|
|
689
|
+
//#endregion
|
|
533
690
|
//#region src/commands/brew/logic.ts
|
|
534
691
|
/** Deduplicated list of packages to install for the chosen toppings and sweetness. */
|
|
535
692
|
function planPackages(pkg, toppings, sweetness, versions) {
|
|
@@ -544,6 +701,15 @@ function planPackages(pkg, toppings, sweetness, versions) {
|
|
|
544
701
|
}
|
|
545
702
|
return [...new Set(packages)];
|
|
546
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Path the VS Code oxc extension should read the oxfmt config from. Mirrors the
|
|
706
|
+
* name `add-oxfmt` writes, so the setting points at a real file whenever oxfmt
|
|
707
|
+
* is a selected topping; without oxfmt the default name is still the sensible
|
|
708
|
+
* value for a config the user may add later.
|
|
709
|
+
*/
|
|
710
|
+
function oxfmtConfigPath(esm) {
|
|
711
|
+
return `oxfmt.config.${esm ? "ts" : "mts"}`;
|
|
712
|
+
}
|
|
547
713
|
/** True when at least one topping gives lint-staged something to run. */
|
|
548
714
|
function wantsLintStaged(toppings) {
|
|
549
715
|
return selectedLinters(toppings).length > 0 || selectedFormatters(toppings).length > 0;
|
|
@@ -614,6 +780,11 @@ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
|
|
|
614
780
|
value: "knip",
|
|
615
781
|
label: "knip",
|
|
616
782
|
hint: "dead code remover"
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
value: "vscode",
|
|
786
|
+
label: "vscode",
|
|
787
|
+
hint: "editor settings + extension recommendations"
|
|
617
788
|
}
|
|
618
789
|
],
|
|
619
790
|
required: false
|
|
@@ -645,9 +816,10 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
|
|
|
645
816
|
});
|
|
646
817
|
const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
|
|
647
818
|
let updated = pkg;
|
|
648
|
-
if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
|
|
649
|
-
if (toppings.includes("oxfmt")) updated = yield* apply$
|
|
650
|
-
if (toppings.includes("knip")) updated = yield* apply$
|
|
819
|
+
if (toppings.includes("oxlint")) updated = yield* apply$1(cwd, esm, updated);
|
|
820
|
+
if (toppings.includes("oxfmt")) updated = yield* apply$2(cwd, esm, updated);
|
|
821
|
+
if (toppings.includes("knip")) updated = yield* apply$4(cwd, esm, updated);
|
|
822
|
+
if (toppings.includes("vscode")) yield* apply(cwd, oxfmtConfigPath(esm));
|
|
651
823
|
return updated;
|
|
652
824
|
});
|
|
653
825
|
const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
|
|
@@ -679,14 +851,14 @@ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, to
|
|
|
679
851
|
const path = yield* Path.Path;
|
|
680
852
|
const prompter = yield* Prompter;
|
|
681
853
|
const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
|
|
682
|
-
yield* apply$
|
|
683
|
-
if (wantsLintStaged(toppings)) yield* apply$
|
|
854
|
+
yield* apply$5(cwd, pm);
|
|
855
|
+
if (wantsLintStaged(toppings)) yield* apply$3(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
|
|
684
856
|
else {
|
|
685
857
|
yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
|
|
686
858
|
const seededHook = path.join(cwd, ".husky", "pre-commit");
|
|
687
859
|
if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
|
|
688
860
|
}
|
|
689
|
-
yield* apply$
|
|
861
|
+
yield* apply$6(cwd, pm);
|
|
690
862
|
});
|
|
691
863
|
const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
|
|
692
864
|
const git = yield* Git;
|
|
@@ -745,10 +917,19 @@ const traceFailure = (cause) => Effect.gen(function* () {
|
|
|
745
917
|
if (isCancellation(cause)) return;
|
|
746
918
|
yield* (yield* Prompter).debug(Cause.pretty(cause));
|
|
747
919
|
});
|
|
920
|
+
/**
|
|
921
|
+
* Pins the error channel to `never` at this boundary. If `catchTags` above ever
|
|
922
|
+
* stops handling an error tag, that tag leaks into `E` and this call fails to
|
|
923
|
+
* compile — forcing every command error to be handled here.
|
|
924
|
+
*/
|
|
925
|
+
const allErrorsHandled = (effect) => effect;
|
|
748
926
|
/** Runs the named command and returns the process exit code. */
|
|
749
927
|
const run = (name, cwd = process.cwd(), options = {}) => {
|
|
750
928
|
const debug = options.debug ?? false;
|
|
751
|
-
return dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
|
|
929
|
+
return allErrorsHandled(dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
|
|
930
|
+
BadArgument: (e) => reportError(`Unexpected error: ${e.message}`),
|
|
931
|
+
SystemError: (e) => reportError(`Unexpected error: ${e.message}`)
|
|
932
|
+
}), Effect.catchTags({
|
|
752
933
|
Cancelled: () => Effect.succeed(0),
|
|
753
934
|
UnknownCommand: (e) => reportError(`Unknown command: ${e.command ?? "(none)"}\nAvailable commands: ${Object.keys(commands).join(", ")}`),
|
|
754
935
|
PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
|
|
@@ -756,7 +937,7 @@ const run = (name, cwd = process.cwd(), options = {}) => {
|
|
|
756
937
|
InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
|
|
757
938
|
HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`),
|
|
758
939
|
NotAtRepoRoot: (e) => reportError(`Git hooks must be set up at the repository root: ${e.root}\nRun \`chanom brew\` there, or choose light sweetness.`)
|
|
759
|
-
})
|
|
940
|
+
}))).pipe(Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
|
|
760
941
|
};
|
|
761
942
|
//#endregion
|
|
762
943
|
//#region src/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chanom/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"tsdown": "0.22.3",
|
|
33
33
|
"typescript": "6.0.3",
|
|
34
34
|
"vitest": "3.2.6",
|
|
35
|
-
"@chanom/dev-config": "0.0.3"
|
|
35
|
+
"@chanom/dev-config": "0.0.3",
|
|
36
|
+
"@chanom/internal": "0.0.0"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsdown",
|