@savvy-web/cli 0.3.0 → 0.4.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 (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,221 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { BiomeSchemaSync, Lint, ManagedSection, SavvyBaseSection, SavvyHooksSection, savvyBasePreamble, savvyHooksHygiene } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { dirname } from "node:path";
5
+ import { applyEdits, modify, parse } from "jsonc-effect";
6
+ import { FileSystem } from "@effect/platform";
7
+ import { chmod } from "node:fs/promises";
8
+ import { isDeepStrictEqual } from "node:util";
9
+
10
+ //#region src/commands/lint/init.ts
11
+ /**
12
+ * Init command - bootstrap lint-staged configuration.
13
+ *
14
+ * @internal
15
+ */
16
+ /** Unicode checkmark symbol. */
17
+ const CHECK_MARK = "✓";
18
+ /** Unicode warning symbol. */
19
+ const WARNING = "⚠";
20
+ /** Executable file permission mode. */
21
+ const EXECUTABLE_MODE = 493;
22
+ /** Formatting options for jsonc-effect surgical edits. */
23
+ const JSONC_FORMAT = {
24
+ tabSize: 1,
25
+ insertSpaces: false
26
+ };
27
+ /** Header written when creating a fresh pre-commit hook. */
28
+ const PRE_COMMIT_HEADER = "#!/usr/bin/env sh\n# Pre-commit hook with savvy managed sections\n# Custom hooks can go above, below, or between the managed sections\n\n";
29
+ /** Header written when creating a fresh hygiene hook (post-checkout / post-merge). */
30
+ const HYGIENE_HEADER = "#!/usr/bin/env sh\n# Managed by savvy-hooks\n# Custom hooks can go above or below the managed section\n\n";
31
+ /**
32
+ * Check if a preset includes the ShellScripts handler.
33
+ *
34
+ * @param preset - The preset to check
35
+ * @returns True if the preset includes ShellScripts (standard and silk)
36
+ */
37
+ function presetIncludesShellScripts(preset) {
38
+ return preset !== "minimal";
39
+ }
40
+ /**
41
+ * Check if a preset includes markdown tooling.
42
+ *
43
+ * @param preset - The preset to check
44
+ * @returns True if the preset includes Markdown (standard and silk)
45
+ */
46
+ function presetIncludesMarkdown(preset) {
47
+ return preset !== "minimal";
48
+ }
49
+ /**
50
+ * Generate the lint-staged config file content.
51
+ *
52
+ * @param preset - The preset to use
53
+ * @returns Config file content
54
+ */
55
+ function generateConfigContent(preset) {
56
+ return `/**
57
+ * lint-staged configuration
58
+ * Generated by savvy lint init
59
+ */
60
+ import { Preset } from "@savvy-web/silk/lint";
61
+
62
+ export default Preset.${preset}();
63
+ `;
64
+ }
65
+ /**
66
+ * Write or update the markdownlint-cli2 config file.
67
+ *
68
+ * @param fs - FileSystem service
69
+ * @param preset - The active preset
70
+ * @param force - Whether to overwrite the entire file
71
+ * @returns Effect that manages the markdownlint config
72
+ */
73
+ function writeMarkdownlintConfig(fs, preset, force) {
74
+ return Effect.gen(function* () {
75
+ const configExists = yield* fs.exists(Lint.MARKDOWNLINT_CONFIG_PATH);
76
+ const fullTemplate = JSON.stringify(Lint.MARKDOWNLINT_TEMPLATE, null, " ");
77
+ if (!configExists) {
78
+ yield* fs.makeDirectory("lib/configs", { recursive: true });
79
+ yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
80
+ yield* Effect.log(`${CHECK_MARK} Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
81
+ return;
82
+ }
83
+ if (preset !== "silk") {
84
+ yield* Effect.log(`${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`);
85
+ return;
86
+ }
87
+ if (force) {
88
+ yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
89
+ yield* Effect.log(`${CHECK_MARK} Replaced ${Lint.MARKDOWNLINT_CONFIG_PATH} (--force)`);
90
+ return;
91
+ }
92
+ const existingText = yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH);
93
+ const existingParsed = yield* parse(existingText);
94
+ let updatedText = existingText;
95
+ let schemaUpdated = false;
96
+ if (existingParsed.$schema !== Lint.MARKDOWNLINT_SCHEMA) {
97
+ const edits = yield* modify(updatedText, ["$schema"], Lint.MARKDOWNLINT_SCHEMA, { formattingOptions: JSONC_FORMAT });
98
+ updatedText = yield* applyEdits(updatedText, edits);
99
+ schemaUpdated = true;
100
+ }
101
+ const existingConfig = existingParsed.config;
102
+ if (!(existingConfig !== void 0 && isDeepStrictEqual(existingConfig, Lint.MARKDOWNLINT_CONFIG))) {
103
+ yield* Effect.log(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
104
+ if (schemaUpdated) {
105
+ yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, updatedText);
106
+ yield* Effect.log(`${CHECK_MARK} Updated $schema in ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
107
+ }
108
+ return;
109
+ }
110
+ if (schemaUpdated) {
111
+ yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, updatedText);
112
+ yield* Effect.log(`${CHECK_MARK} Updated $schema in ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
113
+ } else yield* Effect.log(`${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
114
+ });
115
+ }
116
+ /**
117
+ * Find and sync biome config `$schema` URLs to match the peer dependency version.
118
+ *
119
+ * @returns Effect that syncs biome schemas and logs results
120
+ */
121
+ function syncBiomeSchemas() {
122
+ return Effect.gen(function* () {
123
+ const version = process.env.__BIOME_PEER_VERSION__;
124
+ if (!version) return;
125
+ const result = yield* (yield* BiomeSchemaSync).sync(version);
126
+ for (const configPath of result.current) yield* Effect.log(`${CHECK_MARK} ${configPath}: biome $schema up-to-date`);
127
+ for (const configPath of result.updated) yield* Effect.log(`${CHECK_MARK} Updated $schema in ${configPath}`);
128
+ });
129
+ }
130
+ /* v8 ignore start -- CLI option definitions; handler tested individually */
131
+ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Options.withDescription("Overwrite the pre-commit hook and config file entirely (managed sections in post-checkout/post-merge are never force-reset)"), Options.withDefault(false));
132
+ const configOption = Options.text("config").pipe(Options.withAlias("c"), Options.withDescription("Relative path for the lint-staged config file (from repo root)"), Options.withDefault(Lint.DEFAULT_CONFIG_PATH));
133
+ const presetOption = Options.choice("preset", [
134
+ "minimal",
135
+ "standard",
136
+ "silk"
137
+ ]).pipe(Options.withAlias("p"), Options.withDescription("Preset to use: minimal, standard, or silk"), Options.withDefault("silk"));
138
+ /* v8 ignore stop */
139
+ /** Make a file executable. */
140
+ function makeExecutable(path) {
141
+ return Effect.tryPromise({
142
+ try: () => chmod(path, EXECUTABLE_MODE),
143
+ catch: (e) => new Error(String(e))
144
+ });
145
+ }
146
+ /** Ensure a hook file exists, writing `header` if it does not. */
147
+ function ensureHookFile(path, header) {
148
+ return Effect.gen(function* () {
149
+ const fs = yield* FileSystem.FileSystem;
150
+ if (!(yield* fs.exists(path))) yield* fs.writeFileString(path, header);
151
+ });
152
+ }
153
+ /**
154
+ * Run the full lint init pipeline.
155
+ *
156
+ * Exported so Task B5's unified `savvy init` orchestrator can invoke the
157
+ * lint-staged init step directly without going through the CLI command layer.
158
+ *
159
+ * @param opts - The same options the CLI command receives
160
+ * @returns An Effect that performs initialization
161
+ *
162
+ * @internal
163
+ */
164
+ function runLintInit(opts) {
165
+ const { force, config, preset } = opts;
166
+ return Effect.gen(function* () {
167
+ const fs = yield* FileSystem.FileSystem;
168
+ const ms = yield* ManagedSection;
169
+ if (config.startsWith("/")) yield* Effect.fail(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
170
+ yield* Effect.log("Initializing lint-staged configuration...\n");
171
+ yield* fs.makeDirectory(".husky", { recursive: true });
172
+ if (force) yield* fs.writeFileString(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
173
+ else yield* ensureHookFile(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
174
+ const preCommitResults = yield* ms.syncMany(Lint.HUSKY_HOOK_PATH, [SavvyBaseSection.block(savvyBasePreamble()), Lint.savvyLintBlock(config)]);
175
+ yield* makeExecutable(Lint.HUSKY_HOOK_PATH);
176
+ yield* Effect.log(`${CHECK_MARK} ${force ? "Replaced" : "Synced"} ${Lint.HUSKY_HOOK_PATH} (${preCommitResults.map((r) => r._tag).join(", ")})`);
177
+ if (presetIncludesShellScripts(preset)) for (const hookPath of [Lint.POST_CHECKOUT_HOOK_PATH, Lint.POST_MERGE_HOOK_PATH]) {
178
+ yield* ensureHookFile(hookPath, HYGIENE_HEADER);
179
+ yield* ms.remove(hookPath, Lint.LegacySavvyLintHygieneDef);
180
+ yield* ms.sync(hookPath, SavvyHooksSection.block(savvyHooksHygiene()));
181
+ yield* makeExecutable(hookPath);
182
+ yield* Effect.log(`${CHECK_MARK} Synced ${hookPath}`);
183
+ }
184
+ if (presetIncludesMarkdown(preset)) yield* writeMarkdownlintConfig(fs, preset, force);
185
+ yield* syncBiomeSchemas().pipe(Effect.catchTag("BiomeSyncError", (e) => Effect.log(`${WARNING} Could not sync biome $schema: ${e.message}`)));
186
+ if ((yield* fs.exists(config)) && !force) yield* Effect.log(`${WARNING} ${config} already exists (use --force to overwrite)`);
187
+ else {
188
+ const configDir = dirname(config);
189
+ if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
190
+ yield* fs.writeFileString(config, generateConfigContent(preset));
191
+ yield* Effect.log(`${CHECK_MARK} Created ${config} (preset: ${preset})`);
192
+ }
193
+ yield* Effect.log("\nDone! Lint-staged is ready to use.");
194
+ });
195
+ }
196
+ /**
197
+ * Init command implementation.
198
+ *
199
+ * @remarks
200
+ * Writes:
201
+ * - `.husky/pre-commit` — `savvy-base` preamble + `savvy-lint` tool section, in order
202
+ * (via `ManagedSection.syncMany`).
203
+ * - `.husky/post-checkout` and `.husky/post-merge` — co-owned `savvy-hooks` hygiene
204
+ * (idempotent, shared with `@savvy-web/commitlint`). Migrates legacy `SAVVY-LINT`
205
+ * hygiene blocks by removing them before writing the new section.
206
+ * - `lib/configs/.markdownlint-cli2.jsonc` config (when preset includes Markdown).
207
+ * - lint-staged config at the specified path.
208
+ *
209
+ * Users may add custom commands above, below, or between the managed sections.
210
+ * `--force` resets only the pre-commit hook and the config file; the hygiene sections
211
+ * are always reconciled with `sync`.
212
+ */
213
+ /* v8 ignore next 3 -- CLI registration; handler tested via runLintInit */
214
+ const initCommand = Command.make("init", {
215
+ force: forceOption,
216
+ config: configOption,
217
+ preset: presetOption
218
+ }, (opts) => runLintInit(opts)).pipe(Command.withDescription("Initialize lint-staged configuration and husky hooks"));
219
+
220
+ //#endregion
221
+ export { runLintInit };