@chanom/cli 0.2.2 → 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 +276 -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,42 +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
|
-
|
|
13
|
-
|
|
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"]
|
|
14
18
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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);
|
|
21
30
|
}
|
|
22
|
-
|
|
23
|
-
|
|
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));
|
|
24
35
|
}
|
|
25
|
-
|
|
26
|
-
|
|
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";
|
|
27
39
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
};
|
|
31
111
|
}
|
|
32
112
|
//#endregion
|
|
33
|
-
//#region src/services/command-runner.ts
|
|
113
|
+
//#region ../internal/src/services/command-runner.ts
|
|
34
114
|
const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
|
|
35
115
|
/**
|
|
36
116
|
* Low-level process execution. Everything that shells out (git, package
|
|
37
117
|
* managers, husky) goes through this service so commands can be stubbed in tests.
|
|
38
118
|
*/
|
|
39
|
-
var CommandRunner = class extends Effect.Service()("
|
|
119
|
+
var CommandRunner = class extends Effect.Service()("internal/CommandRunner", { effect: Effect.gen(function* () {
|
|
40
120
|
const executor = yield* CommandExecutor.CommandExecutor;
|
|
41
121
|
const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
|
|
42
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}`)));
|
|
@@ -67,9 +147,9 @@ var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect
|
|
|
67
147
|
};
|
|
68
148
|
}) }) {};
|
|
69
149
|
//#endregion
|
|
70
|
-
//#region src/services/git.ts
|
|
150
|
+
//#region ../internal/src/services/git.ts
|
|
71
151
|
/** Git operations, built on top of {@link CommandRunner}. */
|
|
72
|
-
var Git = class extends Effect.Service()("
|
|
152
|
+
var Git = class extends Effect.Service()("internal/Git", { effect: Effect.gen(function* () {
|
|
73
153
|
const runner = yield* CommandRunner;
|
|
74
154
|
const fs = yield* FileSystem.FileSystem;
|
|
75
155
|
const path = yield* Path.Path;
|
|
@@ -117,35 +197,76 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
|
|
|
117
197
|
};
|
|
118
198
|
}) }) {};
|
|
119
199
|
//#endregion
|
|
120
|
-
//#region src/
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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"
|
|
131
250
|
};
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
|
|
139
|
-
function parsePmField(field) {
|
|
140
|
-
if (!field) return void 0;
|
|
141
|
-
const name = field.split("@")[0];
|
|
142
|
-
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";
|
|
143
257
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!agent) return void 0;
|
|
147
|
-
return KNOWN_PMS.find((pm) => agent.startsWith(pm));
|
|
258
|
+
function isPackageInstalled(pkg, name) {
|
|
259
|
+
return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
|
|
148
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
|
|
149
270
|
/**
|
|
150
271
|
* Extra `add` flags needed when installing at a workspace root: pnpm and yarn
|
|
151
272
|
* classic refuse root installs without `-w` / `-W`. npm, bun, and yarn berry
|
|
@@ -159,10 +280,6 @@ function workspaceRootFlags(pm, hints) {
|
|
|
159
280
|
case "bun": return [];
|
|
160
281
|
}
|
|
161
282
|
}
|
|
162
|
-
/** The `packageManager` field wins over the user agent, which wins over the fallback. */
|
|
163
|
-
function resolvePm(hints) {
|
|
164
|
-
return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
|
|
165
|
-
}
|
|
166
283
|
//#endregion
|
|
167
284
|
//#region src/services/package-installer.ts
|
|
168
285
|
var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
|
|
@@ -190,35 +307,6 @@ var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", {
|
|
|
190
307
|
], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
|
|
191
308
|
}) }) {};
|
|
192
309
|
//#endregion
|
|
193
|
-
//#region src/services/prompter.ts
|
|
194
|
-
var Cancelled = class extends Data.TaggedError("Cancelled") {};
|
|
195
|
-
const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
|
|
196
|
-
/**
|
|
197
|
-
* All terminal interaction (prompts, spinners, log messages). Commands never
|
|
198
|
-
* touch @clack/prompts directly, so tests can script answers and capture output.
|
|
199
|
-
*/
|
|
200
|
-
var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
|
|
201
|
-
intro: (message) => Effect.sync(() => clack.intro(message)),
|
|
202
|
-
outro: (message) => Effect.sync(() => clack.outro(message)),
|
|
203
|
-
warn: (message) => Effect.sync(() => clack.log.warn(message)),
|
|
204
|
-
error: (message) => Effect.sync(() => clack.log.error(message)),
|
|
205
|
-
debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
|
|
206
|
-
select: (params) => guardCancel(() => clack.select({
|
|
207
|
-
message: params.message,
|
|
208
|
-
options: [...params.options]
|
|
209
|
-
})),
|
|
210
|
-
multiselect: (params) => guardCancel(() => clack.multiselect({
|
|
211
|
-
message: params.message,
|
|
212
|
-
options: [...params.options],
|
|
213
|
-
required: params.required ?? false
|
|
214
|
-
})),
|
|
215
|
-
spinner: (message) => Effect.sync(() => {
|
|
216
|
-
const s = clack.spinner();
|
|
217
|
-
s.start(message);
|
|
218
|
-
return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
|
|
219
|
-
})
|
|
220
|
-
} }) {};
|
|
221
|
-
//#endregion
|
|
222
310
|
//#region src/utils/detect-pm.ts
|
|
223
311
|
const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
|
|
224
312
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -323,7 +411,7 @@ function commitMsgHook(pm) {
|
|
|
323
411
|
}
|
|
324
412
|
//#endregion
|
|
325
413
|
//#region src/commands/add-commitlint/index.ts
|
|
326
|
-
const apply$
|
|
414
|
+
const apply$6 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
|
|
327
415
|
const fs = yield* FileSystem.FileSystem;
|
|
328
416
|
const path = yield* Path.Path;
|
|
329
417
|
const prompter = yield* Prompter;
|
|
@@ -345,7 +433,7 @@ function getPackages$4(pkg) {
|
|
|
345
433
|
//#endregion
|
|
346
434
|
//#region src/commands/add-husky/index.ts
|
|
347
435
|
var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
|
|
348
|
-
const apply$
|
|
436
|
+
const apply$5 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
|
|
349
437
|
const fs = yield* FileSystem.FileSystem;
|
|
350
438
|
const path = yield* Path.Path;
|
|
351
439
|
const prompter = yield* Prompter;
|
|
@@ -401,7 +489,7 @@ function getScriptPlan$2(scripts) {
|
|
|
401
489
|
//#endregion
|
|
402
490
|
//#region src/commands/add-knip/index.ts
|
|
403
491
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
404
|
-
const apply$
|
|
492
|
+
const apply$4 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
|
|
405
493
|
const fs = yield* FileSystem.FileSystem;
|
|
406
494
|
const path = yield* Path.Path;
|
|
407
495
|
const prompter = yield* Prompter;
|
|
@@ -438,7 +526,7 @@ function buildConfig(linters, formatters) {
|
|
|
438
526
|
}
|
|
439
527
|
//#endregion
|
|
440
528
|
//#region src/commands/add-lint-staged/index.ts
|
|
441
|
-
const apply$
|
|
529
|
+
const apply$3 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
|
|
442
530
|
if (linters.length === 0 && formatters.length === 0) {
|
|
443
531
|
yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
|
|
444
532
|
return;
|
|
@@ -476,7 +564,7 @@ function getScriptPlan$1(scripts) {
|
|
|
476
564
|
//#endregion
|
|
477
565
|
//#region src/commands/add-oxfmt/index.ts
|
|
478
566
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
479
|
-
const apply$
|
|
567
|
+
const apply$2 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
|
|
480
568
|
const fs = yield* FileSystem.FileSystem;
|
|
481
569
|
const path = yield* Path.Path;
|
|
482
570
|
const prompter = yield* Prompter;
|
|
@@ -517,7 +605,7 @@ function getScriptPlan(scripts) {
|
|
|
517
605
|
//#endregion
|
|
518
606
|
//#region src/commands/add-oxlint/index.ts
|
|
519
607
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
520
|
-
const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
608
|
+
const apply$1 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
521
609
|
const fs = yield* FileSystem.FileSystem;
|
|
522
610
|
const path = yield* Path.Path;
|
|
523
611
|
const prompter = yield* Prompter;
|
|
@@ -535,6 +623,70 @@ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
|
535
623
|
};
|
|
536
624
|
});
|
|
537
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
|
|
538
690
|
//#region src/commands/brew/logic.ts
|
|
539
691
|
/** Deduplicated list of packages to install for the chosen toppings and sweetness. */
|
|
540
692
|
function planPackages(pkg, toppings, sweetness, versions) {
|
|
@@ -549,6 +701,15 @@ function planPackages(pkg, toppings, sweetness, versions) {
|
|
|
549
701
|
}
|
|
550
702
|
return [...new Set(packages)];
|
|
551
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
|
+
}
|
|
552
713
|
/** True when at least one topping gives lint-staged something to run. */
|
|
553
714
|
function wantsLintStaged(toppings) {
|
|
554
715
|
return selectedLinters(toppings).length > 0 || selectedFormatters(toppings).length > 0;
|
|
@@ -619,6 +780,11 @@ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
|
|
|
619
780
|
value: "knip",
|
|
620
781
|
label: "knip",
|
|
621
782
|
hint: "dead code remover"
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
value: "vscode",
|
|
786
|
+
label: "vscode",
|
|
787
|
+
hint: "editor settings + extension recommendations"
|
|
622
788
|
}
|
|
623
789
|
],
|
|
624
790
|
required: false
|
|
@@ -650,9 +816,10 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
|
|
|
650
816
|
});
|
|
651
817
|
const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
|
|
652
818
|
let updated = pkg;
|
|
653
|
-
if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
|
|
654
|
-
if (toppings.includes("oxfmt")) updated = yield* apply$
|
|
655
|
-
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));
|
|
656
823
|
return updated;
|
|
657
824
|
});
|
|
658
825
|
const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
|
|
@@ -684,14 +851,14 @@ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, to
|
|
|
684
851
|
const path = yield* Path.Path;
|
|
685
852
|
const prompter = yield* Prompter;
|
|
686
853
|
const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
|
|
687
|
-
yield* apply$
|
|
688
|
-
if (wantsLintStaged(toppings)) yield* apply$
|
|
854
|
+
yield* apply$5(cwd, pm);
|
|
855
|
+
if (wantsLintStaged(toppings)) yield* apply$3(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
|
|
689
856
|
else {
|
|
690
857
|
yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
|
|
691
858
|
const seededHook = path.join(cwd, ".husky", "pre-commit");
|
|
692
859
|
if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
|
|
693
860
|
}
|
|
694
|
-
yield* apply$
|
|
861
|
+
yield* apply$6(cwd, pm);
|
|
695
862
|
});
|
|
696
863
|
const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
|
|
697
864
|
const git = yield* Git;
|
|
@@ -750,10 +917,19 @@ const traceFailure = (cause) => Effect.gen(function* () {
|
|
|
750
917
|
if (isCancellation(cause)) return;
|
|
751
918
|
yield* (yield* Prompter).debug(Cause.pretty(cause));
|
|
752
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;
|
|
753
926
|
/** Runs the named command and returns the process exit code. */
|
|
754
927
|
const run = (name, cwd = process.cwd(), options = {}) => {
|
|
755
928
|
const debug = options.debug ?? false;
|
|
756
|
-
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({
|
|
757
933
|
Cancelled: () => Effect.succeed(0),
|
|
758
934
|
UnknownCommand: (e) => reportError(`Unknown command: ${e.command ?? "(none)"}\nAvailable commands: ${Object.keys(commands).join(", ")}`),
|
|
759
935
|
PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
|
|
@@ -761,7 +937,7 @@ const run = (name, cwd = process.cwd(), options = {}) => {
|
|
|
761
937
|
InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
|
|
762
938
|
HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`),
|
|
763
939
|
NotAtRepoRoot: (e) => reportError(`Git hooks must be set up at the repository root: ${e.root}\nRun \`chanom brew\` there, or choose light sweetness.`)
|
|
764
|
-
})
|
|
940
|
+
}))).pipe(Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
|
|
765
941
|
};
|
|
766
942
|
//#endregion
|
|
767
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",
|