gflows 1.0.2 → 1.0.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.4] - 2026-07-25
11
+
12
+ ### Fixed
13
+
14
+ - Hub switch flow typecheck when HEAD is detached (`current` may be null)
15
+
16
+ ## [1.0.3] - 2026-07-25
17
+
18
+ ### Fixed
19
+
20
+ - Hub interactive actions no longer freeze/exit after Ink unmount (`stdin.ref` before dispatch; Clack cancel no longer `process.exit`s the hub)
21
+ - `/switch`, `/init`, `/bump` wizards run inside Ink (same as start/list)
22
+
23
+ ### Changed
24
+
25
+ - Local `bun run g` script invokes `src/cli.ts` so the hub gets a real TTY
26
+
10
27
  ## [1.0.2] - 2026-07-25
11
28
 
12
29
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,8 @@
39
39
  "gflows": "bun run src/cli.ts",
40
40
  "publish:all": "bun run scripts/publish.ts",
41
41
  "publish:npm": "bun run scripts/publish.ts -- --npm-only",
42
- "publish:jsr": "bun run scripts/publish.ts -- --jsr-only"
42
+ "publish:jsr": "bun run scripts/publish.ts -- --jsr-only",
43
+ "g": "bun run src/cli.ts"
43
44
  },
44
45
  "type": "module",
45
46
  "dependencies": {
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { EXIT_GIT, EXIT_OK, EXIT_USER } from "./constants.js";
10
10
  import { exitCodeForError, printError } from "./errors.js";
11
11
  import { parse } from "./parse.js";
12
+ import { PromptCancelledError } from "./prompts.js";
12
13
  import type { ParsedArgs } from "./types.js";
13
14
 
14
15
  /** Last parsed args, set at start of run(); used by catch/rejection to respect -v for stack trace. */
@@ -27,7 +28,10 @@ async function run(): Promise<void> {
27
28
  await runHub(process.cwd());
28
29
  return;
29
30
  }
30
- console.error("gflows: missing command. Use 'gflows help' for usage.");
31
+ console.error(
32
+ "gflows: no interactive TTY. Run `gflows` directly (or `alias g=gflows`), not via a bun/npm script that swallows stdin.",
33
+ );
34
+ console.error("Or pass a command: gflows help");
31
35
  process.exit(EXIT_USER);
32
36
  }
33
37
 
@@ -76,6 +80,10 @@ function main(): void {
76
80
  })
77
81
  .catch((err: unknown) => {
78
82
  if (exitCode !== null) return;
83
+ if (err instanceof PromptCancelledError) {
84
+ process.exit(EXIT_OK);
85
+ return;
86
+ }
79
87
  printError(err);
80
88
  const verbose = lastParsedArgs?.verbose ?? !!process.env.GFLOWS_VERBOSE;
81
89
  if (verbose && err instanceof Error && err.stack) {
package/src/prompts.ts CHANGED
@@ -6,12 +6,26 @@
6
6
  import * as clack from "@clack/prompts";
7
7
 
8
8
  /**
9
- * Exits the process when the user cancels a prompt (Ctrl+C / Escape).
9
+ * Thrown when the user cancels a Clack prompt (Ctrl+C / Escape).
10
+ * Callers (CLI / hub) should treat this as a soft abort — do not kill the hub loop via process.exit.
11
+ */
12
+ export class PromptCancelledError extends Error {
13
+ /**
14
+ * Creates a cancellation error.
15
+ */
16
+ constructor() {
17
+ super("Cancelled.");
18
+ this.name = "PromptCancelledError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Converts Clack cancel symbols into {@link PromptCancelledError}.
10
24
  */
11
25
  function exitIfCancel<T>(value: T | symbol): asserts value is T {
12
26
  if (clack.isCancel(value)) {
13
27
  clack.cancel("Cancelled.");
14
- process.exit(0);
28
+ throw new PromptCancelledError();
15
29
  }
16
30
  }
17
31
 
@@ -6,7 +6,15 @@
6
6
  import { Box, Text, useApp, useInput } from "ink";
7
7
  import type React from "react";
8
8
  import { useState } from "react";
9
- import { FinishFlow, ListFlow, StartFlow, SyncFlow } from "./flows.js";
9
+ import {
10
+ BumpFlow,
11
+ FinishFlow,
12
+ InitFlow,
13
+ ListFlow,
14
+ StartFlow,
15
+ SwitchFlow,
16
+ SyncFlow,
17
+ } from "./flows.js";
10
18
  import { HubHome } from "./HubHome.js";
11
19
  import { WizardFrame } from "./prompts.js";
12
20
  import { SLASH_COMMANDS } from "./slash.js";
@@ -25,6 +33,9 @@ type Screen =
25
33
  | { id: "finish" }
26
34
  | { id: "sync" }
27
35
  | { id: "list" }
36
+ | { id: "switch" }
37
+ | { id: "init" }
38
+ | { id: "bump" }
28
39
  | { id: "doctor" }
29
40
  | { id: "help" }
30
41
  | { id: "status" }
@@ -32,13 +43,10 @@ type Screen =
32
43
  | { id: "version" }
33
44
  | { id: "notice"; message: string };
34
45
 
35
- /** Commands that leave Ink for git / side effects. */
46
+ /** Commands that leave Ink for git / side effects (no interactive Clack). */
36
47
  const DISPATCH_COMMANDS = new Set([
37
- "init",
38
48
  "pr",
39
49
  "continue",
40
- "switch",
41
- "bump",
42
50
  "delete",
43
51
  "undo",
44
52
  "abort",
@@ -84,6 +92,15 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
84
92
  case "list":
85
93
  setScreen({ id: "list" });
86
94
  return true;
95
+ case "switch":
96
+ setScreen({ id: "switch" });
97
+ return true;
98
+ case "init":
99
+ setScreen({ id: "init" });
100
+ return true;
101
+ case "bump":
102
+ setScreen({ id: "bump" });
103
+ return true;
87
104
  case "doctor":
88
105
  setScreen({ id: "doctor" });
89
106
  return true;
@@ -147,6 +164,18 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
147
164
  runArgv(["config", ...rest]);
148
165
  return;
149
166
  }
167
+ if (cmd === "switch" && rest.length > 0) {
168
+ runArgv(["switch", ...rest]);
169
+ return;
170
+ }
171
+ if (cmd === "bump" && rest.length > 0) {
172
+ runArgv(["bump", ...rest]);
173
+ return;
174
+ }
175
+ if (cmd === "init" && rest.length > 0) {
176
+ runArgv(["init", ...rest]);
177
+ return;
178
+ }
150
179
 
151
180
  if (openInHub(cmd)) return;
152
181
 
@@ -190,6 +219,15 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
190
219
  if (screen.id === "list") {
191
220
  return <ListFlow cwd={cwd} onDone={cancelWizard} />;
192
221
  }
222
+ if (screen.id === "switch") {
223
+ return <SwitchFlow cwd={cwd} onCancel={cancelWizard} onDone={runArgv} />;
224
+ }
225
+ if (screen.id === "init") {
226
+ return <InitFlow onCancel={cancelWizard} onDone={runArgv} />;
227
+ }
228
+ if (screen.id === "bump") {
229
+ return <BumpFlow onCancel={cancelWizard} onDone={runArgv} />;
230
+ }
193
231
  if (screen.id === "doctor") {
194
232
  return <DoctorView cwd={cwd} onDone={cancelWizard} />;
195
233
  }
package/src/tui/flows.tsx CHANGED
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Multi-step Ink wizards for hub actions (start / finish / sync / list).
2
+ * Multi-step Ink wizards for hub actions (start / finish / sync / list / switch / …).
3
3
  * @module tui/flows
4
4
  */
5
5
 
6
+ import { Text } from "ink";
6
7
  import type React from "react";
7
8
  import { useEffect, useState } from "react";
9
+ import { resolveConfig } from "../config.js";
10
+ import { branchList, getCurrentBranch, isClean, resolveRepoRoot } from "../git.js";
8
11
  import type { BranchType } from "../types.js";
9
12
  import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
10
13
  import { type HubPanelLine, HubScrollPanel } from "./panels.js";
11
14
  import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
12
15
 
16
+ const MUTED = "#8A8A8A";
17
+
13
18
  const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
14
19
 
20
+ type SwitchMode = "move" | "restore" | "clean" | "destroy" | "cancel";
21
+
15
22
  /**
16
23
  * Collects argv for `gflows start …` entirely inside Ink.
17
24
  */
@@ -141,6 +148,268 @@ export function SyncFlow({
141
148
  );
142
149
  }
143
150
 
151
+ /**
152
+ * Switch-branch wizard entirely inside Ink (avoids dead Clack after Ink unmount).
153
+ */
154
+ export function SwitchFlow({
155
+ cwd,
156
+ onDone,
157
+ onCancel,
158
+ }: {
159
+ cwd: string;
160
+ onDone: (argv: string[]) => void;
161
+ onCancel: () => void;
162
+ }): React.ReactElement {
163
+ const [step, setStep] = useState<"load" | "pick" | "dirty" | "error">("load");
164
+ const [choices, setChoices] = useState<{ value: string; label: string }[]>([]);
165
+ const [target, setTarget] = useState("");
166
+ const [error, setError] = useState<string | null>(null);
167
+ const [initialIndex, setInitialIndex] = useState(0);
168
+
169
+ useEffect(() => {
170
+ let cancelled = false;
171
+ void (async () => {
172
+ try {
173
+ const root = await resolveRepoRoot(cwd);
174
+ const config = resolveConfig(root, {}, {});
175
+ const allLocal = await branchList(root, { dryRun: false, verbose: false });
176
+ const workflow = BRANCH_TYPES.map((t) => config.prefixes[t])
177
+ .filter(Boolean)
178
+ .flatMap((prefix) => allLocal.filter((b) => b.startsWith(prefix)));
179
+ const mainAndDev = [config.main, config.dev].filter((b) => allLocal.includes(b));
180
+ const names = [...mainAndDev, ...[...new Set(workflow)].sort()];
181
+ const current = await getCurrentBranch(root, {});
182
+ if (cancelled) return;
183
+ if (names.length === 0) {
184
+ setError("No branches found.");
185
+ setStep("error");
186
+ return;
187
+ }
188
+ const opts = names.map((b) => ({
189
+ value: b,
190
+ label: current && b === current ? `${b} (current)` : b,
191
+ }));
192
+ setChoices(opts);
193
+ setInitialIndex(Math.max(0, current ? names.indexOf(current) : 0));
194
+ setStep("pick");
195
+ } catch (err) {
196
+ if (!cancelled) {
197
+ setError(err instanceof Error ? err.message : String(err));
198
+ setStep("error");
199
+ }
200
+ }
201
+ })();
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [cwd]);
206
+
207
+ if (step === "load") {
208
+ return (
209
+ <WizardFrame title="Switch branch">
210
+ <Text color={MUTED}>Loading…</Text>
211
+ </WizardFrame>
212
+ );
213
+ }
214
+
215
+ if (step === "error") {
216
+ return (
217
+ <HubScrollPanel
218
+ title="Switch branch"
219
+ lines={[{ id: "e", text: error ?? "Unknown error", tone: "bad" }]}
220
+ onDone={onCancel}
221
+ />
222
+ );
223
+ }
224
+
225
+ if (step === "pick") {
226
+ return (
227
+ <WizardFrame title="Switch branch">
228
+ <InkSelect
229
+ message="Switch to branch"
230
+ options={choices}
231
+ initialIndex={initialIndex}
232
+ onCancel={onCancel}
233
+ onSubmit={(branch) => {
234
+ void (async () => {
235
+ setTarget(branch);
236
+ try {
237
+ const root = await resolveRepoRoot(cwd);
238
+ const clean = await isClean(root, {});
239
+ if (clean) {
240
+ onDone(["switch", branch]);
241
+ return;
242
+ }
243
+ setStep("dirty");
244
+ } catch (err) {
245
+ setError(err instanceof Error ? err.message : String(err));
246
+ setStep("error");
247
+ }
248
+ })();
249
+ }}
250
+ />
251
+ </WizardFrame>
252
+ );
253
+ }
254
+
255
+ // dirty
256
+ return (
257
+ <WizardFrame title={`Switch · ${target}`}>
258
+ <InkSelect<SwitchMode>
259
+ message="Working tree has uncommitted changes"
260
+ options={[
261
+ { value: "move", label: "Move", hint: "carry changes to target" },
262
+ { value: "restore", label: "Restore", hint: "stash here, restore target" },
263
+ { value: "clean", label: "Clean", hint: "discard changes" },
264
+ { value: "destroy", label: "Destroy", hint: "delete current branch" },
265
+ { value: "cancel", label: "Cancel", hint: "abort switch" },
266
+ ]}
267
+ onCancel={onCancel}
268
+ onSubmit={(mode) => {
269
+ if (mode === "cancel") {
270
+ onCancel();
271
+ return;
272
+ }
273
+ onDone(["switch", target, `--${mode}`]);
274
+ }}
275
+ />
276
+ </WizardFrame>
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Init wizard inside Ink; dispatches with -y so CLI skips Clack prompts.
282
+ */
283
+ export function InitFlow({
284
+ onDone,
285
+ onCancel,
286
+ }: {
287
+ onDone: (argv: string[]) => void;
288
+ onCancel: () => void;
289
+ }): React.ReactElement {
290
+ const [step, setStep] = useState<"main" | "dev" | "remote" | "alias">("main");
291
+ const [main, setMain] = useState("main");
292
+ const [dev, setDev] = useState("dev");
293
+ const [remote, setRemote] = useState("origin");
294
+
295
+ if (step === "main") {
296
+ return (
297
+ <WizardFrame title="Initialize repo">
298
+ <InkText
299
+ message="Main branch name"
300
+ placeholder="main"
301
+ initialValue="main"
302
+ onCancel={onCancel}
303
+ onSubmit={(v) => {
304
+ setMain(v || "main");
305
+ setStep("dev");
306
+ }}
307
+ />
308
+ </WizardFrame>
309
+ );
310
+ }
311
+ if (step === "dev") {
312
+ return (
313
+ <WizardFrame title="Initialize repo">
314
+ <InkText
315
+ message="Dev branch name"
316
+ placeholder="dev"
317
+ initialValue="dev"
318
+ onCancel={onCancel}
319
+ onSubmit={(v) => {
320
+ setDev(v || "dev");
321
+ setStep("remote");
322
+ }}
323
+ />
324
+ </WizardFrame>
325
+ );
326
+ }
327
+ if (step === "remote") {
328
+ return (
329
+ <WizardFrame title="Initialize repo">
330
+ <InkText
331
+ message="Remote name"
332
+ placeholder="origin"
333
+ initialValue="origin"
334
+ onCancel={onCancel}
335
+ onSubmit={(v) => {
336
+ setRemote(v || "origin");
337
+ setStep("alias");
338
+ }}
339
+ />
340
+ </WizardFrame>
341
+ );
342
+ }
343
+
344
+ return (
345
+ <WizardFrame title="Initialize repo">
346
+ <InkSelect
347
+ message="Add package.json script alias?"
348
+ options={[
349
+ { value: "g", label: 'script "g"', hint: "prefer: alias g=gflows in shell" },
350
+ { value: "gflows", label: 'script "gflows"' },
351
+ { value: "skip", label: "Skip", hint: "shell alias is best for hub TTY" },
352
+ ]}
353
+ onCancel={onCancel}
354
+ onSubmit={(alias) => {
355
+ const argv = ["init", "-y", "-P", "--main", main, "--dev", dev, "--remote", remote];
356
+ if (alias === "skip") argv.push("--no-script-alias");
357
+ else argv.push("--script-alias", alias);
358
+ onDone(argv);
359
+ }}
360
+ />
361
+ </WizardFrame>
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Bump wizard inside Ink.
367
+ */
368
+ export function BumpFlow({
369
+ onDone,
370
+ onCancel,
371
+ }: {
372
+ onDone: (argv: string[]) => void;
373
+ onCancel: () => void;
374
+ }): React.ReactElement {
375
+ const [step, setStep] = useState<"dir" | "type">("dir");
376
+ const [direction, setDirection] = useState<"up" | "down">("up");
377
+
378
+ if (step === "dir") {
379
+ return (
380
+ <WizardFrame title="Bump version">
381
+ <InkSelect<"up" | "down">
382
+ message="Direction"
383
+ options={[
384
+ { value: "up", label: "Up (bump)" },
385
+ { value: "down", label: "Down (rollback)" },
386
+ ]}
387
+ onCancel={onCancel}
388
+ onSubmit={(d) => {
389
+ setDirection(d);
390
+ setStep("type");
391
+ }}
392
+ />
393
+ </WizardFrame>
394
+ );
395
+ }
396
+
397
+ return (
398
+ <WizardFrame title={`Bump · ${direction}`}>
399
+ <InkSelect<"patch" | "minor" | "major">
400
+ message="Semver part"
401
+ options={[
402
+ { value: "patch", label: "patch" },
403
+ { value: "minor", label: "minor" },
404
+ { value: "major", label: "major" },
405
+ ]}
406
+ onCancel={onCancel}
407
+ onSubmit={(type) => onDone(["bump", direction, type])}
408
+ />
409
+ </WizardFrame>
410
+ );
411
+ }
412
+
144
413
  /**
145
414
  * Styled branch list inside the hub (no drop-out to plain stdout).
146
415
  */
package/src/tui/hub.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  import { render } from "ink";
7
7
  import React from "react";
8
8
  import { dispatch } from "../dispatch.js";
9
+ import { PromptCancelledError } from "../prompts.js";
9
10
  import { type HubSessionResult, HubShell } from "./HubShell.js";
11
+ import { prepareStdinAfterInk } from "./stdin.js";
10
12
 
11
13
  /**
12
14
  * Whether stdin/stdout can host the Ink hub.
@@ -27,11 +29,15 @@ export async function runTuiHub(cwd: string): Promise<boolean> {
27
29
  if (result.kind === "quit") break;
28
30
 
29
31
  if (result.kind === "run") {
32
+ // Ink unrefs stdin on unmount — re-arm before Clack / git prompts.
33
+ prepareStdinAfterInk();
30
34
  console.log("");
31
35
  try {
32
36
  await dispatch(cwd, result.argv);
33
37
  } catch (err) {
34
- console.error("gflows:", err instanceof Error ? err.message : String(err));
38
+ if (!(err instanceof PromptCancelledError)) {
39
+ console.error("gflows:", err instanceof Error ? err.message : String(err));
40
+ }
35
41
  }
36
42
  console.log("");
37
43
  await waitEnterRaw("Press enter to return to hub…");
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stdin helpers for surviving Ink teardown before Clack / dispatch.
3
+ * @module tui/stdin
4
+ */
5
+
6
+ /**
7
+ * Re-arm process.stdin after Ink unmounts (Ink calls `stdin.unref()`).
8
+ * Without this, interactive Clack prompts paint once then the process exits.
9
+ */
10
+ export function prepareStdinAfterInk(): void {
11
+ const stdin = process.stdin;
12
+ if (typeof stdin.setRawMode === "function") {
13
+ try {
14
+ stdin.setRawMode(false);
15
+ } catch {
16
+ // ignore — may already be non-raw / closed
17
+ }
18
+ }
19
+ stdin.resume();
20
+ stdin.ref();
21
+
22
+ if (typeof stdin.read === "function") {
23
+ for (;;) {
24
+ const pending = stdin.read();
25
+ if (pending === null) break;
26
+ }
27
+ }
28
+ }