@cvr/stacked 0.3.0 → 0.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.
@@ -1,4 +1,5 @@
1
1
  import { Effect, Layer, Ref, Schema, ServiceMap } from "effect";
2
+ import { rename } from "node:fs/promises";
2
3
  import type { GitError } from "../errors/index.js";
3
4
  import { StackError } from "../errors/index.js";
4
5
  import { GitService } from "./Git.js";
@@ -34,10 +35,11 @@ export class StackService extends ServiceMap.Service<
34
35
  ) => Effect.Effect<void, StackError>;
35
36
  readonly removeBranch: (stackName: string, branch: string) => Effect.Effect<void, StackError>;
36
37
  readonly createStack: (name: string, branches: string[]) => Effect.Effect<void, StackError>;
38
+ readonly findBranchStack: (
39
+ branch: string,
40
+ ) => Effect.Effect<{ name: string; stack: Stack } | null, StackError>;
37
41
  readonly getTrunk: () => Effect.Effect<string, StackError>;
38
42
  readonly setTrunk: (name: string) => Effect.Effect<void, StackError>;
39
- readonly parentOf: (branch: string) => Effect.Effect<string, StackError>;
40
- readonly childrenOf: (branch: string) => Effect.Effect<string[], StackError>;
41
43
  }
42
44
  >()("@cvr/stacked/services/Stack/StackService") {
43
45
  static layer: Layer.Layer<StackService, never, GitService> = Layer.effect(
@@ -47,7 +49,7 @@ export class StackService extends ServiceMap.Service<
47
49
 
48
50
  const stackFilePath = Effect.fn("stackFilePath")(function* () {
49
51
  const gitDir = yield* git
50
- .revParse("--git-dir")
52
+ .revParse("--absolute-git-dir")
51
53
  .pipe(
52
54
  Effect.mapError(
53
55
  (e) => new StackError({ message: `Not a git repository: ${e.message}` }),
@@ -60,25 +62,64 @@ export class StackService extends ServiceMap.Service<
60
62
  const decodeStackFile = Schema.decodeUnknownEffect(StackFileJson);
61
63
  const encodeStackFile = Schema.encodeEffect(StackFileJson);
62
64
 
65
+ const detectTrunk = Effect.fn("StackService.detectTrunk")(function* () {
66
+ // Check common default branch names
67
+ for (const candidate of ["main", "master", "develop"]) {
68
+ const exists = yield* git
69
+ .branchExists(candidate)
70
+ .pipe(Effect.catchTag("GitError", () => Effect.succeed(false)));
71
+ if (exists) return candidate;
72
+ }
73
+ return "main";
74
+ });
75
+
63
76
  const load = Effect.fn("StackService.load")(function* () {
64
77
  const path = yield* stackFilePath();
65
78
  const file = Bun.file(path);
66
- const exists = yield* Effect.promise(() => file.exists());
67
- if (!exists) return emptyStackFile;
68
- const text = yield* Effect.promise(() => file.text());
79
+ const exists = yield* Effect.tryPromise({
80
+ try: () => file.exists(),
81
+ catch: () => new StackError({ message: `Failed to check if ${path} exists` }),
82
+ });
83
+ if (!exists) {
84
+ const trunk = yield* detectTrunk();
85
+ return { ...emptyStackFile, trunk } satisfies StackFile;
86
+ }
87
+ const text = yield* Effect.tryPromise({
88
+ try: () => file.text(),
89
+ catch: () => new StackError({ message: `Failed to read ${path}` }),
90
+ });
69
91
  return yield* decodeStackFile(text).pipe(
70
- Effect.catch(() => Effect.succeed(emptyStackFile)),
92
+ Effect.catchTag("SchemaError", (e) =>
93
+ Effect.gen(function* () {
94
+ const backupPath = `${path}.backup`;
95
+ yield* Effect.tryPromise({
96
+ try: () => Bun.write(backupPath, text),
97
+ catch: () => new StackError({ message: `Failed to write backup to ${backupPath}` }),
98
+ });
99
+ yield* Effect.logWarning(
100
+ `Corrupted stack file, resetting: ${e.message}\nBackup saved to ${backupPath}`,
101
+ );
102
+ const trunk = yield* detectTrunk();
103
+ return { ...emptyStackFile, trunk } satisfies StackFile;
104
+ }),
105
+ ),
71
106
  );
72
107
  });
73
108
 
74
109
  const save = Effect.fn("StackService.save")(function* (data: StackFile) {
75
110
  const path = yield* stackFilePath();
111
+ const tmpPath = `${path}.tmp`;
76
112
  const text = yield* encodeStackFile(data).pipe(
77
113
  Effect.mapError(() => new StackError({ message: `Failed to encode stack data` })),
78
114
  );
79
- yield* Effect.promise(() => Bun.write(path, text + "\n")).pipe(
80
- Effect.mapError(() => new StackError({ message: `Failed to write ${path}` })),
81
- );
115
+ yield* Effect.tryPromise({
116
+ try: () => Bun.write(tmpPath, text + "\n"),
117
+ catch: () => new StackError({ message: `Failed to write ${tmpPath}` }),
118
+ });
119
+ yield* Effect.tryPromise({
120
+ try: () => rename(tmpPath, path),
121
+ catch: () => new StackError({ message: `Failed to rename ${tmpPath} to ${path}` }),
122
+ });
82
123
  });
83
124
 
84
125
  const findBranchStack = (data: StackFile, branch: string) => {
@@ -94,6 +135,9 @@ export class StackService extends ServiceMap.Service<
94
135
  load: () => load(),
95
136
  save: (data) => save(data),
96
137
 
138
+ findBranchStack: (branch: string) =>
139
+ load().pipe(Effect.map((data) => findBranchStack(data, branch))),
140
+
97
141
  currentStack: Effect.fn("StackService.currentStack")(function* () {
98
142
  const branch = yield* git.currentBranch();
99
143
  const data = yield* load();
@@ -106,6 +150,12 @@ export class StackService extends ServiceMap.Service<
106
150
  after?: string,
107
151
  ) {
108
152
  const data = yield* load();
153
+ const existing = findBranchStack(data, branch);
154
+ if (existing !== null) {
155
+ return yield* new StackError({
156
+ message: `Branch "${branch}" is already in stack "${existing.name}"`,
157
+ });
158
+ }
109
159
  const stack = data.stacks[stackName];
110
160
  if (stack === undefined) {
111
161
  return yield* new StackError({ message: `Stack "${stackName}" not found` });
@@ -172,34 +222,11 @@ export class StackService extends ServiceMap.Service<
172
222
  const data = yield* load();
173
223
  yield* save({ ...data, trunk: name });
174
224
  }),
175
-
176
- parentOf: Effect.fn("StackService.parentOf")(function* (branch: string) {
177
- const data = yield* load();
178
- for (const stack of Object.values(data.stacks)) {
179
- const idx = stack.branches.indexOf(branch);
180
- if (idx === 0) return data.trunk;
181
- if (idx > 0) return stack.branches[idx - 1] ?? data.trunk;
182
- }
183
- return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
184
- }),
185
-
186
- childrenOf: Effect.fn("StackService.childrenOf")(function* (branch: string) {
187
- const data = yield* load();
188
- const children: string[] = [];
189
- for (const stack of Object.values(data.stacks)) {
190
- const idx = stack.branches.indexOf(branch);
191
- const child = stack.branches[idx + 1];
192
- if (idx !== -1 && child !== undefined) {
193
- children.push(child);
194
- }
195
- }
196
- return children;
197
- }),
198
225
  };
199
226
  }),
200
227
  );
201
228
 
202
- static layerTest = (data?: StackFile) => {
229
+ static layerTest = (data?: StackFile, options?: { currentBranch?: string }) => {
203
230
  const initial = data ?? emptyStackFile;
204
231
  return Layer.effect(
205
232
  StackService,
@@ -219,9 +246,12 @@ export class StackService extends ServiceMap.Service<
219
246
  load: () => Ref.get(ref),
220
247
  save: (d) => Ref.set(ref, d),
221
248
 
249
+ findBranchStack: (branch: string) =>
250
+ Ref.get(ref).pipe(Effect.map((d) => findBranchStack(d, branch))),
251
+
222
252
  currentStack: Effect.fn("test.currentStack")(function* () {
223
253
  const d = yield* Ref.get(ref);
224
- return findBranchStack(d, "test-branch");
254
+ return findBranchStack(d, options?.currentBranch ?? "test-branch");
225
255
  }),
226
256
 
227
257
  addBranch: Effect.fn("test.addBranch")(function* (
@@ -269,29 +299,6 @@ export class StackService extends ServiceMap.Service<
269
299
 
270
300
  getTrunk: () => Ref.get(ref).pipe(Effect.map((d) => d.trunk)),
271
301
  setTrunk: (name: string) => Ref.update(ref, (d) => ({ ...d, trunk: name })),
272
-
273
- parentOf: Effect.fn("test.parentOf")(function* (branch: string) {
274
- const d = yield* Ref.get(ref);
275
- for (const stack of Object.values(d.stacks)) {
276
- const idx = stack.branches.indexOf(branch);
277
- if (idx === 0) return d.trunk;
278
- if (idx > 0) return stack.branches[idx - 1] ?? d.trunk;
279
- }
280
- return yield* new StackError({ message: `Branch "${branch}" not found in any stack` });
281
- }),
282
-
283
- childrenOf: Effect.fn("test.childrenOf")(function* (branch: string) {
284
- const d = yield* Ref.get(ref);
285
- const children: string[] = [];
286
- for (const stack of Object.values(d.stacks)) {
287
- const idx = stack.branches.indexOf(branch);
288
- const child = stack.branches[idx + 1];
289
- if (idx !== -1 && child !== undefined) {
290
- children.push(child);
291
- }
292
- }
293
- return children;
294
- }),
295
302
  };
296
303
  }),
297
304
  );
package/src/ui.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { createInterface } from "node:readline";
2
+ import pc from "picocolors";
3
+ import { Config, Effect, ServiceMap } from "effect";
4
+
5
+ // ============================================================================
6
+ // TTY & Color Detection
7
+ // ============================================================================
8
+
9
+ const stderrIsTTY = process.stderr.isTTY === true;
10
+ const stdoutIsTTY = process.stdout.isTTY === true;
11
+
12
+ // Lazy color instances — deferred so --no-color flag can set env before first use
13
+ let _stderrColors: ReturnType<typeof pc.createColors> | null = null;
14
+ let _stdoutColors: ReturnType<typeof pc.createColors> | null = null;
15
+
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;
33
+ return isTTY;
34
+ });
35
+
36
+ const getColors = Effect.fn("ui.getColors")(function* () {
37
+ if (_stderrColors !== null) return _stderrColors;
38
+ const enabled = yield* isColorEnabled(stderrIsTTY).pipe(
39
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
40
+ );
41
+ _stderrColors = enabled ? pc : pc.createColors(false);
42
+ return _stderrColors;
43
+ });
44
+
45
+ const getStdoutColors = Effect.fn("ui.getStdoutColors")(function* () {
46
+ if (_stdoutColors !== null) return _stdoutColors;
47
+ const enabled = yield* isColorEnabled(stdoutIsTTY).pipe(
48
+ Effect.catchTag("ConfigError", () => Effect.succeed(false)),
49
+ );
50
+ _stdoutColors = enabled ? pc : pc.createColors(false);
51
+ return _stdoutColors;
52
+ });
53
+
54
+ // ============================================================================
55
+ // Output Config (verbose/quiet, set by global flags)
56
+ // ============================================================================
57
+
58
+ export interface OutputConfig {
59
+ readonly verbose: boolean;
60
+ readonly quiet: boolean;
61
+ readonly yes: boolean;
62
+ }
63
+
64
+ export const OutputConfig = ServiceMap.Reference("@cvr/stacked/OutputConfig", {
65
+ defaultValue: (): OutputConfig => ({ verbose: false, quiet: false, yes: false }),
66
+ });
67
+
68
+ // ============================================================================
69
+ // Interactive Prompts
70
+ // ============================================================================
71
+
72
+ const stdinIsTTY = process.stdin.isTTY === true;
73
+
74
+ export const confirm = Effect.fn("ui.confirm")(function* (message: string) {
75
+ const config = yield* OutputConfig;
76
+ if (config.yes || !stdinIsTTY) return true;
77
+
78
+ process.stderr.write(`${message} [y/N] `);
79
+ const answer = yield* Effect.tryPromise({
80
+ try: () => {
81
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
82
+ return new Promise<string>((resolve) => {
83
+ rl.question("", (ans) => {
84
+ rl.close();
85
+ resolve(ans);
86
+ });
87
+ });
88
+ },
89
+ catch: () => "n" as const,
90
+ });
91
+ return answer.trim().toLowerCase() === "y";
92
+ });
93
+
94
+ // ============================================================================
95
+ // Styled Output (all write to stderr)
96
+ // ============================================================================
97
+
98
+ const write = (msg: string) =>
99
+ Effect.sync(() => {
100
+ process.stderr.write(msg + "\n");
101
+ });
102
+
103
+ export const success = Effect.fn("ui.success")(function* (msg: string) {
104
+ const config = yield* OutputConfig;
105
+ if (config.quiet) return;
106
+ const colors = yield* getColors();
107
+ yield* write(colors.green(`✓ ${msg}`));
108
+ });
109
+
110
+ export const warn = Effect.fn("ui.warn")(function* (msg: string) {
111
+ const config = yield* OutputConfig;
112
+ if (config.quiet) return;
113
+ const colors = yield* getColors();
114
+ yield* write(colors.yellow(`⚠ ${msg}`));
115
+ });
116
+
117
+ export const info = Effect.fn("ui.info")(function* (msg: string) {
118
+ const config = yield* OutputConfig;
119
+ if (config.quiet) return;
120
+ const colors = yield* getColors();
121
+ yield* write(colors.cyan(msg));
122
+ });
123
+
124
+ export const error = Effect.fn("ui.error")(function* (msg: string) {
125
+ const colors = yield* getColors();
126
+ yield* write(colors.red(msg));
127
+ });
128
+
129
+ export const verbose = Effect.fn("ui.verbose")(function* (msg: string) {
130
+ const config = yield* OutputConfig;
131
+ if (!config.verbose) return;
132
+ const colors = yield* getColors();
133
+ yield* write(colors.dim(msg));
134
+ });
135
+
136
+ // ============================================================================
137
+ // Spinner
138
+ // ============================================================================
139
+
140
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
141
+
142
+ export const withSpinner = <A, E, R>(
143
+ message: string,
144
+ effect: Effect.Effect<A, E, R>,
145
+ ): Effect.Effect<A, E, R> => {
146
+ if (!stderrIsTTY) {
147
+ return write(message).pipe(Effect.andThen(effect));
148
+ }
149
+
150
+ return Effect.gen(function* () {
151
+ const c = yield* getColors();
152
+ let frame = 0;
153
+ const interval = setInterval(() => {
154
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
155
+ process.stderr.write(`\r${c.cyan(spinner ?? "⠋")} ${message}`);
156
+ frame++;
157
+ }, 80);
158
+
159
+ const cleanup = (icon: string) =>
160
+ Effect.sync(() => {
161
+ clearInterval(interval);
162
+ process.stderr.write(`\r${icon} ${message}\n`);
163
+ });
164
+
165
+ const result = yield* effect.pipe(
166
+ Effect.tap(() => cleanup(c.green("✓"))),
167
+ Effect.tapError(() => cleanup(c.red("✗"))),
168
+ Effect.onInterrupt(() => cleanup(c.yellow("⚠"))),
169
+ );
170
+
171
+ return result;
172
+ });
173
+ };
174
+
175
+ // ============================================================================
176
+ // Color Helpers — stderr (for tree views, status badges, etc.)
177
+ // ============================================================================
178
+
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
+ });
213
+
214
+ // ============================================================================
215
+ // Color Helpers — stdout (for Console.log output that may be piped)
216
+ // ============================================================================
217
+
218
+ export const stdout = {
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
+ }),
247
+ };