@savvy-web/cli 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/savvy.d.ts +1 -0
  2. package/bin/savvy.js +17 -1
  3. package/cli/index.js +123 -0
  4. package/commands/changeset/commands/analyze-branch.js +108 -0
  5. package/commands/changeset/commands/check.js +71 -0
  6. package/commands/changeset/commands/classify.js +69 -0
  7. package/commands/changeset/commands/config-show.js +100 -0
  8. package/commands/changeset/commands/config-validate.js +63 -0
  9. package/commands/changeset/commands/deps-detect.js +103 -0
  10. package/commands/changeset/commands/deps-regen.js +277 -0
  11. package/commands/changeset/commands/init.js +634 -0
  12. package/commands/changeset/commands/lint.js +62 -0
  13. package/commands/changeset/commands/release-surface.js +96 -0
  14. package/commands/changeset/commands/transform.js +88 -0
  15. package/commands/changeset/commands/validate-file.js +52 -0
  16. package/commands/changeset/commands/version.js +178 -0
  17. package/commands/changeset/index.js +42 -0
  18. package/commands/changeset/utils/config-gate.js +59 -0
  19. package/commands/check.js +74 -0
  20. package/commands/clean.js +186 -0
  21. package/commands/commit/check.js +170 -0
  22. package/commands/commit/constants.js +10 -0
  23. package/commands/commit/hook.js +22 -0
  24. package/commands/commit/hooks/post-commit-verify.js +121 -0
  25. package/commands/commit/hooks/pre-commit-message.js +64 -0
  26. package/commands/commit/hooks/session-start.js +69 -0
  27. package/commands/commit/hooks/user-prompt-submit.js +42 -0
  28. package/commands/commit/index.js +20 -0
  29. package/commands/commit/init.js +127 -0
  30. package/commands/init.js +88 -0
  31. package/commands/lint/check.js +306 -0
  32. package/commands/lint/fmt.js +64 -0
  33. package/commands/lint/index.js +20 -0
  34. package/commands/lint/init.js +221 -0
  35. package/index.d.ts +237 -244
  36. package/index.js +14 -1
  37. package/package.json +39 -51
  38. package/841.js +0 -2394
  39. package/tsdoc-metadata.json +0 -11
@@ -0,0 +1,634 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Data, Effect, Schema } from "effect";
4
+ import { WorkspaceRoot } from "workspaces-effect";
5
+ import { join } from "node:path";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { execSync } from "node:child_process";
8
+ import { applyEdits, modify, parse } from "jsonc-effect";
9
+
10
+ //#region src/commands/changeset/commands/init.ts
11
+ /**
12
+ * Init command -- bootstrap a repository for \@savvy-web/changesets.
13
+ *
14
+ * Creates the `.changeset/` directory, writes (or patches) `config.json`,
15
+ * and configures markdownlint rules scoped to changeset files. Also provides
16
+ * a `--check` mode for verifying existing configuration without writing.
17
+ *
18
+ * @remarks
19
+ * The init command performs the following steps:
20
+ * 1. Detect the GitHub `owner/repo` slug from the git remote origin URL via
21
+ * {@link detectGitHubRepo}.
22
+ * 2. Ensure the `.changeset/` directory exists via {@link ensureChangesetDir}.
23
+ * 3. Write or patch `.changeset/config.json` via {@link handleConfig}.
24
+ * 4. Register custom rules in the base markdownlint config via
25
+ * {@link handleBaseMarkdownlint} (skipped with `--skip-markdownlint`).
26
+ * 5. Write or patch `.changeset/.markdownlint.json` via
27
+ * {@link handleChangesetMarkdownlint}.
28
+ *
29
+ * In `--check` mode, no files are written. Instead the command inspects
30
+ * existing configuration via {@link checkChangesetDir},
31
+ * {@link checkConfig}, {@link checkBaseMarkdownlint}, and
32
+ * {@link checkChangesetMarkdownlint}, reporting any issues as warnings.
33
+ *
34
+ * @remarks
35
+ * `runChangesetInit` backs the changeset step of the unified `savvy init`
36
+ * orchestrator; there is no standalone `savvy changeset init` subcommand. The
37
+ * exported `initCommand` is retained only as a direct test entry point.
38
+ *
39
+ * @example
40
+ * ```bash
41
+ * savvy init # runs the changeset, commit, and lint init steps
42
+ * savvy init --force # overwrite existing config files and hooks
43
+ * ```
44
+ *
45
+ * @internal
46
+ */
47
+ const { LegacyVersionFilesSchema } = Changesets;
48
+ /**
49
+ * Canonical changelog formatter written by `init` — the `@savvy-web/silk`
50
+ * shim that consumers actually install (the standalone `@savvy-web/changesets`
51
+ * package was merged into `@savvy-web/silk`).
52
+ */
53
+ const CHANGELOG_ENTRY = "@savvy-web/silk/changesets/changelog";
54
+ /** Changelog formatters `check` treats as valid (canonical silk shim + legacy). */
55
+ const ACCEPTED_CHANGELOG_ENTRIES = [CHANGELOG_ENTRY, "@savvy-web/changesets/changelog"];
56
+ /** Canonical markdownlint custom-rule entry written by `init` — the silk shim. */
57
+ const CUSTOM_RULES_ENTRY = "@savvy-web/silk/changesets/markdownlint";
58
+ /** Pre-merge standalone custom-rule entry; still accepted by `check`. */
59
+ const LEGACY_CUSTOM_RULES_ENTRY = "@savvy-web/changesets/markdownlint";
60
+ /** Custom-rule entries `check` treats as valid (canonical silk shim + legacy). */
61
+ const ACCEPTED_CUSTOM_RULES_ENTRIES = [CUSTOM_RULES_ENTRY, LEGACY_CUSTOM_RULES_ENTRY];
62
+ const MARKDOWNLINT_CONFIG_PATHS = [
63
+ "lib/configs/.markdownlint-cli2.jsonc",
64
+ "lib/configs/.markdownlint-cli2.json",
65
+ ".markdownlint-cli2.jsonc",
66
+ ".markdownlint-cli2.json"
67
+ ];
68
+ const RULE_NAMES = [
69
+ "changeset-heading-hierarchy",
70
+ "changeset-required-sections",
71
+ "changeset-content-structure",
72
+ "changeset-uncategorized-content",
73
+ "changeset-dependency-table-format"
74
+ ];
75
+ const DEFAULT_CONFIG = {
76
+ $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
77
+ changelog: [CHANGELOG_ENTRY, { repo: "owner/repo" }],
78
+ commit: false,
79
+ access: "restricted",
80
+ baseBranch: "main",
81
+ updateInternalDependencies: "patch",
82
+ ignore: [],
83
+ privatePackages: {
84
+ tag: true,
85
+ version: true
86
+ }
87
+ };
88
+ /**
89
+ * Base class for {@link InitError}, created via `Data.TaggedError`.
90
+ *
91
+ * @internal
92
+ */
93
+ const InitErrorBase = Data.TaggedError("InitError");
94
+ /**
95
+ * Tagged error raised when an init step fails.
96
+ *
97
+ * @remarks
98
+ * Carries the `step` name (e.g., `".changeset directory"`) and a human-readable
99
+ * `reason` string. The `message` getter combines both for logging.
100
+ *
101
+ * @internal
102
+ */
103
+ var InitError = class extends InitErrorBase {
104
+ get message() {
105
+ return `Init failed at ${this.step}: ${this.reason}`;
106
+ }
107
+ };
108
+ /* v8 ignore start -- CLI option definitions; handler functions tested individually */
109
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite existing config files"));
110
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Silence warnings, always exit 0"));
111
+ const skipMarkdownlintOption = Options.boolean("skip-markdownlint").pipe(Options.withDescription("Skip registering rules in base markdownlint config"));
112
+ const checkOption = Options.boolean("check").pipe(Options.withDescription("Check configuration without writing (for postinstall scripts)"));
113
+ /* v8 ignore stop */
114
+ /**
115
+ * Detect the `owner/repo` slug from the git remote origin URL.
116
+ *
117
+ * Attempts to parse both HTTPS (`github.com/owner/repo`) and SSH
118
+ * (`github.com:owner/repo`) URL formats. Returns `null` when git is
119
+ * unavailable, no remote is configured, or the URL does not match a
120
+ * GitHub repository pattern.
121
+ *
122
+ * @param cwd - Working directory in which to run `git remote get-url origin`
123
+ * @returns The `owner/repo` string, or `null` on failure
124
+ *
125
+ * @internal
126
+ */
127
+ function detectGitHubRepo(cwd) {
128
+ try {
129
+ const url = execSync("git remote get-url origin", {
130
+ cwd,
131
+ encoding: "utf-8"
132
+ }).trim();
133
+ const https = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
134
+ if (https) return `${https[1]}/${https[2]}`;
135
+ const ssh = url.match(/github\.com:([^/]+)\/([^/.]+)/);
136
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
137
+ } catch {}
138
+ return null;
139
+ }
140
+ /**
141
+ * Formatting options for `jsonc-effect` modify operations.
142
+ *
143
+ * Uses tabs (not spaces) per the Biome / Silk Suite convention.
144
+ *
145
+ * @internal
146
+ */
147
+ const JSONC_FORMAT = {
148
+ tabSize: 1,
149
+ insertSpaces: false
150
+ };
151
+ /**
152
+ * Resolve the monorepo workspace root from `cwd` using `WorkspaceRoot` service.
153
+ *
154
+ * Falls back to `cwd` itself when the service is unavailable or no workspace
155
+ * root can be determined.
156
+ *
157
+ * @param cwd - The current working directory to search from
158
+ * @returns An Effect yielding the resolved workspace root path
159
+ *
160
+ * @internal
161
+ */
162
+ function resolveWorkspaceRoot(cwd) {
163
+ return WorkspaceRoot.pipe(Effect.flatMap((wr) => wr.find(cwd)), Effect.catchAll(() => Effect.succeed(cwd)));
164
+ }
165
+ /**
166
+ * Find the first existing markdownlint config file from candidate paths.
167
+ *
168
+ * Searches for `lib/configs/.markdownlint-cli2.jsonc`,
169
+ * `lib/configs/.markdownlint-cli2.json`, `.markdownlint-cli2.jsonc`, and
170
+ * `.markdownlint-cli2.json` (in that order) relative to `root`.
171
+ *
172
+ * @param root - The workspace root directory to search in
173
+ * @returns The relative config path if found, or `null`
174
+ *
175
+ * @internal
176
+ */
177
+ function findMarkdownlintConfig(root) {
178
+ for (const configPath of MARKDOWNLINT_CONFIG_PATHS) if (existsSync(join(root, configPath))) return configPath;
179
+ return null;
180
+ }
181
+ /**
182
+ * Ensure the `.changeset/` directory exists under `root`.
183
+ *
184
+ * Creates the directory recursively if it does not already exist.
185
+ *
186
+ * @param root - The workspace root directory
187
+ * @returns An Effect yielding the absolute path to `.changeset/`, or an
188
+ * {@link InitError} on failure
189
+ *
190
+ * @internal
191
+ */
192
+ function ensureChangesetDir(root) {
193
+ return Effect.try({
194
+ try: () => {
195
+ const dir = join(root, ".changeset");
196
+ mkdirSync(dir, { recursive: true });
197
+ return dir;
198
+ },
199
+ catch: (error) => new InitError({
200
+ step: ".changeset directory",
201
+ reason: error instanceof Error ? error.message : String(error)
202
+ })
203
+ });
204
+ }
205
+ /**
206
+ * Write or patch `.changeset/config.json`.
207
+ *
208
+ * When `force` is `true` or the file does not exist, writes a fresh config
209
+ * with the default schema. Otherwise, patches only the `changelog` field to
210
+ * point at `\@savvy-web/silk/changesets/changelog` with the detected `repoSlug`.
211
+ *
212
+ * @param changesetDir - Absolute path to the `.changeset/` directory
213
+ * @param repoSlug - The `owner/repo` GitHub slug to embed in the config
214
+ * @param force - When `true`, overwrite existing config entirely
215
+ * @returns An Effect yielding a human-readable status message, or an
216
+ * {@link InitError} on failure
217
+ *
218
+ * @internal
219
+ */
220
+ function handleConfig(changesetDir, repoSlug, force) {
221
+ return Effect.try({
222
+ try: () => {
223
+ const configPath = join(changesetDir, "config.json");
224
+ if (force || !existsSync(configPath)) {
225
+ const config = {
226
+ ...DEFAULT_CONFIG,
227
+ changelog: [CHANGELOG_ENTRY, { repo: repoSlug }]
228
+ };
229
+ writeFileSync(configPath, `${JSON.stringify(config, null, " ")}\n`);
230
+ return force ? "Overwrote .changeset/config.json" : "Created .changeset/config.json";
231
+ }
232
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
233
+ existing.changelog = [CHANGELOG_ENTRY, {
234
+ ...Array.isArray(existing.changelog) && typeof existing.changelog[1] === "object" && existing.changelog[1] !== null ? existing.changelog[1] : {},
235
+ repo: repoSlug
236
+ }];
237
+ writeFileSync(configPath, `${JSON.stringify(existing, null, " ")}\n`);
238
+ return "Patched changelog in .changeset/config.json";
239
+ },
240
+ catch: (error) => new InitError({
241
+ step: ".changeset/config.json",
242
+ reason: error instanceof Error ? error.message : String(error)
243
+ })
244
+ });
245
+ }
246
+ /**
247
+ * Detect whether a config's changelog options carry the deprecated top-level
248
+ * `versionFiles[]` array (the 0.8.x shape).
249
+ *
250
+ * @remarks
251
+ * The new shape (introduced in 0.9.0) lives under
252
+ * `changelog[1].packages[<name>].versionFiles`. The legacy shape has
253
+ * `versionFiles` as a top-level key of the changelog options object with
254
+ * each entry carrying its own `package` field. Returns `true` only when the
255
+ * legacy shape is present and non-empty.
256
+ *
257
+ * Reads from a pre-parsed config object — `handleConfig` and `checkConfig`
258
+ * already parse the file once; we don't re-parse here.
259
+ *
260
+ * @internal
261
+ */
262
+ function detectLegacyVersionFiles(config) {
263
+ if (typeof config !== "object" || config === null) return false;
264
+ const changelog = config.changelog;
265
+ if (!Array.isArray(changelog) || changelog.length < 2) return false;
266
+ const options = changelog[1];
267
+ if (typeof options !== "object" || options === null) return false;
268
+ return Array.isArray(options.versionFiles) && options.versionFiles.length > 0;
269
+ }
270
+ /**
271
+ * Format the deprecation message emitted when an existing config still
272
+ * uses the legacy top-level `versionFiles[]`.
273
+ *
274
+ * @internal
275
+ */
276
+ function legacyVersionFilesWarning(configPath) {
277
+ return [
278
+ `DEPRECATION: ${configPath} uses the legacy top-level \`versionFiles[]\` array.`,
279
+ " Migrate each entry to `changelog[1].packages[<entry.package>].versionFiles`",
280
+ " and remove the top-level field. Run `savvy changeset config show --json`",
281
+ " to see the normalized form, or check the 0.9.0 release notes for examples.",
282
+ " Removed in @savvy-web/changesets 1.0.0."
283
+ ].join("\n");
284
+ }
285
+ /**
286
+ * Read the config at `configPath` and emit an `Effect.logWarning` if it
287
+ * still uses the deprecated top-level `versionFiles[]` shape. Silent
288
+ * when the file doesn't exist (e.g., the caller just created a fresh
289
+ * default config) or when the file uses the new `packages` shape.
290
+ *
291
+ * @remarks
292
+ * Never fails — config-parse errors are swallowed because deeper diagnosis
293
+ * is the job of `config validate`. This helper exists only to surface the
294
+ * migration hint at `init` time.
295
+ *
296
+ * @internal
297
+ */
298
+ function warnIfLegacyVersionFiles(changesetDir) {
299
+ return Effect.gen(function* () {
300
+ const configPath = join(changesetDir, "config.json");
301
+ if (!existsSync(configPath)) return;
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
305
+ } catch {
306
+ return;
307
+ }
308
+ if (detectLegacyVersionFiles(parsed)) yield* Effect.logWarning(legacyVersionFilesWarning(configPath));
309
+ });
310
+ }
311
+ /**
312
+ * Register custom rules in the base markdownlint config.
313
+ *
314
+ * Locates the project's markdownlint-cli2 config file via
315
+ * {@link findMarkdownlintConfig}, then uses `jsonc-effect` to:
316
+ * 1. Register `\@savvy-web/silk/changesets/markdownlint` in the `customRules`
317
+ * array, migrating any pre-merge `\@savvy-web/changesets/markdownlint` entry.
318
+ * 2. Add each CSH rule name to the `config` object (set to `false` so they
319
+ * are recognized but disabled at the project root -- they are enabled in
320
+ * `.changeset/.markdownlint.json`).
321
+ *
322
+ * @param root - The workspace root directory
323
+ * @returns An Effect yielding a status message, or an {@link InitError}
324
+ *
325
+ * @internal
326
+ */
327
+ function handleBaseMarkdownlint(root) {
328
+ const foundPath = findMarkdownlintConfig(root);
329
+ if (!foundPath) return Effect.succeed(`Warning: no markdownlint config found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`);
330
+ return Effect.gen(function* () {
331
+ const fullPath = join(root, foundPath);
332
+ let text;
333
+ try {
334
+ text = readFileSync(fullPath, "utf-8");
335
+ } catch (error) {
336
+ return yield* Effect.fail(new InitError({
337
+ step: "markdownlint config",
338
+ reason: error instanceof Error ? error.message : String(error)
339
+ }));
340
+ }
341
+ let parsed = yield* parse(text);
342
+ const currentRules = Array.isArray(parsed.customRules) ? parsed.customRules : null;
343
+ if (currentRules === null) {
344
+ const edits = yield* modify(text, ["customRules"], [CUSTOM_RULES_ENTRY], { formattingOptions: JSONC_FORMAT });
345
+ text = yield* applyEdits(text, edits);
346
+ } else {
347
+ const desired = currentRules.filter((r) => r !== LEGACY_CUSTOM_RULES_ENTRY && r !== CUSTOM_RULES_ENTRY);
348
+ desired.push(CUSTOM_RULES_ENTRY);
349
+ if (desired.length !== currentRules.length || desired.some((r, i) => r !== currentRules[i])) {
350
+ const edits = yield* modify(text, ["customRules"], desired, { formattingOptions: JSONC_FORMAT });
351
+ text = yield* applyEdits(text, edits);
352
+ }
353
+ }
354
+ parsed = yield* parse(text);
355
+ const currentConfig = parsed.config;
356
+ if (typeof currentConfig !== "object" || currentConfig === null) {
357
+ const edits = yield* modify(text, ["config"], {}, { formattingOptions: JSONC_FORMAT });
358
+ text = yield* applyEdits(text, edits);
359
+ }
360
+ parsed = yield* parse(text);
361
+ const config = parsed.config;
362
+ for (const rule of RULE_NAMES) if (!(rule in config)) {
363
+ const edits = yield* modify(text, ["config", rule], false, { formattingOptions: JSONC_FORMAT });
364
+ text = yield* applyEdits(text, edits);
365
+ }
366
+ try {
367
+ writeFileSync(fullPath, text);
368
+ } catch (error) {
369
+ return yield* Effect.fail(new InitError({
370
+ step: "markdownlint config",
371
+ reason: error instanceof Error ? error.message : String(error)
372
+ }));
373
+ }
374
+ return `Updated ${foundPath}`;
375
+ }).pipe(Effect.catchAll((error) => {
376
+ if (error instanceof InitError) return Effect.fail(error);
377
+ return Effect.fail(new InitError({
378
+ step: "markdownlint config",
379
+ reason: error instanceof Error ? error.message : String(error)
380
+ }));
381
+ }));
382
+ }
383
+ /**
384
+ * Write or patch `.changeset/.markdownlint.json`.
385
+ *
386
+ * Creates a scoped markdownlint config that extends the base config (if found),
387
+ * disables all default rules (`"default": false`), disables MD041 (first-line
388
+ * heading), and enables all five CSH rules. When the file already exists and
389
+ * `force` is `false`, only the CSH rule entries are patched.
390
+ *
391
+ * @param changesetDir - Absolute path to the `.changeset/` directory
392
+ * @param root - The workspace root directory (for resolving the base config)
393
+ * @param force - When `true`, overwrite the existing config entirely
394
+ * @returns An Effect yielding a status message, or an {@link InitError}
395
+ *
396
+ * @internal
397
+ */
398
+ function handleChangesetMarkdownlint(changesetDir, root, force) {
399
+ return Effect.try({
400
+ try: () => {
401
+ const mdlintPath = join(changesetDir, ".markdownlint.json");
402
+ const baseConfig = findMarkdownlintConfig(root);
403
+ if (force || !existsSync(mdlintPath)) {
404
+ const mdlintConfig = {};
405
+ if (baseConfig) mdlintConfig.extends = `../${baseConfig}`;
406
+ mdlintConfig.default = false;
407
+ mdlintConfig.MD041 = false;
408
+ for (const rule of RULE_NAMES) mdlintConfig[rule] = true;
409
+ writeFileSync(mdlintPath, `${JSON.stringify(mdlintConfig, null, " ")}\n`);
410
+ return force ? "Overwrote .changeset/.markdownlint.json" : "Created .changeset/.markdownlint.json";
411
+ }
412
+ const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
413
+ for (const rule of RULE_NAMES) existing[rule] = true;
414
+ writeFileSync(mdlintPath, `${JSON.stringify(existing, null, " ")}\n`);
415
+ return "Patched rules in .changeset/.markdownlint.json";
416
+ },
417
+ catch: (error) => new InitError({
418
+ step: ".changeset/.markdownlint.json",
419
+ reason: error instanceof Error ? error.message : String(error)
420
+ })
421
+ });
422
+ }
423
+ /**
424
+ * Check that the `.changeset/` directory exists under `root`.
425
+ *
426
+ * @param root - The workspace root directory
427
+ * @returns An array of {@link CheckIssue} items (empty when the directory exists)
428
+ *
429
+ * @internal
430
+ */
431
+ function checkChangesetDir(root) {
432
+ if (!existsSync(join(root, ".changeset"))) return [{
433
+ file: ".changeset/",
434
+ message: "directory does not exist"
435
+ }];
436
+ return [];
437
+ }
438
+ /**
439
+ * Check that `.changeset/config.json` exists and has the correct changelog entry.
440
+ *
441
+ * Verifies that the `changelog` field points to the silk changelog shim
442
+ * (`\@savvy-web/silk/changesets/changelog`; the pre-merge
443
+ * `\@savvy-web/changesets/changelog` is still accepted) and that the embedded
444
+ * `repo` value matches `repoSlug`.
445
+ *
446
+ * @param changesetDir - Absolute path to the `.changeset/` directory
447
+ * @param repoSlug - The expected `owner/repo` GitHub slug
448
+ * @returns An array of {@link CheckIssue} items (empty when config is correct)
449
+ *
450
+ * @internal
451
+ */
452
+ function checkConfig(changesetDir, repoSlug) {
453
+ const configPath = join(changesetDir, "config.json");
454
+ if (!existsSync(configPath)) return [{
455
+ file: ".changeset/config.json",
456
+ message: "file does not exist"
457
+ }];
458
+ try {
459
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
460
+ const issues = [];
461
+ const changelog = config.changelog;
462
+ const entry = Array.isArray(changelog) ? changelog[0] : changelog;
463
+ const repo = Array.isArray(changelog) ? changelog[1]?.repo : void 0;
464
+ if (!ACCEPTED_CHANGELOG_ENTRIES.includes(entry)) issues.push({
465
+ file: ".changeset/config.json",
466
+ message: `changelog formatter is "${entry}", expected "${CHANGELOG_ENTRY}"`
467
+ });
468
+ else if (repo !== repoSlug) issues.push({
469
+ file: ".changeset/config.json",
470
+ message: `changelog repo is "${repo ?? "(not set)"}", expected "${repoSlug}"`
471
+ });
472
+ const options = Array.isArray(changelog) ? changelog[1] : void 0;
473
+ if (options && typeof options === "object" && "versionFiles" in options) {
474
+ if (Schema.decodeUnknownEither(LegacyVersionFilesSchema)(options.versionFiles)._tag === "Left") issues.push({
475
+ file: ".changeset/config.json",
476
+ message: "versionFiles config is invalid"
477
+ });
478
+ }
479
+ if (detectLegacyVersionFiles(config)) issues.push({
480
+ file: ".changeset/config.json",
481
+ message: "uses the legacy top-level `versionFiles[]` array (deprecated; removed in 1.0.0). Migrate to `packages[<name>].versionFiles`."
482
+ });
483
+ return issues;
484
+ } catch {
485
+ return [{
486
+ file: ".changeset/config.json",
487
+ message: "could not parse file"
488
+ }];
489
+ }
490
+ }
491
+ /**
492
+ * Check that the base markdownlint config has the `customRules` entry and
493
+ * all CSH rule names registered in its `config` section.
494
+ *
495
+ * @param root - The workspace root directory
496
+ * @returns An array of {@link CheckIssue} items (empty when config is correct)
497
+ *
498
+ * @internal
499
+ */
500
+ function checkBaseMarkdownlint(root) {
501
+ const foundPath = findMarkdownlintConfig(root);
502
+ if (!foundPath) return [{
503
+ file: "markdownlint config",
504
+ message: `not found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`
505
+ }];
506
+ try {
507
+ const raw = readFileSync(join(root, foundPath), "utf-8");
508
+ const parsed = Effect.runSync(parse(raw));
509
+ const issues = [];
510
+ if (!Array.isArray(parsed.customRules) || !parsed.customRules.some((r) => ACCEPTED_CUSTOM_RULES_ENTRIES.includes(r))) issues.push({
511
+ file: foundPath,
512
+ message: `customRules does not include ${CUSTOM_RULES_ENTRY}`
513
+ });
514
+ const config = parsed.config;
515
+ if (typeof config !== "object" || config === null) issues.push({
516
+ file: foundPath,
517
+ message: "config section is missing"
518
+ });
519
+ else for (const rule of RULE_NAMES) if (!(rule in config)) issues.push({
520
+ file: foundPath,
521
+ message: `rule "${rule}" is not configured`
522
+ });
523
+ return issues;
524
+ } catch {
525
+ return [{
526
+ file: foundPath,
527
+ message: "could not parse file"
528
+ }];
529
+ }
530
+ }
531
+ /**
532
+ * Check that `.changeset/.markdownlint.json` exists and has all five CSH
533
+ * rules enabled (`true`).
534
+ *
535
+ * @param changesetDir - Absolute path to the `.changeset/` directory
536
+ * @returns An array of {@link CheckIssue} items (empty when config is correct)
537
+ *
538
+ * @internal
539
+ */
540
+ function checkChangesetMarkdownlint(changesetDir) {
541
+ const mdlintPath = join(changesetDir, ".markdownlint.json");
542
+ if (!existsSync(mdlintPath)) return [{
543
+ file: ".changeset/.markdownlint.json",
544
+ message: "file does not exist"
545
+ }];
546
+ try {
547
+ const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
548
+ const issues = [];
549
+ for (const rule of RULE_NAMES) if (existing[rule] !== true) issues.push({
550
+ file: ".changeset/.markdownlint.json",
551
+ message: `rule "${rule}" is not enabled`
552
+ });
553
+ return issues;
554
+ } catch {
555
+ return [{
556
+ file: ".changeset/.markdownlint.json",
557
+ message: "could not parse file"
558
+ }];
559
+ }
560
+ }
561
+ /**
562
+ * Run the full init pipeline.
563
+ *
564
+ * Exported so Task B5's unified `savvy init` orchestrator can invoke the
565
+ * changeset init step directly without going through the CLI command layer.
566
+ *
567
+ * @param opts - The same options the CLI command receives
568
+ * @returns An Effect that performs initialization
569
+ *
570
+ * @internal
571
+ */
572
+ function runChangesetInit(opts) {
573
+ const { force, quiet, skipMarkdownlint, check } = opts;
574
+ return Effect.gen(function* () {
575
+ const root = yield* resolveWorkspaceRoot(process.cwd());
576
+ const repo = detectGitHubRepo(root);
577
+ if (!repo && !quiet) yield* Effect.log("Warning: could not detect GitHub repo from git remote, using placeholder");
578
+ const repoSlug = repo ?? "owner/repo";
579
+ if (check) {
580
+ const changesetDir = join(root, ".changeset");
581
+ const issues = [
582
+ ...checkChangesetDir(root),
583
+ ...checkConfig(changesetDir, repoSlug),
584
+ ...!skipMarkdownlint ? checkBaseMarkdownlint(root) : [],
585
+ ...checkChangesetMarkdownlint(changesetDir)
586
+ ];
587
+ if (issues.length === 0) {
588
+ yield* Effect.log("All @savvy-web/changesets config files are up to date.");
589
+ return;
590
+ }
591
+ for (const issue of issues) yield* Effect.logWarning(`${issue.file}: ${issue.message}`);
592
+ yield* Effect.logWarning("Run \"savvy init --force\" to fix.");
593
+ return;
594
+ }
595
+ const changesetDir = yield* ensureChangesetDir(root);
596
+ yield* Effect.log("Ensured .changeset/ directory");
597
+ const errors = [];
598
+ const configResult = yield* handleConfig(changesetDir, repoSlug, force).pipe(Effect.either);
599
+ if (configResult._tag === "Right") {
600
+ yield* Effect.log(configResult.right);
601
+ if (!quiet) yield* warnIfLegacyVersionFiles(changesetDir);
602
+ } else errors.push(configResult.left);
603
+ if (!skipMarkdownlint) {
604
+ const baseResult = yield* handleBaseMarkdownlint(root).pipe(Effect.either);
605
+ if (baseResult._tag === "Right") yield* Effect.log(baseResult.right);
606
+ else errors.push(baseResult.left);
607
+ }
608
+ const mdlintResult = yield* handleChangesetMarkdownlint(changesetDir, root, force).pipe(Effect.either);
609
+ if (mdlintResult._tag === "Right") yield* Effect.log(mdlintResult.right);
610
+ else errors.push(mdlintResult.left);
611
+ if (errors.length > 0) {
612
+ for (const err of errors) yield* Effect.logError(err.message);
613
+ if (!quiet) process.exitCode = 1;
614
+ return;
615
+ }
616
+ yield* Effect.log("Init complete.");
617
+ }).pipe(Effect.catchAll((error) => Effect.gen(function* () {
618
+ if (!quiet) {
619
+ yield* Effect.logError(error instanceof InitError ? error.message : `Init failed: ${String(error)}`);
620
+ process.exitCode = 1;
621
+ }
622
+ })));
623
+ }
624
+ /* v8 ignore start -- CLI orchestration; individual functions tested separately */
625
+ const initCommand = Command.make("init", {
626
+ force: forceOption,
627
+ quiet: quietOption,
628
+ skipMarkdownlint: skipMarkdownlintOption,
629
+ check: checkOption
630
+ }, (opts) => runChangesetInit(opts)).pipe(Command.withDescription("Bootstrap a repo for @savvy-web/changesets"));
631
+ /* v8 ignore stop */
632
+
633
+ //#endregion
634
+ export { runChangesetInit };
@@ -0,0 +1,62 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Changesets } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/changeset/commands/lint.ts
7
+ /**
8
+ * Lint command -- validate changeset files with machine-readable output.
9
+ *
10
+ * Emits one line per error in `file:line:col rule message` format, suitable
11
+ * for consumption by editors, CI tools, and the `--format` flag of
12
+ * markdownlint-cli2.
13
+ *
14
+ * @remarks
15
+ * The command resolves the directory argument, delegates to
16
+ * {@link ChangesetLinter.validate}, and logs each {@link LintMessage} as a
17
+ * single colon-delimited line. When no errors are found and `--quiet` is not
18
+ * set, a success message is printed. Sets `process.exitCode = 1` when errors
19
+ * are found.
20
+ *
21
+ * @example
22
+ * ```bash
23
+ * savvy changeset lint .changeset
24
+ * savvy changeset lint --quiet .changeset
25
+ * ```
26
+ *
27
+ * @internal
28
+ */
29
+ const { ChangesetLinter } = Changesets;
30
+ /* v8 ignore start -- CLI option definitions; handler tested via runLint */
31
+ const dirArg = Args.directory({ name: "dir" }).pipe(Args.withDefault(".changeset"));
32
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output errors, no summary"), Options.withDefault(false));
33
+ /* v8 ignore stop */
34
+ /**
35
+ * Run machine-readable lint validation on all changeset files in `dir`.
36
+ *
37
+ * Outputs one line per error in `file:line:col rule message` format. Sets
38
+ * `process.exitCode = 1` when one or more errors are found.
39
+ *
40
+ * @param dir - Path to the changeset directory (resolved relative to cwd)
41
+ * @param quiet - When `true`, suppress the "No lint errors found" message
42
+ * @returns An Effect that performs validation and logs results
43
+ *
44
+ * @internal
45
+ */
46
+ function runLint(dir, quiet) {
47
+ return Effect.gen(function* () {
48
+ const resolved = resolve(dir);
49
+ const messages = yield* Effect.try(() => ChangesetLinter.validate(resolved));
50
+ for (const msg of messages) yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
51
+ if (!quiet && messages.length === 0) yield* Effect.log("No lint errors found.");
52
+ if (messages.length > 0) process.exitCode = 1;
53
+ });
54
+ }
55
+ /* v8 ignore next 3 -- CLI registration; handler tested via runLint */
56
+ const lintCommand = Command.make("lint", {
57
+ dir: dirArg,
58
+ quiet: quietOption
59
+ }, ({ dir, quiet }) => runLint(dir, quiet)).pipe(Command.withDescription("Validate changeset files"));
60
+
61
+ //#endregion
62
+ export { lintCommand };