@savvy-web/cli 3.5.1 → 3.6.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 (43) 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/check.js +126 -171
  22. package/commands/lint/init.js +52 -33
  23. package/commands/lint/text.js +28 -13
  24. package/commands/repos/commands/add.js +14 -17
  25. package/commands/repos/commands/deregister.js +37 -18
  26. package/commands/repos/commands/note.js +78 -26
  27. package/commands/repos/commands/pin.js +39 -24
  28. package/commands/repos/commands/remove.js +30 -56
  29. package/commands/repos/commands/rename.js +32 -23
  30. package/commands/repos/commands/restore.js +42 -56
  31. package/commands/repos/commands/status.js +43 -47
  32. package/commands/repos/commands/sync.js +11 -40
  33. package/commands/repos/index.js +7 -10
  34. package/commands/repos/shared.js +129 -0
  35. package/index.d.ts +162 -61
  36. package/internal/command-error.js +60 -0
  37. package/internal/confirm.js +52 -0
  38. package/internal/failure-line.js +26 -12
  39. package/internal/report.js +101 -0
  40. package/main.js +23 -16
  41. package/package.json +17 -12
  42. package/version.js +1 -1
  43. package/internal/output.js +0 -52
@@ -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
+ import { CliInteractive, Doc } from "@effected/cli";
4
5
  import { Repos } from "@savvy-web/silk-effects";
5
- import { Effect } from "effect";
6
+ import { Effect, Option } from "effect";
6
7
 
7
8
  //#region src/commands/repos/commands/pin.ts
8
9
  /**
@@ -19,8 +20,14 @@ import { Effect } from "effect";
19
20
  * `GitSubmoduleError` means the underlying git command failed,
20
21
  * `RepoNotFoundError` means the named repo isn't in the manifest, and
21
22
  * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
22
- * tree failed -- all four are real failures, logged and reported via a
23
- * non-zero exit code.
23
+ * tree failed -- all four are real failures, failing as a `CommandError`
24
+ * (exit 1) with a hint.
25
+ *
26
+ * Both positionals are optional to the parser so a person can leave them off:
27
+ * at a terminal a missing name is picked from the vendored repos (`Select`)
28
+ * and a missing ref typed (`TextInput`, starting at the current pin). Anywhere
29
+ * else (an agent, CI, a pipe) a missing one is the usage error it always was,
30
+ * exit 64, and nothing is read.
24
31
  *
25
32
  * @example
26
33
  * ```bash
@@ -30,37 +37,45 @@ import { Effect } from "effect";
30
37
  * @internal
31
38
  */
32
39
  /* v8 ignore start -- CLI option/arg definitions */
33
- const nameArg = Argument.String("name");
34
- const refArg = Argument.String("ref");
40
+ const nameArg = Argument.String("name").pipe(Argument.optional);
41
+ const refArg = Argument.String("ref").pipe(Argument.optional);
35
42
  const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to pin within"), Flag.withDefault("."));
36
43
  /* v8 ignore stop */
37
44
  /**
38
- * Pin handler; exported for tests.
45
+ * Pin handler; exported for tests. `name`/`ref` are `undefined` when left off.
39
46
  *
40
47
  * @internal
41
48
  */
42
49
  const runReposPin = (cwd, name, ref) => Effect.gen(function* () {
43
- const result = yield* (yield* Repos.ReposManager).pin(cwd, name, ref);
44
- yield* Output.ok(`${result.name}: ${result.oldCommit ?? "unknown"} -> ${result.newCommit}`);
45
- yield* Output.detail(result.commitMessage);
46
- yield* Output.detail("staged — review and commit");
47
- for (const staleId of result.staleNoteIds) yield* Output.warn(`note ${staleId} is now stale against ${ref}`);
48
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
49
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
50
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
51
- }), Effect.catchTag("GitSubmoduleError", (error) => {
52
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
53
- }), Effect.catchTag("RepoNotFoundError", (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
- }));
50
+ const target = yield* ReposCli.nameOrPick(cwd, name, {
51
+ command: ["pin"],
52
+ argument: "name",
53
+ message: "Re-pin which repo?"
54
+ });
55
+ const current = ref === void 0 && (yield* CliInteractive) ? (yield* ReposCli.vendored(cwd)).find((repo) => repo.name === target)?.ref : void 0;
56
+ const newRef = yield* ReposCli.textOrAsk(ref, {
57
+ command: ["pin"],
58
+ argument: "ref",
59
+ message: `Re-pin ${target} to which ref? (tag, branch, or commit)`,
60
+ initial: current
61
+ });
62
+ const result = yield* (yield* Repos.ReposManager).pin(cwd, target, newRef);
63
+ yield* Report.print([
64
+ Report.ok(`${result.name}: ${result.oldCommit ?? "unknown"} -> ${result.newCommit}`, "staged — review and commit"),
65
+ Doc.codeBlock(result.commitMessage),
66
+ ...result.staleNoteIds.map((staleId) => Report.warn(`note ${staleId} is now stale against ${newRef}`))
67
+ ]);
68
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("re-pin the repo")(error)), Effect.catchTag([
69
+ "GitSubmoduleError",
70
+ "RepoNotFoundError",
71
+ "ReposLockdownError"
72
+ ], ReposCli.fail("re-pin the repo")));
58
73
  /* v8 ignore start -- CLI registration; handler tested via runReposPin */
59
74
  const pinCommand = Command.make("pin", {
60
75
  name: nameArg,
61
76
  ref: refArg,
62
77
  cwd: cwdOption
63
- }, ({ name, ref, cwd }) => runReposPin(cwd, name, ref)).pipe(Command.withDescription("Re-pin a vendored repo to a new ref; stages the change without committing"));
78
+ }, ({ name, ref, cwd }) => runReposPin(cwd, Option.getOrUndefined(name), Option.getOrUndefined(ref))).pipe(Command.withDescription("Re-pin a vendored repo to a new ref; stages the change without committing"));
64
79
  /* v8 ignore stop */
65
80
 
66
81
  //#endregion
@@ -1,73 +1,47 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
+ import { confirmDestructive, yesFlag } from "../../../internal/confirm.js";
3
+ import { ReposCli } from "../shared.js";
2
4
  import { Argument, Command, Flag } from "effect/cli";
3
- import { CliExit } from "@effected/cli";
5
+ import { Doc } from "@effected/cli";
4
6
  import { Repos } from "@savvy-web/silk-effects";
5
- import { Effect } from "effect";
7
+ import { Effect, Option } from "effect";
6
8
 
7
9
  //#region src/commands/repos/commands/remove.ts
8
- /**
9
- * `repos remove` command -- unvendor a `.repos/` submodule.
10
- *
11
- * @remarks
12
- * A thin adapter over {@link Repos.ReposManager.remove}: deinitializes and
13
- * removes the submodule's gitlink, worktree, and module gitdir, drops its
14
- * `.gitmodules` section, and removes the manifest entry -- it does not
15
- * commit, so the caller reviews and commits the staged removal with the
16
- * ready-made message the result carries. Any notes the entry carried are
17
- * surfaced in the result so a durable one can be promoted elsewhere first.
18
- *
19
- * A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
20
- * the common, friendly case and always exits 0. A `ReposConfigError` with
21
- * kind `"invalid"` means the manifest exists but is corrupt or unreadable.
22
- * `RepoNotFoundError` means the named repo isn't in the manifest -- this is
23
- * a REAL failure (unlike the "missing manifest" case above), since removing
24
- * a name that was never vendored is almost always a typo the caller should
25
- * see. `GitSubmoduleError` means the underlying git command failed, and
26
- * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
27
- * tree failed -- all four (invalid config, not-found, git failure, lockdown
28
- * failure) are real failures, logged and reported via a non-zero exit code.
29
- *
30
- * @example
31
- * ```bash
32
- * savvy repos remove my-repo
33
- * ```
34
- *
35
- * @internal
36
- */
37
10
  /* v8 ignore start -- CLI option/arg definitions */
38
- const nameArg = Argument.String("name");
11
+ const nameArg = Argument.String("name").pipe(Argument.optional);
39
12
  const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to remove within"), Flag.withDefault("."));
40
- /* v8 ignore stop */
41
13
  /**
42
- * Remove handler; exported for tests.
14
+ * Remove handler; exported for tests. `name` is `undefined` when left off.
43
15
  *
44
16
  * @internal
45
17
  */
46
- const runReposRemove = (cwd, name) => Effect.gen(function* () {
47
- const result = yield* (yield* Repos.ReposManager).remove(cwd, name);
48
- yield* Output.ok(`${result.name}: removed (${result.path})`);
49
- yield* Output.detail(result.commitMessage);
50
- yield* Output.detail("staged — review and commit");
51
- for (const note of result.removedNotes) yield* Output.warn(`note ${note.id} (${note.ref}) was removed with the entry — promote first if durable`);
52
- if (result.removedEntry.orientation) {
53
- yield* Output.warn(`the orientation block for ${result.name} was removed with the entry and add will NOT restore it — re-vendoring? capture it now:`);
54
- yield* Output.line(JSON.stringify(result.removedEntry.orientation, null, 2));
55
- }
56
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
57
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
58
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
59
- }), Effect.catchTag("RepoNotFoundError", (error) => {
60
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
61
- }), Effect.catchTag("GitSubmoduleError", (error) => {
62
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
63
- }), Effect.catchTag("ReposLockdownError", (error) => {
64
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
65
- }));
18
+ const runReposRemove = (cwd, name, options = {}) => Effect.gen(function* () {
19
+ const target = yield* ReposCli.nameOrPick(cwd, name, {
20
+ command: ["remove"],
21
+ argument: "name",
22
+ message: "Unvendor which repo?"
23
+ });
24
+ if (!(yield* confirmDestructive({
25
+ message: `Remove ${target} from .repos/? (staged, not committed)`,
26
+ yes: options.yes === true
27
+ }))) return yield* Report.print([Report.skip(`remove cancelled — ${target} left vendored`)]);
28
+ const result = yield* (yield* Repos.ReposManager).remove(cwd, target);
29
+ const blocks = [Report.ok(`${result.name}: removed (${result.path})`, "staged — review and commit"), Doc.codeBlock(result.commitMessage)];
30
+ const lost = result.removedNotes.map((note) => Doc.paragraph(`note ${note.id} (${note.ref}) was removed with the entry — promote it first if it is durable`));
31
+ if (result.removedEntry.orientation) lost.push(Doc.paragraph(`the orientation block for ${result.name} was removed with the entry and add will NOT restore it — re-vendoring? capture it now:`), Doc.codeBlock(JSON.stringify(result.removedEntry.orientation, null, 2), "json"));
32
+ if (lost.length > 0) blocks.push(Doc.callout("warning", lost));
33
+ yield* Report.print(blocks);
34
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("remove the repo")(error)), Effect.catchTag([
35
+ "RepoNotFoundError",
36
+ "GitSubmoduleError",
37
+ "ReposLockdownError"
38
+ ], ReposCli.fail("remove the repo")));
66
39
  /* v8 ignore start -- CLI registration; handler tested via runReposRemove */
67
40
  const removeCommand = Command.make("remove", {
68
41
  name: nameArg,
42
+ yes: yesFlag,
69
43
  cwd: cwdOption
70
- }, ({ name, cwd }) => runReposRemove(cwd, name)).pipe(Command.withDescription("Unvendor a repo under .repos/; stages the removal without committing"));
44
+ }, ({ name, yes, cwd }) => runReposRemove(cwd, Option.getOrUndefined(name), { yes })).pipe(Command.withDescription("Unvendor a repo under .repos/; stages the removal without committing"));
71
45
  /* v8 ignore stop */
72
46
 
73
47
  //#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
+ import { Doc } from "@effected/cli";
4
5
  import { Repos } from "@savvy-web/silk-effects";
5
- import { Effect } from "effect";
6
+ import { Effect, Option } from "effect";
6
7
 
7
8
  //#region src/commands/repos/commands/rename.ts
8
9
  /**
@@ -25,8 +26,13 @@ import { Effect } from "effect";
25
26
  * should see. `GitSubmoduleError` means the underlying git command failed,
26
27
  * and `ReposLockdownError` means the OS-permission lockdown pass on a
27
28
  * vendored tree failed -- all four (invalid config, not-found, git failure,
28
- * lockdown failure) are real failures, logged and reported via a non-zero
29
- * exit code.
29
+ * lockdown failure) are real failures, failing as a `CommandError` (exit 1)
30
+ * with a hint.
31
+ *
32
+ * Both positionals are optional to the parser so a person can leave them off:
33
+ * at a terminal a missing old name is picked from the vendored repos
34
+ * (`Select`) and a missing new name typed (`TextInput`). Anywhere else a
35
+ * missing one is the usage error it always was, exit 64.
30
36
  *
31
37
  * @example
32
38
  * ```bash
@@ -36,36 +42,39 @@ import { Effect } from "effect";
36
42
  * @internal
37
43
  */
38
44
  /* v8 ignore start -- CLI option/arg definitions */
39
- const oldNameArg = Argument.String("old-name");
40
- const newNameArg = Argument.String("new-name");
45
+ const oldNameArg = Argument.String("old-name").pipe(Argument.optional);
46
+ const newNameArg = Argument.String("new-name").pipe(Argument.optional);
41
47
  const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to rename within"), Flag.withDefault("."));
42
48
  /* v8 ignore stop */
43
49
  /**
44
- * Rename handler; exported for tests.
50
+ * Rename handler; exported for tests. Either name is `undefined` when left off.
45
51
  *
46
52
  * @internal
47
53
  */
48
54
  const runReposRename = (cwd, oldName, newName) => Effect.gen(function* () {
49
- const result = yield* (yield* Repos.ReposManager).rename(cwd, oldName, newName);
50
- yield* Output.ok(`${result.oldName}: renamed to ${result.newName} (${result.path})`);
51
- yield* Output.detail(result.commitMessage);
52
- yield* Output.detail("staged — review and commit");
53
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
54
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
55
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
56
- }), Effect.catchTag("RepoNotFoundError", (error) => {
57
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
58
- }), Effect.catchTag("GitSubmoduleError", (error) => {
59
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
60
- }), Effect.catchTag("ReposLockdownError", (error) => {
61
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
62
- }));
55
+ const from = yield* ReposCli.nameOrPick(cwd, oldName, {
56
+ command: ["rename"],
57
+ argument: "old-name",
58
+ message: "Rename which repo?"
59
+ });
60
+ const to = yield* ReposCli.textOrAsk(newName, {
61
+ command: ["rename"],
62
+ argument: "new-name",
63
+ message: `Rename ${from} to?`
64
+ });
65
+ const result = yield* (yield* Repos.ReposManager).rename(cwd, from, to);
66
+ yield* Report.print([Report.ok(`${result.oldName}: renamed to ${result.newName} (${result.path})`, "staged — review and commit"), Doc.codeBlock(result.commitMessage)]);
67
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("rename the repo", "the manifest is unreadable, or the new name is invalid or already vendored; run `savvy repos status`")(error)), Effect.catchTag([
68
+ "RepoNotFoundError",
69
+ "GitSubmoduleError",
70
+ "ReposLockdownError"
71
+ ], ReposCli.fail("rename the repo")));
63
72
  /* v8 ignore start -- CLI registration; handler tested via runReposRename */
64
73
  const renameCommand = Command.make("rename", {
65
74
  oldName: oldNameArg,
66
75
  newName: newNameArg,
67
76
  cwd: cwdOption
68
- }, ({ oldName, newName, cwd }) => runReposRename(cwd, oldName, newName)).pipe(Command.withDescription("Rename a vendored repo's manifest key and worktree; stages the change without committing"));
77
+ }, ({ oldName, newName, cwd }) => runReposRename(cwd, Option.getOrUndefined(oldName), Option.getOrUndefined(newName))).pipe(Command.withDescription("Rename a vendored repo's manifest key and worktree; stages the change without committing"));
69
78
  /* v8 ignore stop */
70
79
 
71
80
  //#endregion
@@ -1,77 +1,63 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
+ import { confirmDestructive, yesFlag } from "../../../internal/confirm.js";
3
+ import { ReposCli } from "../shared.js";
2
4
  import { Argument, Command, Flag } from "effect/cli";
3
- import { CliExit } from "@effected/cli";
5
+ import { CliExit, CliInteractive } from "@effected/cli";
4
6
  import { Repos } from "@savvy-web/silk-effects";
5
7
  import { Effect } from "effect";
6
8
 
7
9
  //#region src/commands/repos/commands/restore.ts
8
- /**
9
- * `repos restore` command -- hard-reset one or more vendored `.repos/`
10
- * submodules back to their pinned gitlink commit.
11
- *
12
- * @remarks
13
- * A thin adapter over {@link Repos.ReposManager.restore}: explicit only --
14
- * never invoked by `sync` or any other implicit path. DESTRUCTIVE to
15
- * uncommitted worktree edits and untracked files in the repos it touches:
16
- * each targeted repo is hard-reset to its staged gitlink commit (falling
17
- * back to the committed one) and its sparse-checkout paths re-applied.
18
- * With one or more `name` arguments, exactly those repos are restored, even
19
- * if already clean -- an explicit ask is always honored. With no names, every
20
- * DIRTY repo (per `status`) is restored and the clean ones are reported as
21
- * skipped.
22
- *
23
- * A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
24
- * the common, friendly case and always exits 0. A `ReposConfigError` with
25
- * kind `"invalid"` means the manifest exists but is corrupt or unreadable.
26
- * `RepoNotFoundError` means an explicitly named repo isn't in the manifest --
27
- * a real failure, since restoring a name that was never vendored is almost
28
- * always a typo the caller should see. `GitSubmoduleError` means the
29
- * underlying git command failed (including the case where a repo has
30
- * neither a staged nor a committed gitlink commit to restore to), and
31
- * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
32
- * tree failed -- all four are real failures, logged and reported via a
33
- * non-zero exit code.
34
- *
35
- * @example
36
- * ```bash
37
- * savvy repos restore my-repo
38
- * savvy repos restore my-repo other-repo
39
- * savvy repos restore
40
- * ```
41
- *
42
- * @internal
43
- */
44
10
  /* v8 ignore start -- CLI option/arg definitions */
45
11
  const namesArg = Argument.String("name").pipe(Argument.variadic());
46
12
  const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to restore within"), Flag.withDefault("."));
47
- /* v8 ignore stop */
13
+ /** Which repos to restore: the names given, or (at a terminal, with none given) the dirty ones a person picks. */
14
+ const choose = (cwd, names) => Effect.gen(function* () {
15
+ if (names.length > 0 || !(yield* CliInteractive)) return {
16
+ names,
17
+ asked: false
18
+ };
19
+ const dirty = (yield* ReposCli.vendored(cwd)).filter((repo) => repo.dirty).map((repo) => repo.name);
20
+ if (dirty.length === 0) return {
21
+ names,
22
+ asked: false
23
+ };
24
+ return {
25
+ names: yield* ReposCli.pickDirty(dirty),
26
+ asked: true
27
+ };
28
+ });
48
29
  /**
49
30
  * Restore handler; exported for tests.
50
31
  *
51
32
  * @internal
52
33
  */
53
- const runReposRestore = (cwd, names) => Effect.gen(function* () {
54
- const result = yield* (yield* Repos.ReposManager).restore(cwd, names.length > 0 ? names : void 0);
55
- for (const entry of result.restored) yield* Output.ok(`${entry.name}: restored to ${entry.commit}`);
56
- for (const name of result.skippedClean) yield* Output.skip(`${name}: clean — skipped`);
57
- for (const name of result.stillDirty) yield* Output.fail(`${name}: reset ran but the worktree is STILL dirty; run \`savvy repos status --drift\``);
34
+ const runReposRestore = (cwd, names, options = {}) => Effect.gen(function* () {
35
+ const chosen = yield* choose(cwd, names);
36
+ if (chosen.asked && chosen.names.length === 0) return yield* Report.print([Report.skip("nothing selected — left unchanged")]);
37
+ const targets = chosen.names;
38
+ if (targets.length > 0) {
39
+ if (!(yield* confirmDestructive({
40
+ message: `Hard-reset ${targets.join(", ")}? Uncommitted edits and untracked files there are lost.`,
41
+ yes: options.yes === true
42
+ }))) return yield* Report.print([Report.skip("restore cancelled — nothing changed")]);
43
+ }
44
+ const result = yield* (yield* Repos.ReposManager).restore(cwd, targets.length > 0 ? targets : void 0);
45
+ const blocks = [...result.restored.map((entry) => Report.ok(`${entry.name}: restored to ${entry.commit}`)), ...result.skippedClean.map((name) => Report.skip(`${name}: clean — skipped`))];
46
+ for (const name of result.stillDirty) blocks.push(Report.fail(`${name}: reset ran but the worktree is STILL dirty; run \`savvy repos status --drift\``));
58
47
  if (result.stillDirty.length > 0) yield* CliExit.set(1);
59
- if (result.restored.length === 0 && result.skippedClean.length === 0) yield* Output.ok("nothing to restore");
60
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
61
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
62
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
63
- }), Effect.catchTag("RepoNotFoundError", (error) => {
64
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
65
- }), Effect.catchTag("GitSubmoduleError", (error) => {
66
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
67
- }), Effect.catchTag("ReposLockdownError", (error) => {
68
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
69
- }));
48
+ if (result.restored.length === 0 && result.skippedClean.length === 0) blocks.push(Report.ok("nothing to restore"));
49
+ yield* Report.print(blocks);
50
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("restore the vendored repos")(error)), Effect.catchTag([
51
+ "RepoNotFoundError",
52
+ "GitSubmoduleError",
53
+ "ReposLockdownError"
54
+ ], ReposCli.fail("restore the vendored repos")));
70
55
  /* v8 ignore start -- CLI registration; handler tested via runReposRestore */
71
56
  const restoreCommand = Command.make("restore", {
72
57
  names: namesArg,
58
+ yes: yesFlag,
73
59
  cwd: cwdOption
74
- }, ({ names, cwd }) => runReposRestore(cwd, names)).pipe(Command.withDescription("Hard-reset vendored repos to their pinned commit; DESTRUCTIVE to uncommitted worktree edits. Given no names, restores every dirty repo"));
60
+ }, ({ names, yes, cwd }) => runReposRestore(cwd, names, { yes })).pipe(Command.withDescription("Hard-reset vendored repos to their pinned commit; DESTRUCTIVE to uncommitted worktree edits. Given no names, restores every dirty repo (asks first at a terminal)"));
75
61
  /* v8 ignore stop */
76
62
 
77
63
  //#endregion
@@ -1,41 +1,43 @@
1
- import { Output } from "../../../internal/output.js";
1
+ import { Report } from "../../../internal/report.js";
2
+ import { ReposCli } from "../shared.js";
2
3
  import { Command, Flag } from "effect/cli";
3
- import { CliExit } from "@effected/cli";
4
+ import { CliExit, Doc, Status } from "@effected/cli";
4
5
  import { Repos } from "@savvy-web/silk-effects";
5
6
  import { Console, Effect } from "effect";
6
7
 
7
8
  //#region src/commands/repos/commands/status.ts
8
- /**
9
- * `repos status` command -- drift report for vendored `.repos/` submodules.
10
- *
11
- * @remarks
12
- * A thin adapter over {@link Repos.ReposManager.status}: reports, per vendored
13
- * repo, whether the submodule is present, dirty, and whether any agent notes
14
- * have gone stale relative to the pinned ref. A `ReposConfigError` with kind
15
- * `"missing"` is the common, friendly case (nothing has been vendored yet) --
16
- * not an error -- so it is rendered as a plain message (or an empty JSON
17
- * report, in `--json` mode) with exit code 0. A `ReposConfigError` with kind
18
- * `"invalid"` means the manifest exists but is corrupt or unreadable -- that
19
- * is a real failure, logged and reported via a non-zero exit code.
20
- *
21
- * With `--drift`, {@link Repos.ReposDrift.check} also runs (after the status
22
- * check), reconciling the manifest, `.gitmodules`, the worktree, and `git
23
- * submodule status`. Each detected drift prints as one line
24
- * (`<name>: <kind> — <detail>`), and any drift flips the exit code to 1,
25
- * mirroring the existing `!clean` rule for the status report itself. Because
26
- * both calls read the same manifest, a missing manifest fails at the status
27
- * call before the drift check ever runs, so the friendly exit-0 case is
28
- * unaffected by `--drift`.
29
- *
30
- * @example
31
- * ```bash
32
- * savvy repos status
33
- * savvy repos status --json
34
- * savvy repos status --drift
35
- * ```
36
- *
37
- * @internal
38
- */
9
+ /** The human status report: a table of repos, and one of drifts under `--drift`. */
10
+ var ReposStatusView = class {
11
+ constructor() {}
12
+ /** One row per repo: its name, pinned ref, and a status cell naming every flag it carries. */
13
+ static repos = (repos) => Doc.table([
14
+ { header: "name" },
15
+ { header: "ref" },
16
+ { header: "state" }
17
+ ], repos.map((repo) => {
18
+ const flags = [
19
+ repo.present ? void 0 : "missing",
20
+ repo.dirty ? "dirty" : void 0,
21
+ repo.staleNoteIds.length > 0 ? `${repo.staleNoteIds.length} stale notes` : void 0
22
+ ].filter((f) => f !== void 0);
23
+ const state = flags.length > 0 ? [Doc.status(Status.core, "warning"), ` ${flags.join(", ")}`] : [Doc.status(Status.core, "success"), " clean"];
24
+ return [
25
+ repo.name,
26
+ repo.ref,
27
+ state
28
+ ];
29
+ }));
30
+ /** One row per drift: the repo, the drift kind, and what disagrees. */
31
+ static drifts = (drifts) => Doc.table([
32
+ { header: "name" },
33
+ { header: "kind" },
34
+ { header: "detail" }
35
+ ], drifts.map((item) => [
36
+ [Doc.status(Status.core, "failure"), ` ${item.name}`],
37
+ item.kind,
38
+ item.detail
39
+ ]));
40
+ };
39
41
  /* v8 ignore start -- CLI option definitions */
40
42
  const jsonOption = Flag.Boolean("json").pipe(Flag.withDescription("Emit the structured drift report as JSON"), Flag.withDefault(false));
41
43
  const driftOption = Flag.Boolean("drift").pipe(Flag.withDescription("Also reconcile the manifest, .gitmodules, worktree, and git submodule status"), Flag.withDefault(false));
@@ -62,32 +64,26 @@ const runReposStatus = (cwd, json, drift = false) => Effect.gen(function* () {
62
64
  yield* Console.log(JSON.stringify(payload, null, 2));
63
65
  return;
64
66
  }
65
- for (const repo of report.repos) {
66
- const flags = [
67
- repo.present ? void 0 : "missing",
68
- repo.dirty ? "dirty" : void 0,
69
- repo.staleNoteIds.length > 0 ? `${repo.staleNoteIds.length} stale notes` : void 0
70
- ].filter((f) => f !== void 0);
71
- yield* flags.length > 0 ? Output.warn(`${repo.name} @ ${repo.ref} [${flags.join(", ")}]`) : Output.ok(`${repo.name} @ ${repo.ref}`);
72
- }
73
- if (driftReport !== void 0) for (const item of driftReport.drifts) yield* Output.fail(`${item.name}: ${item.kind} — ${item.detail}`);
67
+ const blocks = [report.repos.length > 0 ? ReposStatusView.repos(report.repos) : Report.skip("the manifest lists no repos")];
68
+ if (driftReport !== void 0 && driftReport.drifts.length > 0) blocks.push(Report.line(""), Report.heading("drift"), ReposStatusView.drifts(driftReport.drifts));
69
+ yield* Report.print(blocks);
74
70
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
75
71
  if (error.kind === "missing") {
76
72
  if (json) return Console.log(JSON.stringify({
77
73
  repos: [],
78
74
  clean: true
79
75
  }, null, 2));
80
- return Output.skip("no .repos/config.json — nothing vendored");
76
+ return Report.print([Report.skip("no .repos/config.json — nothing vendored")]);
81
77
  }
82
- const report = json ? Console.log(JSON.stringify({
78
+ if (json) return CliExit.set(1).pipe(Effect.andThen(Console.log(JSON.stringify({
83
79
  error: error.message,
84
80
  clean: false
85
- }, null, 2)) : Effect.void;
86
- return CliExit.set(1).pipe(Effect.andThen(report), Effect.andThen(Effect.logError(error.message)));
81
+ }, null, 2))), Effect.andThen(Effect.logError(error.message)));
82
+ return ReposCli.fail("read the repos manifest")(error);
87
83
  }), Effect.catchTag("GitSubmoduleError", (error) => json ? CliExit.set(1).pipe(Effect.andThen(Console.log(JSON.stringify({
88
84
  error: error.message,
89
85
  clean: false
90
- }, null, 2))), Effect.andThen(Effect.logError(error.message))) : Effect.fail(error)));
86
+ }, null, 2))), Effect.andThen(Effect.logError(error.message))) : ReposCli.fail("check the vendored repos")(error)));
91
87
  /* v8 ignore start -- CLI registration; handler tested via runReposStatus */
92
88
  const statusCommand = Command.make("status", {
93
89
  json: jsonOption,
@@ -1,34 +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 { Command, Flag } from "effect/cli";
3
- import { CliExit } from "@effected/cli";
4
4
  import { Repos } from "@savvy-web/silk-effects";
5
5
  import { Effect } from "effect";
6
6
 
7
7
  //#region src/commands/repos/commands/sync.ts
8
- /**
9
- * `repos sync` command -- reconcile vendored `.repos/` submodules with the manifest.
10
- *
11
- * @remarks
12
- * A thin adapter over {@link Repos.ReposManager.sync}: initializes missing
13
- * submodules, re-applies sparse-checkout patterns, clears stale git locks
14
- * left behind by an interrupted fetch, reconciles a submodule's URL when it
15
- * drifts from the manifest, and registers an orphan manifest entry (no
16
- * gitlink at all) from scratch. Sync is idempotent repair, so
17
- * a `ReposConfigError` with kind `"missing"` -- the common, friendly case
18
- * (nothing to sync yet) -- always exits 0. A `ReposConfigError` with kind
19
- * `"invalid"` means the manifest exists but is corrupt or unreadable,
20
- * `GitSubmoduleError` means the underlying git command failed, and
21
- * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
22
- * tree failed -- all three are real failures, logged and reported via a
23
- * non-zero exit code.
24
- *
25
- * @example
26
- * ```bash
27
- * savvy repos sync
28
- * ```
29
- *
30
- * @internal
31
- */
32
8
  /* v8 ignore start -- CLI option definitions */
33
9
  const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to sync"), Flag.withDefault("."));
34
10
  /* v8 ignore stop */
@@ -39,20 +15,15 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
39
15
  */
40
16
  const runReposSync = (cwd) => Effect.gen(function* () {
41
17
  const report = yield* (yield* Repos.ReposManager).sync(cwd);
42
- for (const name of report.clearedLocks) yield* Output.ok(`${name}: cleared stale lock`);
43
- for (const name of report.initialized) yield* Output.ok(`${name}: initialized`);
44
- for (const name of report.sparseApplied) yield* Output.ok(`${name}: sparse-checkout applied`);
45
- for (const name of report.urlSynced) yield* Output.ok(`${name}: url reconciled`);
46
- for (const name of report.registered) yield* Output.ok(`${name}: registered`);
47
- if (report.initialized.length === 0 && report.sparseApplied.length === 0 && report.clearedLocks.length === 0 && report.urlSynced.length === 0 && report.registered.length === 0) yield* Output.ok("all vendored repos up to date");
48
- }).pipe(Effect.catchTag("ReposConfigError", (error) => {
49
- if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
50
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
51
- }), Effect.catchTag("GitSubmoduleError", (error) => {
52
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
53
- }), Effect.catchTag("ReposLockdownError", (error) => {
54
- return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
55
- }));
18
+ const blocks = [];
19
+ for (const name of report.clearedLocks) blocks.push(Report.ok(`${name}: cleared stale lock`));
20
+ for (const name of report.initialized) blocks.push(Report.ok(`${name}: initialized`));
21
+ for (const name of report.sparseApplied) blocks.push(Report.ok(`${name}: sparse-checkout applied`));
22
+ for (const name of report.urlSynced) blocks.push(Report.ok(`${name}: url reconciled`));
23
+ for (const name of report.registered) blocks.push(Report.ok(`${name}: registered`));
24
+ if (report.initialized.length === 0 && report.sparseApplied.length === 0 && report.clearedLocks.length === 0 && report.urlSynced.length === 0 && report.registered.length === 0) blocks.push(Report.ok("all vendored repos up to date"));
25
+ yield* Report.print(blocks);
26
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("sync the vendored repos")(error)), Effect.catchTag(["GitSubmoduleError", "ReposLockdownError"], ReposCli.fail("sync the vendored repos")));
56
27
  /* v8 ignore start -- CLI registration; handler tested via runReposSync */
57
28
  const syncCommand = Command.make("sync", { cwd: cwdOption }, ({ cwd }) => runReposSync(cwd)).pipe(Command.withDescription("Reconcile vendored submodules with the manifest: init missing, apply sparse, clear locks"));
58
29
  /* v8 ignore stop */
@@ -39,16 +39,13 @@ const _reposCommand = Command.make("repos").pipe(Command.withSubcommands([
39
39
  * discharges via `AppLive`. `Repos.ReposDrift` joins the union because
40
40
  * `status --drift` runs `Repos.ReposDrift.check` after the status check.
41
41
  *
42
- * The error channel is `Repos.GitSubmoduleError` alone: `add`/`pin`/`note`/
43
- * `remove`/`rename`/`restore`/`sync`/`deregister` each `catchTag` every error
44
- * their underlying `ReposManager` method can produce — `ReposConfigError`,
45
- * `GitSubmoduleError`, `RepoNotFoundError`, `NoteNotFoundError`, and (for the
46
- * six ops that unlock/re-lock the vendored tree around a git mutation)
47
- * `ReposLockdownError` — down to a logged message and a non-zero exit code,
48
- * so none of those propagate past the handler. `status` only `catchTag`s
49
- * `ReposConfigError`; `GitSubmoduleError` from either `ReposManager.status`
50
- * or (under `--drift`) `Repos.ReposDrift.check` is the sole channel that
51
- * escapes uncaught, so it is the only member left in this union.
42
+ * The error channel is what a handler fails with, never a raw `Repos` error:
43
+ * every handler maps each error its `ReposManager` method (or the picker's
44
+ * `status` read) can produce to a self-rendering `CommandError` (exit 1),
45
+ * except the friendly missing-manifest case and `status --json`'s JSON error
46
+ * document. A repo name or other positional left off is a
47
+ * `CliError.ShowHelp` (exit 64) when no one can be asked, and backing out of
48
+ * a picker or a confirm is the kit's `Cancelled` (exit 130).
52
49
  */
53
50
  const reposCommand = _reposCommand;
54
51