@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,277 @@
|
|
|
1
|
+
import { Command, Options } from "@effect/cli";
|
|
2
|
+
import { Changesets } from "@savvy-web/silk-effects";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { WorkspaceDiscovery } from "workspaces-effect";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
//#region src/commands/changeset/commands/deps-regen.ts
|
|
9
|
+
/**
|
|
10
|
+
* `deps regen` command — delete all pure dependency changesets and
|
|
11
|
+
* write fresh single-package, patch-bump changesets reflecting the
|
|
12
|
+
* cumulative dep diff from base to working tree.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* **The single-package-per-changeset rule.** This command enforces our
|
|
16
|
+
* convention that each `.changeset/*.md` file lists exactly one package
|
|
17
|
+
* in its frontmatter. `@changesets/cli` technically supports multi-package
|
|
18
|
+
* frontmatter, but our agent (and this command) always produces single-
|
|
19
|
+
* package files for clarity and easier hand-editing.
|
|
20
|
+
*
|
|
21
|
+
* **Strict "pure dependency changeset" detection.** A changeset is
|
|
22
|
+
* eligible for deletion-and-regeneration if and only if:
|
|
23
|
+
*
|
|
24
|
+
* 1. Its frontmatter declares exactly one package, and
|
|
25
|
+
* 2. Its body contains exactly one `##` heading, and
|
|
26
|
+
* 3. That heading is `Dependencies`.
|
|
27
|
+
*
|
|
28
|
+
* Anything else (multi-package frontmatter, additional sections, comments,
|
|
29
|
+
* `### Sub-headings`, etc.) is treated as "mixed" and left untouched.
|
|
30
|
+
* That's the safe default — if a human authored something idiosyncratic,
|
|
31
|
+
* we don't clobber it.
|
|
32
|
+
*
|
|
33
|
+
* **The algorithm:**
|
|
34
|
+
*
|
|
35
|
+
* 1. Compute dep diff from `merge-base(baseBranch, HEAD)` to working
|
|
36
|
+
* tree, grouped by workspace package.
|
|
37
|
+
* 2. Find every pure-dependency changeset (strict definition).
|
|
38
|
+
* 3. Delete every one of them — even those for packages with no current
|
|
39
|
+
* dep changes (their changeset is stale by definition).
|
|
40
|
+
* 4. Write a fresh `<adjective>-<noun>-<verb>.md` per workspace package
|
|
41
|
+
* that has current dep changes: single-package frontmatter, `patch`
|
|
42
|
+
* bump, one `## Dependencies` section, one CSH005 table.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```bash
|
|
46
|
+
* savvy changeset deps regen
|
|
47
|
+
* savvy changeset deps regen --dry-run --json
|
|
48
|
+
* savvy changeset deps regen --package @scope/foo
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
const { ConfigInspector, WorkspaceSnapshotReader, computeWorkspaceDependencyDiffs, gitMergeBase, listPublishablePackageNames, serializeDependencyTableToMarkdown, snapshotFromWorktree } = Changesets;
|
|
54
|
+
/* v8 ignore start -- CLI option definitions */
|
|
55
|
+
const cwdOption = Options.directory("cwd").pipe(Options.withDescription("Project root (defaults to the current working directory)"), Options.withDefault("."));
|
|
56
|
+
const baseOption = Options.text("base").pipe(Options.withDescription("Override the base branch (defaults to config baseBranch)"), Options.optional);
|
|
57
|
+
const packageOption = Options.text("package").pipe(Options.withDescription("Restrict regeneration to a single workspace package"), Options.optional);
|
|
58
|
+
const dryRunOption = Options.boolean("dry-run").pipe(Options.withDescription("Print the plan without writing or deleting"), Options.withDefault(false));
|
|
59
|
+
const jsonOption = Options.boolean("json").pipe(Options.withDescription("Emit a structured plan as JSON"), Options.withDefault(false));
|
|
60
|
+
/* v8 ignore stop */
|
|
61
|
+
const ADJECTIVES = [
|
|
62
|
+
"brave",
|
|
63
|
+
"clever",
|
|
64
|
+
"swift",
|
|
65
|
+
"silver",
|
|
66
|
+
"lucky",
|
|
67
|
+
"happy",
|
|
68
|
+
"calm",
|
|
69
|
+
"bright",
|
|
70
|
+
"quiet",
|
|
71
|
+
"wild"
|
|
72
|
+
];
|
|
73
|
+
const NOUNS = [
|
|
74
|
+
"dogs",
|
|
75
|
+
"cats",
|
|
76
|
+
"wolves",
|
|
77
|
+
"foxes",
|
|
78
|
+
"cups",
|
|
79
|
+
"ships",
|
|
80
|
+
"trees",
|
|
81
|
+
"owls",
|
|
82
|
+
"cranes",
|
|
83
|
+
"hills"
|
|
84
|
+
];
|
|
85
|
+
const VERBS = [
|
|
86
|
+
"laugh",
|
|
87
|
+
"dream",
|
|
88
|
+
"fly",
|
|
89
|
+
"sing",
|
|
90
|
+
"dance",
|
|
91
|
+
"wander",
|
|
92
|
+
"soar",
|
|
93
|
+
"rest",
|
|
94
|
+
"leap",
|
|
95
|
+
"ponder"
|
|
96
|
+
];
|
|
97
|
+
function pickRandomTriplet() {
|
|
98
|
+
return `${ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]}-${NOUNS[Math.floor(Math.random() * NOUNS.length)]}-${VERBS[Math.floor(Math.random() * VERBS.length)]}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pick a `<adjective>-<noun>-<verb>` filename slug that does not collide
|
|
102
|
+
* with an existing `.changeset/*.md`. The triplet space is 1,000
|
|
103
|
+
* combinations, so a busy repo can plausibly exhaust it across runs;
|
|
104
|
+
* fall back to a timestamp suffix after 20 unlucky picks.
|
|
105
|
+
*
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
function randomFilename(changesetDir) {
|
|
109
|
+
for (let i = 0; i < 20; i++) {
|
|
110
|
+
const candidate = pickRandomTriplet();
|
|
111
|
+
if (!existsSync(join(changesetDir, `${candidate}.md`))) return candidate;
|
|
112
|
+
}
|
|
113
|
+
return `${pickRandomTriplet()}-${Date.now()}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Strict detection of "pure dependency changesets" per the documented
|
|
117
|
+
* rules: single-package frontmatter, single `## Dependencies` heading,
|
|
118
|
+
* no other body content beyond that section.
|
|
119
|
+
*
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
function isPureDependencyChangeset(content) {
|
|
123
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
124
|
+
if (!fmMatch) return {
|
|
125
|
+
isPure: false,
|
|
126
|
+
package: null
|
|
127
|
+
};
|
|
128
|
+
const frontmatter = fmMatch[1];
|
|
129
|
+
const body = (fmMatch[2] ?? "").trim();
|
|
130
|
+
const fmLines = frontmatter.split(/\r?\n/).filter((l) => l.trim().length > 0 && !/^\s*#/.test(l));
|
|
131
|
+
if (fmLines.length !== 1) return {
|
|
132
|
+
isPure: false,
|
|
133
|
+
package: null
|
|
134
|
+
};
|
|
135
|
+
const pkgMatch = fmLines[0].match(/^\s*["']?([^"':\s]+)["']?\s*:\s*([a-z]+)\s*$/);
|
|
136
|
+
if (!pkgMatch) return {
|
|
137
|
+
isPure: false,
|
|
138
|
+
package: null
|
|
139
|
+
};
|
|
140
|
+
const pkg = pkgMatch[1];
|
|
141
|
+
const bodyTrimmed = body.replace(/^\s+/, "");
|
|
142
|
+
if (!/^## Dependencies\b/.test(bodyTrimmed)) return {
|
|
143
|
+
isPure: false,
|
|
144
|
+
package: null
|
|
145
|
+
};
|
|
146
|
+
if ((bodyTrimmed.match(/^## /gm) ?? []).length !== 1) return {
|
|
147
|
+
isPure: false,
|
|
148
|
+
package: null
|
|
149
|
+
};
|
|
150
|
+
if (/^# /m.test(bodyTrimmed)) return {
|
|
151
|
+
isPure: false,
|
|
152
|
+
package: null
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
isPure: true,
|
|
156
|
+
package: pkg
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function listChangesetFiles(changesetDir) {
|
|
160
|
+
if (!existsSync(changesetDir)) return [];
|
|
161
|
+
return readdirSync(changesetDir).filter((f) => f.endsWith(".md") && f !== "README.md").map((f) => join(changesetDir, f));
|
|
162
|
+
}
|
|
163
|
+
function findPureDependencyChangesets(changesetDir) {
|
|
164
|
+
const result = [];
|
|
165
|
+
for (const file of listChangesetFiles(changesetDir)) {
|
|
166
|
+
let content;
|
|
167
|
+
try {
|
|
168
|
+
content = readFileSync(file, "utf8");
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const detection = isPureDependencyChangeset(content);
|
|
173
|
+
if (detection.isPure && detection.package) result.push({
|
|
174
|
+
file,
|
|
175
|
+
package: detection.package
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
function findMixedDependencyChangesets(changesetDir) {
|
|
181
|
+
const result = [];
|
|
182
|
+
for (const file of listChangesetFiles(changesetDir)) {
|
|
183
|
+
let content;
|
|
184
|
+
try {
|
|
185
|
+
content = readFileSync(file, "utf8");
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (/^## Dependencies\b/m.test(content) && !isPureDependencyChangeset(content).isPure) result.push(file);
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
function renderChangesetContent(diff) {
|
|
194
|
+
return `${`---\n"${diff.package}": patch\n---`}\n\n## Dependencies\n\n${serializeDependencyTableToMarkdown([...diff.rows])}\n`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Handler exported for direct invocation in tests.
|
|
198
|
+
*
|
|
199
|
+
* @internal
|
|
200
|
+
*/
|
|
201
|
+
function runDepsRegen(cwd, base, pkg, dryRun, json) {
|
|
202
|
+
return Effect.gen(function* () {
|
|
203
|
+
const resolvedCwd = resolve(cwd);
|
|
204
|
+
const changesetDir = join(resolvedCwd, ".changeset");
|
|
205
|
+
const reader = yield* WorkspaceSnapshotReader;
|
|
206
|
+
let baseBranch = Option.getOrUndefined(base);
|
|
207
|
+
if (!baseBranch) baseBranch = (yield* (yield* ConfigInspector).inspect(resolvedCwd).pipe(Effect.catchTag("ConfigurationError", () => Effect.succeed({ baseBranch: "main" })))).baseBranch;
|
|
208
|
+
const mergeBase = yield* gitMergeBase(resolvedCwd, baseBranch).pipe(Effect.catchTag("GitError", (err) => {
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
return Effect.fail(err);
|
|
211
|
+
}));
|
|
212
|
+
let diffs = computeWorkspaceDependencyDiffs(yield* reader.snapshotAt(resolvedCwd, mergeBase).pipe(Effect.catchTag("GitError", (err) => {
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return Effect.fail(err);
|
|
215
|
+
})), snapshotFromWorktree(resolvedCwd));
|
|
216
|
+
const targetPkg = Option.getOrUndefined(pkg);
|
|
217
|
+
const publishable = yield* listPublishablePackageNames(yield* (yield* WorkspaceDiscovery).listPackages(resolvedCwd).pipe(Effect.catchAll(() => Effect.succeed([]))));
|
|
218
|
+
if (targetPkg) diffs = diffs.filter((d) => d.package === targetPkg);
|
|
219
|
+
else diffs = diffs.filter((d) => publishable.has(d.package));
|
|
220
|
+
const existingPure = findPureDependencyChangesets(changesetDir);
|
|
221
|
+
const skippedMixed = findMixedDependencyChangesets(changesetDir);
|
|
222
|
+
const toDelete = targetPkg ? existingPure.filter((p) => p.package === targetPkg) : existingPure.filter((p) => publishable.has(p.package));
|
|
223
|
+
const toWrite = diffs.map((diff) => ({
|
|
224
|
+
file: join(changesetDir, `${randomFilename(changesetDir)}.md`),
|
|
225
|
+
package: diff.package,
|
|
226
|
+
diff
|
|
227
|
+
}));
|
|
228
|
+
const plan = {
|
|
229
|
+
toDelete,
|
|
230
|
+
toWrite,
|
|
231
|
+
skippedMixed
|
|
232
|
+
};
|
|
233
|
+
if (dryRun) {
|
|
234
|
+
if (json) yield* Effect.log(JSON.stringify(plan, null, 2));
|
|
235
|
+
else yield* renderHumanPlan(plan);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const entry of toDelete) try {
|
|
239
|
+
unlinkSync(entry.file);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
yield* Effect.logWarning(`Failed to delete ${entry.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
|
+
}
|
|
243
|
+
for (const entry of toWrite) writeFileSync(entry.file, renderChangesetContent(entry.diff));
|
|
244
|
+
if (json) yield* Effect.log(JSON.stringify(plan, null, 2));
|
|
245
|
+
else yield* renderHumanPlan(plan);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function renderHumanPlan(plan) {
|
|
249
|
+
return Effect.gen(function* () {
|
|
250
|
+
if (plan.toDelete.length === 0 && plan.toWrite.length === 0) yield* Effect.log("No dependency changes to regenerate.");
|
|
251
|
+
else {
|
|
252
|
+
if (plan.toDelete.length > 0) {
|
|
253
|
+
yield* Effect.log(`Deleted ${plan.toDelete.length} pure dependency changeset(s):`);
|
|
254
|
+
for (const entry of plan.toDelete) yield* Effect.log(` - ${entry.file} (${entry.package})`);
|
|
255
|
+
}
|
|
256
|
+
if (plan.toWrite.length > 0) {
|
|
257
|
+
yield* Effect.log(`Wrote ${plan.toWrite.length} fresh dependency changeset(s):`);
|
|
258
|
+
for (const entry of plan.toWrite) yield* Effect.log(` + ${entry.file} (${entry.package} — ${entry.diff.rows.length} row${entry.diff.rows.length === 1 ? "" : "s"})`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (plan.skippedMixed.length > 0) {
|
|
262
|
+
yield* Effect.log(`\nSkipped ${plan.skippedMixed.length} mixed changeset(s) (have Dependencies but also other content):`);
|
|
263
|
+
for (const file of plan.skippedMixed) yield* Effect.log(` ~ ${file}`);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/* v8 ignore next 8 */
|
|
268
|
+
const depsRegenCommand = Command.make("regen", {
|
|
269
|
+
cwd: cwdOption,
|
|
270
|
+
base: baseOption,
|
|
271
|
+
package: packageOption,
|
|
272
|
+
dryRun: dryRunOption,
|
|
273
|
+
json: jsonOption
|
|
274
|
+
}, ({ 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"));
|
|
275
|
+
|
|
276
|
+
//#endregion
|
|
277
|
+
export { depsRegenCommand };
|