@savvy-web/cli 2.3.0 → 2.4.1

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/README.md CHANGED
@@ -55,11 +55,11 @@ npx savvy clean --globs dist,.turbo,coverage
55
55
  - `savvy commit` — the husky/Claude hook handlers (session-start, pre-commit-message, post-commit-verify).
56
56
  - `savvy changeset` — changeset lint, check, transform, version, config validation, and dependency changesets.
57
57
  - `savvy lint` — formatters for package.json, the pnpm workspace file and YAML.
58
- - `savvy repos` — the vendored reference repos declared in `.repos/config.json`: `status`, `sync`, `pin`, `add` and `note`.
58
+ - `savvy repos` — the vendored reference repos declared in `.repos/config.json`: `status` (with `--drift`), `sync`, `pin`, `add`, `note`, `remove`, `rename` and `restore`.
59
59
 
60
60
  ## Vendored repos are read-only
61
61
 
62
- `savvy repos sync`, `add` and `pin` leave every vendored tree under `.repos/` read-only at the OS level — files `0444`, directories `0555`. Reading a vendored source needs no extra step; writing to one fails with `EACCES` by design, because those trees mirror an upstream repo at a pinned ref and any local edit is lost on the next sync.
62
+ `savvy repos sync`, `add`, `pin`, `remove`, `rename` and `restore` leave every vendored tree under `.repos/` read-only at the OS level — files `0444`, directories `0555` (an executable file locks at `0555` instead, and unlocks back to `0755` rather than losing its executable bit). Reading a vendored source needs no extra step; writing to one fails with `EACCES` by design, because those trees mirror an upstream repo at a pinned ref and any local edit is lost on the next sync. A dirty tree from a hand bypass recovers with `savvy repos restore <name...>` (or with no names, every dirty tree) rather than a manual `git reset`. Restore is destructive by design: it hard-resets each tree it touches to the pinned commit, so uncommitted edits and untracked files in that tree are gone.
63
63
 
64
64
  ```bash
65
65
  npx savvy repos sync
@@ -67,9 +67,15 @@ npx savvy repos sync
67
67
 
68
68
  npx savvy repos pin effect v4.0.0
69
69
  # fetches, checks out the new ref, re-locks, and stages the gitlink and manifest
70
+
71
+ npx savvy repos restore
72
+ # hard-resets every dirty tree to its pinned commit and reports the clean ones as skipped
73
+
74
+ npx savvy repos status --drift
75
+ # reports where the manifest, .gitmodules, the worktree and git submodule status disagree
70
76
  ```
71
77
 
72
- A manual `chmod` only holds until the next `sync` or `pin`. Route changes through `savvy repos` instead.
78
+ A manual `chmod` only holds until the next mutation. Route changes through `savvy repos` instead.
73
79
 
74
80
  Run any command with `--help` to see its full surface:
75
81
 
package/cli/index.js CHANGED
@@ -76,7 +76,7 @@ const rootCommand = Command.make("savvy").pipe(Command.withSubcommands([
76
76
  * CLI application: reads argv from the Stdio service provided by NodeServices.
77
77
  * (v4's `Command.run` takes only `version` — the name comes from the root command.)
78
78
  */
79
- const cli = Command.run(rootCommand, { version: "2.3.0" });
79
+ const cli = Command.run(rootCommand, { version: "2.4.1" });
80
80
  /**
81
81
  * Shared workspace services from `@effected/workspaces`, wired as a
82
82
  * self-contained unit and built ONCE (layers memoize by reference).
@@ -115,7 +115,7 @@ const InspectorAndAnalyzerLive = Changesets.BranchAnalyzer.layer.pipe(Layer.prov
115
115
  * `Repos.ReposLockdown.layer` dependencies plus the shared `Git` service.
116
116
  * Platform requirements (`FileSystem`, `Path`) flow up to `NodeServices.layer`.
117
117
  */
118
- const ReposGroupLive = Repos.ReposManager.layer.pipe(Layer.provide(Repos.ReposConfigStore.layer), Layer.provide(Repos.ReposLockdown.layer), Layer.provide(GitLive));
118
+ const ReposGroupLive = Layer.mergeAll(Repos.ReposManager.layer, Repos.ReposDrift.layer).pipe(Layer.provide(Repos.ReposConfigStore.layer), Layer.provide(Repos.ReposLockdown.layer), Layer.provide(GitLive));
119
119
  /**
120
120
  * `ToolDiscovery` from `@effected/commands`, wired to this workspace. Its
121
121
  * `LocalExec` contract — the argv prefix that runs a project-local binary — is
@@ -285,14 +285,15 @@ function runLintCheck(opts) {
285
285
  if (tsgoAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsgo)`);
286
286
  else if (tscAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsc)`);
287
287
  else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
288
- if (hasMarkdownlintConfig) if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
289
- else {
290
- const issues = [];
291
- if (!markdownlintStatus.schemaMatches) issues.push("$schema");
292
- if (!markdownlintStatus.configMatches) issues.push("config");
293
- yield* Effect.log(` ${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
294
- }
295
- else yield* Effect.log(` ${BULLET} ${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
288
+ if (hasMarkdownlintConfig) {
289
+ if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
290
+ else {
291
+ const issues = [];
292
+ if (!markdownlintStatus.schemaMatches) issues.push("$schema");
293
+ if (!markdownlintStatus.configMatches) issues.push("config");
294
+ yield* Effect.log(` ${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
295
+ }
296
+ } else yield* Effect.log(` ${BULLET} ${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
296
297
  for (const status of biomeSchemaStatus.statuses) if (status.matches) yield* Effect.log(` ${CHECK_MARK} ${status.path}: biome $schema up-to-date`);
297
298
  else yield* Effect.log(` ${WARNING} ${status.path}: biome $schema outdated (run 'savvy init' to update)`);
298
299
  yield* Effect.log("");
@@ -0,0 +1,72 @@
1
+ import { Repos } from "@savvy-web/silk-effects";
2
+ import { Effect } from "effect";
3
+ import { Argument, Command, Flag } from "effect/unstable/cli";
4
+
5
+ //#region src/commands/repos/commands/remove.ts
6
+ /**
7
+ * `repos remove` command -- unvendor a `.repos/` submodule.
8
+ *
9
+ * @remarks
10
+ * A thin adapter over {@link Repos.ReposManager.remove}: deinitializes and
11
+ * removes the submodule's gitlink, worktree, and module gitdir, drops its
12
+ * `.gitmodules` section, and removes the manifest entry -- it does not
13
+ * commit, so the caller reviews and commits the staged removal with the
14
+ * ready-made message the result carries. Any notes the entry carried are
15
+ * surfaced in the result so a durable one can be promoted elsewhere first.
16
+ *
17
+ * A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
18
+ * the common, friendly case and always exits 0. A `ReposConfigError` with
19
+ * kind `"invalid"` means the manifest exists but is corrupt or unreadable.
20
+ * `RepoNotFoundError` means the named repo isn't in the manifest -- this is
21
+ * a REAL failure (unlike the "missing manifest" case above), since removing
22
+ * a name that was never vendored is almost always a typo the caller should
23
+ * see. `GitSubmoduleError` means the underlying git command failed, and
24
+ * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
25
+ * tree failed -- all four (invalid config, not-found, git failure, lockdown
26
+ * failure) are real failures, logged and reported via a non-zero exit code.
27
+ *
28
+ * @example
29
+ * ```bash
30
+ * savvy repos remove my-repo
31
+ * ```
32
+ *
33
+ * @internal
34
+ */
35
+ /* v8 ignore start -- CLI option/arg definitions */
36
+ const nameArg = Argument.string("name");
37
+ const cwdOption = Flag.directory("cwd").pipe(Flag.withDescription("Repo root to remove within"), Flag.withDefault("."));
38
+ /* v8 ignore stop */
39
+ /**
40
+ * Remove handler; exported for tests.
41
+ *
42
+ * @internal
43
+ */
44
+ const runReposRemove = (cwd, name) => Effect.gen(function* () {
45
+ const result = yield* (yield* Repos.ReposManager).remove(cwd, name);
46
+ yield* Effect.log(`${result.name}: removed (${result.path})`);
47
+ yield* Effect.log(result.commitMessage);
48
+ yield* Effect.log("staged — review and commit");
49
+ for (const note of result.removedNotes) yield* Effect.log(`warning: note ${note.id} (${note.ref}) was removed with the entry — promote first if durable`);
50
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => {
51
+ if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
52
+ process.exitCode = 1;
53
+ return Effect.log(error.message);
54
+ }), Effect.catchTag("RepoNotFoundError", (error) => {
55
+ process.exitCode = 1;
56
+ return Effect.log(error.message);
57
+ }), Effect.catchTag("GitSubmoduleError", (error) => {
58
+ process.exitCode = 1;
59
+ return Effect.log(error.message);
60
+ }), Effect.catchTag("ReposLockdownError", (error) => {
61
+ process.exitCode = 1;
62
+ return Effect.log(error.message);
63
+ }));
64
+ /* v8 ignore start -- CLI registration; handler tested via runReposRemove */
65
+ const removeCommand = Command.make("remove", {
66
+ name: nameArg,
67
+ cwd: cwdOption
68
+ }, ({ name, cwd }) => runReposRemove(cwd, name)).pipe(Command.withDescription("Unvendor a repo under .repos/; stages the removal without committing"));
69
+ /* v8 ignore stop */
70
+
71
+ //#endregion
72
+ export { removeCommand, runReposRemove };
@@ -0,0 +1,74 @@
1
+ import { Repos } from "@savvy-web/silk-effects";
2
+ import { Effect } from "effect";
3
+ import { Argument, Command, Flag } from "effect/unstable/cli";
4
+
5
+ //#region src/commands/repos/commands/rename.ts
6
+ /**
7
+ * `repos rename` command -- rename a vendored `.repos/` submodule's manifest
8
+ * key and worktree.
9
+ *
10
+ * @remarks
11
+ * A thin adapter over {@link Repos.ReposManager.rename}: moves the worktree
12
+ * (`git mv`), re-points the module gitdir's `core.worktree` values,
13
+ * canonicalizes the `.gitmodules` section name, and renames the manifest
14
+ * key -- it does not commit, so the caller reviews and commits the staged
15
+ * rename with the ready-made message the result carries.
16
+ *
17
+ * A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
18
+ * the common, friendly case and always exits 0. A `ReposConfigError` with
19
+ * kind `"invalid"` means either the manifest exists but is corrupt/unreadable
20
+ * OR the requested new name is invalid/already vendored. `RepoNotFoundError`
21
+ * means the old name isn't in the manifest -- this is a REAL failure, since
22
+ * renaming a name that was never vendored is almost always a typo the caller
23
+ * should see. `GitSubmoduleError` means the underlying git command failed,
24
+ * and `ReposLockdownError` means the OS-permission lockdown pass on a
25
+ * vendored tree failed -- all four (invalid config, not-found, git failure,
26
+ * lockdown failure) are real failures, logged and reported via a non-zero
27
+ * exit code.
28
+ *
29
+ * @example
30
+ * ```bash
31
+ * savvy repos rename old-name new-name
32
+ * ```
33
+ *
34
+ * @internal
35
+ */
36
+ /* v8 ignore start -- CLI option/arg definitions */
37
+ const oldNameArg = Argument.string("old-name");
38
+ const newNameArg = Argument.string("new-name");
39
+ const cwdOption = Flag.directory("cwd").pipe(Flag.withDescription("Repo root to rename within"), Flag.withDefault("."));
40
+ /* v8 ignore stop */
41
+ /**
42
+ * Rename handler; exported for tests.
43
+ *
44
+ * @internal
45
+ */
46
+ const runReposRename = (cwd, oldName, newName) => Effect.gen(function* () {
47
+ const result = yield* (yield* Repos.ReposManager).rename(cwd, oldName, newName);
48
+ yield* Effect.log(`${result.oldName}: renamed to ${result.newName} (${result.path})`);
49
+ yield* Effect.log(result.commitMessage);
50
+ yield* Effect.log("staged — review and commit");
51
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => {
52
+ if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
53
+ process.exitCode = 1;
54
+ return Effect.log(error.message);
55
+ }), Effect.catchTag("RepoNotFoundError", (error) => {
56
+ process.exitCode = 1;
57
+ return Effect.log(error.message);
58
+ }), Effect.catchTag("GitSubmoduleError", (error) => {
59
+ process.exitCode = 1;
60
+ return Effect.log(error.message);
61
+ }), Effect.catchTag("ReposLockdownError", (error) => {
62
+ process.exitCode = 1;
63
+ return Effect.log(error.message);
64
+ }));
65
+ /* v8 ignore start -- CLI registration; handler tested via runReposRename */
66
+ const renameCommand = Command.make("rename", {
67
+ oldName: oldNameArg,
68
+ newName: newNameArg,
69
+ cwd: cwdOption
70
+ }, ({ oldName, newName, cwd }) => runReposRename(cwd, oldName, newName)).pipe(Command.withDescription("Rename a vendored repo's manifest key and worktree; stages the change without committing"));
71
+ /* v8 ignore stop */
72
+
73
+ //#endregion
74
+ export { renameCommand, runReposRename };
@@ -0,0 +1,78 @@
1
+ import { Repos } from "@savvy-web/silk-effects";
2
+ import { Effect } from "effect";
3
+ import { Argument, Command, Flag } from "effect/unstable/cli";
4
+
5
+ //#region src/commands/repos/commands/restore.ts
6
+ /**
7
+ * `repos restore` command -- hard-reset one or more vendored `.repos/`
8
+ * submodules back to their pinned gitlink commit.
9
+ *
10
+ * @remarks
11
+ * A thin adapter over {@link Repos.ReposManager.restore}: explicit only --
12
+ * never invoked by `sync` or any other implicit path. DESTRUCTIVE to
13
+ * uncommitted worktree edits and untracked files in the repos it touches:
14
+ * each targeted repo is hard-reset to its staged gitlink commit (falling
15
+ * back to the committed one) and its sparse-checkout paths re-applied.
16
+ * With one or more `name` arguments, exactly those repos are restored, even
17
+ * if already clean -- an explicit ask is always honored. With no names, every
18
+ * DIRTY repo (per `status`) is restored and the clean ones are reported as
19
+ * skipped.
20
+ *
21
+ * A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
22
+ * the common, friendly case and always exits 0. A `ReposConfigError` with
23
+ * kind `"invalid"` means the manifest exists but is corrupt or unreadable.
24
+ * `RepoNotFoundError` means an explicitly named repo isn't in the manifest --
25
+ * a real failure, since restoring a name that was never vendored is almost
26
+ * always a typo the caller should see. `GitSubmoduleError` means the
27
+ * underlying git command failed (including the case where a repo has
28
+ * neither a staged nor a committed gitlink commit to restore to), and
29
+ * `ReposLockdownError` means the OS-permission lockdown pass on a vendored
30
+ * tree failed -- all four are real failures, logged and reported via a
31
+ * non-zero exit code.
32
+ *
33
+ * @example
34
+ * ```bash
35
+ * savvy repos restore my-repo
36
+ * savvy repos restore my-repo other-repo
37
+ * savvy repos restore
38
+ * ```
39
+ *
40
+ * @internal
41
+ */
42
+ /* v8 ignore start -- CLI option/arg definitions */
43
+ const namesArg = Argument.string("name").pipe(Argument.variadic());
44
+ const cwdOption = Flag.directory("cwd").pipe(Flag.withDescription("Repo root to restore within"), Flag.withDefault("."));
45
+ /* v8 ignore stop */
46
+ /**
47
+ * Restore handler; exported for tests.
48
+ *
49
+ * @internal
50
+ */
51
+ const runReposRestore = (cwd, names) => Effect.gen(function* () {
52
+ const result = yield* (yield* Repos.ReposManager).restore(cwd, names.length > 0 ? names : void 0);
53
+ for (const entry of result.restored) yield* Effect.log(`${entry.name}: restored to ${entry.commit}`);
54
+ for (const name of result.skippedClean) yield* Effect.log(`${name}: clean — skipped`);
55
+ if (result.restored.length === 0 && result.skippedClean.length === 0) yield* Effect.log("nothing to restore");
56
+ }).pipe(Effect.catchTag("ReposConfigError", (error) => {
57
+ if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
58
+ process.exitCode = 1;
59
+ return Effect.log(error.message);
60
+ }), Effect.catchTag("RepoNotFoundError", (error) => {
61
+ process.exitCode = 1;
62
+ return Effect.log(error.message);
63
+ }), Effect.catchTag("GitSubmoduleError", (error) => {
64
+ process.exitCode = 1;
65
+ return Effect.log(error.message);
66
+ }), Effect.catchTag("ReposLockdownError", (error) => {
67
+ process.exitCode = 1;
68
+ return Effect.log(error.message);
69
+ }));
70
+ /* v8 ignore start -- CLI registration; handler tested via runReposRestore */
71
+ const restoreCommand = Command.make("restore", {
72
+ names: namesArg,
73
+ 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"));
75
+ /* v8 ignore stop */
76
+
77
+ //#endregion
78
+ export { restoreCommand, runReposRestore };
@@ -16,16 +16,27 @@ import { Command, Flag } from "effect/unstable/cli";
16
16
  * `"invalid"` means the manifest exists but is corrupt or unreadable -- that
17
17
  * is a real failure, logged and reported via a non-zero exit code.
18
18
  *
19
+ * With `--drift`, {@link Repos.ReposDrift.check} also runs (after the status
20
+ * check), reconciling the manifest, `.gitmodules`, the worktree, and `git
21
+ * submodule status`. Each detected drift prints as one line
22
+ * (`<name>: <kind> — <detail>`), and any drift flips the exit code to 1,
23
+ * mirroring the existing `!clean` rule for the status report itself. Because
24
+ * both calls read the same manifest, a missing manifest fails at the status
25
+ * call before the drift check ever runs, so the friendly exit-0 case is
26
+ * unaffected by `--drift`.
27
+ *
19
28
  * @example
20
29
  * ```bash
21
30
  * savvy repos status
22
31
  * savvy repos status --json
32
+ * savvy repos status --drift
23
33
  * ```
24
34
  *
25
35
  * @internal
26
36
  */
27
37
  /* v8 ignore start -- CLI option definitions */
28
38
  const jsonOption = Flag.boolean("json").pipe(Flag.withDescription("Emit the structured drift report as JSON"), Flag.withDefault(false));
39
+ const driftOption = Flag.boolean("drift").pipe(Flag.withDescription("Also reconcile the manifest, .gitmodules, worktree, and git submodule status"), Flag.withDefault(false));
29
40
  const cwdOption = Flag.directory("cwd").pipe(Flag.withDescription("Repo root to inspect"), Flag.withDefault("."));
30
41
  /* v8 ignore stop */
31
42
  /**
@@ -33,11 +44,20 @@ const cwdOption = Flag.directory("cwd").pipe(Flag.withDescription("Repo root to
33
44
  *
34
45
  * @internal
35
46
  */
36
- const runReposStatus = (cwd, json) => Effect.gen(function* () {
47
+ const runReposStatus = (cwd, json, drift = false) => Effect.gen(function* () {
37
48
  const report = yield* (yield* Repos.ReposManager).status(cwd);
38
49
  if (!report.clean) process.exitCode = 1;
50
+ let driftReport;
51
+ if (drift) {
52
+ driftReport = yield* (yield* Repos.ReposDrift).check(cwd);
53
+ if (!driftReport.clean) process.exitCode = 1;
54
+ }
39
55
  if (json) {
40
- yield* Console.log(JSON.stringify(report, null, 2));
56
+ const payload = driftReport === void 0 ? report : {
57
+ ...report,
58
+ drift: driftReport
59
+ };
60
+ yield* Console.log(JSON.stringify(payload, null, 2));
41
61
  return;
42
62
  }
43
63
  for (const repo of report.repos) {
@@ -48,6 +68,7 @@ const runReposStatus = (cwd, json) => Effect.gen(function* () {
48
68
  ].filter((f) => f !== void 0);
49
69
  yield* Effect.log(`${repo.name} @ ${repo.ref}${flags.length > 0 ? ` [${flags.join(", ")}]` : " [ok]"}`);
50
70
  }
71
+ if (driftReport !== void 0) for (const item of driftReport.drifts) yield* Effect.log(`${item.name}: ${item.kind} — ${item.detail}`);
51
72
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
52
73
  if (error.kind === "missing") {
53
74
  if (json) return Console.log(JSON.stringify({
@@ -62,8 +83,9 @@ const runReposStatus = (cwd, json) => Effect.gen(function* () {
62
83
  /* v8 ignore start -- CLI registration; handler tested via runReposStatus */
63
84
  const statusCommand = Command.make("status", {
64
85
  json: jsonOption,
86
+ drift: driftOption,
65
87
  cwd: cwdOption
66
- }, ({ json, cwd }) => runReposStatus(cwd, json)).pipe(Command.withDescription("Drift report: gitlink vs manifest ref, dirty and unsynced submodules"));
88
+ }, ({ json, drift, cwd }) => runReposStatus(cwd, json, drift)).pipe(Command.withDescription("Drift report: gitlink vs manifest ref, dirty and unsynced submodules"));
67
89
  /* v8 ignore stop */
68
90
 
69
91
  //#endregion
@@ -8,8 +8,10 @@ import { Command, Flag } from "effect/unstable/cli";
8
8
  *
9
9
  * @remarks
10
10
  * A thin adapter over {@link Repos.ReposManager.sync}: initializes missing
11
- * submodules, re-applies sparse-checkout patterns, and clears stale git
12
- * locks left behind by an interrupted fetch. Sync is idempotent repair, so
11
+ * submodules, re-applies sparse-checkout patterns, clears stale git locks
12
+ * left behind by an interrupted fetch, reconciles a submodule's URL when it
13
+ * drifts from the manifest, and registers an orphan manifest entry (no
14
+ * gitlink at all) from scratch. Sync is idempotent repair, so
13
15
  * a `ReposConfigError` with kind `"missing"` -- the common, friendly case
14
16
  * (nothing to sync yet) -- always exits 0. A `ReposConfigError` with kind
15
17
  * `"invalid"` means the manifest exists but is corrupt or unreadable,
@@ -38,7 +40,9 @@ const runReposSync = (cwd) => Effect.gen(function* () {
38
40
  for (const name of report.clearedLocks) yield* Effect.log(`${name}: cleared stale lock`);
39
41
  for (const name of report.initialized) yield* Effect.log(`${name}: initialized`);
40
42
  for (const name of report.sparseApplied) yield* Effect.log(`${name}: sparse-checkout applied`);
41
- if (report.initialized.length === 0 && report.sparseApplied.length === 0 && report.clearedLocks.length === 0) yield* Effect.log("all vendored repos up to date");
43
+ for (const name of report.urlSynced) yield* Effect.log(`${name}: url reconciled`);
44
+ for (const name of report.registered) yield* Effect.log(`${name}: registered`);
45
+ if (report.initialized.length === 0 && report.sparseApplied.length === 0 && report.clearedLocks.length === 0 && report.urlSynced.length === 0 && report.registered.length === 0) yield* Effect.log("all vendored repos up to date");
42
46
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
43
47
  if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
44
48
  process.exitCode = 1;
@@ -1,6 +1,9 @@
1
1
  import { addCommand, runReposAdd } from "./commands/add.js";
2
2
  import { noteCommand, runReposNote } from "./commands/note.js";
3
3
  import { pinCommand, runReposPin } from "./commands/pin.js";
4
+ import { removeCommand, runReposRemove } from "./commands/remove.js";
5
+ import { renameCommand, runReposRename } from "./commands/rename.js";
6
+ import { restoreCommand, runReposRestore } from "./commands/restore.js";
4
7
  import { runReposStatus, statusCommand } from "./commands/status.js";
5
8
  import { runReposSync, syncCommand } from "./commands/sync.js";
6
9
  import { Command } from "effect/unstable/cli";
@@ -12,7 +15,10 @@ const _reposCommand = Command.make("repos").pipe(Command.withSubcommands([
12
15
  syncCommand,
13
16
  pinCommand,
14
17
  addCommand,
15
- noteCommand
18
+ noteCommand,
19
+ removeCommand,
20
+ renameCommand,
21
+ restoreCommand
16
22
  ]), Command.withDescription("Vendored reference repos under .repos/"));
17
23
  /**
18
24
  * The `savvy repos` command group.
@@ -25,13 +31,22 @@ const _reposCommand = Command.make("repos").pipe(Command.withSubcommands([
25
31
  * annotation preserves the exact Error/Requirements channels, so the root
26
32
  * layer graph stays compiler-validated.
27
33
  *
28
- * The requirements channel names `Repos.ReposManager` rather than `never`:
29
- * `Command.withSubcommands` propagates each subcommand's requirements up into
30
- * the group's `R`, which the root assembly discharges via `AppLive`.
34
+ * The requirements channel names `Repos.ReposManager | Repos.ReposDrift`
35
+ * rather than `never`: `Command.withSubcommands` propagates each
36
+ * subcommand's requirements up into the group's `R`, which the root assembly
37
+ * discharges via `AppLive`. `Repos.ReposDrift` joins the union because
38
+ * `status --drift` runs `Repos.ReposDrift.check` after the status check.
31
39
  *
32
- * The error channel now also names `Repos.ReposLockdownError`: `sync`/`add`/
33
- * `pin` unlock and re-lock the vendored tree around their git mutations
34
- * (Task 2), so a lockdown chmod failure can surface from any of the three.
40
+ * The error channel is `Repos.GitSubmoduleError` alone: `add`/`pin`/`note`/
41
+ * `remove`/`rename`/`restore`/`sync` each `catchTag` every error their
42
+ * underlying `ReposManager` method can produce `ReposConfigError`,
43
+ * `GitSubmoduleError`, `RepoNotFoundError`, `NoteNotFoundError`, and (for the
44
+ * six ops that unlock/re-lock the vendored tree around a git mutation)
45
+ * `ReposLockdownError` — down to a logged message and a non-zero exit code,
46
+ * so none of those propagate past the handler. `status` only `catchTag`s
47
+ * `ReposConfigError`; `GitSubmoduleError` from either `ReposManager.status`
48
+ * or (under `--drift`) `Repos.ReposDrift.check` is the sole channel that
49
+ * escapes uncaught, so it is the only member left in this union.
35
50
  */
36
51
  const reposCommand = _reposCommand;
37
52
 
package/index.d.ts CHANGED
@@ -272,7 +272,7 @@ declare const lintCommand: Command.Command<"lint", {}, {}, import("@effected/yam
272
272
  *
273
273
  * @internal
274
274
  */
275
- declare const runReposStatus: (cwd: string, json: boolean) => Effect.Effect<void, Repos.GitSubmoduleError, Repos.ReposManager>;
275
+ declare const runReposStatus: (cwd: string, json: boolean, drift?: boolean) => Effect.Effect<void, Repos.GitSubmoduleError, Repos.ReposDrift | Repos.ReposManager>;
276
276
  //#endregion
277
277
  //#region src/commands/repos/commands/sync.d.ts
278
278
  /**
@@ -294,15 +294,24 @@ declare const runReposSync: (cwd: string) => Effect.Effect<void, never, Repos.Re
294
294
  * annotation preserves the exact Error/Requirements channels, so the root
295
295
  * layer graph stays compiler-validated.
296
296
  *
297
- * The requirements channel names `Repos.ReposManager` rather than `never`:
298
- * `Command.withSubcommands` propagates each subcommand's requirements up into
299
- * the group's `R`, which the root assembly discharges via `AppLive`.
300
- *
301
- * The error channel now also names `Repos.ReposLockdownError`: `sync`/`add`/
302
- * `pin` unlock and re-lock the vendored tree around their git mutations
303
- * (Task 2), so a lockdown chmod failure can surface from any of the three.
297
+ * The requirements channel names `Repos.ReposManager | Repos.ReposDrift`
298
+ * rather than `never`: `Command.withSubcommands` propagates each
299
+ * subcommand's requirements up into the group's `R`, which the root assembly
300
+ * discharges via `AppLive`. `Repos.ReposDrift` joins the union because
301
+ * `status --drift` runs `Repos.ReposDrift.check` after the status check.
302
+ *
303
+ * The error channel is `Repos.GitSubmoduleError` alone: `add`/`pin`/`note`/
304
+ * `remove`/`rename`/`restore`/`sync` each `catchTag` every error their
305
+ * underlying `ReposManager` method can produce — `ReposConfigError`,
306
+ * `GitSubmoduleError`, `RepoNotFoundError`, `NoteNotFoundError`, and (for the
307
+ * six ops that unlock/re-lock the vendored tree around a git mutation)
308
+ * `ReposLockdownError` — down to a logged message and a non-zero exit code,
309
+ * so none of those propagate past the handler. `status` only `catchTag`s
310
+ * `ReposConfigError`; `GitSubmoduleError` from either `ReposManager.status`
311
+ * or (under `--drift`) `Repos.ReposDrift.check` is the sole channel that
312
+ * escapes uncaught, so it is the only member left in this union.
304
313
  */
305
- declare const reposCommand: Command.Command<"repos", Record<string, never>, Record<string, never>, Repos.GitSubmoduleError | Repos.ReposLockdownError, Repos.ReposManager>;
314
+ declare const reposCommand: Command.Command<"repos", Record<string, never>, Record<string, never>, Repos.GitSubmoduleError, Repos.ReposManager | Repos.ReposDrift>;
306
315
  //#endregion
307
316
  export { changesetCommand, checkCommand, commitCommand, initCommand, lintCommand, reposCommand, runChangesetCheck, runChangesetInit, runCheck, runCli, runCommitCheck, runCommitInit, runInit, runLintCheck, runLintInit, runReposStatus, runReposSync };
308
317
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "private": false,
5
5
  "description": "The savvy CLI — unified commit, changeset, and lint commands for the Silk Suite",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/cli",
@@ -31,14 +31,14 @@
31
31
  "savvy": "bin/savvy.js"
32
32
  },
33
33
  "dependencies": {
34
- "@effect/platform-node": "4.0.0-beta.101",
35
- "@effected/commands": "^0.3.1",
36
- "@effected/git": "^0.5.2",
37
- "@effected/jsonc": "^0.5.2",
38
- "@effected/templates": "^0.1.1",
39
- "@effected/workspaces": "^0.10.0",
40
- "@effected/yaml": "^0.6.1",
41
- "@savvy-web/silk-effects": "5.4.0",
42
- "effect": "4.0.0-beta.101"
34
+ "@effect/platform-node": "4.0.0-beta.107",
35
+ "@effected/commands": "^0.4.0",
36
+ "@effected/git": "^0.7.0",
37
+ "@effected/jsonc": "^0.6.0",
38
+ "@effected/templates": "^0.2.0",
39
+ "@effected/workspaces": "^0.11.0",
40
+ "@effected/yaml": "^0.7.0",
41
+ "@savvy-web/silk-effects": "5.5.1",
42
+ "effect": "4.0.0-beta.107"
43
43
  }
44
44
  }