@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
package/commands/lint/init.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../internal/report.js";
|
|
2
|
+
import { CommandError } from "../../internal/command-error.js";
|
|
2
3
|
import { BIOME_VERSION } from "./biome-version.js";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { BiomeSchemaSync, LIFECYCLE_SCRIPTS_CONFIG_KEY, Lint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, publishesBuiltLinkDirectory, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyOkfBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
|
|
@@ -73,26 +74,27 @@ export default Preset.${preset}();
|
|
|
73
74
|
* @param fs - FileSystem service
|
|
74
75
|
* @param preset - The active preset
|
|
75
76
|
* @param force - Whether to overwrite the entire file
|
|
76
|
-
* @returns Effect that manages the markdownlint config
|
|
77
|
+
* @returns Effect that manages the markdownlint config, yielding the report lines for what it did
|
|
77
78
|
*/
|
|
78
79
|
function writeMarkdownlintConfig(fs, preset, force) {
|
|
79
80
|
return Effect.gen(function* () {
|
|
81
|
+
const blocks = [];
|
|
80
82
|
const configExists = yield* fs.exists(Lint.MARKDOWNLINT_CONFIG_PATH);
|
|
81
83
|
const fullTemplate = JSON.stringify(Lint.MARKDOWNLINT_TEMPLATE, null, " ");
|
|
82
84
|
if (!configExists) {
|
|
83
85
|
yield* fs.makeDirectory("lib/configs", { recursive: true });
|
|
84
86
|
yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
|
|
85
|
-
|
|
86
|
-
return;
|
|
87
|
+
blocks.push(Report.ok(`Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`));
|
|
88
|
+
return blocks;
|
|
87
89
|
}
|
|
88
90
|
if (preset !== "silk") {
|
|
89
|
-
|
|
90
|
-
return;
|
|
91
|
+
blocks.push(Report.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`));
|
|
92
|
+
return blocks;
|
|
91
93
|
}
|
|
92
94
|
if (force) {
|
|
93
95
|
yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
|
|
94
|
-
|
|
95
|
-
return;
|
|
96
|
+
blocks.push(Report.ok(`Replaced ${Lint.MARKDOWNLINT_CONFIG_PATH} (--force)`));
|
|
97
|
+
return blocks;
|
|
96
98
|
}
|
|
97
99
|
const existingText = yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH);
|
|
98
100
|
const existingParsed = yield* Jsonc.parse(existingText);
|
|
@@ -113,11 +115,12 @@ function writeMarkdownlintConfig(fs, preset, force) {
|
|
|
113
115
|
}
|
|
114
116
|
const existingConfig = existingParsed.config;
|
|
115
117
|
const configMatches = existingConfig !== void 0 && isDeepStrictEqual(existingConfig, Lint.MARKDOWNLINT_CONFIG);
|
|
116
|
-
if (!configMatches)
|
|
118
|
+
if (!configMatches) blocks.push(Report.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`));
|
|
117
119
|
if (applied.length > 0) {
|
|
118
120
|
yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, updatedText);
|
|
119
|
-
|
|
120
|
-
} else if (configMatches)
|
|
121
|
+
blocks.push(Report.ok(`Updated ${applied.join(", ")} in ${Lint.MARKDOWNLINT_CONFIG_PATH}`));
|
|
122
|
+
} else if (configMatches) blocks.push(Report.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`));
|
|
123
|
+
return blocks;
|
|
121
124
|
});
|
|
122
125
|
}
|
|
123
126
|
/**
|
|
@@ -168,6 +171,8 @@ function biomeConfigRoots() {
|
|
|
168
171
|
*
|
|
169
172
|
* Every failure reads as "say nothing": an unreadable or malformed manifest
|
|
170
173
|
* cannot make the notice wrong, only absent.
|
|
174
|
+
*
|
|
175
|
+
* @returns Effect yielding the notice's report lines, empty when none is due
|
|
171
176
|
*/
|
|
172
177
|
function noticeLifecycleScripts() {
|
|
173
178
|
return Effect.gen(function* () {
|
|
@@ -175,12 +180,9 @@ function noticeLifecycleScripts() {
|
|
|
175
180
|
const roots = yield* biomeConfigRoots();
|
|
176
181
|
for (const root of roots) {
|
|
177
182
|
const manifest = yield* fs.readFileString(join(root, "package.json")).pipe(Effect.map((raw) => JSON.parse(raw)), Effect.catch(() => Effect.succeed(void 0)));
|
|
178
|
-
if (publishesBuiltLinkDirectory(manifest)) {
|
|
179
|
-
yield* Output.warn("This workspace publishes through built link directories, so a hook-time install must run lifecycle scripts.");
|
|
180
|
-
yield* Output.detail(`Allow them with: git config --local ${LIFECYCLE_SCRIPTS_CONFIG_KEY} true`);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
+
if (publishesBuiltLinkDirectory(manifest)) return [Report.warn("This workspace publishes through built link directories, so a hook-time install must run lifecycle scripts.", `Allow them with: git config --local ${LIFECYCLE_SCRIPTS_CONFIG_KEY} true`)];
|
|
183
184
|
}
|
|
185
|
+
return [];
|
|
184
186
|
});
|
|
185
187
|
}
|
|
186
188
|
/**
|
|
@@ -197,26 +199,40 @@ function noticeLifecycleScripts() {
|
|
|
197
199
|
* volume — `runLintInit` itself chmods hook files through `node:fs/promises`,
|
|
198
200
|
* which no `FileSystem` double can intercept.
|
|
199
201
|
*
|
|
200
|
-
* @returns Effect that syncs biome schemas
|
|
202
|
+
* @returns Effect that syncs biome schemas, yielding one report line per config
|
|
203
|
+
* checked or updated and per root that could not be synced
|
|
201
204
|
*
|
|
202
205
|
* @internal
|
|
203
206
|
*/
|
|
204
207
|
function syncBiomeSchemas() {
|
|
205
208
|
return Effect.gen(function* () {
|
|
209
|
+
const blocks = [];
|
|
206
210
|
const syncer = yield* BiomeSchemaSync;
|
|
207
211
|
const roots = yield* biomeConfigRoots();
|
|
208
212
|
const passes = roots.length > 0 ? roots.map((cwd) => ({ cwd })) : [{ cwd: process.cwd() }];
|
|
209
|
-
for (const options of passes)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
+
for (const options of passes) {
|
|
214
|
+
const passBlocks = yield* syncer.sync(BIOME_VERSION, options).pipe(Effect.map((result) => [...result.current.map((configPath) => Report.ok(`${configPath}: biome $schema up-to-date`)), ...result.updated.map((configPath) => Report.ok(`Updated $schema in ${configPath}`))]), Effect.catchTag("BiomeSyncError", (e) => Effect.succeed([Report.warn(`Could not sync biome $schema: ${e.message}`)])));
|
|
215
|
+
blocks.push(...passBlocks);
|
|
216
|
+
}
|
|
217
|
+
return blocks;
|
|
213
218
|
});
|
|
214
219
|
}
|
|
215
220
|
/** Make a file executable. */
|
|
216
221
|
function makeExecutable(path) {
|
|
217
222
|
return Effect.tryPromise({
|
|
218
223
|
try: () => chmod(path, EXECUTABLE_MODE),
|
|
219
|
-
catch: (e) =>
|
|
224
|
+
catch: (e) => CommandError.from(e, {
|
|
225
|
+
message: `could not make ${path} executable`,
|
|
226
|
+
hint: `Check that you own ${path}, then re-run the init.`
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** The failure for a `--config` path given as absolute. */
|
|
231
|
+
function absoluteConfigError(config) {
|
|
232
|
+
return new CommandError({
|
|
233
|
+
message: "Config path must be relative to repository root, not absolute",
|
|
234
|
+
detail: [`got ${config}`],
|
|
235
|
+
hint: "Pass the config path relative to the repository root, e.g. lib/configs/<name>.config.ts."
|
|
220
236
|
});
|
|
221
237
|
}
|
|
222
238
|
/** Ensure a hook file exists, writing `header` if it does not. */
|
|
@@ -232,6 +248,10 @@ function ensureHookFile(path, header) {
|
|
|
232
248
|
* Exported so Task B5's unified `savvy init` orchestrator can invoke the
|
|
233
249
|
* lint-staged init step directly without going through the CLI command layer.
|
|
234
250
|
*
|
|
251
|
+
* @remarks
|
|
252
|
+
* Prints a `lint-staged` report section on stdout, one step at a time as each
|
|
253
|
+
* completes, so a later step's failure still leaves what was written on screen.
|
|
254
|
+
*
|
|
235
255
|
* @param opts - The same options the CLI command receives
|
|
236
256
|
* @returns An Effect that performs initialization
|
|
237
257
|
*
|
|
@@ -242,8 +262,8 @@ function runLintInit(opts) {
|
|
|
242
262
|
return Effect.gen(function* () {
|
|
243
263
|
const fs = yield* FileSystem.FileSystem;
|
|
244
264
|
const ms = yield* ManagedSection;
|
|
245
|
-
if (config.startsWith("/")) yield* Effect.fail(
|
|
246
|
-
yield*
|
|
265
|
+
if (config.startsWith("/")) return yield* Effect.fail(absoluteConfigError(config));
|
|
266
|
+
yield* Report.print([Report.heading("lint-staged")]);
|
|
247
267
|
yield* fs.makeDirectory(".husky", { recursive: true });
|
|
248
268
|
if (force) yield* fs.writeFileString(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
|
|
249
269
|
else yield* ensureHookFile(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
|
|
@@ -253,7 +273,7 @@ function runLintInit(opts) {
|
|
|
253
273
|
savvyOkfBlock()
|
|
254
274
|
]);
|
|
255
275
|
yield* makeExecutable(Lint.HUSKY_HOOK_PATH);
|
|
256
|
-
yield*
|
|
276
|
+
yield* Report.print([Report.ok(`${force ? "Replaced" : "Synced"} ${Lint.HUSKY_HOOK_PATH} (${preCommitResults.map((r) => r._tag).join(", ")})`)]);
|
|
257
277
|
if (presetIncludesShellScripts(preset)) for (const [hookPath, installHook] of HYGIENE_HOOKS) {
|
|
258
278
|
yield* ensureHookFile(hookPath, HYGIENE_HEADER);
|
|
259
279
|
yield* ms.remove(hookPath, Lint.LegacySavvyLintHygieneDef);
|
|
@@ -264,20 +284,19 @@ function runLintInit(opts) {
|
|
|
264
284
|
}
|
|
265
285
|
yield* ms.syncAll(hookPath, sections);
|
|
266
286
|
yield* makeExecutable(hookPath);
|
|
267
|
-
yield*
|
|
287
|
+
yield* Report.print([Report.ok(`Synced ${hookPath}`)]);
|
|
268
288
|
}
|
|
269
|
-
if (presetIncludesShellScripts(preset)) yield* noticeLifecycleScripts();
|
|
270
|
-
if (presetIncludesMarkdown(preset)) yield* writeMarkdownlintConfig(fs, preset, force);
|
|
271
|
-
yield* syncBiomeSchemas();
|
|
272
|
-
if ((yield* fs.exists(config)) && !force) yield*
|
|
289
|
+
if (presetIncludesShellScripts(preset)) yield* Report.print(yield* noticeLifecycleScripts());
|
|
290
|
+
if (presetIncludesMarkdown(preset)) yield* Report.print(yield* writeMarkdownlintConfig(fs, preset, force));
|
|
291
|
+
yield* Report.print(yield* syncBiomeSchemas());
|
|
292
|
+
if ((yield* fs.exists(config)) && !force) yield* Report.print([Report.warn(`${config} already exists (use --force to overwrite)`)]);
|
|
273
293
|
else {
|
|
274
294
|
const configDir = dirname(config);
|
|
275
295
|
if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
|
|
276
296
|
yield* fs.writeFileString(config, generateConfigContent(preset));
|
|
277
|
-
yield*
|
|
297
|
+
yield* Report.print([Report.ok(`Created ${config} (preset: ${preset})`)]);
|
|
278
298
|
}
|
|
279
|
-
yield*
|
|
280
|
-
yield* Output.ok("lint-staged is ready to use");
|
|
299
|
+
yield* Report.print([Report.line(""), Report.ok("lint-staged is ready to use")]);
|
|
281
300
|
});
|
|
282
301
|
}
|
|
283
302
|
|
package/commands/lint/text.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../internal/report.js";
|
|
2
2
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import { CliExit } from "@effected/cli";
|
|
3
|
+
import { CliExit, Doc } from "@effected/cli";
|
|
4
4
|
import { Lint } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
5
|
+
import { Effect, Path } from "effect";
|
|
6
6
|
|
|
7
7
|
//#region src/commands/lint/text.ts
|
|
8
8
|
/**
|
|
@@ -32,32 +32,47 @@ import { Effect } from "effect";
|
|
|
32
32
|
const filesArg = Argument.File("files", { mustExist: true }).pipe(Argument.withDescription("Files to check; omit to check every git-tracked source/text file"), Argument.variadic());
|
|
33
33
|
const stagedFlag = Flag.Boolean("staged").pipe(Flag.withDescription("Read each file's staged content from the git index instead of the working tree"), Flag.withDefault(false));
|
|
34
34
|
/**
|
|
35
|
-
* Check `files` (or, when empty, every tracked file under `cwd`) and print
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* Check `files` (or, when empty, every tracked file under `cwd`) and print the
|
|
36
|
+
* findings as one document: a `✗ path:line:col message` line per finding (the
|
|
37
|
+
* form editors link and agents grep), a GitHub Actions error annotation per
|
|
38
|
+
* finding (written only under Actions, with the path relative to `cwd`), and a
|
|
39
|
+
* summary. Any finding sets exit code 1. Under `--staged`, every file is still
|
|
40
|
+
* checked when one has no index entry: that file's read error is logged to
|
|
41
|
+
* stderr (a diagnostic, not a finding in its content), counted as failed, and
|
|
42
|
+
* also sets exit code 1. Exported for tests.
|
|
40
43
|
*
|
|
41
44
|
* @internal
|
|
42
45
|
*/
|
|
43
46
|
const runLintText = (files, options = {}) => Effect.gen(function* () {
|
|
47
|
+
const path = yield* Path.Path;
|
|
48
|
+
const cwd = options.cwd ?? ".";
|
|
44
49
|
const staged = options.staged ?? false;
|
|
45
|
-
const targets = files.length > 0 ? files : yield* Lint.TextFiles.listTracked(
|
|
50
|
+
const targets = files.length > 0 ? files : yield* Lint.TextFiles.listTracked(cwd, { staged });
|
|
46
51
|
const { findings, unreadable } = staged ? yield* Lint.TextFiles.checkStagedFiles(targets) : {
|
|
47
52
|
findings: yield* Lint.TextFiles.checkFiles(targets),
|
|
48
53
|
unreadable: []
|
|
49
54
|
};
|
|
50
55
|
for (const error of unreadable) yield* Effect.logError(error.message);
|
|
51
56
|
if (findings.length === 0 && unreadable.length === 0) {
|
|
52
|
-
yield*
|
|
57
|
+
yield* Report.print([Report.ok(`${targets.length} ${targets.length === 1 ? "file is" : "files are"} grep-visible text`)]);
|
|
53
58
|
return findings;
|
|
54
59
|
}
|
|
55
|
-
|
|
60
|
+
const root = path.resolve(cwd);
|
|
61
|
+
const annotationPath = (file) => {
|
|
62
|
+
const relative = path.relative(root, path.resolve(file));
|
|
63
|
+
return relative.startsWith("..") || path.isAbsolute(relative) ? file : relative;
|
|
64
|
+
};
|
|
56
65
|
const failedFiles = (/* @__PURE__ */ new Set([...findings.map((finding) => finding.path), ...unreadable.map((error) => error.path)])).size;
|
|
57
|
-
yield*
|
|
66
|
+
yield* Report.print([...findings.flatMap((finding) => [Report.fail(`${finding.location} ${finding.message}`), Doc.annotation({
|
|
67
|
+
level: "error",
|
|
68
|
+
file: annotationPath(finding.path),
|
|
69
|
+
line: finding.line,
|
|
70
|
+
col: finding.column,
|
|
71
|
+
title: "grep-invisible text"
|
|
72
|
+
}, finding.message)]), Report.summary({
|
|
58
73
|
ok: targets.length - failedFiles,
|
|
59
74
|
fail: failedFiles
|
|
60
|
-
});
|
|
75
|
+
})]);
|
|
61
76
|
yield* CliExit.set(1);
|
|
62
77
|
return findings;
|
|
63
78
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
3
|
+
import { Argument, Command, Flag, Prompt } from "effect/cli";
|
|
4
|
+
import { CliPrompt } from "@effected/cli";
|
|
4
5
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
6
|
import { Effect, Option } from "effect";
|
|
6
7
|
|
|
@@ -20,7 +21,11 @@ import { Effect, Option } from "effect";
|
|
|
20
21
|
* manifest exists but is corrupt or unreadable, `GitSubmoduleError` means
|
|
21
22
|
* the underlying git command failed, and `ReposLockdownError` means the
|
|
22
23
|
* OS-permission lockdown pass on a vendored tree failed -- all three are
|
|
23
|
-
* real failures,
|
|
24
|
+
* real failures, failing as a `CommandError` (exit 1) with a hint.
|
|
25
|
+
*
|
|
26
|
+
* `--ref` and `--purpose` are required. Left off at a terminal, each is asked
|
|
27
|
+
* for with core's line prompt (`CliPrompt.fallback`); left off anywhere else
|
|
28
|
+
* (an agent, CI, a pipe) it stays core's missing-flag usage error, exit 64.
|
|
24
29
|
*
|
|
25
30
|
* @example
|
|
26
31
|
* ```bash
|
|
@@ -32,8 +37,8 @@ import { Effect, Option } from "effect";
|
|
|
32
37
|
*/
|
|
33
38
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
34
39
|
const urlArg = Argument.String("url");
|
|
35
|
-
const refOption = Flag.String("ref").pipe(Flag.withDescription("Ref (tag, branch, or commit) to check out"));
|
|
36
|
-
const purposeOption = Flag.String("purpose").pipe(Flag.withDescription("Why this repo is vendored"));
|
|
40
|
+
const refOption = Flag.String("ref").pipe(Flag.withDescription("Ref (tag, branch, or commit) to check out"), Flag.withFallbackPrompt(CliPrompt.fallback(Prompt.String({ message: "Ref to check out (tag, branch, or commit)" }), { flag: "ref" })));
|
|
41
|
+
const purposeOption = Flag.String("purpose").pipe(Flag.withDescription("Why this repo is vendored"), Flag.withFallbackPrompt(CliPrompt.fallback(Prompt.String({ message: "Why is this repo vendored?" }), { flag: "purpose" })));
|
|
37
42
|
const nameOption = Flag.String("name").pipe(Flag.withDescription("Vendored directory name; defaults to the URL's last path segment"), Flag.optional);
|
|
38
43
|
const sparseOption = Flag.String("sparse").pipe(Flag.withDescription("Sparse-checkout path; repeatable"), Flag.atLeast(0));
|
|
39
44
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to add within"), Flag.withDefault("."));
|
|
@@ -45,17 +50,9 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
|
|
|
45
50
|
*/
|
|
46
51
|
const runReposAdd = (cwd, opts) => Effect.gen(function* () {
|
|
47
52
|
const result = yield* (yield* Repos.ReposManager).add(cwd, opts);
|
|
48
|
-
yield*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
|
|
52
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
53
|
-
}), Effect.catchTag("GitSubmoduleError", (error) => {
|
|
54
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
55
|
-
}), Effect.catchTag("ReposLockdownError", (error) => {
|
|
56
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
57
|
-
}));
|
|
58
|
-
/* v8 ignore start -- CLI registration; handler tested via runReposAdd */
|
|
53
|
+
yield* Report.print([Report.ok(`${result.name} @ ${result.ref} -> ${result.path}`, "staged — review and commit")]);
|
|
54
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail(`vendor ${opts.url}`)(error)), Effect.catchTag(["GitSubmoduleError", "ReposLockdownError"], ReposCli.fail(`vendor ${opts.url}`)));
|
|
55
|
+
/* v8 ignore start -- CLI registration; handler tested via runReposAdd, the fallbacks via the command itself */
|
|
59
56
|
const addCommand = Command.make("add", {
|
|
60
57
|
url: urlArg,
|
|
61
58
|
ref: refOption,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import {
|
|
4
|
+
import { CliInteractive } from "@effected/cli";
|
|
4
5
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
6
|
+
import { Effect, Option } from "effect";
|
|
7
|
+
import { CliUi, Select } from "@effected/cli/ui";
|
|
6
8
|
|
|
7
9
|
//#region src/commands/repos/commands/deregister.ts
|
|
8
10
|
/**
|
|
@@ -26,9 +28,16 @@ import { Effect } from "effect";
|
|
|
26
28
|
* the section still backs a live manifest entry (use `remove` to unvendor, or
|
|
27
29
|
* `sync` to reconcile), the section is not registered at all (usually a
|
|
28
30
|
* typo), or the manifest itself is unreadable; a `GitSubmoduleError` means
|
|
29
|
-
* the underlying git command failed.
|
|
30
|
-
* exit-0 case: the manager folds
|
|
31
|
-
*
|
|
31
|
+
* the underlying git command failed. Each fails as a `CommandError` with a
|
|
32
|
+
* hint. There is no friendly missing-manifest exit-0 case: the manager folds
|
|
33
|
+
* a missing manifest to "no live entries" and proceeds, since a stale
|
|
34
|
+
* registration can outlive the manifest itself.
|
|
35
|
+
*
|
|
36
|
+
* Left off at a terminal, the section is picked (`Select`) from the stale
|
|
37
|
+
* registrations `ReposDrift.check` reports — a `localRegistrationDivergence`
|
|
38
|
+
* with no manifest value — never from the vendored repos, which this command
|
|
39
|
+
* refuses. Anywhere else, or with nothing stale to pick, a missing section is
|
|
40
|
+
* the usage error it always was, exit 64.
|
|
32
41
|
*
|
|
33
42
|
* @example
|
|
34
43
|
* ```bash
|
|
@@ -38,29 +47,39 @@ import { Effect } from "effect";
|
|
|
38
47
|
* @internal
|
|
39
48
|
*/
|
|
40
49
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
41
|
-
const sectionArg = Argument.String("section");
|
|
50
|
+
const sectionArg = Argument.String("section").pipe(Argument.optional);
|
|
42
51
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to deregister within"), Flag.withDefault("."));
|
|
43
52
|
/* v8 ignore stop */
|
|
53
|
+
/** The section given, or one picked from the stale registrations at a terminal; else a usage error. */
|
|
54
|
+
const sectionOrPick = (cwd, given) => Effect.gen(function* () {
|
|
55
|
+
if (given !== void 0) return given;
|
|
56
|
+
if (!(yield* CliInteractive)) return yield* Effect.fail(ReposCli.missingArgument(["deregister"], "section"));
|
|
57
|
+
const stale = (yield* (yield* Repos.ReposDrift).check(cwd)).drifts.filter((item) => item.kind === "localRegistrationDivergence" && item.manifestValue === void 0);
|
|
58
|
+
if (stale.length === 0) return yield* Effect.fail(ReposCli.missingArgument(["deregister"], "section"));
|
|
59
|
+
return yield* CliUi.prompt(Select.screen({
|
|
60
|
+
message: "Deregister which stale registration?",
|
|
61
|
+
choices: stale.map((item) => ({
|
|
62
|
+
label: item.name,
|
|
63
|
+
value: item.name,
|
|
64
|
+
detail: item.detail
|
|
65
|
+
}))
|
|
66
|
+
})).pipe(Effect.catchTag("NotInteractive", () => Effect.fail(ReposCli.missingArgument(["deregister"], "section"))));
|
|
67
|
+
});
|
|
44
68
|
/**
|
|
45
|
-
* Deregister handler; exported for tests.
|
|
69
|
+
* Deregister handler; exported for tests. `section` is `undefined` when left off.
|
|
46
70
|
*
|
|
47
71
|
* @internal
|
|
48
72
|
*/
|
|
49
73
|
const runReposDeregister = (cwd, section) => Effect.gen(function* () {
|
|
50
|
-
const
|
|
51
|
-
yield*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}).pipe(Effect.catchTag("ReposConfigError", (error) => {
|
|
55
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
56
|
-
}), Effect.catchTag("GitSubmoduleError", (error) => {
|
|
57
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
58
|
-
}));
|
|
74
|
+
const target = yield* sectionOrPick(cwd, section);
|
|
75
|
+
const result = yield* (yield* Repos.ReposManager).deregister(cwd, target);
|
|
76
|
+
yield* Report.print([Report.ok(`${result.section}: deregistered (${result.removedKeys.length} config keys removed)`, ...result.removedKeys.map((key) => `removed ${key}`), "local git config only — nothing to commit")]);
|
|
77
|
+
}).pipe(Effect.catchTag("ReposConfigError", ReposCli.fail("deregister the submodule registration", "it still backs a manifest entry (use `savvy repos remove`), is not registered, or the manifest is unreadable; `savvy repos status --drift` lists the stale ones")), Effect.catchTag("GitSubmoduleError", ReposCli.fail("deregister the submodule registration")));
|
|
59
78
|
/* v8 ignore start -- CLI registration; handler tested via runReposDeregister */
|
|
60
79
|
const deregisterCommand = Command.make("deregister", {
|
|
61
80
|
section: sectionArg,
|
|
62
81
|
cwd: cwdOption
|
|
63
|
-
}, ({ section, cwd }) => runReposDeregister(cwd, section)).pipe(Command.withDescription("Clear a stale submodule registration (a submodule.<section> section matching no manifest entry) from the local git config"));
|
|
82
|
+
}, ({ section, cwd }) => runReposDeregister(cwd, Option.getOrUndefined(section))).pipe(Command.withDescription("Clear a stale submodule registration (a submodule.<section> section matching no manifest entry) from the local git config"));
|
|
64
83
|
/* v8 ignore stop */
|
|
65
84
|
|
|
66
85
|
//#endregion
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import { CliExit } from "@effected/cli";
|
|
4
4
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
5
|
+
import { Effect, Option } from "effect";
|
|
6
|
+
import { CliUi, Select } from "@effected/cli/ui";
|
|
6
7
|
|
|
7
8
|
//#region src/commands/repos/commands/note.ts
|
|
8
9
|
/**
|
|
@@ -23,7 +24,14 @@ import { Effect } from "effect";
|
|
|
23
24
|
* kind `"invalid"` means the manifest exists but is corrupt or unreadable,
|
|
24
25
|
* `RepoNotFoundError` means the named repo isn't in the manifest, and
|
|
25
26
|
* `NoteNotFoundError` means the note id doesn't exist on that repo -- all
|
|
26
|
-
* three are real failures,
|
|
27
|
+
* three are real failures, failing as a `CommandError` (exit 1) with a hint.
|
|
28
|
+
*
|
|
29
|
+
* Each leaf's positionals are optional to the parser so a person can leave
|
|
30
|
+
* them off: at a terminal a missing repo name is picked from the vendored
|
|
31
|
+
* repos (`Select`) and a missing note text or id typed (`TextInput`), and a
|
|
32
|
+
* missing `promote --into` is picked from `layout`/`startHere` while parsing
|
|
33
|
+
* (`CliUi.fallback`). Anywhere else a missing one is the usage error it always
|
|
34
|
+
* was, exit 64.
|
|
27
35
|
*
|
|
28
36
|
* @example
|
|
29
37
|
* ```bash
|
|
@@ -34,27 +42,71 @@ import { Effect } from "effect";
|
|
|
34
42
|
*
|
|
35
43
|
* @internal
|
|
36
44
|
*/
|
|
45
|
+
/** `request` with its missing text or id asked for (or a usage error), as `ReposManager.note` takes it. */
|
|
46
|
+
const complete = (name, request) => {
|
|
47
|
+
switch (request.op) {
|
|
48
|
+
case "add": return Effect.map(ReposCli.textOrAsk(request.note, {
|
|
49
|
+
command: ["note", "add"],
|
|
50
|
+
argument: "text",
|
|
51
|
+
message: `Note to add to ${name}:`
|
|
52
|
+
}), (note) => ({
|
|
53
|
+
op: "add",
|
|
54
|
+
note
|
|
55
|
+
}));
|
|
56
|
+
case "remove": return Effect.map(ReposCli.textOrAsk(request.id, {
|
|
57
|
+
command: ["note", "remove"],
|
|
58
|
+
argument: "id",
|
|
59
|
+
message: `Id of the ${name} note to remove:`
|
|
60
|
+
}), (id) => ({
|
|
61
|
+
op: "remove",
|
|
62
|
+
id
|
|
63
|
+
}));
|
|
64
|
+
case "promote": return Effect.map(ReposCli.textOrAsk(request.id, {
|
|
65
|
+
command: ["note", "promote"],
|
|
66
|
+
argument: "id",
|
|
67
|
+
message: `Id of the ${name} note to promote:`
|
|
68
|
+
}), (id) => ({
|
|
69
|
+
op: "promote",
|
|
70
|
+
id,
|
|
71
|
+
into: request.into
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
37
75
|
/**
|
|
38
|
-
* Note handler; exported for tests.
|
|
76
|
+
* Note handler; exported for tests. `name` is `undefined` when left off.
|
|
39
77
|
*
|
|
40
78
|
* @internal
|
|
41
79
|
*/
|
|
42
|
-
const runReposNote = (cwd, name,
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
const runReposNote = (cwd, name, request) => Effect.gen(function* () {
|
|
81
|
+
const target = yield* ReposCli.nameOrPick(cwd, name, {
|
|
82
|
+
command: ["note", request.op],
|
|
83
|
+
argument: "name",
|
|
84
|
+
message: `Which repo's notes (${request.op})?`
|
|
85
|
+
});
|
|
86
|
+
const op = yield* complete(target, request);
|
|
87
|
+
const result = yield* (yield* Repos.ReposManager).note(cwd, target, op);
|
|
88
|
+
yield* Report.print([Report.ok(`${result.name}: ${result.op} note ${result.id} (${result.noteCount} notes)`)]);
|
|
89
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail(`${request.op} the note`)(error)), Effect.catchTag([
|
|
90
|
+
"RepoNotFoundError",
|
|
91
|
+
"NoteNotFoundError",
|
|
92
|
+
"GitSubmoduleError"
|
|
93
|
+
], ReposCli.fail(`${request.op} the note`)));
|
|
53
94
|
/* v8 ignore start -- CLI registration; handler tested via runReposNote */
|
|
54
|
-
const nameArg = Argument.String("name");
|
|
55
|
-
const noteTextArg = Argument.String("text");
|
|
56
|
-
const noteIdArg = Argument.String("id");
|
|
57
|
-
const intoOption = Flag.Literals("into", ["layout", "startHere"]).pipe(Flag.withDescription("Curated orientation field to promote the note into")
|
|
95
|
+
const nameArg = Argument.String("name").pipe(Argument.optional);
|
|
96
|
+
const noteTextArg = Argument.String("text").pipe(Argument.optional);
|
|
97
|
+
const noteIdArg = Argument.String("id").pipe(Argument.optional);
|
|
98
|
+
const intoOption = Flag.Literals("into", ["layout", "startHere"]).pipe(Flag.withDescription("Curated orientation field to promote the note into"), Flag.withFallbackPrompt(CliUi.fallback(Select.screen({
|
|
99
|
+
message: "Promote the note into which orientation field?",
|
|
100
|
+
choices: [{
|
|
101
|
+
label: "layout",
|
|
102
|
+
value: "layout",
|
|
103
|
+
detail: "where things live in the vendored tree"
|
|
104
|
+
}, {
|
|
105
|
+
label: "startHere",
|
|
106
|
+
value: "startHere",
|
|
107
|
+
detail: "where an agent should begin reading"
|
|
108
|
+
}]
|
|
109
|
+
}), { flag: "into" })));
|
|
58
110
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root whose manifest holds the notes"), Flag.withDefault("."));
|
|
59
111
|
const _noteGroup = Command.make("note").pipe(Command.withSharedFlags({ cwd: cwdOption }));
|
|
60
112
|
const addLeaf = Command.make("add", {
|
|
@@ -62,9 +114,9 @@ const addLeaf = Command.make("add", {
|
|
|
62
114
|
note: noteTextArg
|
|
63
115
|
}, ({ name, note }) => Effect.gen(function* () {
|
|
64
116
|
const { cwd } = yield* _noteGroup;
|
|
65
|
-
yield* runReposNote(cwd, name, {
|
|
117
|
+
yield* runReposNote(cwd, Option.getOrUndefined(name), {
|
|
66
118
|
op: "add",
|
|
67
|
-
note
|
|
119
|
+
note: Option.getOrUndefined(note)
|
|
68
120
|
});
|
|
69
121
|
})).pipe(Command.withDescription("Append an agent note to a vendored repo"));
|
|
70
122
|
const removeLeaf = Command.make("remove", {
|
|
@@ -72,9 +124,9 @@ const removeLeaf = Command.make("remove", {
|
|
|
72
124
|
id: noteIdArg
|
|
73
125
|
}, ({ name, id }) => Effect.gen(function* () {
|
|
74
126
|
const { cwd } = yield* _noteGroup;
|
|
75
|
-
yield* runReposNote(cwd, name, {
|
|
127
|
+
yield* runReposNote(cwd, Option.getOrUndefined(name), {
|
|
76
128
|
op: "remove",
|
|
77
|
-
id
|
|
129
|
+
id: Option.getOrUndefined(id)
|
|
78
130
|
});
|
|
79
131
|
})).pipe(Command.withDescription("Remove an agent note from a vendored repo"));
|
|
80
132
|
const promoteLeaf = Command.make("promote", {
|
|
@@ -83,9 +135,9 @@ const promoteLeaf = Command.make("promote", {
|
|
|
83
135
|
into: intoOption
|
|
84
136
|
}, ({ name, id, into }) => Effect.gen(function* () {
|
|
85
137
|
const { cwd } = yield* _noteGroup;
|
|
86
|
-
yield* runReposNote(cwd, name, {
|
|
138
|
+
yield* runReposNote(cwd, Option.getOrUndefined(name), {
|
|
87
139
|
op: "promote",
|
|
88
|
-
id,
|
|
140
|
+
id: Option.getOrUndefined(id),
|
|
89
141
|
into
|
|
90
142
|
});
|
|
91
143
|
})).pipe(Command.withDescription("Promote an agent note into curated orientation (layout or startHere)"));
|