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