@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.
Files changed (3) hide show
  1. package/README.md +20 -6
  2. package/dist/index.js +281 -100
  3. 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 | Scripts added |
64
- | -------- | -------------------- | ------------------------ |
65
- | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
66
- | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
67
- | `knip` | `knip.config.ts`\* | `knip` |
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 pc from "picocolors";
3
+ import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
6
4
  import * as clack from "@clack/prompts";
7
- //#region src/bundled-versions.ts
8
- const bundledVersions = {
9
- oxlint: "1.73.0",
10
- oxfmt: "0.58.0",
11
- knip: "6.26.0",
12
- devConfig: "0.0.3"
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
- //#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";
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
- function isPackageInstalled(pkg, name) {
22
- return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
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
- function stripRangePrefix(version) {
25
- return version.replace(/^[\^~]/, "");
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
- 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}`;
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()("cli/CommandRunner", { effect: Effect.gen(function* () {
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()("cli/Git", { effect: Effect.gen(function* () {
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/domain/package-manager.ts
120
- /** How to run a locally installed binary with each package manager. */
121
- const PM_EXEC = {
122
- pnpm: ["pnpm", "exec"],
123
- npm: [
124
- "npx",
125
- "--no",
126
- "--"
127
- ],
128
- yarn: ["yarn"],
129
- bun: ["bunx"]
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
- const KNOWN_PMS = [
132
- "pnpm",
133
- "yarn",
134
- "bun",
135
- "npm"
136
- ];
137
- /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
138
- function parsePmField(field) {
139
- if (!field) return void 0;
140
- const name = field.split("@")[0];
141
- return KNOWN_PMS.find((pm) => pm === name);
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
- /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
144
- function parsePmUserAgent(agent) {
145
- if (!agent) return void 0;
146
- return KNOWN_PMS.find((pm) => agent.startsWith(pm));
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$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
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$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
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$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
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$2 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
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$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
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 [getMismatchedPackage(pkg, "oxlint", versions.oxlint), getMismatchedPackage(pkg, "@chanom/dev-config", versions.devConfig)].filter((p) => p !== void 0);
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$1(cwd, esm, updated);
650
- if (toppings.includes("knip")) updated = yield* apply$3(cwd, esm, updated);
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$4(cwd, pm);
683
- if (wantsLintStaged(toppings)) yield* apply$2(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
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$5(cwd, pm);
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
- }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
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.2.1",
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",