@cvr/stacked 0.4.0 → 0.4.2

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
@@ -13,11 +13,12 @@ bun run build # compiles binary to bin/stacked + symlinks to ~/.bun/bin/
13
13
  ## Setup
14
14
 
15
15
  ```sh
16
- # Trunk is auto-detected (main > master > develop). Override if needed:
17
- stacked trunk develop
18
-
19
16
  # Install the Claude skill (optional):
20
17
  stacked init
18
+
19
+ # Trunk is auto-detected on first use from origin/HEAD when available
20
+ # (fallback: main > master > develop). Override only if needed:
21
+ stacked trunk <name>
21
22
  ```
22
23
 
23
24
  ## Usage
@@ -131,7 +132,7 @@ stacked --yes clean # skip confirmation prompts
131
132
 
132
133
  Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by array position — `branches[0]`'s parent is trunk, `branches[n]`'s parent is `branches[n-1]`.
133
134
 
134
- Trunk is auto-detected on first use by checking for `main`, `master`, or `develop` branches. Override with `stacked trunk <name>`.
135
+ Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
135
136
 
136
137
  ## Output Conventions
137
138
 
package/bin/stacked CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cvr/stacked",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/stacked"
@@ -32,20 +32,20 @@
32
32
  "prepare": "effect-language-service patch && lefthook install"
33
33
  },
34
34
  "dependencies": {
35
- "@effect/platform-bun": "4.0.0-beta.12",
36
- "effect": "4.0.0-beta.12",
35
+ "@effect/platform-bun": "4.0.0-beta.27",
36
+ "effect": "4.0.0-beta.27",
37
37
  "picocolors": "^1.1.1"
38
38
  },
39
39
  "devDependencies": {
40
- "@changesets/changelog-github": "^0.5.2",
41
- "@changesets/cli": "^2.29.8",
42
- "@effect/language-service": "^0.76.0",
43
- "@types/bun": "^1.3.9",
40
+ "@changesets/changelog-github": "^0.6.0",
41
+ "@changesets/cli": "^2.30.0",
42
+ "@effect/language-service": "^0.77.0",
43
+ "@types/bun": "^1.3.10",
44
44
  "concurrently": "^9.2.1",
45
45
  "effect-bun-test": "^0.2.1",
46
- "lefthook": "^2.1.1",
47
- "oxfmt": "^0.35.0",
48
- "oxlint": "^1.50.0",
46
+ "lefthook": "^2.1.2",
47
+ "oxfmt": "^0.36.0",
48
+ "oxlint": "^1.51.0",
49
49
  "typescript": "^5.9.3"
50
50
  }
51
51
  }
@@ -45,7 +45,7 @@ What do you need?
45
45
  | `stacked down` | Move down one branch in the stack |
46
46
  | `stacked top` | Jump to top of stack |
47
47
  | `stacked bottom` | Jump to bottom of stack |
48
- | `stacked sync` | Fetch + rebase stack on trunk (`--from`, `--dry-run`, `--json`) |
48
+ | `stacked sync` | Fetch + rebase + force-push stack on trunk (`--from`, `--dry-run`, `--json`) |
49
49
  | `stacked detect` | Detect branch chains and register as stacks (`--dry-run`, `--json`) |
50
50
  | `stacked clean` | Remove merged branches + remote branches (`--dry-run`, `--json`) |
51
51
  | `stacked delete <name>` | Remove branch from stack + git + remote (`--keep-remote`, `--force`, `--dry-run`, `--json`) |
@@ -71,11 +71,12 @@ What do you need?
71
71
  ## Setup
72
72
 
73
73
  ```sh
74
- # Trunk is auto-detected (main > master > develop). Override if needed:
75
- stacked trunk develop
76
-
77
74
  # Install the Claude skill (optional, compiled binary only):
78
75
  stacked init
76
+
77
+ # Trunk is auto-detected on first use from origin/HEAD when available
78
+ # (fallback: main > master > develop). Override only if needed:
79
+ stacked trunk <name>
79
80
  ```
80
81
 
81
82
  Requires `gh` CLI installed and authenticated for `submit` and `clean`.
@@ -140,13 +141,13 @@ stacked bottom # jump to bottom (trunk-adjacent)
140
141
 
141
142
  All navigation commands support `--json` for structured output. `checkout` falls through to `git checkout` for branches not in any stack.
142
143
 
143
- ## Syncing / Rebasing
144
+ ## Syncing / Rebasing / Pushing
144
145
 
145
- Fetch latest trunk and rebase the entire stack bottom-to-top:
146
+ Fetch latest trunk, rebase the entire stack bottom-to-top, then force-push each rebased branch (`--force-with-lease`):
146
147
 
147
148
  ```sh
148
149
  stacked sync
149
- stacked sync --dry-run # preview rebase plan without executing
150
+ stacked sync --dry-run # preview rebase/push plan without executing
150
151
  stacked sync --json # structured output: { branches: [{ name, action, base }] }
151
152
  ```
152
153
 
@@ -315,7 +316,7 @@ Stack metadata lives in `.git/stacked.json`. Each branch's parent is implied by
315
316
  - `branches[0]` → parent is trunk
316
317
  - `branches[n]` → parent is `branches[n-1]`
317
318
 
318
- Trunk is auto-detected on first use by checking for `main`, `master`, or `develop` branches. Override with `stacked trunk <name>`.
319
+ Trunk is auto-detected on first use from `origin/HEAD` when available, then falls back to local `main`, `master`, or `develop`. Override with `stacked trunk <name>`.
319
320
 
320
321
  A repo can have multiple independent stacks. The current stack is determined by which branch you're on.
321
322
 
@@ -355,13 +356,13 @@ stacked down # go to previous branch
355
356
  ## Gotchas
356
357
 
357
358
  - `stacked sync` requires a clean working tree — commit or stash first (except `--dry-run`)
358
- - `stacked sync` rebases bottom-to-top resolve conflicts one branch at a time
359
+ - `stacked sync` rebases bottom-to-top and force-pushes each rebased branch with lease
359
360
  - `stacked sync` leaves rebase in progress on conflict — resolve with `git rebase --continue`, then resume with `stacked sync --from <parent>`
360
361
  - `stacked submit` force-pushes by default (use `--no-force` to disable)
361
362
  - `stacked submit` and `stacked clean` require `gh` CLI authenticated (`gh auth login`)
362
363
  - PRs target parent branches, not trunk — this is intentional for stacked review
363
364
  - PRs include auto-generated stack metadata (position, navigation links)
364
- - Trunk is auto-detected (`main` > `master` > `develop`) — use `stacked trunk <name>` to override
365
+ - Trunk is auto-detected (`origin/HEAD` first, then `main` > `master` > `develop`) — use `stacked trunk <name>` only when detection is wrong
365
366
  - Forked branches (one parent, multiple children) are not supported — `detect` reports them but skips
366
367
  - `stacked delete --force` on a mid-stack branch requires `stacked sync` afterward
367
368
  - `stacked checkout` falls through to `git checkout` for branches not in a stack
@@ -94,7 +94,8 @@ export const adopt = Command.make("adopt", {
94
94
  yield* Console.log(JSON.stringify({ adopted: branch, stack: stackName }, null, 2));
95
95
  } else {
96
96
  yield* Console.error(`Adopted ${branch} into stack`);
97
- yield* Console.error(dim("Run 'stacked sync' to rebase onto the new parent."));
97
+ const hint = yield* dim("Run 'stacked sync' to rebase onto the new parent.");
98
+ yield* Console.error(hint);
98
99
  }
99
100
  }),
100
101
  ),
@@ -37,14 +37,21 @@ export const amend = Command.make("amend", {
37
37
  });
38
38
  }
39
39
 
40
- yield* git.commitAmend({ edit });
41
-
42
40
  const fromBranch = Option.isSome(from) ? from.value : currentBranch;
43
41
 
44
42
  // Find children to rebase
45
43
  const { branches } = result.stack;
46
44
  const idx = branches.indexOf(fromBranch);
47
- if (idx === -1 || idx >= branches.length - 1) {
45
+ if (idx === -1) {
46
+ return yield* new StackError({
47
+ code: ErrorCode.BRANCH_NOT_FOUND,
48
+ message: `Branch "${fromBranch}" not found in stack "${result.name}"`,
49
+ });
50
+ }
51
+
52
+ yield* git.commitAmend({ edit });
53
+
54
+ if (idx >= branches.length - 1) {
48
55
  if (json) {
49
56
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
50
57
  yield* Console.log(JSON.stringify({ amended: currentBranch, synced: [] }, null, 2));
@@ -79,7 +79,8 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
79
79
  `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
80
80
  );
81
81
  for (const { branch, stackName } of skippedMerged) {
82
- yield* Console.error(dim(` ${branch} (${stackName})`));
82
+ const line = yield* dim(` ${branch} (${stackName})`);
83
+ yield* Console.error(line);
83
84
  }
84
85
  }
85
86
  }
@@ -88,7 +89,8 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
88
89
 
89
90
  if (!dryRun) {
90
91
  for (const { branch } of toRemove) {
91
- yield* Console.error(dim(` ${branch}`));
92
+ const line = yield* dim(` ${branch}`);
93
+ yield* Console.error(line);
92
94
  }
93
95
  const confirmed = yield* confirm(
94
96
  `Remove ${toRemove.length} merged branch${toRemove.length === 1 ? "" : "es"}?`,
@@ -146,16 +148,18 @@ export const clean = Command.make("clean", { dryRun: dryRunFlag, json: jsonFlag
146
148
  yield* success(
147
149
  `Cleaned ${removed.length} merged branch${removed.length === 1 ? "" : "es"}`,
148
150
  );
149
- yield* Console.error(
150
- dim("Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs."),
151
+ const hint = yield* dim(
152
+ "Run 'stacked sync' then 'stacked submit' to rebase and retarget PRs.",
151
153
  );
154
+ yield* Console.error(hint);
152
155
 
153
156
  if (skippedMerged.length > 0) {
154
157
  yield* warn(
155
158
  `${skippedMerged.length} merged branch${skippedMerged.length === 1 ? "" : "es"} skipped (non-merged branches below):`,
156
159
  );
157
160
  for (const { branch, stackName } of skippedMerged) {
158
- yield* Console.error(dim(` ${branch} (${stackName})`));
161
+ const line = yield* dim(` ${branch} (${stackName})`);
162
+ yield* Console.error(line);
159
163
  }
160
164
  }
161
165
  }
@@ -3,6 +3,7 @@ import { Console, Effect } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { success, warn, info } from "../ui.js";
6
+ import { detectLimitConfig, limitUntrackedBranches } from "./helpers/detect.js";
6
7
 
7
8
  const dryRunFlag = Flag.boolean("dry-run").pipe(
8
9
  Flag.withDescription("Show what would be detected without making changes"),
@@ -26,13 +27,21 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
26
27
 
27
28
  const data = yield* stacks.load();
28
29
  const alreadyTracked = new Set(Object.values(data.stacks).flatMap((s) => [...s.branches]));
29
- const untracked = candidates.filter((b) => !alreadyTracked.has(b));
30
+ const untrackedAll = candidates.filter((b) => !alreadyTracked.has(b));
31
+ const detectLimit = yield* detectLimitConfig;
32
+ const { untracked, skipped } = limitUntrackedBranches(untrackedAll, detectLimit);
30
33
 
31
34
  if (untracked.length === 0) {
32
35
  yield* Console.error("No untracked branches found");
33
36
  return;
34
37
  }
35
38
 
39
+ if (skipped > 0) {
40
+ yield* warn(
41
+ `Analyzing ${untracked.length}/${untrackedAll.length} untracked branches (set STACKED_DETECT_MAX_BRANCHES to adjust)`,
42
+ );
43
+ }
44
+
36
45
  // Build parent map: for each branch, find its direct parent among other branches
37
46
  // A parent is the closest ancestor — i.e., an ancestor that is not an ancestor of another ancestor
38
47
  const childOf = new Map<string, string>();
@@ -75,15 +84,24 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
75
84
 
76
85
  // Build linear chains from trunk
77
86
  // Find branches whose parent is trunk (chain roots)
87
+ const childrenByParent = new Map<string, string[]>();
88
+ for (const branch of untracked) {
89
+ const parent = childOf.get(branch);
90
+ if (parent === undefined) continue;
91
+ const children = childrenByParent.get(parent) ?? [];
92
+ children.push(branch);
93
+ childrenByParent.set(parent, children);
94
+ }
95
+
78
96
  const chains: string[][] = [];
79
- const roots = untracked.filter((b) => childOf.get(b) === trunk);
97
+ const roots = childrenByParent.get(trunk) ?? [];
80
98
 
81
99
  for (const root of roots) {
82
100
  const chain = [root];
83
101
  let current = root;
84
102
 
85
103
  while (true) {
86
- const children = untracked.filter((b) => childOf.get(b) === current);
104
+ const children = childrenByParent.get(current) ?? [];
87
105
  const child = children[0];
88
106
  if (children.length === 1 && child !== undefined) {
89
107
  chain.push(child);
@@ -98,13 +116,10 @@ export const detect = Command.make("detect", { dryRun: dryRunFlag, json: jsonFla
98
116
  }
99
117
 
100
118
  // Report forks
101
- const forkPoints = untracked.filter((b) => {
102
- const children = untracked.filter((c) => childOf.get(c) === b);
103
- return children.length > 1;
104
- });
119
+ const forkPoints = untracked.filter((b) => (childrenByParent.get(b)?.length ?? 0) > 1);
105
120
  const forks = forkPoints.map((b) => ({
106
121
  branch: b,
107
- children: untracked.filter((c) => childOf.get(c) === b),
122
+ children: childrenByParent.get(b) ?? [],
108
123
  }));
109
124
 
110
125
  if (json) {
@@ -1,5 +1,5 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Console, Effect, Option } from "effect";
3
3
  import { GitService } from "../services/Git.js";
4
4
  import { StackService } from "../services/Stack.js";
5
5
  import { success, warn } from "../ui.js";
@@ -33,20 +33,20 @@ export const doctor = Command.make("doctor", { fix: fixFlag, json: jsonFlag }).p
33
33
  .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
34
34
  if (!trunkExists) {
35
35
  if (fix) {
36
- // Auto-detect a trunk
37
- for (const candidate of ["main", "master", "develop"]) {
38
- const exists = yield* git
39
- .branchExists(candidate)
40
- .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
41
- if (exists) {
42
- yield* stacks.setTrunk(candidate);
43
- findings.push({
44
- type: "missing_trunk",
45
- message: `Trunk "${data.trunk}" not found, set to "${candidate}"`,
46
- fixed: true,
47
- });
48
- break;
49
- }
36
+ const candidate = yield* stacks.detectTrunkCandidate();
37
+ if (Option.isSome(candidate)) {
38
+ yield* stacks.setTrunk(candidate.value);
39
+ findings.push({
40
+ type: "missing_trunk",
41
+ message: `Trunk "${data.trunk}" not found, set to "${candidate.value}"`,
42
+ fixed: true,
43
+ });
44
+ } else {
45
+ findings.push({
46
+ type: "missing_trunk",
47
+ message: `Trunk branch "${data.trunk}" does not exist and no replacement could be auto-detected`,
48
+ fixed: false,
49
+ });
50
50
  }
51
51
  } else {
52
52
  findings.push({
@@ -0,0 +1,22 @@
1
+ import { Config } from "effect";
2
+
3
+ export const DEFAULT_MAX_DETECT_BRANCHES = 200;
4
+
5
+ export interface LimitedBranches {
6
+ readonly untracked: readonly string[];
7
+ readonly skipped: number;
8
+ }
9
+
10
+ export const detectLimitConfig = Config.int("STACKED_DETECT_MAX_BRANCHES").pipe(
11
+ Config.withDefault(DEFAULT_MAX_DETECT_BRANCHES),
12
+ Config.orElse(() => Config.succeed(DEFAULT_MAX_DETECT_BRANCHES)),
13
+ Config.map((value) => (value > 0 ? value : DEFAULT_MAX_DETECT_BRANCHES)),
14
+ );
15
+
16
+ export const limitUntrackedBranches = (
17
+ untrackedAll: readonly string[],
18
+ limit: number,
19
+ ): LimitedBranches => {
20
+ const untracked = untrackedAll.slice(0, limit);
21
+ return { untracked, skipped: untrackedAll.length - untracked.length };
22
+ };
@@ -30,7 +30,7 @@ const root = Command.make("stacked").pipe(
30
30
  Command.withExamples([
31
31
  { command: "stacked create feat-auth", description: "Create a new branch in the stack" },
32
32
  { command: "stacked list", description: "Show branches in the current stack" },
33
- { command: "stacked sync", description: "Rebase all branches in order" },
33
+ { command: "stacked sync", description: "Update trunk, then rebase stack branches in order" },
34
34
  { command: "stacked submit", description: "Push and create/update PRs" },
35
35
  ]),
36
36
  );
@@ -1,5 +1,5 @@
1
1
  import { Command } from "effect/unstable/cli";
2
- import { Console, Effect } from "effect";
2
+ import { Config, Console, Effect } from "effect";
3
3
  import { StackError } from "../errors/index.js";
4
4
  import { mkdirSync, writeFileSync } from "fs";
5
5
  import { join } from "path";
@@ -18,7 +18,9 @@ export const init = Command.make("init").pipe(
18
18
  });
19
19
  }
20
20
 
21
- const skillsDir = process.env["STACKED_SKILLS_DIR"] ?? join(homedir(), ".claude", "skills");
21
+ const skillsDir = yield* Config.string("STACKED_SKILLS_DIR").pipe(
22
+ Config.withDefault(join(homedir(), ".claude", "skills")),
23
+ );
22
24
  const targetDir = join(skillsDir, "stacked");
23
25
  const targetPath = join(targetDir, "SKILL.md");
24
26
 
@@ -33,8 +35,10 @@ export const init = Command.make("init").pipe(
33
35
 
34
36
  yield* Console.error(`Installed stacked skill to ${targetPath}`);
35
37
  yield* Console.error("\nNext steps:");
36
- yield* Console.error(" stacked trunk # verify/set your trunk branch");
37
38
  yield* Console.error(" stacked create <name> # start your first stack");
39
+ yield* Console.error(
40
+ " stacked trunk <name> # only if auto-detection picks the wrong trunk",
41
+ );
38
42
  }),
39
43
  ),
40
44
  );
@@ -84,27 +84,27 @@ export const list = Command.make("list", { stackName: stackNameArg, json: jsonFl
84
84
 
85
85
  const lines: string[] = [];
86
86
 
87
- lines.push(`Stack: ${stdout.bold(targetStackName)}`);
88
- lines.push(`Trunk: ${stdout.dim(trunk)}`);
87
+ lines.push(`Stack: ${yield* stdout.bold(targetStackName)}`);
88
+ lines.push(`Trunk: ${yield* stdout.dim(trunk)}`);
89
89
  lines.push("");
90
90
 
91
91
  for (let i = targetStack.branches.length - 1; i >= 0; i--) {
92
92
  const branch = targetStack.branches[i];
93
93
  if (branch === undefined) continue;
94
94
  const isCurrent = branch === currentBranch;
95
- const marker = isCurrent ? stdout.green("* ") : " ";
96
- const prefix = stdout.dim(i === 0 ? "└─" : "├─");
97
- const name = isCurrent ? stdout.bold(branch) : branch;
95
+ const marker = isCurrent ? yield* stdout.green("* ") : " ";
96
+ const prefix = yield* stdout.dim(i === 0 ? "└─" : "├─");
97
+ const name = isCurrent ? yield* stdout.bold(branch) : branch;
98
98
 
99
99
  const pr = prMap.get(branch) ?? null;
100
100
  const status =
101
101
  pr === null
102
102
  ? ""
103
103
  : pr.state === "MERGED"
104
- ? stdout.green(" [merged]")
104
+ ? yield* stdout.green(" [merged]")
105
105
  : pr.state === "CLOSED"
106
- ? stdout.dim(" [closed]")
107
- : stdout.cyan(` [#${pr.number}]`);
106
+ ? yield* stdout.dim(" [closed]")
107
+ : yield* stdout.cyan(` [#${pr.number}]`);
108
108
 
109
109
  lines.push(`${marker}${prefix} ${name}${status}`);
110
110
  }
@@ -56,6 +56,16 @@ export const reorder = Command.make("reorder", {
56
56
  if (currentIdx === -1) return;
57
57
 
58
58
  const target = Option.isSome(before) ? before.value : Option.getOrElse(after, () => "");
59
+ if (target === branch) {
60
+ if (json) {
61
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
62
+ yield* Console.log(JSON.stringify({ branch, stack: stackName, branches }, null, 2));
63
+ } else {
64
+ yield* warn(`"${branch}" is already at the requested position`);
65
+ }
66
+ return;
67
+ }
68
+
59
69
  const targetIdx = branches.indexOf(target);
60
70
  if (targetIdx === -1) {
61
71
  return yield* new StackError({
@@ -45,12 +45,11 @@ export const stacks = Command.make("stacks", { json: jsonFlag }).pipe(
45
45
  const lines: string[] = [];
46
46
  for (const [name, stack] of entries) {
47
47
  const isCurrent = stack.branches.includes(currentBranch);
48
- const marker = isCurrent ? stdout.green("* ") : " ";
49
- const label = isCurrent ? stdout.bold(name) : name;
48
+ const marker = isCurrent ? yield* stdout.green("* ") : " ";
49
+ const label = isCurrent ? yield* stdout.bold(name) : name;
50
50
  const count = stack.branches.length;
51
- lines.push(
52
- `${marker}${label} ${stdout.dim(`(${count} branch${count === 1 ? "" : "es"})`)}`,
53
- );
51
+ const countText = yield* stdout.dim(`(${count} branch${count === 1 ? "" : "es"})`);
52
+ lines.push(`${marker}${label} ${countText}`);
54
53
  }
55
54
 
56
55
  yield* Console.log(lines.join("\n"));
@@ -36,17 +36,19 @@ export const status = Command.make("status", { json: jsonFlag }).pipe(
36
36
  }
37
37
 
38
38
  const lines: string[] = [];
39
- lines.push(`Branch: ${stdout.bold(currentBranch)}`);
40
- lines.push(`Working tree: ${clean ? stdout.green("clean") : stdout.yellow("dirty")}`);
39
+ lines.push(`Branch: ${yield* stdout.bold(currentBranch)}`);
40
+ lines.push(
41
+ `Working tree: ${clean ? yield* stdout.green("clean") : yield* stdout.yellow("dirty")}`,
42
+ );
41
43
 
42
44
  if (result !== null) {
43
45
  const { branches } = result.stack;
44
46
  const idx = branches.indexOf(currentBranch);
45
- lines.push(
46
- `Stack: ${stdout.bold(result.name)} ${stdout.dim(`(${idx + 1} of ${branches.length})`)}`,
47
- );
47
+ const stackName = yield* stdout.bold(result.name);
48
+ const position = yield* stdout.dim(`(${idx + 1} of ${branches.length})`);
49
+ lines.push(`Stack: ${stackName} ${position}`);
48
50
  } else {
49
- lines.push(stdout.dim("Not in a stack. Run 'stacked create <name>' to start one."));
51
+ lines.push(yield* stdout.dim("Not in a stack. Run 'stacked create <name>' to start one."));
50
52
  }
51
53
 
52
54
  yield* Console.log(lines.join("\n"));
@@ -32,9 +32,10 @@ const onlyFlag = Flag.boolean("only").pipe(Flag.withDescription("Only submit the
32
32
 
33
33
  interface SubmitResult {
34
34
  branch: string;
35
- number: number;
36
- url: string;
37
- action: "created" | "updated" | "unchanged";
35
+ base: string;
36
+ number?: number;
37
+ url?: string;
38
+ action: "created" | "updated" | "unchanged" | "would-create" | "would-update" | "would-unchanged";
38
39
  }
39
40
 
40
41
  const STACKED_MARKER_START = "<!-- stacked -->";
@@ -210,7 +211,23 @@ export const submit = Command.make("submit", {
210
211
  if (only && branch !== currentBranch) continue;
211
212
 
212
213
  if (dryRun) {
213
- yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
214
+ const existingPR = yield* gh.getPR(branch);
215
+ const action =
216
+ existingPR === null
217
+ ? "would-create"
218
+ : existingPR.base !== base
219
+ ? "would-update"
220
+ : "would-unchanged";
221
+ results.push({
222
+ branch,
223
+ base,
224
+ number: existingPR?.number,
225
+ url: existingPR?.url,
226
+ action,
227
+ });
228
+ if (!json) {
229
+ yield* Console.error(`Would push ${branch} and create/update PR (base: ${base})`);
230
+ }
214
231
  continue;
215
232
  }
216
233
 
@@ -225,6 +242,7 @@ export const submit = Command.make("submit", {
225
242
  yield* gh.updatePR({ branch, base });
226
243
  results.push({
227
244
  branch,
245
+ base,
228
246
  number: existingPR.number,
229
247
  url: existingPR.url,
230
248
  action: "updated",
@@ -233,6 +251,7 @@ export const submit = Command.make("submit", {
233
251
  yield* Console.error(`PR #${existingPR.number} already exists: ${existingPR.url}`);
234
252
  results.push({
235
253
  branch,
254
+ base,
236
255
  number: existingPR.number,
237
256
  url: existingPR.url,
238
257
  action: "unchanged",
@@ -257,6 +276,7 @@ export const submit = Command.make("submit", {
257
276
  yield* success(`Created PR #${pr.number}: ${pr.url}`);
258
277
  results.push({
259
278
  branch,
279
+ base,
260
280
  number: pr.number,
261
281
  url: pr.url,
262
282
  action: "created",
@@ -264,16 +284,23 @@ export const submit = Command.make("submit", {
264
284
  }
265
285
  }
266
286
 
267
- if (dryRun) return;
287
+ if (dryRun) {
288
+ if (json) {
289
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
290
+ yield* Console.log(JSON.stringify({ results }, null, 2));
291
+ }
292
+ return;
293
+ }
268
294
 
269
- // Update existing PRs with stack metadata
295
+ // Update all processed PRs with complete stack metadata.
296
+ // This includes newly created PRs so placeholders get replaced in one submit run.
270
297
  for (let i = 0; i < branches.length; i++) {
271
298
  const branch = branches[i];
272
299
  if (branch === undefined) continue;
273
300
  if (only && branch !== currentBranch) continue;
274
301
 
275
302
  const entry = results.find((x) => x.branch === branch);
276
- if (entry === undefined || entry.action === "created") continue;
303
+ if (entry === undefined) continue;
277
304
 
278
305
  const metadata = generateStackMetadata(branches, prMap, i, result.name);
279
306
  const existingPrData = prMap.get(branch) ?? null;
@@ -32,9 +32,11 @@ export const sync = Command.make("sync", {
32
32
  json: jsonFlag,
33
33
  dryRun: dryRunFlag,
34
34
  }).pipe(
35
- Command.withDescription("Fetch and rebase stack on trunk. Use --from to start from a branch."),
35
+ Command.withDescription(
36
+ "Fetch, rebase, and force-push stack branches. Use --from to start from a branch.",
37
+ ),
36
38
  Command.withExamples([
37
- { command: "stacked sync", description: "Rebase entire stack on trunk" },
39
+ { command: "stacked sync", description: "Sync local trunk, then rebase entire stack on trunk" },
38
40
  { command: "stacked sync --from feat-auth", description: "Resume from a specific branch" },
39
41
  { command: "stacked sync --dry-run", description: "Preview rebase plan" },
40
42
  ]),
@@ -44,6 +46,7 @@ export const sync = Command.make("sync", {
44
46
  const stacks = yield* StackService;
45
47
 
46
48
  const trunk = Option.isSome(trunkOpt) ? trunkOpt.value : yield* stacks.getTrunk();
49
+ const originTrunk = `origin/${trunk}`;
47
50
  const currentBranch = yield* git.currentBranch();
48
51
 
49
52
  if (!dryRun) {
@@ -87,13 +90,18 @@ export const sync = Command.make("sync", {
87
90
  const results: SyncResult[] = [];
88
91
 
89
92
  if (dryRun) {
93
+ results.push({ name: trunk, action: "skipped", base: originTrunk });
94
+ if (!json) {
95
+ yield* Console.error(`Would fetch and rebase ${trunk} onto ${originTrunk}`);
96
+ }
97
+
90
98
  for (let i = startIdx; i < branches.length; i++) {
91
99
  const branch = branches[i];
92
100
  if (branch === undefined) continue;
93
- const base = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
101
+ const base = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
94
102
  results.push({ name: branch, action: "skipped", base });
95
103
  if (!json) {
96
- yield* Console.error(`Would rebase ${branch} onto ${base}`);
104
+ yield* Console.error(`Would rebase and force-push ${branch} onto ${base}`);
97
105
  }
98
106
  }
99
107
 
@@ -109,12 +117,24 @@ export const sync = Command.make("sync", {
109
117
  }
110
118
 
111
119
  yield* withSpinner(`Fetching ${trunk}`, git.fetch());
120
+ yield* git.checkout(trunk);
121
+ yield* withSpinner(`Rebasing ${trunk} onto ${originTrunk}`, git.rebase(originTrunk)).pipe(
122
+ Effect.catchTag("GitError", (e) =>
123
+ Effect.fail(
124
+ new StackError({
125
+ code: ErrorCode.REBASE_CONFLICT,
126
+ message: `Rebase conflict on ${trunk}: ${e.message}\n\nResolve conflicts, then run:\n git rebase --continue`,
127
+ }),
128
+ ),
129
+ ),
130
+ );
131
+ results.push({ name: trunk, action: "rebased", base: originTrunk });
112
132
 
113
133
  yield* Effect.gen(function* () {
114
134
  for (let i = startIdx; i < branches.length; i++) {
115
135
  const branch = branches[i];
116
136
  if (branch === undefined) continue;
117
- const newBase = i === 0 ? `origin/${trunk}` : (branches[i - 1] ?? `origin/${trunk}`);
137
+ const newBase = i === 0 ? originTrunk : (branches[i - 1] ?? originTrunk);
118
138
 
119
139
  // Compute old base (merge-base of this branch and its parent) before rebasing
120
140
  const oldBase = yield* git
@@ -137,6 +157,7 @@ export const sync = Command.make("sync", {
137
157
  );
138
158
  }),
139
159
  );
160
+ yield* withSpinner(`Pushing ${branch}`, git.push(branch, { force: true }));
140
161
  results.push({ name: branch, action: "rebased", base: newBase });
141
162
  }
142
163
  }).pipe(
@@ -155,7 +176,7 @@ export const sync = Command.make("sync", {
155
176
  // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
156
177
  yield* Console.log(JSON.stringify({ branches: results }, null, 2));
157
178
  } else {
158
- yield* success("Stack synced");
179
+ yield* success(`Stack synced (including trunk ${trunk})`);
159
180
  }
160
181
  }),
161
182
  ),
@@ -1,4 +1,4 @@
1
- import { Schema } from "effect";
1
+ import { Runtime, Schema } from "effect";
2
2
 
3
3
  // ============================================================================
4
4
  // Error Codes
@@ -43,3 +43,9 @@ export class GitHubError extends Schema.TaggedErrorClass<GitHubError>()("GitHubE
43
43
  command: Schema.optional(Schema.String),
44
44
  code: Schema.optional(Schema.String),
45
45
  }) {}
46
+
47
+ export class GlobalFlagConflictError extends Error {
48
+ override readonly name = "GlobalFlagConflictError";
49
+ override readonly [Runtime.errorExitCode] = 2;
50
+ override readonly [Runtime.errorReported] = false;
51
+ }
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { GitService } from "./services/Git.js";
7
7
  import { StackService } from "./services/Stack.js";
8
8
  import { GitHubService } from "./services/GitHub.js";
9
9
  import { OutputConfig } from "./ui.js";
10
+ import { GlobalFlagConflictError } from "./errors/index.js";
10
11
 
11
12
  const version = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
12
13
 
@@ -25,10 +26,12 @@ const isYes = flagArgs.has("--yes") || flagArgs.has("-y");
25
26
 
26
27
  if (isNoColor) process.env["NO_COLOR"] = "1";
27
28
 
28
- if (isVerbose && isQuiet) {
29
- process.stderr.write("Error: --verbose and --quiet are mutually exclusive\n");
30
- process.exitCode = 2;
31
- }
29
+ const preflight =
30
+ isVerbose && isQuiet
31
+ ? Console.error("Error: --verbose and --quiet are mutually exclusive").pipe(
32
+ Effect.andThen(Effect.fail(new GlobalFlagConflictError())),
33
+ )
34
+ : Effect.void;
32
35
 
33
36
  // ============================================================================
34
37
  // CLI
@@ -70,7 +73,8 @@ const handleKnownError = (e: { message: string; code?: string | undefined }) =>
70
73
 
71
74
  // @effect-diagnostics-next-line effect/strictEffectProvide:off
72
75
  BunRuntime.runMain(
73
- cli.pipe(
76
+ preflight.pipe(
77
+ Effect.andThen(cli),
74
78
  Effect.provideService(OutputConfig, { verbose: isVerbose, quiet: isQuiet, yes: isYes }),
75
79
  Effect.provide(AppLayer),
76
80
  Effect.catchTags({
@@ -78,12 +82,16 @@ BunRuntime.runMain(
78
82
  StackError: (e) => handleKnownError(e),
79
83
  GitHubError: (e) => handleKnownError(e),
80
84
  }),
81
- Effect.catch((e) => {
82
- const msg =
83
- e !== null && typeof e === "object" && "message" in e
84
- ? String(e.message)
85
- : JSON.stringify(e, null, 2);
86
- return handleKnownError({ message: `Unexpected error: ${msg}` });
87
- }),
85
+ Effect.catchIf(
86
+ (e): e is GlobalFlagConflictError => e instanceof GlobalFlagConflictError,
87
+ Effect.fail,
88
+ (e) => {
89
+ const msg =
90
+ e !== null && typeof e === "object" && "message" in e
91
+ ? String(e.message)
92
+ : JSON.stringify(e, null, 2);
93
+ return handleKnownError({ message: `Unexpected error: ${msg}` });
94
+ },
95
+ ),
88
96
  ),
89
97
  );
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { Effect, Layer, ServiceMap } from "effect";
2
+ import { Effect, Layer, Option, ServiceMap } from "effect";
3
3
  import { GitError } from "../errors/index.js";
4
4
 
5
5
  export class GitService extends ServiceMap.Service<
@@ -8,6 +8,7 @@ export class GitService extends ServiceMap.Service<
8
8
  readonly currentBranch: () => Effect.Effect<string, GitError>;
9
9
  readonly listBranches: () => Effect.Effect<string[], GitError>;
10
10
  readonly branchExists: (name: string) => Effect.Effect<boolean, GitError>;
11
+ readonly remoteDefaultBranch: (remote?: string) => Effect.Effect<Option.Option<string>, never>;
11
12
  readonly createBranch: (name: string, from?: string) => Effect.Effect<void, GitError>;
12
13
  readonly deleteBranch: (name: string, force?: boolean) => Effect.Effect<void, GitError>;
13
14
  readonly checkout: (name: string) => Effect.Effect<void, GitError>;
@@ -93,7 +94,12 @@ export class GitService extends ServiceMap.Service<
93
94
  ),
94
95
 
95
96
  listBranches: () =>
96
- run(["branch", "--format=%(refname:short)"]).pipe(
97
+ run([
98
+ "for-each-ref",
99
+ "--sort=-committerdate",
100
+ "--format=%(refname:short)",
101
+ "refs/heads",
102
+ ]).pipe(
97
103
  Effect.map((output) =>
98
104
  output
99
105
  .split("\n")
@@ -108,6 +114,12 @@ export class GitService extends ServiceMap.Service<
108
114
  Effect.catchTag("GitError", () => Effect.succeed(false)),
109
115
  ),
110
116
 
117
+ remoteDefaultBranch: (remote = "origin") =>
118
+ run(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`]).pipe(
119
+ Effect.map((ref) => Option.some(ref.replace(new RegExp(`^${remote}/`), ""))),
120
+ Effect.catchTag("GitError", () => Effect.succeed(Option.none())),
121
+ ),
122
+
111
123
  createBranch: (name, from) => {
112
124
  const args = from !== undefined ? ["checkout", "-b", name, from] : ["checkout", "-b", name];
113
125
  return run(args).pipe(Effect.asVoid);
@@ -178,6 +190,7 @@ export class GitService extends ServiceMap.Service<
178
190
  currentBranch: () => Effect.succeed("main"),
179
191
  listBranches: () => Effect.succeed([]),
180
192
  branchExists: () => Effect.succeed(false),
193
+ remoteDefaultBranch: () => Effect.succeed(Option.none()),
181
194
  createBranch: () => Effect.void,
182
195
  deleteBranch: () => Effect.void,
183
196
  checkout: () => Effect.void,
@@ -1,4 +1,4 @@
1
- import { Effect, Layer, Ref, Schema, ServiceMap } from "effect";
1
+ import { Effect, Layer, Option, Ref, Schema, ServiceMap } from "effect";
2
2
  import { rename } from "node:fs/promises";
3
3
  import type { GitError } from "../errors/index.js";
4
4
  import { StackError } from "../errors/index.js";
@@ -38,6 +38,7 @@ export class StackService extends ServiceMap.Service<
38
38
  readonly findBranchStack: (
39
39
  branch: string,
40
40
  ) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
41
+ readonly detectTrunkCandidate: () => Effect.Effect<Option.Option<string>, never>;
41
42
  readonly getTrunk: () => Effect.Effect<string, StackError>;
42
43
  readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
43
44
  }
@@ -62,15 +63,27 @@ export class StackService extends ServiceMap.Service<
62
63
  const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
63
64
  const encodeStackFile = Schema.encodeEffect(StackFileJson);
64
65
 
65
- const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
66
- // Check common default branch names
66
+ const detectTrunkCandidate = Effect.fn("StackService.detectTrunkCandidate")(function* () {
67
+ const remoteDefault = yield* git.remoteDefaultBranch("origin");
68
+ if (Option.isSome(remoteDefault)) {
69
+ const exists = yield* git
70
+ .branchExists(remoteDefault.value)
71
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
72
+ if (exists) return remoteDefault;
73
+ }
74
+
67
75
  for (const candidate of ["main", "master", "develop"]) {
68
76
  const exists = yield* git
69
77
  .branchExists(candidate)
70
78
  .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
71
- if (exists) return candidate;
79
+ if (exists) return Option.some(candidate);
72
80
  }
73
- return "main";
81
+ return Option.none();
82
+ });
83
+
84
+ const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
85
+ const candidate = yield* detectTrunkCandidate();
86
+ return Option.getOrElse(candidate, () => "main");
74
87
  });
75
88
 
76
89
  const load = Effect.fn("StackService.load")(function* () {
@@ -137,6 +150,7 @@ export class StackService extends ServiceMap.Service<
137
150
 
138
151
  findBranchStack: (branch: string) =>
139
152
  load().pipe(Effect.map((data) => findBranchStack(data, branch))),
153
+ detectTrunkCandidate: () => detectTrunkCandidate(),
140
154
 
141
155
  currentStack: Effect.fn("StackService.currentStack")(function* () {
142
156
  const branch = yield* git.currentBranch();
@@ -226,7 +240,10 @@ export class StackService extends ServiceMap.Service<
226
240
  }),
227
241
  );
228
242
 
229
- static layerTest = (data?: StackFile, options?: { currentBranch?: string }) => {
243
+ static layerTest = (
244
+ data?: StackFile,
245
+ options?: { currentBranch?: string; detectTrunkCandidate?: Option.Option<string> },
246
+ ) => {
230
247
  const initial = data ?? emptyStackFile;
231
248
  return Layer.effect(
232
249
  StackService,
@@ -297,6 +314,12 @@ export class StackService extends ServiceMap.Service<
297
314
  }));
298
315
  }),
299
316
 
317
+ detectTrunkCandidate: () =>
318
+ Effect.succeed(
319
+ options?.detectTrunkCandidate !== undefined
320
+ ? options.detectTrunkCandidate
321
+ : Option.some(initial.trunk),
322
+ ),
300
323
  getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
301
324
  setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
302
325
  };
package/src/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import pc from "picocolors";
3
- import { Effect, ServiceMap } from "effect";
3
+ import { Config, Effect, ServiceMap } from "effect";
4
4
 
5
5
  // ============================================================================
6
6
  // TTY & Color Detection
@@ -13,24 +13,43 @@ const stdoutIsTTY = process.stdout.isTTY === true;
13
13
  let _stderrColors: ReturnType<typeof pc.createColors> | null = null;
14
14
  let _stdoutColors: ReturnType<typeof pc.createColors> | null = null;
15
15
 
16
- const isColorEnabled = (isTTY: boolean) => {
17
- if (process.env["NO_COLOR"] !== undefined) return false;
18
- if (process.env["FORCE_COLOR"] !== undefined) return true;
19
- if (process.env["TERM"] === "dumb") return false;
16
+ const colorRuntimeConfig = Config.all({
17
+ noColor: Config.string("NO_COLOR").pipe(
18
+ Config.map(() => true),
19
+ Config.orElse(() => Config.succeed(false)),
20
+ ),
21
+ forceColor: Config.string("FORCE_COLOR").pipe(
22
+ Config.map(() => true),
23
+ Config.orElse(() => Config.succeed(false)),
24
+ ),
25
+ term: Config.string("TERM").pipe(Config.withDefault("")),
26
+ });
27
+
28
+ const isColorEnabled = Effect.fn("ui.isColorEnabled")(function* (isTTY: boolean) {
29
+ const runtime = yield* colorRuntimeConfig;
30
+ if (runtime.noColor) return false;
31
+ if (runtime.forceColor) return true;
32
+ if (runtime.term === "dumb") return false;
20
33
  return isTTY;
21
- };
34
+ });
22
35
 
23
- const getColors = () => {
36
+ const getColors = Effect.fn("ui.getColors")(function* () {
24
37
  if (_stderrColors !== null) return _stderrColors;
25
- _stderrColors = isColorEnabled(stderrIsTTY) ? pc : pc.createColors(false);
38
+ const enabled = yield* isColorEnabled(stderrIsTTY).pipe(
39
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
40
+ );
41
+ _stderrColors = enabled ? pc : pc.createColors(false);
26
42
  return _stderrColors;
27
- };
43
+ });
28
44
 
29
- const getStdoutColors = () => {
45
+ const getStdoutColors = Effect.fn("ui.getStdoutColors")(function* () {
30
46
  if (_stdoutColors !== null) return _stdoutColors;
31
- _stdoutColors = isColorEnabled(stdoutIsTTY) ? pc : pc.createColors(false);
47
+ const enabled = yield* isColorEnabled(stdoutIsTTY).pipe(
48
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
49
+ );
50
+ _stdoutColors = enabled ? pc : pc.createColors(false);
32
51
  return _stdoutColors;
33
- };
52
+ });
34
53
 
35
54
  // ============================================================================
36
55
  // Output Config (verbose/quiet, set by global flags)
@@ -84,27 +103,34 @@ const write = (msg: string) =>
84
103
  export const success = Effect.fn("ui.success")(function* (msg: string) {
85
104
  const config = yield* OutputConfig;
86
105
  if (config.quiet) return;
87
- yield* write(getColors().green(`✓ ${msg}`));
106
+ const colors = yield* getColors();
107
+ yield* write(colors.green(`✓ ${msg}`));
88
108
  });
89
109
 
90
110
  export const warn = Effect.fn("ui.warn")(function* (msg: string) {
91
111
  const config = yield* OutputConfig;
92
112
  if (config.quiet) return;
93
- yield* write(getColors().yellow(`⚠ ${msg}`));
113
+ const colors = yield* getColors();
114
+ yield* write(colors.yellow(`⚠ ${msg}`));
94
115
  });
95
116
 
96
117
  export const info = Effect.fn("ui.info")(function* (msg: string) {
97
118
  const config = yield* OutputConfig;
98
119
  if (config.quiet) return;
99
- yield* write(getColors().cyan(msg));
120
+ const colors = yield* getColors();
121
+ yield* write(colors.cyan(msg));
100
122
  });
101
123
 
102
- export const error = (msg: string) => write(getColors().red(msg));
124
+ export const error = Effect.fn("ui.error")(function* (msg: string) {
125
+ const colors = yield* getColors();
126
+ yield* write(colors.red(msg));
127
+ });
103
128
 
104
129
  export const verbose = Effect.fn("ui.verbose")(function* (msg: string) {
105
130
  const config = yield* OutputConfig;
106
131
  if (!config.verbose) return;
107
- yield* write(getColors().dim(msg));
132
+ const colors = yield* getColors();
133
+ yield* write(colors.dim(msg));
108
134
  });
109
135
 
110
136
  // ============================================================================
@@ -122,7 +148,7 @@ export const withSpinner = <A, E, R>(
122
148
  }
123
149
 
124
150
  return Effect.gen(function* () {
125
- const c = getColors();
151
+ const c = yield* getColors();
126
152
  let frame = 0;
127
153
  const interval = setInterval(() => {
128
154
  const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
@@ -150,24 +176,72 @@ export const withSpinner = <A, E, R>(
150
176
  // Color Helpers — stderr (for tree views, status badges, etc.)
151
177
  // ============================================================================
152
178
 
153
- export const dim = (s: string) => getColors().dim(s);
154
- export const bold = (s: string) => getColors().bold(s);
155
- export const green = (s: string) => getColors().green(s);
156
- export const yellow = (s: string) => getColors().yellow(s);
157
- export const cyan = (s: string) => getColors().cyan(s);
158
- export const red = (s: string) => getColors().red(s);
159
- export const magenta = (s: string) => getColors().magenta(s);
179
+ export const dim = Effect.fn("ui.dim")(function* (s: string) {
180
+ const colors = yield* getColors();
181
+ return colors.dim(s);
182
+ });
183
+
184
+ export const bold = Effect.fn("ui.bold")(function* (s: string) {
185
+ const colors = yield* getColors();
186
+ return colors.bold(s);
187
+ });
188
+
189
+ export const green = Effect.fn("ui.green")(function* (s: string) {
190
+ const colors = yield* getColors();
191
+ return colors.green(s);
192
+ });
193
+
194
+ export const yellow = Effect.fn("ui.yellow")(function* (s: string) {
195
+ const colors = yield* getColors();
196
+ return colors.yellow(s);
197
+ });
198
+
199
+ export const cyan = Effect.fn("ui.cyan")(function* (s: string) {
200
+ const colors = yield* getColors();
201
+ return colors.cyan(s);
202
+ });
203
+
204
+ export const red = Effect.fn("ui.red")(function* (s: string) {
205
+ const colors = yield* getColors();
206
+ return colors.red(s);
207
+ });
208
+
209
+ export const magenta = Effect.fn("ui.magenta")(function* (s: string) {
210
+ const colors = yield* getColors();
211
+ return colors.magenta(s);
212
+ });
160
213
 
161
214
  // ============================================================================
162
215
  // Color Helpers — stdout (for Console.log output that may be piped)
163
216
  // ============================================================================
164
217
 
165
218
  export const stdout = {
166
- dim: (s: string) => getStdoutColors().dim(s),
167
- bold: (s: string) => getStdoutColors().bold(s),
168
- green: (s: string) => getStdoutColors().green(s),
169
- yellow: (s: string) => getStdoutColors().yellow(s),
170
- cyan: (s: string) => getStdoutColors().cyan(s),
171
- red: (s: string) => getStdoutColors().red(s),
172
- magenta: (s: string) => getStdoutColors().magenta(s),
219
+ dim: Effect.fn("ui.stdout.dim")(function* (s: string) {
220
+ const colors = yield* getStdoutColors();
221
+ return colors.dim(s);
222
+ }),
223
+ bold: Effect.fn("ui.stdout.bold")(function* (s: string) {
224
+ const colors = yield* getStdoutColors();
225
+ return colors.bold(s);
226
+ }),
227
+ green: Effect.fn("ui.stdout.green")(function* (s: string) {
228
+ const colors = yield* getStdoutColors();
229
+ return colors.green(s);
230
+ }),
231
+ yellow: Effect.fn("ui.stdout.yellow")(function* (s: string) {
232
+ const colors = yield* getStdoutColors();
233
+ return colors.yellow(s);
234
+ }),
235
+ cyan: Effect.fn("ui.stdout.cyan")(function* (s: string) {
236
+ const colors = yield* getStdoutColors();
237
+ return colors.cyan(s);
238
+ }),
239
+ red: Effect.fn("ui.stdout.red")(function* (s: string) {
240
+ const colors = yield* getStdoutColors();
241
+ return colors.red(s);
242
+ }),
243
+ magenta: Effect.fn("ui.stdout.magenta")(function* (s: string) {
244
+ const colors = yield* getStdoutColors();
245
+ return colors.magenta(s);
246
+ }),
173
247
  };