@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.
Files changed (44) hide show
  1. package/cli/index.js +19 -3
  2. package/commands/changeset/commands/check.js +82 -21
  3. package/commands/changeset/commands/config-validate.js +9 -6
  4. package/commands/changeset/commands/deps-detect.js +2 -2
  5. package/commands/changeset/commands/deps-regen.js +94 -48
  6. package/commands/changeset/commands/init.js +56 -23
  7. package/commands/changeset/commands/transform.js +82 -8
  8. package/commands/changeset/commands/validate-file.js +12 -11
  9. package/commands/changeset/commands/version.js +48 -17
  10. package/commands/changeset/index.js +3 -3
  11. package/commands/changeset/utils/config-gate.js +12 -10
  12. package/commands/check-section.js +34 -0
  13. package/commands/check.js +48 -39
  14. package/commands/clean/progress.js +71 -0
  15. package/commands/clean/view.js +26 -0
  16. package/commands/clean.js +113 -50
  17. package/commands/commit/check.js +80 -77
  18. package/commands/commit/init.js +21 -10
  19. package/commands/commit/lint.js +2 -2
  20. package/commands/init.js +122 -34
  21. package/commands/lint/biome-version.js +1 -1
  22. package/commands/lint/check.js +126 -171
  23. package/commands/lint/init.js +52 -33
  24. package/commands/lint/text.js +28 -13
  25. package/commands/repos/commands/add.js +14 -17
  26. package/commands/repos/commands/deregister.js +37 -18
  27. package/commands/repos/commands/note.js +78 -26
  28. package/commands/repos/commands/pin.js +39 -24
  29. package/commands/repos/commands/remove.js +30 -56
  30. package/commands/repos/commands/rename.js +32 -23
  31. package/commands/repos/commands/restore.js +42 -56
  32. package/commands/repos/commands/status.js +43 -47
  33. package/commands/repos/commands/sync.js +11 -40
  34. package/commands/repos/index.js +7 -10
  35. package/commands/repos/shared.js +129 -0
  36. package/index.d.ts +162 -61
  37. package/internal/command-error.js +60 -0
  38. package/internal/confirm.js +52 -0
  39. package/internal/failure-line.js +26 -12
  40. package/internal/report.js +101 -0
  41. package/main.js +23 -16
  42. package/package.json +17 -12
  43. package/version.js +1 -1
  44. package/internal/output.js +0 -52
@@ -1,4 +1,5 @@
1
- import { Output } from "../../internal/output.js";
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
- yield* Output.ok(`Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
86
- return;
87
+ blocks.push(Report.ok(`Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`));
88
+ return blocks;
87
89
  }
88
90
  if (preset !== "silk") {
89
- yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`);
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
- yield* Output.ok(`Replaced ${Lint.MARKDOWNLINT_CONFIG_PATH} (--force)`);
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) yield* Output.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
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
- yield* Output.ok(`Updated ${applied.join(", ")} in ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
120
- } else if (configMatches) yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
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 and logs results
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) yield* syncer.sync(BIOME_VERSION, options).pipe(Effect.flatMap((result) => Effect.gen(function* () {
210
- for (const configPath of result.current) yield* Output.ok(`${configPath}: biome $schema up-to-date`);
211
- for (const configPath of result.updated) yield* Output.ok(`Updated $schema in ${configPath}`);
212
- })), Effect.catchTag("BiomeSyncError", (e) => Output.warn(`Could not sync biome $schema: ${e.message}`)));
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) => new Error(String(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(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
246
- yield* Output.heading("lint-staged");
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* Output.ok(`${force ? "Replaced" : "Synced"} ${Lint.HUSKY_HOOK_PATH} (${preCommitResults.map((r) => r._tag).join(", ")})`);
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* Output.ok(`Synced ${hookPath}`);
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* Output.warn(`${config} already exists (use --force to overwrite)`);
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* Output.ok(`Created ${config} (preset: ${preset})`);
297
+ yield* Report.print([Report.ok(`Created ${config} (preset: ${preset})`)]);
278
298
  }
279
- yield* Output.line("");
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
 
@@ -1,8 +1,8 @@
1
- import { Output } from "../../internal/output.js";
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 one
36
- * line per finding; any finding sets exit code 1. Under `--staged`, every file
37
- * is still checked when one has no index entry: that file's read error is
38
- * logged to stderr, counted as failed, and also sets exit code 1. Exported for
39
- * tests.
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(options.cwd ?? ".", { staged });
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* Output.ok(`${targets.length} ${targets.length === 1 ? "file is" : "files are"} grep-visible text`);
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
- for (const finding of findings) yield* Output.fail(`${finding.location} ${finding.message}`);
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* Output.summary({
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 { Output } from "../../../internal/output.js";
2
- import { Argument, Command, Flag } from "effect/cli";
3
- import { CliExit } from "@effected/cli";
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, logged and reported via a non-zero exit code.
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* Output.ok(`${result.name} @ ${result.ref} -> ${result.path}`);
49
- yield* Output.detail("staged — review and commit");
50
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
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 { Output } from "../../../internal/output.js";
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
+ 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. There is no friendly missing-manifest
30
- * exit-0 case: the manager folds a missing manifest to "no live entries" and
31
- * proceeds, since a stale registration can outlive the manifest itself.
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 result = yield* (yield* Repos.ReposManager).deregister(cwd, section);
51
- yield* Output.ok(`${result.section}: deregistered (${result.removedKeys.length} config keys removed)`);
52
- for (const key of result.removedKeys) yield* Output.detail(`removed ${key}`);
53
- yield* Output.detail("local git config only — nothing to commit");
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 { Output } from "../../../internal/output.js";
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, logged and reported via a non-zero exit code.
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, op) => Effect.gen(function* () {
43
- const result = yield* (yield* Repos.ReposManager).note(cwd, name, op);
44
- yield* Output.ok(`${result.name}: ${result.op} note ${result.id} (${result.noteCount} notes)`);
45
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
46
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
47
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
48
- }), Effect.catchTag("RepoNotFoundError", (error) => {
49
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
50
- }), Effect.catchTag("NoteNotFoundError", (error) => {
51
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
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)"));