@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.
Files changed (3) hide show
  1. package/README.md +20 -6
  2. package/dist/index.js +276 -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,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 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
- oxlintTsgolint: "0.24.0",
11
- oxfmt: "0.58.0",
12
- knip: "6.26.0",
13
- 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"]
14
18
  };
15
- //#endregion
16
- //#region src/domain/pkg.ts
17
- var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
18
- var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
19
- function isEsm(pkg) {
20
- 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);
21
30
  }
22
- function isPackageInstalled(pkg, name) {
23
- 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));
24
35
  }
25
- function stripRangePrefix(version) {
26
- 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";
27
39
  }
28
- function getMismatchedPackage(pkg, name, version) {
29
- const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
30
- 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
+ };
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()("cli/CommandRunner", { effect: Effect.gen(function* () {
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()("cli/Git", { effect: Effect.gen(function* () {
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/domain/package-manager.ts
121
- /** How to run a locally installed binary with each package manager. */
122
- const PM_EXEC = {
123
- pnpm: ["pnpm", "exec"],
124
- npm: [
125
- "npx",
126
- "--no",
127
- "--"
128
- ],
129
- yarn: ["yarn"],
130
- 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"
131
250
  };
132
- const KNOWN_PMS = [
133
- "pnpm",
134
- "yarn",
135
- "bun",
136
- "npm"
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
- /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
145
- function parsePmUserAgent(agent) {
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$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
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$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
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$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
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$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) {
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$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
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$1(cwd, esm, updated);
655
- 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));
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$4(cwd, pm);
688
- 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);
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$5(cwd, pm);
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
- }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
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.2.2",
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",