@savvy-web/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/841.js +850 -688
  2. package/README.md +18 -10
  3. package/package.json +1 -1
package/841.js CHANGED
@@ -3,12 +3,12 @@ import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
3
  import { BiomeSchemaSync, BiomeSchemaSyncLive, ChangesetConfigReaderLive, Changesets, CheckResult, Commitlint, ConfigDiscovery, ConfigDiscoveryLive, Lint, ManagedSection, ManagedSectionLive, SavvyBaseSection, SavvyHooksSection, SectionDefinition, SilkPublishabilityDetectorLive, ToolDefinition, ToolDiscovery, ToolDiscoveryLive, VersioningStrategy, VersioningStrategyLive, savvyBasePreamble, savvyHooksHygiene, savvyToolSection } from "@savvy-web/silk-effects";
4
4
  import { Data, Effect, Layer, Option, Schema } from "effect";
5
5
  import { PackageManagerDetector, PackageManagerDetectorLive, WorkspaceDiscovery, WorkspaceDiscoveryLive, WorkspaceRoot, WorkspaceRootLive } from "workspaces-effect";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { dirname, join, resolve, sep } from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
8
8
  import { execFile, execSync } from "node:child_process";
9
9
  import { applyEdits, modify, parse } from "jsonc-effect";
10
10
  import { FileSystem } from "@effect/platform";
11
- import { chmod } from "node:fs/promises";
11
+ import { chmod, glob, realpath, rm } from "node:fs/promises";
12
12
  import { isDeepStrictEqual, promisify } from "node:util";
13
13
  import { parse as external_yaml_parse, stringify } from "yaml";
14
14
  const { BranchAnalyzer: BranchAnalyzer } = Changesets;
@@ -78,40 +78,6 @@ const analyzeBranchCommand = Command.make("analyze-branch", {
78
78
  base: baseOption,
79
79
  json: jsonOption
80
80
  }, ({ cwd, base, json })=>runAnalyzeBranch(cwd, base, json)).pipe(Command.withDescription("Diff the current branch and classify every changed file by owning package"));
81
- const { ChangesetLinter: ChangesetLinter } = Changesets;
82
- const dirArg = Args.directory({
83
- name: "dir"
84
- }).pipe(Args.withDefault(".changeset"));
85
- function runChangesetCheck(dir) {
86
- return Effect.gen(function*() {
87
- const resolved = resolve(dir);
88
- const messages = yield* Effect["try"]({
89
- try: ()=>ChangesetLinter.validate(resolved),
90
- catch: (e)=>new Error(String(e))
91
- });
92
- const byFile = new Map();
93
- for (const msg of messages){
94
- const existing = byFile.get(msg.file);
95
- if (existing) existing.push(msg);
96
- else byFile.set(msg.file, [
97
- msg
98
- ]);
99
- }
100
- for (const [file, fileMessages] of byFile){
101
- yield* Effect.log(`\n${file}`);
102
- for (const msg of fileMessages)yield* Effect.log(` ${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
103
- }
104
- const errorCount = messages.length;
105
- const filesWithErrors = byFile.size;
106
- if (errorCount > 0) {
107
- yield* Effect.log(`\n${filesWithErrors} file(s) with errors, ${errorCount} error(s) found`);
108
- process.exitCode = 1;
109
- } else yield* Effect.log("All changeset files passed validation.");
110
- });
111
- }
112
- const checkCommand = Command.make("check", {
113
- dir: dirArg
114
- }, ({ dir })=>runChangesetCheck(dir)).pipe(Command.withDescription("Full changeset validation with summary"));
115
81
  const { ConfigInspector: ConfigInspector } = Changesets;
116
82
  const pathsArg = Args.text({
117
83
  name: "path"
@@ -142,7 +108,7 @@ const classifyCommand = Command.make("classify", {
142
108
  json: classify_jsonOption
143
109
  }, ({ paths, cwd, json })=>runClassify(cwd, paths, json)).pipe(Command.withDescription("Map paths to their owning package per .changeset/config.json"));
144
110
  const { ConfigInspector: config_show_ConfigInspector } = Changesets;
145
- const config_show_dirArg = Args.directory({
111
+ const dirArg = Args.directory({
146
112
  name: "dir"
147
113
  }).pipe(Args.withDefault("."));
148
114
  const config_show_jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
@@ -198,7 +164,7 @@ function runConfigShow(dir, json) {
198
164
  });
199
165
  }
200
166
  const configShowCommand = Command.make("show", {
201
- dir: config_show_dirArg,
167
+ dir: dirArg,
202
168
  json: config_show_jsonOption
203
169
  }, ({ dir, json })=>runConfigShow(dir, json)).pipe(Command.withDescription("Print the resolved .changeset/config.json"));
204
170
  const { ConfigInspector: config_validate_ConfigInspector } = Changesets;
@@ -512,677 +478,726 @@ const depsRegenCommand = Command.make("regen", {
512
478
  dryRun: dryRunOption,
513
479
  json: deps_regen_jsonOption
514
480
  }, ({ cwd, base, package: pkg, dryRun, json })=>runDepsRegen(cwd, base, pkg, dryRun, json)).pipe(Command.withDescription("Delete pure dependency changesets and regenerate them from the current diff"));
515
- const { LegacyVersionFilesSchema: LegacyVersionFilesSchema } = Changesets;
516
- const CUSTOM_RULES_ENTRY = "@savvy-web/changesets/markdownlint";
517
- const CHANGELOG_ENTRY = "@savvy-web/changesets/changelog";
518
- const MARKDOWNLINT_CONFIG_PATHS = [
519
- "lib/configs/.markdownlint-cli2.jsonc",
520
- "lib/configs/.markdownlint-cli2.json",
521
- ".markdownlint-cli2.jsonc",
522
- ".markdownlint-cli2.json"
523
- ];
524
- const RULE_NAMES = [
525
- "changeset-heading-hierarchy",
526
- "changeset-required-sections",
527
- "changeset-content-structure",
528
- "changeset-uncategorized-content",
529
- "changeset-dependency-table-format"
530
- ];
531
- const DEFAULT_CONFIG = {
532
- $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
533
- changelog: [
534
- CHANGELOG_ENTRY,
535
- {
536
- repo: "owner/repo"
537
- }
538
- ],
539
- commit: false,
540
- access: "restricted",
541
- baseBranch: "main",
542
- updateInternalDependencies: "patch",
543
- ignore: [],
544
- privatePackages: {
545
- tag: true,
546
- version: true
481
+ const { ChangesetLinter: ChangesetLinter } = Changesets;
482
+ const lint_dirArg = Args.directory({
483
+ name: "dir"
484
+ }).pipe(Args.withDefault(".changeset"));
485
+ const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output errors, no summary"), Options.withDefault(false));
486
+ function runLint(dir, quiet) {
487
+ return Effect.gen(function*() {
488
+ const resolved = resolve(dir);
489
+ const messages = yield* Effect["try"](()=>ChangesetLinter.validate(resolved));
490
+ for (const msg of messages)yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
491
+ if (!quiet && 0 === messages.length) yield* Effect.log("No lint errors found.");
492
+ if (messages.length > 0) process.exitCode = 1;
493
+ });
494
+ }
495
+ const lintCommand = Command.make("lint", {
496
+ dir: lint_dirArg,
497
+ quiet: quietOption
498
+ }, ({ dir, quiet })=>runLint(dir, quiet)).pipe(Command.withDescription("Validate changeset files"));
499
+ const { ConfigInspector: release_surface_ConfigInspector, ConfigurationError: ConfigurationError } = Changesets;
500
+ const packageArg = Args.text({
501
+ name: "package"
502
+ });
503
+ const release_surface_cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
504
+ const release_surface_jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
505
+ function release_surface_renderHuman(pkg) {
506
+ const lines = [];
507
+ lines.push(`Package: ${pkg.name} v${pkg.version}`);
508
+ lines.push(`Workspace: ${pkg.workspaceDir}`);
509
+ if (0 === pkg.additionalScopes.length && 0 === pkg.versionFiles.length) {
510
+ lines.push("");
511
+ lines.push("(no additionalScopes or versionFiles — workspace dir is the entire release surface)");
512
+ return lines.join("\n");
547
513
  }
548
- };
549
- const InitErrorBase = Data.TaggedError("InitError");
550
- class InitError extends InitErrorBase {
551
- get message() {
552
- return `Init failed at ${this.step}: ${this.reason}`;
514
+ if (pkg.additionalScopes.length > 0) {
515
+ lines.push("");
516
+ lines.push(`additionalScopes (${pkg.additionalScopes.length} glob${1 === pkg.additionalScopes.length ? "" : "s"}):`);
517
+ for (const g of pkg.additionalScopes)lines.push(` - ${g}`);
518
+ lines.push(`Resolved files (${pkg.additionalScopeFiles.length}):`);
519
+ for (const f of pkg.additionalScopeFiles)lines.push(` ${f}`);
553
520
  }
521
+ if (pkg.versionFiles.length > 0) {
522
+ lines.push("");
523
+ lines.push(`versionFiles (${pkg.versionFiles.length}):`);
524
+ for (const vf of pkg.versionFiles){
525
+ lines.push(` ${vf.glob} → ${vf.paths.join(", ")}`);
526
+ for (const f of vf.matchedFiles)lines.push(` ${f}`);
527
+ }
528
+ }
529
+ return lines.join("\n");
554
530
  }
555
- const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite existing config files"));
556
- const quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Silence warnings, always exit 0"));
557
- const skipMarkdownlintOption = Options.boolean("skip-markdownlint").pipe(Options.withDescription("Skip registering rules in base markdownlint config"));
558
- const checkOption = Options.boolean("check").pipe(Options.withDescription("Check configuration without writing (for postinstall scripts)"));
559
- function detectGitHubRepo(cwd) {
560
- try {
561
- const url = execSync("git remote get-url origin", {
562
- cwd,
563
- encoding: "utf-8"
564
- }).trim();
565
- const https = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
566
- if (https) return `${https[1]}/${https[2]}`;
567
- const ssh = url.match(/github\.com:([^/]+)\/([^/.]+)/);
568
- if (ssh) return `${ssh[1]}/${ssh[2]}`;
569
- } catch {}
570
- return null;
571
- }
572
- const JSONC_FORMAT = {
573
- tabSize: 1,
574
- insertSpaces: false
575
- };
576
- function resolveWorkspaceRoot(cwd) {
577
- return WorkspaceRoot.pipe(Effect.flatMap((wr)=>wr.find(cwd)), Effect.catchAll(()=>Effect.succeed(cwd)));
578
- }
579
- function findMarkdownlintConfig(root) {
580
- for (const configPath of MARKDOWNLINT_CONFIG_PATHS)if (existsSync(join(root, configPath))) return configPath;
581
- return null;
582
- }
583
- function ensureChangesetDir(root) {
584
- return Effect["try"]({
585
- try: ()=>{
586
- const dir = join(root, ".changeset");
587
- mkdirSync(dir, {
588
- recursive: true
589
- });
590
- return dir;
591
- },
592
- catch: (error)=>new InitError({
593
- step: ".changeset directory",
594
- reason: error instanceof Error ? error.message : String(error)
595
- })
596
- });
597
- }
598
- function handleConfig(changesetDir, repoSlug, force) {
599
- return Effect["try"]({
600
- try: ()=>{
601
- const configPath = join(changesetDir, "config.json");
602
- if (force || !existsSync(configPath)) {
603
- const config = {
604
- ...DEFAULT_CONFIG,
605
- changelog: [
606
- CHANGELOG_ENTRY,
607
- {
608
- repo: repoSlug
609
- }
610
- ]
611
- };
612
- writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`);
613
- return force ? "Overwrote .changeset/config.json" : "Created .changeset/config.json";
614
- }
615
- const existing = JSON.parse(readFileSync(configPath, "utf-8"));
616
- const currentOptions = Array.isArray(existing.changelog) && "object" == typeof existing.changelog[1] && null !== existing.changelog[1] ? existing.changelog[1] : {};
617
- existing.changelog = [
618
- CHANGELOG_ENTRY,
619
- {
620
- ...currentOptions,
621
- repo: repoSlug
622
- }
623
- ];
624
- writeFileSync(configPath, `${JSON.stringify(existing, null, "\t")}\n`);
625
- return "Patched changelog in .changeset/config.json";
626
- },
627
- catch: (error)=>new InitError({
628
- step: ".changeset/config.json",
629
- reason: error instanceof Error ? error.message : String(error)
630
- })
531
+ function runReleaseSurface(cwd, pkgName, json) {
532
+ return Effect.gen(function*() {
533
+ const inspector = yield* release_surface_ConfigInspector;
534
+ const resolvedCwd = resolve(cwd);
535
+ const config = yield* inspector.inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", (err)=>{
536
+ process.exitCode = 1;
537
+ return Effect.fail(err);
538
+ }));
539
+ const scope = config.packages.find((p)=>p.name === pkgName);
540
+ if (!scope) {
541
+ process.exitCode = 1;
542
+ return yield* Effect.fail(new ConfigurationError({
543
+ field: `packages["${pkgName}"]`,
544
+ reason: `Package "${pkgName}" is not declared in .changeset/config.json#packages. Declared packages: ${config.packages.map((p)=>p.name).join(", ") || "(none)"}.`
545
+ }));
546
+ }
547
+ const output = json ? JSON.stringify(scope, null, 2) : release_surface_renderHuman(scope);
548
+ yield* Effect.log(output);
631
549
  });
632
550
  }
633
- function detectLegacyVersionFiles(config) {
634
- if ("object" != typeof config || null === config) return false;
635
- const cfg = config;
636
- const changelog = cfg.changelog;
637
- if (!Array.isArray(changelog) || changelog.length < 2) return false;
638
- const options = changelog[1];
639
- if ("object" != typeof options || null === options) return false;
640
- return Array.isArray(options.versionFiles) && options.versionFiles.length > 0;
641
- }
642
- function legacyVersionFilesWarning(configPath) {
643
- return [
644
- `DEPRECATION: ${configPath} uses the legacy top-level \`versionFiles[]\` array.`,
645
- " Migrate each entry to `changelog[1].packages[<entry.package>].versionFiles`",
646
- " and remove the top-level field. Run `savvy changeset config show --json`",
647
- " to see the normalized form, or check the 0.9.0 release notes for examples.",
648
- " Removed in @savvy-web/changesets 1.0.0."
649
- ].join("\n");
650
- }
651
- function warnIfLegacyVersionFiles(changesetDir) {
551
+ const releaseSurfaceCommand = Command.make("release-surface", {
552
+ package: packageArg,
553
+ cwd: release_surface_cwdOption,
554
+ json: release_surface_jsonOption
555
+ }, ({ package: pkgName, cwd, json })=>runReleaseSurface(cwd, pkgName, json)).pipe(Command.withDescription("Print every path owned by a package — workspace dir, additionalScopes, versionFiles"));
556
+ const { ConfigInspector: config_gate_ConfigInspector } = Changesets;
557
+ function requireValidConfig(cwd) {
652
558
  return Effect.gen(function*() {
653
- const configPath = join(changesetDir, "config.json");
559
+ const projectDir = resolve(cwd);
560
+ const configPath = join(projectDir, ".changeset", "config.json");
654
561
  if (!existsSync(configPath)) return;
655
- let parsed;
656
- try {
657
- parsed = JSON.parse(readFileSync(configPath, "utf-8"));
658
- } catch {
659
- return;
660
- }
661
- if (detectLegacyVersionFiles(parsed)) yield* Effect.logWarning(legacyVersionFilesWarning(configPath));
562
+ const inspector = yield* config_gate_ConfigInspector;
563
+ yield* inspector.inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err)=>{
564
+ process.exitCode = 1;
565
+ return Effect.fail(err);
566
+ }));
662
567
  });
663
568
  }
664
- function handleBaseMarkdownlint(root) {
665
- const foundPath = findMarkdownlintConfig(root);
666
- if (!foundPath) return Effect.succeed(`Warning: no markdownlint config found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`);
569
+ const { ChangelogTransformer: ChangelogTransformer } = Changesets;
570
+ const fileArg = Args.file({
571
+ name: "file"
572
+ }).pipe(Args.withDefault("CHANGELOG.md"));
573
+ const transform_dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Print transformed output instead of writing"), Options.withDefault(false));
574
+ const checkOption = Options.boolean("check").pipe(Options.withAlias("c"), Options.withDescription("Exit 1 if file would change (for CI)"), Options.withDefault(false));
575
+ function runTransform(file, dryRun, check) {
667
576
  return Effect.gen(function*() {
668
- const fullPath = join(root, foundPath);
669
- let text;
670
- try {
671
- text = readFileSync(fullPath, "utf-8");
672
- } catch (error) {
673
- return yield* Effect.fail(new InitError({
674
- step: "markdownlint config",
675
- reason: error instanceof Error ? error.message : String(error)
676
- }));
677
- }
678
- let parsed = yield* parse(text);
679
- if (!Array.isArray(parsed.customRules) || !parsed.customRules.includes(CUSTOM_RULES_ENTRY)) if (Array.isArray(parsed.customRules)) {
680
- const currentArray = parsed.customRules;
681
- const edits = yield* modify(text, [
682
- "customRules",
683
- currentArray.length
684
- ], CUSTOM_RULES_ENTRY, {
685
- formattingOptions: JSONC_FORMAT
686
- });
687
- text = yield* applyEdits(text, edits);
688
- } else {
689
- const edits = yield* modify(text, [
690
- "customRules"
691
- ], [
692
- CUSTOM_RULES_ENTRY
693
- ], {
694
- formattingOptions: JSONC_FORMAT
695
- });
696
- text = yield* applyEdits(text, edits);
697
- }
698
- parsed = yield* parse(text);
699
- const currentConfig = parsed.config;
700
- if ("object" != typeof currentConfig || null === currentConfig) {
701
- const edits = yield* modify(text, [
702
- "config"
703
- ], {}, {
704
- formattingOptions: JSONC_FORMAT
705
- });
706
- text = yield* applyEdits(text, edits);
707
- }
708
- parsed = yield* parse(text);
709
- const config = parsed.config;
710
- for (const rule of RULE_NAMES)if (!(rule in config)) {
711
- const edits = yield* modify(text, [
712
- "config",
713
- rule
714
- ], false, {
715
- formattingOptions: JSONC_FORMAT
716
- });
717
- text = yield* applyEdits(text, edits);
718
- }
719
- try {
720
- writeFileSync(fullPath, text);
721
- } catch (error) {
722
- return yield* Effect.fail(new InitError({
723
- step: "markdownlint config",
724
- reason: error instanceof Error ? error.message : String(error)
725
- }));
577
+ const resolved = resolve(file);
578
+ yield* requireValidConfig(dirname(resolved));
579
+ const content = yield* Effect["try"](()=>readFileSync(resolved, "utf-8"));
580
+ const result = ChangelogTransformer.transformContent(content);
581
+ if (dryRun) return void (yield* Effect.log(result));
582
+ if (check) {
583
+ if (result !== content) {
584
+ yield* Effect.log(`${resolved} would be modified by transform.`);
585
+ process.exitCode = 1;
586
+ } else yield* Effect.log(`${resolved} is already formatted.`);
587
+ return;
726
588
  }
727
- return `Updated ${foundPath}`;
728
- }).pipe(Effect.catchAll((error)=>{
729
- if (error instanceof InitError) return Effect.fail(error);
730
- return Effect.fail(new InitError({
731
- step: "markdownlint config",
732
- reason: error instanceof Error ? error.message : String(error)
733
- }));
734
- }));
589
+ yield* Effect["try"](()=>writeFileSync(resolved, result, "utf-8"));
590
+ yield* Effect.log(`Transformed ${resolved}`);
591
+ });
735
592
  }
736
- function handleChangesetMarkdownlint(changesetDir, root, force) {
737
- return Effect["try"]({
738
- try: ()=>{
739
- const mdlintPath = join(changesetDir, ".markdownlint.json");
740
- const baseConfig = findMarkdownlintConfig(root);
741
- if (force || !existsSync(mdlintPath)) {
742
- const mdlintConfig = {};
743
- if (baseConfig) mdlintConfig.extends = `../${baseConfig}`;
744
- mdlintConfig.default = false;
745
- mdlintConfig.MD041 = false;
746
- for (const rule of RULE_NAMES)mdlintConfig[rule] = true;
747
- writeFileSync(mdlintPath, `${JSON.stringify(mdlintConfig, null, "\t")}\n`);
748
- return force ? "Overwrote .changeset/.markdownlint.json" : "Created .changeset/.markdownlint.json";
749
- }
750
- const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
751
- for (const rule of RULE_NAMES)existing[rule] = true;
752
- writeFileSync(mdlintPath, `${JSON.stringify(existing, null, "\t")}\n`);
753
- return "Patched rules in .changeset/.markdownlint.json";
754
- },
755
- catch: (error)=>new InitError({
756
- step: ".changeset/.markdownlint.json",
757
- reason: error instanceof Error ? error.message : String(error)
758
- })
593
+ const transformCommand = Command.make("transform", {
594
+ file: fileArg,
595
+ dryRun: transform_dryRunOption,
596
+ check: checkOption
597
+ }, ({ file, dryRun, check })=>runTransform(file, dryRun, check)).pipe(Command.withDescription("Post-process CHANGELOG.md"));
598
+ const { ChangesetLinter: validate_file_ChangesetLinter } = Changesets;
599
+ const validate_file_fileArg = Args.file({
600
+ name: "file"
601
+ });
602
+ function runValidateFile(filePath) {
603
+ return Effect.gen(function*() {
604
+ const result = yield* Effect["try"](()=>validate_file_ChangesetLinter.validateFile(filePath)).pipe(Effect.catchAll((error)=>Effect.gen(function*() {
605
+ yield* Effect.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
606
+ process.exitCode = 1;
607
+ return null;
608
+ })));
609
+ if (null === result) return;
610
+ for (const msg of result)yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
611
+ if (result.length > 0) process.exitCode = 1;
612
+ else yield* Effect.log("Valid.");
759
613
  });
760
614
  }
761
- function checkChangesetDir(root) {
762
- const dir = join(root, ".changeset");
763
- if (!existsSync(dir)) return [
764
- {
765
- file: ".changeset/",
766
- message: "directory does not exist"
767
- }
768
- ];
769
- return [];
615
+ const validateFileCommand = Command.make("validate-file", {
616
+ file: validate_file_fileArg
617
+ }, ({ file })=>runValidateFile(file)).pipe(Command.withDescription("Validate a single changeset file"));
618
+ const { ChangelogTransformer: version_ChangelogTransformer, ConfigInspector: version_ConfigInspector, VersionFileError: VersionFileError, VersionFiles: VersionFiles } = Changesets;
619
+ function getChangesetVersionCommand(pm) {
620
+ switch(pm){
621
+ case "pnpm":
622
+ return "pnpm exec changeset version";
623
+ case "yarn":
624
+ return "yarn exec changeset version";
625
+ case "bun":
626
+ return "bun x changeset version";
627
+ default:
628
+ return "npx changeset version";
629
+ }
770
630
  }
771
- function checkConfig(changesetDir, repoSlug) {
772
- const configPath = join(changesetDir, "config.json");
773
- if (!existsSync(configPath)) return [
774
- {
775
- file: ".changeset/config.json",
776
- message: "file does not exist"
777
- }
778
- ];
779
- try {
780
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
781
- const issues = [];
782
- const changelog = config.changelog;
783
- const entry = Array.isArray(changelog) ? changelog[0] : changelog;
784
- const repo = Array.isArray(changelog) ? changelog[1]?.repo : void 0;
785
- if (entry !== CHANGELOG_ENTRY) issues.push({
786
- file: ".changeset/config.json",
787
- message: `changelog formatter is "${entry}", expected "${CHANGELOG_ENTRY}"`
788
- });
789
- else if (repo !== repoSlug) issues.push({
790
- file: ".changeset/config.json",
791
- message: `changelog repo is "${repo ?? "(not set)"}", expected "${repoSlug}"`
792
- });
793
- const options = Array.isArray(changelog) ? changelog[1] : void 0;
794
- if (options && "object" == typeof options && "versionFiles" in options) {
795
- const result = Schema.decodeUnknownEither(LegacyVersionFilesSchema)(options.versionFiles);
796
- if ("Left" === result._tag) issues.push({
797
- file: ".changeset/config.json",
798
- message: "versionFiles config is invalid"
631
+ const version_dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Skip changeset version, only transform existing CHANGELOGs"), Options.withDefault(false));
632
+ function runVersion(dryRun) {
633
+ return Effect.gen(function*() {
634
+ const cwd = process.cwd();
635
+ const detector = yield* PackageManagerDetector;
636
+ const detected = yield* detector.detect(cwd).pipe(Effect.catchAll(()=>Effect.succeed({
637
+ type: "npm",
638
+ version: void 0
639
+ })));
640
+ const pm = detected.type;
641
+ yield* Effect.log(`Detected package manager: ${pm}`);
642
+ yield* requireValidConfig(cwd);
643
+ if (dryRun) yield* Effect.log("Dry run: skipping changeset version");
644
+ else {
645
+ const cmd = getChangesetVersionCommand(pm);
646
+ yield* Effect.log(`Running: ${cmd}`);
647
+ yield* Effect["try"]({
648
+ try: ()=>execSync(cmd, {
649
+ cwd,
650
+ stdio: "inherit"
651
+ }),
652
+ catch: (error)=>new Error(`changeset version failed: ${error instanceof Error ? error.message : String(error)}`)
799
653
  });
800
654
  }
801
- if (detectLegacyVersionFiles(config)) issues.push({
802
- file: ".changeset/config.json",
803
- message: "uses the legacy top-level `versionFiles[]` array (deprecated; removed in 1.0.0). Migrate to `packages[<name>].versionFiles`."
804
- });
805
- return issues;
806
- } catch {
807
- return [
808
- {
809
- file: ".changeset/config.json",
810
- message: "could not parse file"
655
+ const discovery = yield* WorkspaceDiscovery;
656
+ const packages = yield* discovery.listPackages().pipe(Effect.catchAll(()=>Effect.succeed([])));
657
+ const changelogs = [];
658
+ const seen = new Set();
659
+ const resolvedCwd = resolve(cwd);
660
+ for (const pkg of packages){
661
+ const changelogPath = join(pkg.path, "CHANGELOG.md");
662
+ if (existsSync(changelogPath) && !seen.has(pkg.path)) {
663
+ seen.add(pkg.path);
664
+ changelogs.push({
665
+ name: pkg.name,
666
+ path: pkg.path,
667
+ changelogPath
668
+ });
811
669
  }
812
- ];
813
- }
814
- }
815
- function checkBaseMarkdownlint(root) {
816
- const foundPath = findMarkdownlintConfig(root);
817
- if (!foundPath) return [
818
- {
819
- file: "markdownlint config",
820
- message: `not found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`
821
670
  }
822
- ];
823
- try {
824
- const raw = readFileSync(join(root, foundPath), "utf-8");
825
- const parsed = Effect.runSync(parse(raw));
826
- const issues = [];
827
- if (!Array.isArray(parsed.customRules) || !parsed.customRules.includes(CUSTOM_RULES_ENTRY)) issues.push({
828
- file: foundPath,
829
- message: `customRules does not include ${CUSTOM_RULES_ENTRY}`
830
- });
831
- const config = parsed.config;
832
- if ("object" != typeof config || null === config) issues.push({
833
- file: foundPath,
834
- message: "config section is missing"
835
- });
836
- else for (const rule of RULE_NAMES)if (!(rule in config)) issues.push({
837
- file: foundPath,
838
- message: `rule "${rule}" is not configured`
671
+ if (!seen.has(resolvedCwd)) {
672
+ const rootChangelog = join(resolvedCwd, "CHANGELOG.md");
673
+ if (existsSync(rootChangelog)) {
674
+ let rootName = "root";
675
+ try {
676
+ const pkg = JSON.parse(readFileSync(join(resolvedCwd, "package.json"), "utf-8"));
677
+ if (pkg.name) rootName = pkg.name;
678
+ } catch {}
679
+ changelogs.push({
680
+ name: rootName,
681
+ path: resolvedCwd,
682
+ changelogPath: rootChangelog
683
+ });
684
+ }
685
+ }
686
+ if (0 === changelogs.length) yield* Effect.log("No CHANGELOG.md files found.");
687
+ else {
688
+ yield* Effect.log(`Found ${changelogs.length} CHANGELOG.md file(s)`);
689
+ for (const entry of changelogs){
690
+ yield* Effect["try"]({
691
+ try: ()=>version_ChangelogTransformer.transformFile(entry.changelogPath),
692
+ catch: (error)=>new Error(`Failed to transform ${entry.changelogPath}: ${error instanceof Error ? error.message : String(error)}`)
693
+ });
694
+ yield* Effect.log(`Transformed ${entry.name} → ${entry.changelogPath}`);
695
+ }
696
+ }
697
+ const configPath = join(resolvedCwd, ".changeset", "config.json");
698
+ if (!existsSync(configPath)) return;
699
+ const inspector = yield* version_ConfigInspector;
700
+ const inspected = yield* inspector.inspect(resolvedCwd);
701
+ const scopesWithVersionFiles = inspected.packages.filter((p)=>p.versionFiles.length > 0).map((p)=>{
702
+ const fresh = readPackageVersionFromDisk(p.workspaceDir);
703
+ return fresh && fresh !== p.version ? {
704
+ ...p,
705
+ version: fresh
706
+ } : p;
839
707
  });
840
- return issues;
841
- } catch {
842
- return [
843
- {
844
- file: foundPath,
845
- message: "could not parse file"
708
+ if (0 === scopesWithVersionFiles.length) return;
709
+ yield* Effect.log(`Found ${scopesWithVersionFiles.length} package${1 === scopesWithVersionFiles.length ? "" : "s"} with versionFiles`);
710
+ const updates = yield* Effect["try"]({
711
+ try: ()=>VersionFiles.processResolvedVersionFiles(scopesWithVersionFiles, dryRun),
712
+ catch: (error)=>{
713
+ const message = error instanceof Error ? error.message : String(error);
714
+ return new VersionFileError({
715
+ filePath: message.match(/Failed to update (.+?):/)?.[1] ?? cwd,
716
+ reason: message
717
+ });
846
718
  }
847
- ];
848
- }
849
- }
850
- function checkChangesetMarkdownlint(changesetDir) {
851
- const mdlintPath = join(changesetDir, ".markdownlint.json");
852
- if (!existsSync(mdlintPath)) return [
853
- {
854
- file: ".changeset/.markdownlint.json",
855
- message: "file does not exist"
719
+ });
720
+ for (const update of updates){
721
+ const action = dryRun ? "Would update" : "Updated";
722
+ yield* Effect.log(`${action} ${update.filePath} → ${update.version}`);
856
723
  }
857
- ];
724
+ });
725
+ }
726
+ function readPackageVersionFromDisk(workspaceDir) {
858
727
  try {
859
- const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
860
- const issues = [];
861
- for (const rule of RULE_NAMES)if (true !== existing[rule]) issues.push({
862
- file: ".changeset/.markdownlint.json",
863
- message: `rule "${rule}" is not enabled`
864
- });
865
- return issues;
728
+ const pkg = JSON.parse(readFileSync(join(workspaceDir, "package.json"), "utf-8"));
729
+ return pkg.version ?? null;
866
730
  } catch {
867
- return [
868
- {
869
- file: ".changeset/.markdownlint.json",
870
- message: "could not parse file"
871
- }
872
- ];
731
+ return null;
873
732
  }
874
733
  }
875
- function runChangesetInit(opts) {
876
- const { force, quiet, skipMarkdownlint, check } = opts;
734
+ const versionCommand = Command.make("version", {
735
+ dryRun: version_dryRunOption
736
+ }, ({ dryRun })=>runVersion(dryRun)).pipe(Command.withDescription("Run changeset version and transform all CHANGELOGs"));
737
+ const { ChangesetLinter: check_ChangesetLinter } = Changesets;
738
+ const check_dirArg = Args.directory({
739
+ name: "dir"
740
+ }).pipe(Args.withDefault(".changeset"));
741
+ function runChangesetCheck(dir) {
877
742
  return Effect.gen(function*() {
878
- const root = yield* resolveWorkspaceRoot(process.cwd());
879
- const repo = detectGitHubRepo(root);
880
- if (!repo && !quiet) yield* Effect.log("Warning: could not detect GitHub repo from git remote, using placeholder");
881
- const repoSlug = repo ?? "owner/repo";
882
- if (check) {
883
- const changesetDir = join(root, ".changeset");
884
- const issues = [
885
- ...checkChangesetDir(root),
886
- ...checkConfig(changesetDir, repoSlug),
887
- ...skipMarkdownlint ? [] : checkBaseMarkdownlint(root),
888
- ...checkChangesetMarkdownlint(changesetDir)
889
- ];
890
- if (0 === issues.length) return void (yield* Effect.log("All @savvy-web/changesets config files are up to date."));
891
- for (const issue of issues)yield* Effect.logWarning(`${issue.file}: ${issue.message}`);
892
- yield* Effect.logWarning('Run "savvy changeset init --force" to fix.');
893
- return;
743
+ const resolved = resolve(dir);
744
+ const messages = yield* Effect["try"]({
745
+ try: ()=>check_ChangesetLinter.validate(resolved),
746
+ catch: (e)=>new Error(String(e))
747
+ });
748
+ const byFile = new Map();
749
+ for (const msg of messages){
750
+ const existing = byFile.get(msg.file);
751
+ if (existing) existing.push(msg);
752
+ else byFile.set(msg.file, [
753
+ msg
754
+ ]);
894
755
  }
895
- const changesetDir = yield* ensureChangesetDir(root);
896
- yield* Effect.log("Ensured .changeset/ directory");
897
- const errors = [];
898
- const configResult = yield* handleConfig(changesetDir, repoSlug, force).pipe(Effect.either);
899
- if ("Right" === configResult._tag) {
900
- yield* Effect.log(configResult.right);
901
- if (!quiet) yield* warnIfLegacyVersionFiles(changesetDir);
902
- } else errors.push(configResult.left);
903
- if (!skipMarkdownlint) {
904
- const baseResult = yield* handleBaseMarkdownlint(root).pipe(Effect.either);
905
- if ("Right" === baseResult._tag) yield* Effect.log(baseResult.right);
906
- else errors.push(baseResult.left);
756
+ for (const [file, fileMessages] of byFile){
757
+ yield* Effect.log(`\n${file}`);
758
+ for (const msg of fileMessages)yield* Effect.log(` ${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
907
759
  }
908
- const mdlintResult = yield* handleChangesetMarkdownlint(changesetDir, root, force).pipe(Effect.either);
909
- if ("Right" === mdlintResult._tag) yield* Effect.log(mdlintResult.right);
910
- else errors.push(mdlintResult.left);
911
- if (errors.length > 0) {
912
- for (const err of errors)yield* Effect.logError(err.message);
913
- if (!quiet) process.exitCode = 1;
914
- return;
760
+ const errorCount = messages.length;
761
+ const filesWithErrors = byFile.size;
762
+ if (errorCount > 0) {
763
+ yield* Effect.log(`\n${filesWithErrors} file(s) with errors, ${errorCount} error(s) found`);
764
+ process.exitCode = 1;
765
+ } else yield* Effect.log("All changeset files passed validation.");
766
+ });
767
+ }
768
+ Command.make("check", {
769
+ dir: check_dirArg
770
+ }, ({ dir })=>runChangesetCheck(dir)).pipe(Command.withDescription("Full changeset validation with summary"));
771
+ const { LegacyVersionFilesSchema: LegacyVersionFilesSchema } = Changesets;
772
+ const CHANGELOG_ENTRY = "@savvy-web/silk/changesets/changelog";
773
+ const LEGACY_CHANGELOG_ENTRY = "@savvy-web/changesets/changelog";
774
+ const ACCEPTED_CHANGELOG_ENTRIES = [
775
+ CHANGELOG_ENTRY,
776
+ LEGACY_CHANGELOG_ENTRY
777
+ ];
778
+ const CUSTOM_RULES_ENTRY = "@savvy-web/silk/changesets/markdownlint";
779
+ const LEGACY_CUSTOM_RULES_ENTRY = "@savvy-web/changesets/markdownlint";
780
+ const ACCEPTED_CUSTOM_RULES_ENTRIES = [
781
+ CUSTOM_RULES_ENTRY,
782
+ LEGACY_CUSTOM_RULES_ENTRY
783
+ ];
784
+ const MARKDOWNLINT_CONFIG_PATHS = [
785
+ "lib/configs/.markdownlint-cli2.jsonc",
786
+ "lib/configs/.markdownlint-cli2.json",
787
+ ".markdownlint-cli2.jsonc",
788
+ ".markdownlint-cli2.json"
789
+ ];
790
+ const RULE_NAMES = [
791
+ "changeset-heading-hierarchy",
792
+ "changeset-required-sections",
793
+ "changeset-content-structure",
794
+ "changeset-uncategorized-content",
795
+ "changeset-dependency-table-format"
796
+ ];
797
+ const DEFAULT_CONFIG = {
798
+ $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
799
+ changelog: [
800
+ CHANGELOG_ENTRY,
801
+ {
802
+ repo: "owner/repo"
915
803
  }
916
- yield* Effect.log("Init complete.");
917
- }).pipe(Effect.catchAll((error)=>Effect.gen(function*() {
918
- if (!quiet) {
919
- yield* Effect.logError(error instanceof InitError ? error.message : `Init failed: ${String(error)}`);
920
- process.exitCode = 1;
921
- }
922
- })));
804
+ ],
805
+ commit: false,
806
+ access: "restricted",
807
+ baseBranch: "main",
808
+ updateInternalDependencies: "patch",
809
+ ignore: [],
810
+ privatePackages: {
811
+ tag: true,
812
+ version: true
813
+ }
814
+ };
815
+ const InitErrorBase = Data.TaggedError("InitError");
816
+ class InitError extends InitErrorBase {
817
+ get message() {
818
+ return `Init failed at ${this.step}: ${this.reason}`;
819
+ }
923
820
  }
924
- const initCommand = Command.make("init", {
925
- force: forceOption,
926
- quiet: quietOption,
927
- skipMarkdownlint: skipMarkdownlintOption,
928
- check: checkOption
929
- }, (opts)=>runChangesetInit(opts)).pipe(Command.withDescription("Bootstrap a repo for @savvy-web/changesets"));
930
- const { ChangesetLinter: lint_ChangesetLinter } = Changesets;
931
- const lint_dirArg = Args.directory({
932
- name: "dir"
933
- }).pipe(Args.withDefault(".changeset"));
934
- const lint_quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Only output errors, no summary"), Options.withDefault(false));
935
- function runLint(dir, quiet) {
936
- return Effect.gen(function*() {
937
- const resolved = resolve(dir);
938
- const messages = yield* Effect["try"](()=>lint_ChangesetLinter.validate(resolved));
939
- for (const msg of messages)yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
940
- if (!quiet && 0 === messages.length) yield* Effect.log("No lint errors found.");
941
- if (messages.length > 0) process.exitCode = 1;
821
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite existing config files"));
822
+ const init_quietOption = Options.boolean("quiet").pipe(Options.withAlias("q"), Options.withDescription("Silence warnings, always exit 0"));
823
+ const skipMarkdownlintOption = Options.boolean("skip-markdownlint").pipe(Options.withDescription("Skip registering rules in base markdownlint config"));
824
+ const init_checkOption = Options.boolean("check").pipe(Options.withDescription("Check configuration without writing (for postinstall scripts)"));
825
+ function detectGitHubRepo(cwd) {
826
+ try {
827
+ const url = execSync("git remote get-url origin", {
828
+ cwd,
829
+ encoding: "utf-8"
830
+ }).trim();
831
+ const https = url.match(/github\.com\/([^/]+)\/([^/.]+)/);
832
+ if (https) return `${https[1]}/${https[2]}`;
833
+ const ssh = url.match(/github\.com:([^/]+)\/([^/.]+)/);
834
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
835
+ } catch {}
836
+ return null;
837
+ }
838
+ const JSONC_FORMAT = {
839
+ tabSize: 1,
840
+ insertSpaces: false
841
+ };
842
+ function resolveWorkspaceRoot(cwd) {
843
+ return WorkspaceRoot.pipe(Effect.flatMap((wr)=>wr.find(cwd)), Effect.catchAll(()=>Effect.succeed(cwd)));
844
+ }
845
+ function findMarkdownlintConfig(root) {
846
+ for (const configPath of MARKDOWNLINT_CONFIG_PATHS)if (existsSync(join(root, configPath))) return configPath;
847
+ return null;
848
+ }
849
+ function ensureChangesetDir(root) {
850
+ return Effect["try"]({
851
+ try: ()=>{
852
+ const dir = join(root, ".changeset");
853
+ mkdirSync(dir, {
854
+ recursive: true
855
+ });
856
+ return dir;
857
+ },
858
+ catch: (error)=>new InitError({
859
+ step: ".changeset directory",
860
+ reason: error instanceof Error ? error.message : String(error)
861
+ })
862
+ });
863
+ }
864
+ function handleConfig(changesetDir, repoSlug, force) {
865
+ return Effect["try"]({
866
+ try: ()=>{
867
+ const configPath = join(changesetDir, "config.json");
868
+ if (force || !existsSync(configPath)) {
869
+ const config = {
870
+ ...DEFAULT_CONFIG,
871
+ changelog: [
872
+ CHANGELOG_ENTRY,
873
+ {
874
+ repo: repoSlug
875
+ }
876
+ ]
877
+ };
878
+ writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`);
879
+ return force ? "Overwrote .changeset/config.json" : "Created .changeset/config.json";
880
+ }
881
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
882
+ const currentOptions = Array.isArray(existing.changelog) && "object" == typeof existing.changelog[1] && null !== existing.changelog[1] ? existing.changelog[1] : {};
883
+ existing.changelog = [
884
+ CHANGELOG_ENTRY,
885
+ {
886
+ ...currentOptions,
887
+ repo: repoSlug
888
+ }
889
+ ];
890
+ writeFileSync(configPath, `${JSON.stringify(existing, null, "\t")}\n`);
891
+ return "Patched changelog in .changeset/config.json";
892
+ },
893
+ catch: (error)=>new InitError({
894
+ step: ".changeset/config.json",
895
+ reason: error instanceof Error ? error.message : String(error)
896
+ })
942
897
  });
943
898
  }
944
- const lintCommand = Command.make("lint", {
945
- dir: lint_dirArg,
946
- quiet: lint_quietOption
947
- }, ({ dir, quiet })=>runLint(dir, quiet)).pipe(Command.withDescription("Validate changeset files"));
948
- const { ConfigInspector: release_surface_ConfigInspector, ConfigurationError: ConfigurationError } = Changesets;
949
- const packageArg = Args.text({
950
- name: "package"
951
- });
952
- const release_surface_cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
953
- const release_surface_jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit JSON instead of human-readable output"), Options.withDefault(false));
954
- function release_surface_renderHuman(pkg) {
955
- const lines = [];
956
- lines.push(`Package: ${pkg.name} v${pkg.version}`);
957
- lines.push(`Workspace: ${pkg.workspaceDir}`);
958
- if (0 === pkg.additionalScopes.length && 0 === pkg.versionFiles.length) {
959
- lines.push("");
960
- lines.push("(no additionalScopes or versionFiles — workspace dir is the entire release surface)");
961
- return lines.join("\n");
962
- }
963
- if (pkg.additionalScopes.length > 0) {
964
- lines.push("");
965
- lines.push(`additionalScopes (${pkg.additionalScopes.length} glob${1 === pkg.additionalScopes.length ? "" : "s"}):`);
966
- for (const g of pkg.additionalScopes)lines.push(` - ${g}`);
967
- lines.push(`Resolved files (${pkg.additionalScopeFiles.length}):`);
968
- for (const f of pkg.additionalScopeFiles)lines.push(` ${f}`);
969
- }
970
- if (pkg.versionFiles.length > 0) {
971
- lines.push("");
972
- lines.push(`versionFiles (${pkg.versionFiles.length}):`);
973
- for (const vf of pkg.versionFiles){
974
- lines.push(` ${vf.glob} → ${vf.paths.join(", ")}`);
975
- for (const f of vf.matchedFiles)lines.push(` ${f}`);
976
- }
977
- }
978
- return lines.join("\n");
899
+ function detectLegacyVersionFiles(config) {
900
+ if ("object" != typeof config || null === config) return false;
901
+ const cfg = config;
902
+ const changelog = cfg.changelog;
903
+ if (!Array.isArray(changelog) || changelog.length < 2) return false;
904
+ const options = changelog[1];
905
+ if ("object" != typeof options || null === options) return false;
906
+ return Array.isArray(options.versionFiles) && options.versionFiles.length > 0;
979
907
  }
980
- function runReleaseSurface(cwd, pkgName, json) {
981
- return Effect.gen(function*() {
982
- const inspector = yield* release_surface_ConfigInspector;
983
- const resolvedCwd = resolve(cwd);
984
- const config = yield* inspector.inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", (err)=>{
985
- process.exitCode = 1;
986
- return Effect.fail(err);
987
- }));
988
- const scope = config.packages.find((p)=>p.name === pkgName);
989
- if (!scope) {
990
- process.exitCode = 1;
991
- return yield* Effect.fail(new ConfigurationError({
992
- field: `packages["${pkgName}"]`,
993
- reason: `Package "${pkgName}" is not declared in .changeset/config.json#packages. Declared packages: ${config.packages.map((p)=>p.name).join(", ") || "(none)"}.`
994
- }));
995
- }
996
- const output = json ? JSON.stringify(scope, null, 2) : release_surface_renderHuman(scope);
997
- yield* Effect.log(output);
998
- });
908
+ function legacyVersionFilesWarning(configPath) {
909
+ return [
910
+ `DEPRECATION: ${configPath} uses the legacy top-level \`versionFiles[]\` array.`,
911
+ " Migrate each entry to `changelog[1].packages[<entry.package>].versionFiles`",
912
+ " and remove the top-level field. Run `savvy changeset config show --json`",
913
+ " to see the normalized form, or check the 0.9.0 release notes for examples.",
914
+ " Removed in @savvy-web/changesets 1.0.0."
915
+ ].join("\n");
999
916
  }
1000
- const releaseSurfaceCommand = Command.make("release-surface", {
1001
- package: packageArg,
1002
- cwd: release_surface_cwdOption,
1003
- json: release_surface_jsonOption
1004
- }, ({ package: pkgName, cwd, json })=>runReleaseSurface(cwd, pkgName, json)).pipe(Command.withDescription("Print every path owned by a package — workspace dir, additionalScopes, versionFiles"));
1005
- const { ConfigInspector: config_gate_ConfigInspector } = Changesets;
1006
- function requireValidConfig(cwd) {
917
+ function warnIfLegacyVersionFiles(changesetDir) {
1007
918
  return Effect.gen(function*() {
1008
- const projectDir = resolve(cwd);
1009
- const configPath = join(projectDir, ".changeset", "config.json");
919
+ const configPath = join(changesetDir, "config.json");
1010
920
  if (!existsSync(configPath)) return;
1011
- const inspector = yield* config_gate_ConfigInspector;
1012
- yield* inspector.inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err)=>{
1013
- process.exitCode = 1;
1014
- return Effect.fail(err);
1015
- }));
1016
- });
1017
- }
1018
- const { ChangelogTransformer: ChangelogTransformer } = Changesets;
1019
- const fileArg = Args.file({
1020
- name: "file"
1021
- }).pipe(Args.withDefault("CHANGELOG.md"));
1022
- const transform_dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Print transformed output instead of writing"), Options.withDefault(false));
1023
- const transform_checkOption = Options.boolean("check").pipe(Options.withAlias("c"), Options.withDescription("Exit 1 if file would change (for CI)"), Options.withDefault(false));
1024
- function runTransform(file, dryRun, check) {
1025
- return Effect.gen(function*() {
1026
- const resolved = resolve(file);
1027
- yield* requireValidConfig(dirname(resolved));
1028
- const content = yield* Effect["try"](()=>readFileSync(resolved, "utf-8"));
1029
- const result = ChangelogTransformer.transformContent(content);
1030
- if (dryRun) return void (yield* Effect.log(result));
1031
- if (check) {
1032
- if (result !== content) {
1033
- yield* Effect.log(`${resolved} would be modified by transform.`);
1034
- process.exitCode = 1;
1035
- } else yield* Effect.log(`${resolved} is already formatted.`);
921
+ let parsed;
922
+ try {
923
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
924
+ } catch {
1036
925
  return;
1037
926
  }
1038
- yield* Effect["try"](()=>writeFileSync(resolved, result, "utf-8"));
1039
- yield* Effect.log(`Transformed ${resolved}`);
1040
- });
1041
- }
1042
- const transformCommand = Command.make("transform", {
1043
- file: fileArg,
1044
- dryRun: transform_dryRunOption,
1045
- check: transform_checkOption
1046
- }, ({ file, dryRun, check })=>runTransform(file, dryRun, check)).pipe(Command.withDescription("Post-process CHANGELOG.md"));
1047
- const { ChangesetLinter: validate_file_ChangesetLinter } = Changesets;
1048
- const validate_file_fileArg = Args.file({
1049
- name: "file"
1050
- });
1051
- function runValidateFile(filePath) {
1052
- return Effect.gen(function*() {
1053
- const result = yield* Effect["try"](()=>validate_file_ChangesetLinter.validateFile(filePath)).pipe(Effect.catchAll((error)=>Effect.gen(function*() {
1054
- yield* Effect.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
1055
- process.exitCode = 1;
1056
- return null;
1057
- })));
1058
- if (null === result) return;
1059
- for (const msg of result)yield* Effect.log(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
1060
- if (result.length > 0) process.exitCode = 1;
1061
- else yield* Effect.log("Valid.");
927
+ if (detectLegacyVersionFiles(parsed)) yield* Effect.logWarning(legacyVersionFilesWarning(configPath));
1062
928
  });
1063
929
  }
1064
- const validateFileCommand = Command.make("validate-file", {
1065
- file: validate_file_fileArg
1066
- }, ({ file })=>runValidateFile(file)).pipe(Command.withDescription("Validate a single changeset file"));
1067
- const { ChangelogTransformer: version_ChangelogTransformer, ConfigInspector: version_ConfigInspector, VersionFileError: VersionFileError, VersionFiles: VersionFiles } = Changesets;
1068
- function getChangesetVersionCommand(pm) {
1069
- switch(pm){
1070
- case "pnpm":
1071
- return "pnpm exec changeset version";
1072
- case "yarn":
1073
- return "yarn exec changeset version";
1074
- case "bun":
1075
- return "bun x changeset version";
1076
- default:
1077
- return "npx changeset version";
1078
- }
1079
- }
1080
- const version_dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Skip changeset version, only transform existing CHANGELOGs"), Options.withDefault(false));
1081
- function runVersion(dryRun) {
930
+ function handleBaseMarkdownlint(root) {
931
+ const foundPath = findMarkdownlintConfig(root);
932
+ if (!foundPath) return Effect.succeed(`Warning: no markdownlint config found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`);
1082
933
  return Effect.gen(function*() {
1083
- const cwd = process.cwd();
1084
- const detector = yield* PackageManagerDetector;
1085
- const detected = yield* detector.detect(cwd).pipe(Effect.catchAll(()=>Effect.succeed({
1086
- type: "npm",
1087
- version: void 0
1088
- })));
1089
- const pm = detected.type;
1090
- yield* Effect.log(`Detected package manager: ${pm}`);
1091
- yield* requireValidConfig(cwd);
1092
- if (dryRun) yield* Effect.log("Dry run: skipping changeset version");
1093
- else {
1094
- const cmd = getChangesetVersionCommand(pm);
1095
- yield* Effect.log(`Running: ${cmd}`);
1096
- yield* Effect["try"]({
1097
- try: ()=>execSync(cmd, {
1098
- cwd,
1099
- stdio: "inherit"
1100
- }),
1101
- catch: (error)=>new Error(`changeset version failed: ${error instanceof Error ? error.message : String(error)}`)
1102
- });
934
+ const fullPath = join(root, foundPath);
935
+ let text;
936
+ try {
937
+ text = readFileSync(fullPath, "utf-8");
938
+ } catch (error) {
939
+ return yield* Effect.fail(new InitError({
940
+ step: "markdownlint config",
941
+ reason: error instanceof Error ? error.message : String(error)
942
+ }));
1103
943
  }
1104
- const discovery = yield* WorkspaceDiscovery;
1105
- const packages = yield* discovery.listPackages().pipe(Effect.catchAll(()=>Effect.succeed([])));
1106
- const changelogs = [];
1107
- const seen = new Set();
1108
- const resolvedCwd = resolve(cwd);
1109
- for (const pkg of packages){
1110
- const changelogPath = join(pkg.path, "CHANGELOG.md");
1111
- if (existsSync(changelogPath) && !seen.has(pkg.path)) {
1112
- seen.add(pkg.path);
1113
- changelogs.push({
1114
- name: pkg.name,
1115
- path: pkg.path,
1116
- changelogPath
944
+ let parsed = yield* parse(text);
945
+ const currentRules = Array.isArray(parsed.customRules) ? parsed.customRules : null;
946
+ if (null === currentRules) {
947
+ const edits = yield* modify(text, [
948
+ "customRules"
949
+ ], [
950
+ CUSTOM_RULES_ENTRY
951
+ ], {
952
+ formattingOptions: JSONC_FORMAT
953
+ });
954
+ text = yield* applyEdits(text, edits);
955
+ } else {
956
+ const desired = currentRules.filter((r)=>r !== LEGACY_CUSTOM_RULES_ENTRY && r !== CUSTOM_RULES_ENTRY);
957
+ desired.push(CUSTOM_RULES_ENTRY);
958
+ const changed = desired.length !== currentRules.length || desired.some((r, i)=>r !== currentRules[i]);
959
+ if (changed) {
960
+ const edits = yield* modify(text, [
961
+ "customRules"
962
+ ], desired, {
963
+ formattingOptions: JSONC_FORMAT
1117
964
  });
965
+ text = yield* applyEdits(text, edits);
966
+ }
967
+ }
968
+ parsed = yield* parse(text);
969
+ const currentConfig = parsed.config;
970
+ if ("object" != typeof currentConfig || null === currentConfig) {
971
+ const edits = yield* modify(text, [
972
+ "config"
973
+ ], {}, {
974
+ formattingOptions: JSONC_FORMAT
975
+ });
976
+ text = yield* applyEdits(text, edits);
977
+ }
978
+ parsed = yield* parse(text);
979
+ const config = parsed.config;
980
+ for (const rule of RULE_NAMES)if (!(rule in config)) {
981
+ const edits = yield* modify(text, [
982
+ "config",
983
+ rule
984
+ ], false, {
985
+ formattingOptions: JSONC_FORMAT
986
+ });
987
+ text = yield* applyEdits(text, edits);
988
+ }
989
+ try {
990
+ writeFileSync(fullPath, text);
991
+ } catch (error) {
992
+ return yield* Effect.fail(new InitError({
993
+ step: "markdownlint config",
994
+ reason: error instanceof Error ? error.message : String(error)
995
+ }));
996
+ }
997
+ return `Updated ${foundPath}`;
998
+ }).pipe(Effect.catchAll((error)=>{
999
+ if (error instanceof InitError) return Effect.fail(error);
1000
+ return Effect.fail(new InitError({
1001
+ step: "markdownlint config",
1002
+ reason: error instanceof Error ? error.message : String(error)
1003
+ }));
1004
+ }));
1005
+ }
1006
+ function handleChangesetMarkdownlint(changesetDir, root, force) {
1007
+ return Effect["try"]({
1008
+ try: ()=>{
1009
+ const mdlintPath = join(changesetDir, ".markdownlint.json");
1010
+ const baseConfig = findMarkdownlintConfig(root);
1011
+ if (force || !existsSync(mdlintPath)) {
1012
+ const mdlintConfig = {};
1013
+ if (baseConfig) mdlintConfig.extends = `../${baseConfig}`;
1014
+ mdlintConfig.default = false;
1015
+ mdlintConfig.MD041 = false;
1016
+ for (const rule of RULE_NAMES)mdlintConfig[rule] = true;
1017
+ writeFileSync(mdlintPath, `${JSON.stringify(mdlintConfig, null, "\t")}\n`);
1018
+ return force ? "Overwrote .changeset/.markdownlint.json" : "Created .changeset/.markdownlint.json";
1118
1019
  }
1020
+ const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
1021
+ for (const rule of RULE_NAMES)existing[rule] = true;
1022
+ writeFileSync(mdlintPath, `${JSON.stringify(existing, null, "\t")}\n`);
1023
+ return "Patched rules in .changeset/.markdownlint.json";
1024
+ },
1025
+ catch: (error)=>new InitError({
1026
+ step: ".changeset/.markdownlint.json",
1027
+ reason: error instanceof Error ? error.message : String(error)
1028
+ })
1029
+ });
1030
+ }
1031
+ function checkChangesetDir(root) {
1032
+ const dir = join(root, ".changeset");
1033
+ if (!existsSync(dir)) return [
1034
+ {
1035
+ file: ".changeset/",
1036
+ message: "directory does not exist"
1119
1037
  }
1120
- if (!seen.has(resolvedCwd)) {
1121
- const rootChangelog = join(resolvedCwd, "CHANGELOG.md");
1122
- if (existsSync(rootChangelog)) {
1123
- let rootName = "root";
1124
- try {
1125
- const pkg = JSON.parse(readFileSync(join(resolvedCwd, "package.json"), "utf-8"));
1126
- if (pkg.name) rootName = pkg.name;
1127
- } catch {}
1128
- changelogs.push({
1129
- name: rootName,
1130
- path: resolvedCwd,
1131
- changelogPath: rootChangelog
1132
- });
1133
- }
1038
+ ];
1039
+ return [];
1040
+ }
1041
+ function checkConfig(changesetDir, repoSlug) {
1042
+ const configPath = join(changesetDir, "config.json");
1043
+ if (!existsSync(configPath)) return [
1044
+ {
1045
+ file: ".changeset/config.json",
1046
+ message: "file does not exist"
1134
1047
  }
1135
- if (0 === changelogs.length) yield* Effect.log("No CHANGELOG.md files found.");
1136
- else {
1137
- yield* Effect.log(`Found ${changelogs.length} CHANGELOG.md file(s)`);
1138
- for (const entry of changelogs){
1139
- yield* Effect["try"]({
1140
- try: ()=>version_ChangelogTransformer.transformFile(entry.changelogPath),
1141
- catch: (error)=>new Error(`Failed to transform ${entry.changelogPath}: ${error instanceof Error ? error.message : String(error)}`)
1142
- });
1143
- yield* Effect.log(`Transformed ${entry.name} ${entry.changelogPath}`);
1144
- }
1048
+ ];
1049
+ try {
1050
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
1051
+ const issues = [];
1052
+ const changelog = config.changelog;
1053
+ const entry = Array.isArray(changelog) ? changelog[0] : changelog;
1054
+ const repo = Array.isArray(changelog) ? changelog[1]?.repo : void 0;
1055
+ if (ACCEPTED_CHANGELOG_ENTRIES.includes(entry)) {
1056
+ if (repo !== repoSlug) issues.push({
1057
+ file: ".changeset/config.json",
1058
+ message: `changelog repo is "${repo ?? "(not set)"}", expected "${repoSlug}"`
1059
+ });
1060
+ } else issues.push({
1061
+ file: ".changeset/config.json",
1062
+ message: `changelog formatter is "${entry}", expected "${CHANGELOG_ENTRY}"`
1063
+ });
1064
+ const options = Array.isArray(changelog) ? changelog[1] : void 0;
1065
+ if (options && "object" == typeof options && "versionFiles" in options) {
1066
+ const result = Schema.decodeUnknownEither(LegacyVersionFilesSchema)(options.versionFiles);
1067
+ if ("Left" === result._tag) issues.push({
1068
+ file: ".changeset/config.json",
1069
+ message: "versionFiles config is invalid"
1070
+ });
1145
1071
  }
1146
- const configPath = join(resolvedCwd, ".changeset", "config.json");
1147
- if (!existsSync(configPath)) return;
1148
- const inspector = yield* version_ConfigInspector;
1149
- const inspected = yield* inspector.inspect(resolvedCwd);
1150
- const scopesWithVersionFiles = inspected.packages.filter((p)=>p.versionFiles.length > 0).map((p)=>{
1151
- const fresh = readPackageVersionFromDisk(p.workspaceDir);
1152
- return fresh && fresh !== p.version ? {
1153
- ...p,
1154
- version: fresh
1155
- } : p;
1072
+ if (detectLegacyVersionFiles(config)) issues.push({
1073
+ file: ".changeset/config.json",
1074
+ message: "uses the legacy top-level `versionFiles[]` array (deprecated; removed in 1.0.0). Migrate to `packages[<name>].versionFiles`."
1156
1075
  });
1157
- if (0 === scopesWithVersionFiles.length) return;
1158
- yield* Effect.log(`Found ${scopesWithVersionFiles.length} package${1 === scopesWithVersionFiles.length ? "" : "s"} with versionFiles`);
1159
- const updates = yield* Effect["try"]({
1160
- try: ()=>VersionFiles.processResolvedVersionFiles(scopesWithVersionFiles, dryRun),
1161
- catch: (error)=>{
1162
- const message = error instanceof Error ? error.message : String(error);
1163
- return new VersionFileError({
1164
- filePath: message.match(/Failed to update (.+?):/)?.[1] ?? cwd,
1165
- reason: message
1166
- });
1076
+ return issues;
1077
+ } catch {
1078
+ return [
1079
+ {
1080
+ file: ".changeset/config.json",
1081
+ message: "could not parse file"
1167
1082
  }
1168
- });
1169
- for (const update of updates){
1170
- const action = dryRun ? "Would update" : "Updated";
1171
- yield* Effect.log(`${action} ${update.filePath} → ${update.version}`);
1083
+ ];
1084
+ }
1085
+ }
1086
+ function checkBaseMarkdownlint(root) {
1087
+ const foundPath = findMarkdownlintConfig(root);
1088
+ if (!foundPath) return [
1089
+ {
1090
+ file: "markdownlint config",
1091
+ message: `not found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`
1172
1092
  }
1173
- });
1093
+ ];
1094
+ try {
1095
+ const raw = readFileSync(join(root, foundPath), "utf-8");
1096
+ const parsed = Effect.runSync(parse(raw));
1097
+ const issues = [];
1098
+ if (!Array.isArray(parsed.customRules) || !parsed.customRules.some((r)=>ACCEPTED_CUSTOM_RULES_ENTRIES.includes(r))) issues.push({
1099
+ file: foundPath,
1100
+ message: `customRules does not include ${CUSTOM_RULES_ENTRY}`
1101
+ });
1102
+ const config = parsed.config;
1103
+ if ("object" != typeof config || null === config) issues.push({
1104
+ file: foundPath,
1105
+ message: "config section is missing"
1106
+ });
1107
+ else for (const rule of RULE_NAMES)if (!(rule in config)) issues.push({
1108
+ file: foundPath,
1109
+ message: `rule "${rule}" is not configured`
1110
+ });
1111
+ return issues;
1112
+ } catch {
1113
+ return [
1114
+ {
1115
+ file: foundPath,
1116
+ message: "could not parse file"
1117
+ }
1118
+ ];
1119
+ }
1174
1120
  }
1175
- function readPackageVersionFromDisk(workspaceDir) {
1121
+ function checkChangesetMarkdownlint(changesetDir) {
1122
+ const mdlintPath = join(changesetDir, ".markdownlint.json");
1123
+ if (!existsSync(mdlintPath)) return [
1124
+ {
1125
+ file: ".changeset/.markdownlint.json",
1126
+ message: "file does not exist"
1127
+ }
1128
+ ];
1176
1129
  try {
1177
- const pkg = JSON.parse(readFileSync(join(workspaceDir, "package.json"), "utf-8"));
1178
- return pkg.version ?? null;
1130
+ const existing = JSON.parse(readFileSync(mdlintPath, "utf-8"));
1131
+ const issues = [];
1132
+ for (const rule of RULE_NAMES)if (true !== existing[rule]) issues.push({
1133
+ file: ".changeset/.markdownlint.json",
1134
+ message: `rule "${rule}" is not enabled`
1135
+ });
1136
+ return issues;
1179
1137
  } catch {
1180
- return null;
1138
+ return [
1139
+ {
1140
+ file: ".changeset/.markdownlint.json",
1141
+ message: "could not parse file"
1142
+ }
1143
+ ];
1181
1144
  }
1182
1145
  }
1183
- const versionCommand = Command.make("version", {
1184
- dryRun: version_dryRunOption
1185
- }, ({ dryRun })=>runVersion(dryRun)).pipe(Command.withDescription("Run changeset version and transform all CHANGELOGs"));
1146
+ function runChangesetInit(opts) {
1147
+ const { force, quiet, skipMarkdownlint, check } = opts;
1148
+ return Effect.gen(function*() {
1149
+ const root = yield* resolveWorkspaceRoot(process.cwd());
1150
+ const repo = detectGitHubRepo(root);
1151
+ if (!repo && !quiet) yield* Effect.log("Warning: could not detect GitHub repo from git remote, using placeholder");
1152
+ const repoSlug = repo ?? "owner/repo";
1153
+ if (check) {
1154
+ const changesetDir = join(root, ".changeset");
1155
+ const issues = [
1156
+ ...checkChangesetDir(root),
1157
+ ...checkConfig(changesetDir, repoSlug),
1158
+ ...skipMarkdownlint ? [] : checkBaseMarkdownlint(root),
1159
+ ...checkChangesetMarkdownlint(changesetDir)
1160
+ ];
1161
+ if (0 === issues.length) return void (yield* Effect.log("All @savvy-web/changesets config files are up to date."));
1162
+ for (const issue of issues)yield* Effect.logWarning(`${issue.file}: ${issue.message}`);
1163
+ yield* Effect.logWarning('Run "savvy init --force" to fix.');
1164
+ return;
1165
+ }
1166
+ const changesetDir = yield* ensureChangesetDir(root);
1167
+ yield* Effect.log("Ensured .changeset/ directory");
1168
+ const errors = [];
1169
+ const configResult = yield* handleConfig(changesetDir, repoSlug, force).pipe(Effect.either);
1170
+ if ("Right" === configResult._tag) {
1171
+ yield* Effect.log(configResult.right);
1172
+ if (!quiet) yield* warnIfLegacyVersionFiles(changesetDir);
1173
+ } else errors.push(configResult.left);
1174
+ if (!skipMarkdownlint) {
1175
+ const baseResult = yield* handleBaseMarkdownlint(root).pipe(Effect.either);
1176
+ if ("Right" === baseResult._tag) yield* Effect.log(baseResult.right);
1177
+ else errors.push(baseResult.left);
1178
+ }
1179
+ const mdlintResult = yield* handleChangesetMarkdownlint(changesetDir, root, force).pipe(Effect.either);
1180
+ if ("Right" === mdlintResult._tag) yield* Effect.log(mdlintResult.right);
1181
+ else errors.push(mdlintResult.left);
1182
+ if (errors.length > 0) {
1183
+ for (const err of errors)yield* Effect.logError(err.message);
1184
+ if (!quiet) process.exitCode = 1;
1185
+ return;
1186
+ }
1187
+ yield* Effect.log("Init complete.");
1188
+ }).pipe(Effect.catchAll((error)=>Effect.gen(function*() {
1189
+ if (!quiet) {
1190
+ yield* Effect.logError(error instanceof InitError ? error.message : `Init failed: ${String(error)}`);
1191
+ process.exitCode = 1;
1192
+ }
1193
+ })));
1194
+ }
1195
+ Command.make("init", {
1196
+ force: forceOption,
1197
+ quiet: init_quietOption,
1198
+ skipMarkdownlint: skipMarkdownlintOption,
1199
+ check: init_checkOption
1200
+ }, (opts)=>runChangesetInit(opts)).pipe(Command.withDescription("Bootstrap a repo for @savvy-web/changesets"));
1186
1201
  const configGroup = Command.make("config").pipe(Command.withSubcommands([
1187
1202
  configShowCommand,
1188
1203
  configValidateCommand
@@ -1192,8 +1207,6 @@ const depsGroup = Command.make("deps").pipe(Command.withSubcommands([
1192
1207
  depsRegenCommand
1193
1208
  ]), Command.withDescription("Generate or regenerate dependency changesets"));
1194
1209
  const _changesetCommand = Command.make("changeset").pipe(Command.withSubcommands([
1195
- initCommand,
1196
- checkCommand,
1197
1210
  lintCommand,
1198
1211
  transformCommand,
1199
1212
  validateFileCommand,
@@ -1280,7 +1293,7 @@ function runCommitInit(opts) {
1280
1293
  yield* Effect.log("\nDone! Install @commitlint/cli if not already installed.");
1281
1294
  });
1282
1295
  }
1283
- const init_initCommand = Command.make("init", {
1296
+ Command.make("init", {
1284
1297
  force: init_forceOption,
1285
1298
  config: configOption
1286
1299
  }, (opts)=>runCommitInit(opts)).pipe(Command.withDescription("Initialize commitlint configuration and husky hooks"));
@@ -1353,10 +1366,10 @@ function runCommitCheck() {
1353
1366
  if (CheckResult.$is("Found")(baseStatus) && baseStatus.isUpToDate) yield* Effect.log("✓ Base section: up-to-date");
1354
1367
  else if (CheckResult.$is("Found")(baseStatus)) {
1355
1368
  sectionsHealthy = false;
1356
- yield* Effect.log("⚠ Base section: outdated (run 'savvy commit init' to update)");
1369
+ yield* Effect.log("⚠ Base section: outdated (run 'savvy init' to update)");
1357
1370
  } else {
1358
1371
  sectionsHealthy = false;
1359
- yield* Effect.log(`${BULLET} Base section: not found (run 'savvy commit init' to add)`);
1372
+ yield* Effect.log(`${BULLET} Base section: not found (run 'savvy init' to add)`);
1360
1373
  }
1361
1374
  const block = yield* ms.read(HUSKY_HOOK_PATH, SECTION_DEF);
1362
1375
  if (block) {
@@ -1366,15 +1379,15 @@ function runCommitCheck() {
1366
1379
  if (CheckResult.$is("Found")(status) && status.isUpToDate) yield* Effect.log("✓ Commit section: up-to-date");
1367
1380
  else {
1368
1381
  sectionsHealthy = false;
1369
- yield* Effect.log("⚠ Commit section: outdated (run 'savvy commit init' to update)");
1382
+ yield* Effect.log("⚠ Commit section: outdated (run 'savvy init' to update)");
1370
1383
  }
1371
1384
  } else {
1372
1385
  sectionsHealthy = false;
1373
- yield* Effect.log("⚠ Commit section: outdated (run 'savvy commit init' to update)");
1386
+ yield* Effect.log("⚠ Commit section: outdated (run 'savvy init' to update)");
1374
1387
  }
1375
1388
  } else {
1376
1389
  sectionsHealthy = false;
1377
- yield* Effect.log(`${BULLET} Commit section: not found (run 'savvy commit init' to add)`);
1390
+ yield* Effect.log(`${BULLET} Commit section: not found (run 'savvy init' to add)`);
1378
1391
  }
1379
1392
  }
1380
1393
  for (const hookPath of [
@@ -1384,17 +1397,17 @@ function runCommitCheck() {
1384
1397
  const hygieneExists = yield* fs.exists(hookPath);
1385
1398
  if (!hygieneExists) {
1386
1399
  sectionsHealthy = false;
1387
- yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} not found (run 'savvy commit init' to add)`);
1400
+ yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} not found (run 'savvy init' to add)`);
1388
1401
  continue;
1389
1402
  }
1390
1403
  const hygieneStatus = yield* ms.check(hookPath, SavvyHooksSection.block(savvyHooksHygiene()));
1391
1404
  if (CheckResult.$is("Found")(hygieneStatus) && hygieneStatus.isUpToDate) yield* Effect.log(`✓ Hygiene hook: ${hookPath}`);
1392
1405
  else if (CheckResult.$is("Found")(hygieneStatus)) {
1393
1406
  sectionsHealthy = false;
1394
- yield* Effect.log(`⚠ Hygiene hook: ${hookPath} outdated (run 'savvy commit init' to update)`);
1407
+ yield* Effect.log(`⚠ Hygiene hook: ${hookPath} outdated (run 'savvy init' to update)`);
1395
1408
  } else {
1396
1409
  sectionsHealthy = false;
1397
- yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} section not found (run 'savvy commit init' to add)`);
1410
+ yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} section not found (run 'savvy init' to add)`);
1398
1411
  }
1399
1412
  }
1400
1413
  const hasDCOFile = yield* fs.exists(DCO_FILE_PATH);
@@ -1409,11 +1422,11 @@ function runCommitCheck() {
1409
1422
  yield* Effect.log(` Detected scopes: ${scopeDisplay}`);
1410
1423
  yield* Effect.log("");
1411
1424
  const hasIssues = !foundConfig || !hasHuskyHook || !sectionsHealthy;
1412
- if (hasIssues) yield* Effect.log(`${CROSS_MARK} Commitlint needs configuration. Run: savvy commit init`);
1425
+ if (hasIssues) yield* Effect.log(`${CROSS_MARK} Commitlint needs configuration. Run: savvy init`);
1413
1426
  else yield* Effect.log("✓ Commitlint is configured correctly.");
1414
1427
  });
1415
1428
  }
1416
- const check_checkCommand = Command.make("check", {}, ()=>runCommitCheck()).pipe(Command.withDescription("Check current commitlint configuration and detected settings"));
1429
+ Command.make("check", {}, ()=>runCommitCheck()).pipe(Command.withDescription("Check current commitlint configuration and detected settings"));
1417
1430
  const check_CHECK_MARK = "✓";
1418
1431
  const check_CROSS_MARK = "✗";
1419
1432
  const check_WARNING = "⚠";
@@ -1494,7 +1507,7 @@ function checkBiomeSchemas() {
1494
1507
  path: configPath,
1495
1508
  matches: false
1496
1509
  });
1497
- warnings.push(`${check_WARNING} ${configPath}: biome $schema is outdated.\n Run 'savvy lint init' to update it.`);
1510
+ warnings.push(`${check_WARNING} ${configPath}: biome $schema is outdated.\n Run 'savvy init' to update it.`);
1498
1511
  }
1499
1512
  }
1500
1513
  return {
@@ -1542,12 +1555,12 @@ function runLintCheck(opts) {
1542
1555
  sectionsHealthy = false;
1543
1556
  }
1544
1557
  } else sectionsHealthy = false;
1545
- if ("up-to-date" !== baseStatusLabel || "up-to-date" !== lintStatusLabel) warnings.push(`${check_WARNING} Your ${Lint.HUSKY_HOOK_PATH} managed sections are out of date.\n Run 'savvy lint init' to update (preserves your custom hooks).`);
1558
+ if ("up-to-date" !== baseStatusLabel || "up-to-date" !== lintStatusLabel) warnings.push(`${check_WARNING} Your ${Lint.HUSKY_HOOK_PATH} managed sections are out of date.\n Run 'savvy init' to update (preserves your custom hooks).`);
1546
1559
  } else {
1547
1560
  sectionsHealthy = false;
1548
- warnings.push(`${check_WARNING} No husky pre-commit hook found.\n Run 'savvy lint init' to create it.`);
1561
+ warnings.push(`${check_WARNING} No husky pre-commit hook found.\n Run 'savvy init' to create it.`);
1549
1562
  }
1550
- if (!foundConfig) warnings.push(`${check_WARNING} No lint-staged config file found.\n Run 'savvy lint init' to create one.`);
1563
+ if (!foundConfig) warnings.push(`${check_WARNING} No lint-staged config file found.\n Run 'savvy init' to create one.`);
1551
1564
  const shellHookPaths = [
1552
1565
  Lint.POST_CHECKOUT_HOOK_PATH,
1553
1566
  Lint.POST_MERGE_HOOK_PATH
@@ -1574,11 +1587,11 @@ function runLintCheck(opts) {
1574
1587
  if (found) {
1575
1588
  if (!isUpToDate) {
1576
1589
  sectionsHealthy = false;
1577
- warnings.push(`${check_WARNING} ${hookPath} savvy-hooks section is outdated.\n Run 'savvy lint init' to update.`);
1590
+ warnings.push(`${check_WARNING} ${hookPath} savvy-hooks section is outdated.\n Run 'savvy init' to update.`);
1578
1591
  }
1579
1592
  } else {
1580
1593
  sectionsHealthy = false;
1581
- warnings.push(`${check_WARNING} ${hookPath} has no savvy-hooks section.\n Run 'savvy lint init' to add it.`);
1594
+ warnings.push(`${check_WARNING} ${hookPath} has no savvy-hooks section.\n Run 'savvy init' to add it.`);
1582
1595
  }
1583
1596
  }
1584
1597
  const biomeSchemaStatus = yield* checkBiomeSchemas().pipe(Effect.catchAll(()=>Effect.succeed({
@@ -1598,8 +1611,8 @@ function runLintCheck(opts) {
1598
1611
  if (hasMarkdownlintConfig) {
1599
1612
  const mdContent = yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH);
1600
1613
  markdownlintStatus = yield* checkMarkdownlintConfig(mdContent);
1601
- if (!markdownlintStatus.schemaMatches) warnings.push(`${check_WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy lint init' to update it.`);
1602
- if (!markdownlintStatus.configMatches) warnings.push(`${check_WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy lint init --force' to overwrite.`);
1614
+ if (!markdownlintStatus.schemaMatches) warnings.push(`${check_WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: $schema differs from template.\n Run 'savvy init' to update it.`);
1615
+ if (!markdownlintStatus.configMatches) warnings.push(`${check_WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy init --force' to overwrite.`);
1603
1616
  }
1604
1617
  if (quiet) {
1605
1618
  if (warnings.length > 0) for (const warning of warnings)yield* Effect.log(warning);
@@ -1612,15 +1625,15 @@ function runLintCheck(opts) {
1612
1625
  else yield* Effect.log(`${check_CROSS_MARK} No husky pre-commit hook found`);
1613
1626
  if (hasHuskyHook) {
1614
1627
  if ("up-to-date" === baseStatusLabel) yield* Effect.log(`${check_CHECK_MARK} Base section: up-to-date`);
1615
- else if ("outdated" === baseStatusLabel) yield* Effect.log(`${check_WARNING} Base section: outdated (run 'savvy lint init' to update)`);
1616
- else yield* Effect.log(`${check_BULLET} Base section: not found (run 'savvy lint init' to add)`);
1628
+ else if ("outdated" === baseStatusLabel) yield* Effect.log(`${check_WARNING} Base section: outdated (run 'savvy init' to update)`);
1629
+ else yield* Effect.log(`${check_BULLET} Base section: not found (run 'savvy init' to add)`);
1617
1630
  const lintLabel = detectedConfigPath ? ` (config: ${detectedConfigPath})` : "";
1618
1631
  if ("up-to-date" === lintStatusLabel) yield* Effect.log(`${check_CHECK_MARK} Lint section: up-to-date${lintLabel}`);
1619
- else if ("outdated" === lintStatusLabel) yield* Effect.log(`${check_WARNING} Lint section: outdated (run 'savvy lint init' to update)`);
1620
- else yield* Effect.log(`${check_BULLET} Lint section: not found (run 'savvy lint init' to add)`);
1632
+ else if ("outdated" === lintStatusLabel) yield* Effect.log(`${check_WARNING} Lint section: outdated (run 'savvy init' to update)`);
1633
+ else yield* Effect.log(`${check_BULLET} Lint section: not found (run 'savvy init' to add)`);
1621
1634
  }
1622
1635
  for (const status of shellHookStatuses)if (status.found) if (status.isUpToDate) yield* Effect.log(`${check_CHECK_MARK} ${status.path}: up-to-date`);
1623
- else yield* Effect.log(`${check_WARNING} ${status.path}: outdated (run 'savvy lint init' to update)`);
1636
+ else yield* Effect.log(`${check_WARNING} ${status.path}: outdated (run 'savvy init' to update)`);
1624
1637
  else yield* Effect.log(`${check_BULLET} ${status.path}: savvy-hooks section not found`);
1625
1638
  yield* Effect.log("\nTool availability:");
1626
1639
  const biomeAvailable = yield* td.isAvailable(ToolDefinition.make({
@@ -1668,16 +1681,16 @@ function runLintCheck(opts) {
1668
1681
  }
1669
1682
  else yield* Effect.log(` ${check_BULLET} ${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
1670
1683
  for (const status of biomeSchemaStatus.statuses)if (status.matches) yield* Effect.log(` ${check_CHECK_MARK} ${status.path}: biome $schema up-to-date`);
1671
- else yield* Effect.log(` ${check_WARNING} ${status.path}: biome $schema outdated (run 'savvy lint init' to update)`);
1684
+ else yield* Effect.log(` ${check_WARNING} ${status.path}: biome $schema outdated (run 'savvy init' to update)`);
1672
1685
  yield* Effect.log("");
1673
1686
  const hasMarkdownlintIssues = hasMarkdownlintConfig && !markdownlintStatus.isUpToDate;
1674
1687
  const hasBiomeSchemaIssues = biomeSchemaStatus.statuses.some((s)=>!s.matches);
1675
1688
  const hasIssues = !foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintIssues || hasBiomeSchemaIssues;
1676
- if (hasIssues) yield* Effect.log(`${check_WARNING} Some issues found. Run 'savvy lint init' to fix.`);
1689
+ if (hasIssues) yield* Effect.log(`${check_WARNING} Some issues found. Run 'savvy init' to fix.`);
1677
1690
  else yield* Effect.log(`${check_CHECK_MARK} Lint-staged is configured correctly.`);
1678
1691
  });
1679
1692
  }
1680
- const lint_check_checkCommand = Command.make("check", {
1693
+ Command.make("check", {
1681
1694
  quiet: check_quietOption
1682
1695
  }, (opts)=>runLintCheck(opts)).pipe(Command.withDescription("Check current lint-staged configuration and tool availability"));
1683
1696
  const DEFAULT_CHANGESET_DIR = ".changeset";
@@ -1704,6 +1717,158 @@ const _checkCommand = Command.make("check", {
1704
1717
  })
1705
1718
  })).pipe(Command.withDescription("Validate all Silk Suite tool configurations in one pass"));
1706
1719
  const commands_check_checkCommand = _checkCommand;
1720
+ const DEFAULT_GLOBS = [
1721
+ "dist",
1722
+ ".turbo",
1723
+ "coverage",
1724
+ "node_modules",
1725
+ ".rslib"
1726
+ ];
1727
+ const NO_DESCEND = new Set([
1728
+ "node_modules",
1729
+ ".git"
1730
+ ]);
1731
+ class CleanError extends Data.TaggedError("CleanError") {
1732
+ }
1733
+ function collectTargets(pkgPath, patterns) {
1734
+ return Effect.tryPromise({
1735
+ try: async ()=>{
1736
+ const rootReal = await realpath(pkgPath);
1737
+ const seen = new Map();
1738
+ for await (const entry of glob(patterns, {
1739
+ cwd: pkgPath,
1740
+ withFileTypes: true,
1741
+ exclude: (dirent)=>NO_DESCEND.has(dirent.name) && dirent.isDirectory()
1742
+ })){
1743
+ const abs = join(entry.parentPath, entry.name);
1744
+ let real;
1745
+ try {
1746
+ real = await realpath(abs);
1747
+ } catch {
1748
+ continue;
1749
+ }
1750
+ if (real !== rootReal && real.startsWith(rootReal + sep)) {
1751
+ if (real !== join(rootReal, "package.json")) seen.set(abs, {
1752
+ path: abs,
1753
+ kind: entry.isDirectory() ? "dir" : "file"
1754
+ });
1755
+ }
1756
+ }
1757
+ return [
1758
+ ...seen.values()
1759
+ ];
1760
+ },
1761
+ catch: (e)=>new CleanError({
1762
+ step: `glob ${pkgPath}`,
1763
+ reason: e instanceof Error ? e.message : String(e)
1764
+ })
1765
+ });
1766
+ }
1767
+ const REMOVE_CONCURRENCY = 8;
1768
+ function removeTargets(targets, dryRun) {
1769
+ return Effect.gen(function*() {
1770
+ const results = yield* Effect.forEach(targets, (target)=>dryRun ? Effect.succeed({
1771
+ target,
1772
+ reason: null
1773
+ }) : Effect.tryPromise(()=>rm(target.path, {
1774
+ recursive: true,
1775
+ force: true
1776
+ })).pipe(Effect.match({
1777
+ onSuccess: ()=>({
1778
+ target,
1779
+ reason: null
1780
+ }),
1781
+ onFailure: (e)=>({
1782
+ target,
1783
+ reason: e instanceof Error ? e.message : String(e)
1784
+ })
1785
+ })), {
1786
+ concurrency: REMOVE_CONCURRENCY
1787
+ });
1788
+ return {
1789
+ removed: results.filter((r)=>null === r.reason).map((r)=>r.target),
1790
+ failed: results.filter((r)=>null !== r.reason).map((r)=>({
1791
+ target: r.target,
1792
+ reason: r.reason
1793
+ }))
1794
+ };
1795
+ });
1796
+ }
1797
+ const clean_CHECK_MARK = "✓";
1798
+ const clean_BULLET = "•";
1799
+ const WARN_MARK = "⚠";
1800
+ function parseGlobs(raw) {
1801
+ const parts = raw.split(",").map((s)=>s.trim()).filter((s)=>s.length > 0);
1802
+ return parts.length > 0 ? parts : DEFAULT_GLOBS;
1803
+ }
1804
+ function runClean(opts) {
1805
+ const patterns = parseGlobs(opts.globs);
1806
+ return Effect.gen(function*() {
1807
+ const discovery = yield* WorkspaceDiscovery;
1808
+ const packages = yield* discovery.listPackages(process.cwd()).pipe(Effect.mapError((e)=>new CleanError({
1809
+ step: "discover workspaces",
1810
+ reason: e.message
1811
+ })));
1812
+ const leaves = packages.filter((p)=>!p.isRootWorkspace);
1813
+ const roots = packages.filter((p)=>p.isRootWorkspace);
1814
+ const ordered = [
1815
+ ...leaves,
1816
+ ...roots
1817
+ ];
1818
+ const planned = yield* Effect.forEach(ordered, (pkg)=>collectTargets(pkg.path, patterns).pipe(Effect.map((targets)=>({
1819
+ pkg,
1820
+ targets
1821
+ }))));
1822
+ const seen = new Set();
1823
+ const groups = planned.map(({ pkg, targets })=>{
1824
+ const unique = targets.filter((t)=>{
1825
+ if (seen.has(t.path)) return false;
1826
+ seen.add(t.path);
1827
+ return true;
1828
+ });
1829
+ return {
1830
+ pkg,
1831
+ targets: unique
1832
+ };
1833
+ });
1834
+ const leafGroups = groups.filter((g)=>!g.pkg.isRootWorkspace);
1835
+ const rootGroups = groups.filter((g)=>g.pkg.isRootWorkspace);
1836
+ const verb = opts.dryRun ? "would remove" : "removed";
1837
+ let total = 0;
1838
+ const failures = [];
1839
+ for (const phase of [
1840
+ leafGroups,
1841
+ rootGroups
1842
+ ]){
1843
+ const reports = yield* Effect.forEach(phase, (g)=>removeTargets(g.targets, opts.dryRun).pipe(Effect.map((report)=>({
1844
+ g,
1845
+ report
1846
+ }))));
1847
+ for (const { g, report } of reports)if (0 !== g.targets.length) {
1848
+ yield* Effect.log(`\n${"." === g.pkg.relativePath ? "<root>" : g.pkg.relativePath}`);
1849
+ for (const t of report.removed)yield* Effect.log(` ${clean_BULLET} ${verb} [${t.kind}] ${t.path}`);
1850
+ for (const f of report.failed)yield* Effect.log(` ${WARN_MARK} failed [${f.target.kind}] ${f.target.path}: ${f.reason}`);
1851
+ total += report.removed.length;
1852
+ failures.push(...report.failed);
1853
+ }
1854
+ }
1855
+ yield* Effect.log(`\n${clean_CHECK_MARK} ${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s).`);
1856
+ if (failures.length > 0) {
1857
+ for (const f of failures)yield* Effect.logError(`Failed to remove ${f.target.path}: ${f.reason}`);
1858
+ return yield* Effect.fail(new CleanError({
1859
+ step: "remove",
1860
+ reason: `${failures.length} target(s) could not be removed`
1861
+ }));
1862
+ }
1863
+ });
1864
+ }
1865
+ const globsOption = Options.text("globs").pipe(Options.withAlias("g"), Options.withDescription(`Comma-separated glob patterns to remove from each workspace root (default: ${DEFAULT_GLOBS.join(",")})`), Options.withDefault(DEFAULT_GLOBS.join(",")));
1866
+ const clean_dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Report what would be removed without deleting anything"), Options.withDefault(false));
1867
+ const _cleanCommand = Command.make("clean", {
1868
+ globs: globsOption,
1869
+ dryRun: clean_dryRunOption
1870
+ }, (opts)=>runClean(opts)).pipe(Command.withDescription("Remove build/cache artifacts across the workspace (leaves first, root last)"));
1871
+ const cleanCommand = _cleanCommand;
1707
1872
  const execFileP = promisify(execFile);
1708
1873
  function buildPostCommitAdvice(i) {
1709
1874
  const lines = [];
@@ -1971,8 +2136,6 @@ const hookCommand = Command.make("hook").pipe(Command.withSubcommands([
1971
2136
  userPromptSubmitCommand
1972
2137
  ])).pipe(Command.withDescription("Internal hook handlers used by the @savvy-web/commitlint plugin"));
1973
2138
  const _commitCommand = Command.make("commit").pipe(Command.withSubcommands([
1974
- init_initCommand,
1975
- check_checkCommand,
1976
2139
  hookCommand
1977
2140
  ]), Command.withDescription("Commit standards: config, checks, and Claude hook handlers"));
1978
2141
  const commitCommand = _commitCommand;
@@ -2121,7 +2284,7 @@ function runLintInit(opts) {
2121
2284
  yield* Effect.log("\nDone! Lint-staged is ready to use.");
2122
2285
  });
2123
2286
  }
2124
- const lint_init_initCommand = Command.make("init", {
2287
+ Command.make("init", {
2125
2288
  force: lint_init_forceOption,
2126
2289
  config: init_configOption,
2127
2290
  preset: presetOption
@@ -2205,21 +2368,20 @@ const _fmtCommand = Command.make("fmt").pipe(Command.withSubcommands([
2205
2368
  ]));
2206
2369
  const fmtCommand = _fmtCommand;
2207
2370
  const _lintCommand = Command.make("lint").pipe(Command.withSubcommands([
2208
- lint_init_initCommand,
2209
- lint_check_checkCommand,
2210
2371
  fmtCommand
2211
2372
  ]), Command.withDescription("Code-quality: lint-staged config, checks, and in-place formatting"));
2212
2373
  const lint_lintCommand = _lintCommand;
2213
2374
  const rootCommand = Command.make("savvy").pipe(Command.withSubcommands([
2214
2375
  commands_init_initCommand,
2215
2376
  commands_check_checkCommand,
2377
+ cleanCommand,
2216
2378
  commitCommand,
2217
2379
  changesetCommand,
2218
2380
  lint_lintCommand
2219
2381
  ]));
2220
2382
  const cli = Command.run(rootCommand, {
2221
2383
  name: "savvy",
2222
- version: "0.2.1"
2384
+ version: "0.3.0"
2223
2385
  });
2224
2386
  const WorkspaceLive = Layer.mergeAll(WorkspaceRootLive, PackageManagerDetectorLive, WorkspaceDiscoveryLive.pipe(Layer.provide(WorkspaceRootLive)));
2225
2387
  const BaseLive = Layer.mergeAll(WorkspaceLive, ChangesetConfigReaderLive, ManagedSectionLive, BiomeSchemaSyncLive, ConfigDiscoveryLive, SilkPublishabilityDetectorLive, Changesets.WorkspaceSnapshotReaderLive);