@savvy-web/cli 3.5.1 → 3.7.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/cli/index.js +19 -3
- package/commands/changeset/commands/check.js +82 -21
- package/commands/changeset/commands/config-validate.js +9 -6
- package/commands/changeset/commands/deps-detect.js +2 -2
- package/commands/changeset/commands/deps-regen.js +94 -48
- package/commands/changeset/commands/init.js +56 -23
- package/commands/changeset/commands/transform.js +82 -8
- package/commands/changeset/commands/validate-file.js +12 -11
- package/commands/changeset/commands/version.js +48 -17
- package/commands/changeset/index.js +3 -3
- package/commands/changeset/utils/config-gate.js +12 -10
- package/commands/check-section.js +34 -0
- package/commands/check.js +48 -39
- package/commands/clean/progress.js +71 -0
- package/commands/clean/view.js +26 -0
- package/commands/clean.js +113 -50
- package/commands/commit/check.js +80 -77
- package/commands/commit/init.js +21 -10
- package/commands/commit/lint.js +2 -2
- package/commands/init.js +122 -34
- package/commands/lint/biome-version.js +1 -1
- package/commands/lint/check.js +126 -171
- package/commands/lint/init.js +52 -33
- package/commands/lint/text.js +28 -13
- package/commands/repos/commands/add.js +14 -17
- package/commands/repos/commands/deregister.js +37 -18
- package/commands/repos/commands/note.js +78 -26
- package/commands/repos/commands/pin.js +39 -24
- package/commands/repos/commands/remove.js +30 -56
- package/commands/repos/commands/rename.js +32 -23
- package/commands/repos/commands/restore.js +42 -56
- package/commands/repos/commands/status.js +43 -47
- package/commands/repos/commands/sync.js +11 -40
- package/commands/repos/index.js +7 -10
- package/commands/repos/shared.js +129 -0
- package/index.d.ts +162 -61
- package/internal/command-error.js +60 -0
- package/internal/confirm.js +52 -0
- package/internal/failure-line.js +26 -12
- package/internal/report.js +101 -0
- package/main.js +23 -16
- package/package.json +17 -12
- package/version.js +1 -1
- package/internal/output.js +0 -52
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
2
|
import { requireValidConfig } from "../utils/config-gate.js";
|
|
3
3
|
import { Argument, Command, Flag } from "effect/cli";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { CliExit } from "@effected/cli";
|
|
5
|
+
import { CliExit, Doc } from "@effected/cli";
|
|
6
6
|
import { Changesets } from "@savvy-web/silk-effects";
|
|
7
7
|
import { Effect } from "effect";
|
|
8
8
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
@@ -21,7 +21,8 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
21
21
|
* - **`--dry-run` / `-n`** -- print the transformed output to stdout
|
|
22
22
|
* without writing.
|
|
23
23
|
* - **`--check` / `-c`** -- compare the transformed output against the
|
|
24
|
-
* original and exit with code 1 if they differ (useful in CI)
|
|
24
|
+
* original and exit with code 1 if they differ (useful in CI), showing
|
|
25
|
+
* the drifted lines as a capped diff (original against transformed).
|
|
25
26
|
*
|
|
26
27
|
* Before any of those modes run, the command requires a valid
|
|
27
28
|
* `.changeset/config.json` (when one exists). A broken config indicates
|
|
@@ -39,6 +40,79 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
39
40
|
* @internal
|
|
40
41
|
*/
|
|
41
42
|
const { ChangelogTransformer } = Changesets;
|
|
43
|
+
/** The most diff lines `--check` shows under its finding; the rest is elided. */
|
|
44
|
+
const DIFF_CAP = 40;
|
|
45
|
+
/** A text's lines; a final line break ends the last line rather than starting an empty one. */
|
|
46
|
+
const lines = (text) => {
|
|
47
|
+
const all = text.split("\n");
|
|
48
|
+
if (all.at(-1) === "") all.pop();
|
|
49
|
+
return all;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Narrow `original` and `transformed` to the lines between their common
|
|
53
|
+
* leading and trailing lines, or `undefined` when the two differ only in the
|
|
54
|
+
* file's final line break. Exported for tests only.
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
const driftWindow = (original, transformed) => {
|
|
59
|
+
const a = lines(original);
|
|
60
|
+
const b = lines(transformed);
|
|
61
|
+
let head = 0;
|
|
62
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
|
63
|
+
let tail = 0;
|
|
64
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
|
|
65
|
+
if (head + tail >= a.length && head + tail >= b.length) return void 0;
|
|
66
|
+
return {
|
|
67
|
+
removed: a.slice(head, a.length - tail),
|
|
68
|
+
added: b.slice(head, b.length - tail),
|
|
69
|
+
head
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/** `1 line`, `3 blank lines`: a count of `lines`, called blank when every one is. */
|
|
73
|
+
const lineCount = (lines) => {
|
|
74
|
+
const blank = lines.every((line) => line.trim() === "") ? "blank " : "";
|
|
75
|
+
return `${lines.length} ${blank}${lines.length === 1 ? "line" : "lines"}`;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* `lines` drawn as one side of a diff (`+ text`, or a bare `+` for a blank
|
|
79
|
+
* line), unless every one is blank; capped at {@link DIFF_CAP} lines with an
|
|
80
|
+
* elision line, as `Doc.diff` caps each side.
|
|
81
|
+
*/
|
|
82
|
+
const listed = (sign, lines) => {
|
|
83
|
+
if (lines.every((line) => line.trim() === "")) return [];
|
|
84
|
+
const shown = lines.slice(0, DIFF_CAP).map((line) => line === "" ? sign : `${sign} ${line}`);
|
|
85
|
+
const hidden = lines.length - shown.length;
|
|
86
|
+
if (hidden > 0) shown.push(` … ${hidden} more ${hidden === 1 ? "line" : "lines"}`);
|
|
87
|
+
return [Doc.verbatim(shown.join("\n"), { indent: 2 })];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* What `--check` shows under its finding for one drifted region.
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* `Doc.diff` is not a line diff: it draws every expected line as removed and
|
|
94
|
+
* every received line as added, and an empty side joins to the same `""` as a
|
|
95
|
+
* single blank line. So the window holds only lines that changed (an unchanged
|
|
96
|
+
* context line would read as removed and re-added), and a region that only
|
|
97
|
+
* inserts or only removes lines is described in words, with its non-blank
|
|
98
|
+
* lines listed, rather than drawn as a diff against nothing. A replacement is
|
|
99
|
+
* a `Doc.diff`, capped so a long CHANGELOG never floods the report.
|
|
100
|
+
* Exported for tests only.
|
|
101
|
+
*
|
|
102
|
+
* @internal
|
|
103
|
+
*/
|
|
104
|
+
const driftReport = (file, drift) => {
|
|
105
|
+
const finding = `${file} would be modified by transform`;
|
|
106
|
+
if (drift === void 0) return [Report.warn(finding, "only its final line break differs")];
|
|
107
|
+
const { removed, added, head } = drift;
|
|
108
|
+
if (removed.length === 0) {
|
|
109
|
+
const where = head === 0 ? "at the top" : `after line ${head}`;
|
|
110
|
+
return [Report.warn(finding, `inserts ${lineCount(added)} ${where}`), ...listed("+", added)];
|
|
111
|
+
}
|
|
112
|
+
const span = removed.length === 1 ? `line ${head + 1}` : `lines ${head + 1}-${head + removed.length}`;
|
|
113
|
+
if (added.length === 0) return [Report.warn(finding, `removes ${lineCount(removed)} at ${span}`), ...listed("-", removed)];
|
|
114
|
+
return [Report.warn(finding, `${span}:`), Doc.diff(removed.join("\n"), added.join("\n"), { cap: DIFF_CAP })];
|
|
115
|
+
};
|
|
42
116
|
/* v8 ignore start -- CLI option definitions; handler tested via runTransform */
|
|
43
117
|
const fileArg = Argument.File("file").pipe(Argument.withDefault("CHANGELOG.md"));
|
|
44
118
|
const dryRunOption = Flag.Boolean("dry-run").pipe(Flag.withAlias("n"), Flag.withDescription("Print transformed output instead of writing"), Flag.withDefault(false));
|
|
@@ -65,18 +139,18 @@ function runTransform(file, dryRun, check) {
|
|
|
65
139
|
const content = yield* Effect.try(() => readFileSync(resolved, "utf-8"));
|
|
66
140
|
const result = ChangelogTransformer.transformContent(content);
|
|
67
141
|
if (dryRun) {
|
|
68
|
-
yield*
|
|
142
|
+
yield* Report.print([Report.verbatim(result)]);
|
|
69
143
|
return;
|
|
70
144
|
}
|
|
71
145
|
if (check) {
|
|
72
146
|
if (result !== content) {
|
|
73
|
-
yield*
|
|
147
|
+
yield* Report.print(driftReport(resolved, driftWindow(content, result)));
|
|
74
148
|
yield* CliExit.set(1);
|
|
75
|
-
} else yield*
|
|
149
|
+
} else yield* Report.print([Report.ok(`${resolved} is already formatted`)]);
|
|
76
150
|
return;
|
|
77
151
|
}
|
|
78
152
|
yield* Effect.try(() => writeFileSync(resolved, result, "utf-8"));
|
|
79
|
-
yield*
|
|
153
|
+
yield* Report.print([Report.ok(`Transformed ${resolved}`)]);
|
|
80
154
|
});
|
|
81
155
|
}
|
|
82
156
|
/* v8 ignore next 5 -- CLI registration; handler tested via runTransform */
|
|
@@ -87,4 +161,4 @@ const transformCommand = Command.make("transform", {
|
|
|
87
161
|
}, ({ file, dryRun, check }) => runTransform(file, dryRun, check)).pipe(Command.withDescription("Post-process CHANGELOG.md"));
|
|
88
162
|
|
|
89
163
|
//#endregion
|
|
90
|
-
export { runTransform, transformCommand };
|
|
164
|
+
export { driftReport, driftWindow, runTransform, transformCommand };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { CommandError } from "../../../internal/command-error.js";
|
|
2
3
|
import { Argument, Command } from "effect/cli";
|
|
3
4
|
import { CliExit } from "@effected/cli";
|
|
4
5
|
import { Changesets } from "@savvy-web/silk-effects";
|
|
@@ -26,8 +27,9 @@ const fileArg = Argument.File("file");
|
|
|
26
27
|
* Run lint validation on a single changeset file.
|
|
27
28
|
*
|
|
28
29
|
* Outputs one line per error in `file:line:col rule message` format.
|
|
29
|
-
*
|
|
30
|
-
* when errors are found
|
|
30
|
+
* Prints `✓ Valid` when the file passes. Sets exit code 1 through
|
|
31
|
+
* `CliExit.set` when errors are found; fails with a {@link CommandError}
|
|
32
|
+
* (reported on stderr, exit 1) when the file cannot be read.
|
|
31
33
|
*
|
|
32
34
|
* @param filePath - Path to the changeset `.md` file
|
|
33
35
|
* @returns An Effect that performs validation and logs results
|
|
@@ -36,15 +38,14 @@ const fileArg = Argument.File("file");
|
|
|
36
38
|
*/
|
|
37
39
|
function runValidateFile(filePath) {
|
|
38
40
|
return Effect.gen(function* () {
|
|
39
|
-
const result = yield* Effect.try(
|
|
40
|
-
|
|
41
|
+
const result = yield* Effect.try({
|
|
42
|
+
try: () => ChangesetLinter.validateFile(filePath),
|
|
43
|
+
catch: (error) => CommandError.from(error instanceof Error ? error : String(error), { message: `could not validate ${filePath}` })
|
|
44
|
+
});
|
|
45
|
+
if (result.length > 0) {
|
|
46
|
+
yield* Report.print([Report.verbatim(result.map((msg) => `${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`).join("\n"))]);
|
|
41
47
|
yield* CliExit.set(1);
|
|
42
|
-
|
|
43
|
-
})));
|
|
44
|
-
if (result === null) return;
|
|
45
|
-
for (const msg of result) yield* Output.line(`${msg.file}:${msg.line}:${msg.column} ${msg.rule} ${msg.message}`);
|
|
46
|
-
if (result.length > 0) yield* CliExit.set(1);
|
|
47
|
-
else yield* Output.ok("Valid");
|
|
48
|
+
} else yield* Report.print([Report.ok("Valid")]);
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
/* v8 ignore next 3 -- CLI registration; handler tested via runValidateFile */
|
|
@@ -1,20 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
2
|
import { requireValidConfig } from "../utils/config-gate.js";
|
|
3
3
|
import { Command, Flag } from "effect/cli";
|
|
4
|
+
import { Doc } from "@effected/cli";
|
|
4
5
|
import { Changesets } from "@savvy-web/silk-effects";
|
|
5
6
|
import { Effect } from "effect";
|
|
6
7
|
|
|
7
8
|
//#region src/commands/changeset/commands/version.ts
|
|
8
|
-
/**
|
|
9
|
-
* Version command -- natively apply pending changesets and report the result.
|
|
10
|
-
*
|
|
11
|
-
* Validates the config, then runs {@link ReleasePlanner.apply}, which bumps
|
|
12
|
-
* versions, writes + transforms CHANGELOGs, deletes consumed changesets, and
|
|
13
|
-
* updates configured versionFiles -- all without shelling out to a `changeset`
|
|
14
|
-
* CLI binary.
|
|
15
|
-
*
|
|
16
|
-
* @internal
|
|
17
|
-
*/
|
|
18
9
|
/* v8 ignore start -- CLI option definitions; handler tested via runVersion */
|
|
19
10
|
const dryRunOption = Flag.Boolean("dry-run").pipe(Flag.withAlias("n"), Flag.withDescription("Compute and report the release without writing anything"), Flag.withDefault(false));
|
|
20
11
|
/* v8 ignore stop */
|
|
@@ -28,17 +19,57 @@ function runVersion(dryRun) {
|
|
|
28
19
|
return Effect.gen(function* () {
|
|
29
20
|
const cwd = process.cwd();
|
|
30
21
|
yield* requireValidConfig(cwd);
|
|
31
|
-
const
|
|
22
|
+
const planner = yield* Changesets.ReleasePlanner;
|
|
23
|
+
if (dryRun) {
|
|
24
|
+
const result = yield* planner.apply(cwd, { dryRun });
|
|
25
|
+
if (result.releases.length === 0) {
|
|
26
|
+
yield* Report.print([Report.ok("No pending changesets")]);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
yield* Report.print([...releaseBlocks(result.releases, true), ...versionFileBlocks(result.versionFileUpdates, true)]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let engineReported = false;
|
|
33
|
+
let versionFilesReported = false;
|
|
34
|
+
const onStep = (step) => Effect.suspend(() => {
|
|
35
|
+
if (step._tag === "EngineApplied") {
|
|
36
|
+
engineReported = true;
|
|
37
|
+
return step.releases.length === 0 ? Effect.void : Report.print([...releaseBlocks(step.releases, false), Report.detail(`Touched ${step.touchedFiles.length} file(s)`)]);
|
|
38
|
+
}
|
|
39
|
+
versionFilesReported = true;
|
|
40
|
+
return Report.print(versionFileBlocks(step.updates, false));
|
|
41
|
+
});
|
|
42
|
+
const result = yield* planner.apply(cwd, { onStep });
|
|
32
43
|
if (result.releases.length === 0) {
|
|
33
|
-
yield*
|
|
44
|
+
yield* Report.print([Report.ok("No pending changesets")]);
|
|
34
45
|
return;
|
|
35
46
|
}
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (!
|
|
39
|
-
|
|
47
|
+
const remaining = [];
|
|
48
|
+
if (!engineReported) remaining.push(...releaseBlocks(result.releases, false), Report.detail(`Touched ${result.touchedFiles.length} file(s)`));
|
|
49
|
+
if (!versionFilesReported) remaining.push(...versionFileBlocks(result.versionFileUpdates, false));
|
|
50
|
+
if (remaining.length > 0) yield* Report.print(remaining);
|
|
40
51
|
});
|
|
41
52
|
}
|
|
53
|
+
/** The columns of the releases table. */
|
|
54
|
+
const RELEASE_COLUMNS = [
|
|
55
|
+
{ header: "package" },
|
|
56
|
+
{ header: "version" },
|
|
57
|
+
{ header: "bump" }
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* The releases as one status line (plan-phrased on a dry run) over a
|
|
61
|
+
* package / old → new / bump table.
|
|
62
|
+
*/
|
|
63
|
+
const releaseBlocks = (releases, dryRun) => {
|
|
64
|
+
const count = `${releases.length} package${releases.length === 1 ? "" : "s"}`;
|
|
65
|
+
return [dryRun ? Report.line(`Would release ${count}:`) : Report.ok(`Released ${count}`), Doc.table(RELEASE_COLUMNS, releases.map((r) => [
|
|
66
|
+
r.name,
|
|
67
|
+
`${r.oldVersion} → ${r.newVersion}`,
|
|
68
|
+
r.type
|
|
69
|
+
]))];
|
|
70
|
+
};
|
|
71
|
+
/** One detail line per versionFiles update, plan-phrased on a dry run. */
|
|
72
|
+
const versionFileBlocks = (updates, dryRun) => updates.map((u) => Report.detail(`${dryRun ? "Would update" : "Updated"} ${u.filePath} -> ${u.version}`));
|
|
42
73
|
/* v8 ignore next 4 -- CLI registration; handler tested via runVersion */
|
|
43
74
|
const versionCommand = Command.make("version", { dryRun: dryRunOption }, ({ dryRun }) => runVersion(dryRun)).pipe(Command.withDescription("Apply pending changesets: bump versions and transform CHANGELOGs"));
|
|
44
75
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkCommand, runChangesetCheck } from "./commands/check.js";
|
|
1
|
+
import { changesetCheckSection, checkCommand, runChangesetCheck } from "./commands/check.js";
|
|
2
2
|
import { configValidateCommand } from "./commands/config-validate.js";
|
|
3
3
|
import { depsDetectCommand } from "./commands/deps-detect.js";
|
|
4
4
|
import { depsRegenCommand } from "./commands/deps-regen.js";
|
|
@@ -6,7 +6,7 @@ import { lintCommand } from "./commands/lint.js";
|
|
|
6
6
|
import { transformCommand } from "./commands/transform.js";
|
|
7
7
|
import { validateFileCommand } from "./commands/validate-file.js";
|
|
8
8
|
import { versionCommand } from "./commands/version.js";
|
|
9
|
-
import { runChangesetInit } from "./commands/init.js";
|
|
9
|
+
import { runChangesetInit, runChangesetInitOrFail } from "./commands/init.js";
|
|
10
10
|
import { Command } from "effect/cli";
|
|
11
11
|
|
|
12
12
|
//#region src/commands/changeset/index.ts
|
|
@@ -40,4 +40,4 @@ const _changesetCommand = Command.make("changeset").pipe(Command.withSubcommands
|
|
|
40
40
|
const changesetCommand = _changesetCommand;
|
|
41
41
|
|
|
42
42
|
//#endregion
|
|
43
|
-
export { changesetCommand, runChangesetCheck, runChangesetInit };
|
|
43
|
+
export { changesetCheckSection, changesetCommand, runChangesetCheck, runChangesetInit };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { CommandError } from "../../../internal/command-error.js";
|
|
1
2
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { CliExit } from "@effected/cli";
|
|
3
3
|
import { Changesets } from "@savvy-web/silk-effects";
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
@@ -26,9 +26,10 @@ import { existsSync } from "node:fs";
|
|
|
26
26
|
* pre-0.9.0 behavior where `transform` and `version` worked on
|
|
27
27
|
* projects that have not yet been bootstrapped.
|
|
28
28
|
* - **Config present**: the gate invokes
|
|
29
|
-
* {@link ConfigInspector.inspect}.
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
29
|
+
* {@link ConfigInspector.inspect}. A `ConfigurationError` becomes a
|
|
30
|
+
* {@link CommandError} (the refusal, the config problem as its detail, and
|
|
31
|
+
* a `config validate` hint) that short-circuits the caller's `Effect.gen`;
|
|
32
|
+
* the runtime reports it on stderr and exits 1.
|
|
32
33
|
*
|
|
33
34
|
* @internal
|
|
34
35
|
*/
|
|
@@ -36,12 +37,12 @@ const { ConfigInspector } = Changesets;
|
|
|
36
37
|
/**
|
|
37
38
|
* Require a valid `.changeset/config.json` (when one exists) before
|
|
38
39
|
* proceeding. Resolves to `void` on a clean config OR on a project that
|
|
39
|
-
* doesn't have a config at all; fails with {@link
|
|
40
|
-
* an invalid config.
|
|
40
|
+
* doesn't have a config at all; fails with a {@link CommandError} wrapping
|
|
41
|
+
* the {@link ConfigurationError} on an invalid config.
|
|
41
42
|
*
|
|
42
43
|
* @param cwd - Project root (will be resolved against the process cwd)
|
|
43
44
|
* @returns Effect that succeeds on valid/absent config, fails with
|
|
44
|
-
* {@link
|
|
45
|
+
* {@link CommandError} otherwise
|
|
45
46
|
*
|
|
46
47
|
* @internal
|
|
47
48
|
*/
|
|
@@ -50,9 +51,10 @@ function requireValidConfig(cwd) {
|
|
|
50
51
|
const projectDir = resolve(cwd);
|
|
51
52
|
const configPath = join(projectDir, ".changeset", "config.json");
|
|
52
53
|
if (!existsSync(configPath)) return;
|
|
53
|
-
yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
yield* (yield* ConfigInspector).inspect(projectDir).pipe(Effect.catchTag("ConfigurationError", (err) => Effect.fail(CommandError.from(err, {
|
|
55
|
+
message: `refusing to run: ${configPath} is invalid`,
|
|
56
|
+
hint: "Run savvy changeset config validate to see the full report."
|
|
57
|
+
}))));
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Doc, Status } from "@effected/cli";
|
|
2
|
+
|
|
3
|
+
//#region src/commands/check-section.ts
|
|
4
|
+
/**
|
|
5
|
+
* A status cell for a hook table: the glyph and the state, e.g. `✓ up-to-date`.
|
|
6
|
+
* `not installed` is advisory (`↷`); `outdated`/`missing` are findings (`✗`).
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const stateCell = (state) => {
|
|
11
|
+
const status = state === "up-to-date" ? "success" : state === "not installed" ? "skip" : "failure";
|
|
12
|
+
return [
|
|
13
|
+
Doc.status(Status.core, status),
|
|
14
|
+
" ",
|
|
15
|
+
state
|
|
16
|
+
];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The hook-section table: one row per `[hook, section, state]`.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const hookTable = (rows) => Doc.table([
|
|
24
|
+
{ header: "hook" },
|
|
25
|
+
{ header: "section" },
|
|
26
|
+
{ header: "state" }
|
|
27
|
+
], rows.map(([hook, section, state]) => [
|
|
28
|
+
hook,
|
|
29
|
+
section,
|
|
30
|
+
stateCell(state)
|
|
31
|
+
]));
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { hookTable, stateCell };
|
package/commands/check.js
CHANGED
|
@@ -1,54 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Report } from "../internal/report.js";
|
|
2
|
+
import { changesetCheckSection } from "./changeset/commands/check.js";
|
|
3
3
|
import "./changeset/index.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { commitCheckSection } from "./commit/check.js";
|
|
5
|
+
import { lintCheckSection } from "./lint/check.js";
|
|
6
6
|
import { Command, Flag } from "effect/cli";
|
|
7
|
+
import { CliExit, Doc } from "@effected/cli";
|
|
7
8
|
import { Effect, Result } from "effect";
|
|
8
9
|
|
|
9
10
|
//#region src/commands/check.ts
|
|
10
|
-
/**
|
|
11
|
-
* Unified `savvy check` orchestrator.
|
|
12
|
-
*
|
|
13
|
-
* @remarks
|
|
14
|
-
* Runs all three tool-specific check handlers — changeset, commit, lint —
|
|
15
|
-
* sequentially so each tool's console output is cleanly grouped rather than
|
|
16
|
-
* interleaved. Unlike the `init` orchestrator, check MUST NOT short-circuit:
|
|
17
|
-
* the user wants to see every failing check in a single pass. Uses
|
|
18
|
-
* `Effect.all` with `{ concurrency: 1, mode: "validate" }` to run all three
|
|
19
|
-
* regardless of individual failures and then surface the full set of errors.
|
|
20
|
-
*
|
|
21
|
-
* Runtime layer provision (ManagedSection, FileSystem, WorkspaceRoot,
|
|
22
|
-
* ToolDiscovery, etc.) is deferred to Task B7 (root `main`). The Effect
|
|
23
|
-
* returned by `checkCommand`'s handler therefore carries the full union of the
|
|
24
|
-
* three handlers' requirements in its R channel.
|
|
25
|
-
*
|
|
26
|
-
* @internal
|
|
27
|
-
*/
|
|
28
11
|
/* v8 ignore start -- CLI option definitions; orchestration logic tested via runCheck */
|
|
29
12
|
const changesetDirOption = Flag.String("changeset-dir").pipe(Flag.withDescription("Path to the changeset directory"), Flag.withDefault(".changeset"));
|
|
30
13
|
const quietOption = Flag.Boolean("quiet").pipe(Flag.withAlias("q"), Flag.withDescription("Only output warnings from lint check"), Flag.withDefault(false));
|
|
31
14
|
/* v8 ignore stop */
|
|
32
15
|
/**
|
|
33
|
-
* Run
|
|
16
|
+
* Run the three checks without short-circuiting and print them as one
|
|
17
|
+
* document.
|
|
34
18
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Each step builds a {@link CheckSection}; each section is folded into a
|
|
21
|
+
* collapsible (a `::group::` under GitHub Actions, whose body is where a
|
|
22
|
+
* section's annotations are written, so its blocks are never nested deeper).
|
|
23
|
+
* A step that fails still lets the others run and report; the first failure
|
|
24
|
+
* is re-raised after the document is printed.
|
|
39
25
|
*
|
|
40
|
-
* @param steps - The three step Effects
|
|
41
|
-
* @returns An Effect that
|
|
42
|
-
*
|
|
26
|
+
* @param steps - The three step Effects. Injected for testability.
|
|
27
|
+
* @returns An Effect that prints the report and sets exit code 1 on any
|
|
28
|
+
* finding, or fails with the first failing step's error.
|
|
43
29
|
*/
|
|
44
30
|
function runCheck(steps) {
|
|
45
31
|
return Effect.gen(function* () {
|
|
46
|
-
const
|
|
47
|
-
Effect.result(steps.changeset),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
]
|
|
51
|
-
|
|
32
|
+
const sections = [
|
|
33
|
+
["changesets", yield* Effect.result(steps.changeset)],
|
|
34
|
+
["commitlint", yield* Effect.result(steps.commit)],
|
|
35
|
+
["lint-staged", yield* Effect.result(steps.lint)]
|
|
36
|
+
];
|
|
37
|
+
const blocks = [];
|
|
38
|
+
const verdicts = [];
|
|
39
|
+
let initFixes = false;
|
|
40
|
+
for (const [title, result] of sections) {
|
|
41
|
+
if (Result.isFailure(result)) {
|
|
42
|
+
verdicts.push("failure");
|
|
43
|
+
blocks.push(Report.fail(`${title}: the check could not run`));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const section = result.success;
|
|
47
|
+
verdicts.push(section.verdict);
|
|
48
|
+
initFixes ||= section.fixedByInit;
|
|
49
|
+
if (section.blocks.length > 0) blocks.push(Doc.collapsible(section.title, section.blocks), Report.line(""));
|
|
50
|
+
}
|
|
51
|
+
const count = (verdict) => verdicts.filter((v) => v === verdict).length;
|
|
52
|
+
blocks.push(Report.summary({
|
|
53
|
+
ok: count("success"),
|
|
54
|
+
warn: count("warning"),
|
|
55
|
+
fail: count("failure")
|
|
56
|
+
}));
|
|
57
|
+
if (initFixes) blocks.push(Doc.callout("tip", [Doc.paragraph("run savvy init to install or update the missing pieces")]));
|
|
58
|
+
yield* Report.print(blocks);
|
|
59
|
+
if (verdicts.includes("failure")) yield* CliExit.set(1);
|
|
60
|
+
for (const [, result] of sections) if (Result.isFailure(result)) return yield* Effect.fail(result.failure);
|
|
52
61
|
});
|
|
53
62
|
}
|
|
54
63
|
/* v8 ignore start -- CLI registration; orchestration logic tested via runCheck */
|
|
@@ -56,9 +65,9 @@ const _checkCommand = Command.make("check", {
|
|
|
56
65
|
changesetDir: changesetDirOption,
|
|
57
66
|
quiet: quietOption
|
|
58
67
|
}, (opts) => runCheck({
|
|
59
|
-
changeset:
|
|
60
|
-
commit:
|
|
61
|
-
lint:
|
|
68
|
+
changeset: changesetCheckSection(opts.changesetDir),
|
|
69
|
+
commit: commitCheckSection(),
|
|
70
|
+
lint: lintCheckSection({ quiet: opts.quiet })
|
|
62
71
|
})).pipe(Command.withDescription("Validate all Silk Suite tool configurations in one pass"));
|
|
63
72
|
/* v8 ignore stop */
|
|
64
73
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Doc, Status } from "@effected/cli";
|
|
2
|
+
|
|
3
|
+
//#region src/commands/clean/progress.ts
|
|
4
|
+
/** The state before a run starts. */
|
|
5
|
+
const initialCleanState = {
|
|
6
|
+
phase: "idle",
|
|
7
|
+
dryRun: false,
|
|
8
|
+
workspaces: 0,
|
|
9
|
+
finished: 0,
|
|
10
|
+
current: void 0,
|
|
11
|
+
removed: 0,
|
|
12
|
+
failed: []
|
|
13
|
+
};
|
|
14
|
+
/** Fold one event into the state; a start begins afresh. */
|
|
15
|
+
const reduceClean = (state, event) => {
|
|
16
|
+
switch (event._tag) {
|
|
17
|
+
case "Started": return {
|
|
18
|
+
...initialCleanState,
|
|
19
|
+
phase: "running",
|
|
20
|
+
dryRun: event.dryRun,
|
|
21
|
+
workspaces: event.workspaces
|
|
22
|
+
};
|
|
23
|
+
case "WorkspaceStarted": return {
|
|
24
|
+
...state,
|
|
25
|
+
current: event.name
|
|
26
|
+
};
|
|
27
|
+
case "WorkspaceDone": return {
|
|
28
|
+
...state,
|
|
29
|
+
finished: state.finished + 1,
|
|
30
|
+
removed: state.removed + event.removed.length,
|
|
31
|
+
failed: [...state.failed, ...event.failed]
|
|
32
|
+
};
|
|
33
|
+
case "Ended": return {
|
|
34
|
+
...state,
|
|
35
|
+
phase: "done",
|
|
36
|
+
current: void 0
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** `n removed, m failed` (or `would remove` on a dry run); a zero `failed` is left out. */
|
|
41
|
+
const counts = (state) => Doc.counts({
|
|
42
|
+
layout: "inline",
|
|
43
|
+
share: false,
|
|
44
|
+
counters: [Doc.counter(Status.core, "success", {
|
|
45
|
+
key: "removed",
|
|
46
|
+
label: state.dryRun ? "would remove" : "removed",
|
|
47
|
+
n: state.removed,
|
|
48
|
+
showZero: true
|
|
49
|
+
}), Doc.counter(Status.core, "failure", {
|
|
50
|
+
key: "failed",
|
|
51
|
+
label: "failed",
|
|
52
|
+
n: state.failed.length
|
|
53
|
+
})]
|
|
54
|
+
});
|
|
55
|
+
/** The paths that could not be removed, as a table. */
|
|
56
|
+
const failures = (failed) => failed.length === 0 ? [] : [Doc.table([
|
|
57
|
+
{ header: "could not remove" },
|
|
58
|
+
{ header: "kind" },
|
|
59
|
+
{ header: "reason" }
|
|
60
|
+
], failed.map((f) => [
|
|
61
|
+
Doc.file(f.target.path),
|
|
62
|
+
f.target.kind,
|
|
63
|
+
f.reason
|
|
64
|
+
]))];
|
|
65
|
+
/** While the run goes: which workspace, how many are done, and the counts so far. */
|
|
66
|
+
const progressDoc = (state, spinner) => [Doc.line([Doc.text(spinner, "accent"), ` ${state.dryRun ? "Previewing" : "Cleaning"} ${state.current ?? ""} (${state.finished}/${state.workspaces} workspaces)`]), counts(state)];
|
|
67
|
+
/** The committed summary: the counts, then any failures as a table. */
|
|
68
|
+
const summaryDoc = (state) => [counts(state), ...failures(state.failed)];
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
export { initialCleanState, progressDoc, reduceClean, summaryDoc };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { initialCleanState, progressDoc, reduceClean, summaryDoc } from "./progress.js";
|
|
2
|
+
import { DocView, useGlyphs } from "@effected/cli/ui";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/commands/clean/view.tsx
|
|
6
|
+
/** Draws the run: progress while it goes, the summary once it has ended. */
|
|
7
|
+
const CleanProgress = ({ state, frame }) => {
|
|
8
|
+
const glyphs = useGlyphs();
|
|
9
|
+
if (state.phase === "done") return /* @__PURE__ */ jsx(DocView, { doc: summaryDoc(state) });
|
|
10
|
+
const spinner = glyphs.spinner[frame % glyphs.spinner.length] ?? "";
|
|
11
|
+
return /* @__PURE__ */ jsx(DocView, { doc: progressDoc(state, spinner) });
|
|
12
|
+
};
|
|
13
|
+
/** The view `savvy clean` mounts, minus its events; the tests drive this same value. */
|
|
14
|
+
const cleanView = {
|
|
15
|
+
initial: initialCleanState,
|
|
16
|
+
reduce: reduceClean,
|
|
17
|
+
render: (state, frame) => /* @__PURE__ */ jsx(CleanProgress, {
|
|
18
|
+
state,
|
|
19
|
+
frame
|
|
20
|
+
}),
|
|
21
|
+
isStart: (event) => event._tag === "Started",
|
|
22
|
+
isTerminal: (event) => event._tag === "Ended"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
export { cleanView };
|