gflows 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,6 +93,24 @@ const TOOLS = [
93
93
  },
94
94
  },
95
95
  },
96
+ {
97
+ name: "gflows_release",
98
+ description:
99
+ "Quick release from dev: bump package version, merge into main, tag, sync main→dev. Requires bumpType.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ path: { type: "string" },
104
+ bumpType: {
105
+ type: "string",
106
+ description: "Semver segment to bump: patch, minor, or major",
107
+ },
108
+ push: { type: "boolean" },
109
+ preview: { type: "boolean" },
110
+ },
111
+ required: ["bumpType"],
112
+ },
113
+ },
96
114
  {
97
115
  name: "gflows_schema",
98
116
  description: "Return the gflows command schema JSON.",
@@ -159,6 +177,14 @@ async function runTool(
159
177
  if (args.push) argv.push("-p");
160
178
  else argv.push("-P");
161
179
  break;
180
+ case "gflows_release": {
181
+ const bumpType = String(args.bumpType ?? "patch");
182
+ argv.push("release", "up", bumpType);
183
+ if (args.preview) argv.push("--preview");
184
+ if (args.push) argv.push("-p");
185
+ else argv.push("-P");
186
+ break;
187
+ }
162
188
  case "gflows_schema":
163
189
  argv.push("schema");
164
190
  break;
@@ -185,7 +211,7 @@ async function runTool(
185
211
  */
186
212
  export async function run(_args: ParsedArgs): Promise<void> {
187
213
  console.error(
188
- "gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, info, list, start, sync, finish, schema.",
214
+ "gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, info, list, start, sync, finish, release, schema.",
189
215
  );
190
216
 
191
217
  const decoder = new TextDecoder();
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Quick release from the long-lived `dev` branch: bump, merge into main, tag, sync main→dev.
3
+ * @module commands/release
4
+ */
5
+
6
+ import { join } from "node:path";
7
+ import { resolveConfig } from "../config.js";
8
+ import { EXIT_USER } from "../constants.js";
9
+ import { DirtyWorkingTreeError, NothingToFinishError } from "../errors.js";
10
+ import { normalizeTagVersion } from "../flow.js";
11
+ import {
12
+ assertNoRebaseOrMerge,
13
+ assertNotDetached,
14
+ getAheadBehind,
15
+ getCurrentBranch,
16
+ isClean,
17
+ resolveRepoRoot,
18
+ runGit,
19
+ tagExists,
20
+ } from "../git.js";
21
+ import { hint, success } from "../out.js";
22
+ import { PACKAGE_JSON } from "../packages.js";
23
+ import type { BumpType, ParsedArgs } from "../types.js";
24
+ import { applyVersionToPackages, computeBump, JSR_JSON } from "../version-bump.js";
25
+ import { type FinishContext, runFinishPlan } from "./finish.js";
26
+
27
+ /**
28
+ * Runs quick release: only valid when HEAD is the configured `dev` branch.
29
+ */
30
+ export async function run(args: ParsedArgs): Promise<void> {
31
+ const repoRoot = await resolveRepoRoot(args.cwd);
32
+ const config = resolveConfig(
33
+ repoRoot,
34
+ { main: args.main, dev: args.dev, remote: args.remote },
35
+ { verbose: args.verbose },
36
+ );
37
+
38
+ const opts = { dryRun: args.dryRun, verbose: args.verbose, quiet: args.quiet };
39
+ const isTTY = Boolean(process.stdin.isTTY);
40
+
41
+ await assertNotDetached(repoRoot);
42
+ assertNoRebaseOrMerge(repoRoot);
43
+
44
+ const current = await getCurrentBranch(repoRoot, opts);
45
+ if (!current || current !== config.dev) {
46
+ console.error(
47
+ `gflows release: must be on '${config.dev}' (currently ${current ? `'${current}'` : "detached"}).`,
48
+ );
49
+ hint("Checkout dev, or use: gflows start release vX.Y.Z && gflows finish release -y -P");
50
+ process.exit(2);
51
+ }
52
+
53
+ if (!args.force) {
54
+ const clean = await isClean(repoRoot, opts);
55
+ if (!clean) {
56
+ throw new DirtyWorkingTreeError(
57
+ "Working tree has uncommitted changes. Commit or stash them before release, or use --force.",
58
+ );
59
+ }
60
+ }
61
+
62
+ const { ahead } = await getAheadBehind(repoRoot, config.main, config.dev, opts);
63
+ if (ahead === 0) {
64
+ throw new NothingToFinishError(
65
+ `Nothing to release: '${config.dev}' has no commits beyond '${config.main}'.`,
66
+ );
67
+ }
68
+
69
+ let bumpType: BumpType | undefined = args.bumpType;
70
+ if (args.bumpDirection === "down") {
71
+ console.error(
72
+ "gflows release: only 'up' is supported (not 'down'). Example: gflows release up patch",
73
+ );
74
+ process.exit(EXIT_USER);
75
+ }
76
+ if (args.bumpDirection && args.bumpDirection !== "up") {
77
+ console.error(
78
+ "gflows release: expected 'up <patch|minor|major>'. Example: gflows release up patch",
79
+ );
80
+ process.exit(EXIT_USER);
81
+ }
82
+
83
+ if (!bumpType) {
84
+ if (!isTTY) {
85
+ console.error(
86
+ "gflows release: when not in a TTY, bump type is required. Example: gflows release up patch -y -P",
87
+ );
88
+ process.exit(EXIT_USER);
89
+ }
90
+ const { selectPrompt } = await import("../prompts.js");
91
+ const computedPreview = (() => {
92
+ try {
93
+ return computeBump(repoRoot, "up", "patch");
94
+ } catch {
95
+ return null;
96
+ }
97
+ })();
98
+ const currentVer = computedPreview?.oldVersion ?? "?";
99
+ bumpType = await selectPrompt<"patch" | "minor" | "major">({
100
+ message: `Bump version (current ${currentVer})`,
101
+ options: [
102
+ { label: "patch (x.y.Z)", value: "patch" },
103
+ { label: "minor (x.Y.0)", value: "minor" },
104
+ { label: "major (X.0.0)", value: "major" },
105
+ ],
106
+ });
107
+ }
108
+
109
+ let doPush = false;
110
+ if (args.push && !args.noPush) doPush = true;
111
+ else if (args.noPush) doPush = false;
112
+ else if (!isTTY) {
113
+ console.error("gflows release: specify --push (-p) or --no-push (-P) when not interactive.");
114
+ process.exit(EXIT_USER);
115
+ } else if (!args.yes) {
116
+ const { confirmPrompt } = await import("../prompts.js");
117
+ doPush = await confirmPrompt({
118
+ message: "Push after release?",
119
+ initialValue: false,
120
+ });
121
+ } else {
122
+ // -y without push polarity in TTY: default no-push (same as finish when -y alone)
123
+ doPush = false;
124
+ }
125
+
126
+ const { oldVersion, newVersion, roots } = computeBump(repoRoot, "up", bumpType);
127
+ const tagName = normalizeTagVersion(newVersion);
128
+
129
+ if (await tagExists(repoRoot, tagName, opts)) {
130
+ console.error(`gflows release: tag '${tagName}' already exists.`);
131
+ hint("Use a different bump (minor/major), or delete the tag if you intend to recreate it.");
132
+ process.exit(2);
133
+ }
134
+
135
+ console.error("gflows release plan:");
136
+ console.error(` from: ${config.dev}`);
137
+ console.error(` bump: ${oldVersion} → ${newVersion}`);
138
+ console.error(` merge: → ${config.main}, then ${config.main} → ${config.dev}`);
139
+ console.error(` tag: ${tagName}`);
140
+ console.error(` push: ${doPush ? "yes" : "no"}`);
141
+
142
+ if (args.preview) {
143
+ success("gflows: preview only (no changes).");
144
+ return;
145
+ }
146
+
147
+ if (!args.yes && isTTY) {
148
+ const { confirmPrompt } = await import("../prompts.js");
149
+ const ok = await confirmPrompt({
150
+ message: "Proceed with quick release?",
151
+ initialValue: true,
152
+ });
153
+ if (!ok) {
154
+ success("gflows: release cancelled.");
155
+ return;
156
+ }
157
+ } else if (!args.yes && !isTTY) {
158
+ console.error("gflows release: pass -y to accept the plan when not interactive.");
159
+ process.exit(EXIT_USER);
160
+ }
161
+
162
+ if (!opts.dryRun) {
163
+ applyVersionToPackages(repoRoot, roots, newVersion);
164
+ for (const dir of roots) {
165
+ await runGit(["add", "--", join(dir, PACKAGE_JSON)], {
166
+ cwd: repoRoot,
167
+ dryRun: false,
168
+ verbose: args.verbose,
169
+ }).catch(() => undefined);
170
+ await runGit(["add", "--", join(dir, JSR_JSON)], {
171
+ cwd: repoRoot,
172
+ dryRun: false,
173
+ verbose: args.verbose,
174
+ }).catch(() => undefined);
175
+ }
176
+ await runGit(["commit", "-m", `chore: bump to ${newVersion}`], {
177
+ cwd: repoRoot,
178
+ dryRun: false,
179
+ verbose: args.verbose,
180
+ });
181
+ if (!args.quiet) {
182
+ success(`gflows: bumped version ${oldVersion} → ${newVersion} and committed.`);
183
+ }
184
+ }
185
+
186
+ const ctx: FinishContext = {
187
+ branchToFinish: config.dev,
188
+ type: "release",
189
+ version: newVersion,
190
+ mergeTarget: "main-then-dev",
191
+ shouldDelete: false,
192
+ doPush,
193
+ noFf: args.noFf,
194
+ squash: false,
195
+ message: args.message,
196
+ signTag: args.signTag,
197
+ noTag: false,
198
+ tagMessage: args.tagMessage,
199
+ remote: args.remote ?? config.remote,
200
+ main: config.main,
201
+ dev: config.dev,
202
+ bumpOnFinish: false,
203
+ };
204
+
205
+ await runFinishPlan(repoRoot, ctx, {
206
+ ...opts,
207
+ printPlan: false,
208
+ });
209
+ }
@@ -56,6 +56,14 @@ export async function run(_args: ParsedArgs): Promise<void> {
56
56
  "-M/--tag-message",
57
57
  "-m/--message",
58
58
  ],
59
+ release: [
60
+ "up patch|minor|major",
61
+ "-y",
62
+ "-p/--push",
63
+ "-P/--no-push",
64
+ "--preview",
65
+ "-s/--sign",
66
+ ],
59
67
  start: ["-o/--from", "--force", "-p/--push"],
60
68
  sync: ["--rebase", "--force"],
61
69
  list: ["-r/--include-remote", "--json"],
@@ -66,6 +74,7 @@ export async function run(_args: ParsedArgs): Promise<void> {
66
74
  agentNotes: [
67
75
  "Always pass explicit flags in non-TTY (never assume interactive hub).",
68
76
  "Prefer -y to accept finish plan (delete defaults on); pass -P or -p for push.",
77
+ "Quick release from dev: gflows release up patch -y -p (or -P).",
69
78
  "On conflict: gflows continue | gflows abort | gflows undo.",
70
79
  "Use gflows schema and AGENTS.md as source of truth.",
71
80
  ],
@@ -78,6 +87,7 @@ function commandMeta(command: string): { summary: string; interactiveOk: boolean
78
87
  init: { summary: "Ensure main; create dev", interactiveOk: true },
79
88
  start: { summary: "Create workflow branch", interactiveOk: true },
80
89
  finish: { summary: "Merge and close workflow branch", interactiveOk: true },
90
+ release: { summary: "Quick release from dev (bump, merge main, tag)", interactiveOk: true },
81
91
  switch: { summary: "Switch workflow branch", interactiveOk: true },
82
92
  delete: { summary: "Delete local workflow branches", interactiveOk: true },
83
93
  list: { summary: "List workflow branches", interactiveOk: false },
@@ -82,6 +82,11 @@ function buildLegacyOptions(
82
82
  { value: "sync", label: "Sync this branch with base" },
83
83
  { value: "pr", label: "Open a pull request" },
84
84
  { value: "finish", label: "Finish / merge this branch" },
85
+ );
86
+ if (snap?.current === snap?.dev) {
87
+ items.push({ value: "release", label: "Quick release to main" });
88
+ }
89
+ items.push(
85
90
  { value: "switch", label: "Switch branch" },
86
91
  { value: "list", label: "List branches" },
87
92
  { value: "viz", label: "Refresh map" },
@@ -121,6 +126,19 @@ async function runHubAction(cwd: string, action: string): Promise<void> {
121
126
  await dispatch(cwd, ["finish", "-y", push ? "-p" : "-P"]);
122
127
  return;
123
128
  }
129
+ if (action === "release") {
130
+ const bumpType = await selectPrompt<"patch" | "minor" | "major">({
131
+ message: "Bump version",
132
+ options: [
133
+ { label: "patch (x.y.Z)", value: "patch" },
134
+ { label: "minor (x.Y.0)", value: "minor" },
135
+ { label: "major (X.0.0)", value: "major" },
136
+ ],
137
+ });
138
+ const push = await confirmPrompt({ message: "Push after release?", initialValue: false });
139
+ await dispatch(cwd, ["release", "up", bumpType, "-y", push ? "-p" : "-P"]);
140
+ return;
141
+ }
124
142
  if (action === "sync") {
125
143
  const rebase = await confirmPrompt({
126
144
  message: "Rebase onto base (instead of merge)?",
package/src/parse.ts CHANGED
@@ -234,7 +234,7 @@ function resolveName(
234
234
  if (shell === "bash" || shell === "zsh" || shell === "fish") return shell;
235
235
  return undefined;
236
236
  }
237
- if (command === "bump") {
237
+ if (command === "bump" || command === "release") {
238
238
  const dir = positionals[skip];
239
239
  if (dir === "up" || dir === "down") return dir;
240
240
  return undefined;
@@ -249,8 +249,8 @@ function resolveBump(positionals: string[]): {
249
249
  direction?: "up" | "down";
250
250
  type?: "patch" | "minor" | "major";
251
251
  } {
252
- // Skip leading "bump" when present; with -U, positionals start at up/down
253
- const skip = positionals[0] === "bump" ? 1 : 0;
252
+ // Skip leading command when present; with -U, positionals start at up/down
253
+ const skip = positionals[0] === "bump" || positionals[0] === "release" ? 1 : 0;
254
254
  const a = positionals[skip];
255
255
  const b = positionals[skip + 1];
256
256
  const direction = a === "up" || a === "down" ? a : undefined;
@@ -337,7 +337,9 @@ export function parse(
337
337
  const type = resolveType(command, positionals, v);
338
338
  const name = resolveName(command, positionals, v);
339
339
  const { direction: bumpDirection, type: bumpType } =
340
- command === "bump" ? resolveBump(positionals) : { direction: undefined, type: undefined };
340
+ command === "bump" || command === "release"
341
+ ? resolveBump(positionals)
342
+ : { direction: undefined, type: undefined };
341
343
  const configArgs = command === "config" ? resolveConfigArgs(positionals) : {};
342
344
 
343
345
  const branchNames =
package/src/recommend.ts CHANGED
@@ -11,6 +11,7 @@ export type RecommendAction =
11
11
  | "continue"
12
12
  | "sync"
13
13
  | "finish"
14
+ | "release"
14
15
  | "pr"
15
16
  | "start"
16
17
  | "commit"
@@ -79,7 +80,7 @@ export function recommend(snap: VizSnapshot): Recommendation {
79
80
  return {
80
81
  action: "start",
81
82
  label: "Start a feature (or chore / bugfix)",
82
- detail: "Integration line — short-lived branches start here",
83
+ detail: "Integration line — short-lived branches start here; /release for quick ship to main",
83
84
  menuLabel: "Start new work",
84
85
  };
85
86
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Soft process.exit for hub-embedded command dispatch.
3
+ * Commands that call process.exit would otherwise kill the hub loop.
4
+ * @module soft-exit
5
+ */
6
+
7
+ /**
8
+ * Thrown instead of terminating the process when soft-exit mode is active.
9
+ */
10
+ export class SoftExitError extends Error {
11
+ /**
12
+ * @param code - Exit code the command requested.
13
+ */
14
+ constructor(readonly code: number) {
15
+ super(`process.exit(${code})`);
16
+ this.name = "SoftExitError";
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Runs `fn` while intercepting `process.exit` as {@link SoftExitError}.
22
+ * Restores the real `process.exit` afterward.
23
+ */
24
+ export async function runWithSoftExit<T>(fn: () => Promise<T>): Promise<T> {
25
+ const realExit = process.exit;
26
+ const softExit = ((code?: number) => {
27
+ throw new SoftExitError(code ?? 0);
28
+ }) as typeof process.exit;
29
+
30
+ process.exit = softExit;
31
+ try {
32
+ return await fn();
33
+ } finally {
34
+ process.exit = realExit;
35
+ }
36
+ }
@@ -289,7 +289,7 @@ export function HubHome({
289
289
  ) : (
290
290
  <Text color={MUTED}>
291
291
  {showHelp
292
- ? "/init /start /sync /pr /finish /doctor /info /help · esc clear · ctrl+c quit"
292
+ ? "/init /start /sync /pr /finish /release /doctor /info /help · esc clear · ctrl+c quit"
293
293
  : "? for shortcuts · / for command menu"}
294
294
  </Text>
295
295
  )}
@@ -372,6 +372,7 @@ function buildActions(snap: VizSnapshot | null, recommended: RecommendAction | n
372
372
  push("sync", "Sync with base", "/sync");
373
373
  push("pr", "Open pull request", "/pr");
374
374
  push("finish", "Finish / merge branch", "/finish");
375
+ if (snap?.current === snap?.dev) push("release", "Quick release to main", "/release");
375
376
  if (snap?.suspended) push("continue", "Continue suspended", "/continue");
376
377
  push("switch", "Switch branch", "/switch");
377
378
  push("list", "List branches", "/list");
@@ -11,6 +11,7 @@ import {
11
11
  FinishFlow,
12
12
  InitFlow,
13
13
  ListFlow,
14
+ ReleaseFlow,
14
15
  StartFlow,
15
16
  SwitchFlow,
16
17
  SyncFlow,
@@ -31,6 +32,7 @@ type Screen =
31
32
  | { id: "home"; flash?: string }
32
33
  | { id: "start" }
33
34
  | { id: "finish" }
35
+ | { id: "release" }
34
36
  | { id: "sync" }
35
37
  | { id: "list" }
36
38
  | { id: "switch" }
@@ -87,6 +89,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
87
89
  case "finish":
88
90
  setScreen({ id: "finish" });
89
91
  return true;
92
+ case "release":
93
+ setScreen({ id: "release" });
94
+ return true;
90
95
  case "sync":
91
96
  setScreen({ id: "sync" });
92
97
  return true;
@@ -156,6 +161,14 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
156
161
  setScreen({ id: "finish" });
157
162
  return;
158
163
  }
164
+ if (cmd === "release") {
165
+ if (rest.length > 0) {
166
+ runArgv(["release", "-y", "-P", ...rest]);
167
+ return;
168
+ }
169
+ setScreen({ id: "release" });
170
+ return;
171
+ }
159
172
  if (cmd === "sync") {
160
173
  if (rest.length > 0) {
161
174
  runArgv(["sync", "--force", ...rest]);
@@ -217,6 +230,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
217
230
  if (screen.id === "finish") {
218
231
  return <FinishFlow onCancel={cancelWizard} onDone={runArgv} />;
219
232
  }
233
+ if (screen.id === "release") {
234
+ return <ReleaseFlow onCancel={cancelWizard} onDone={runArgv} />;
235
+ }
220
236
  if (screen.id === "sync") {
221
237
  return <SyncFlow onCancel={cancelWizard} onDone={runArgv} />;
222
238
  }
package/src/tui/flows.tsx CHANGED
@@ -124,6 +124,53 @@ export function FinishFlow({
124
124
  );
125
125
  }
126
126
 
127
+ /**
128
+ * Collects argv for `gflows release …` (quick release from dev) inside Ink.
129
+ */
130
+ export function ReleaseFlow({
131
+ onDone,
132
+ onCancel,
133
+ }: {
134
+ onDone: (argv: string[]) => void;
135
+ onCancel: () => void;
136
+ }): React.ReactElement {
137
+ const [step, setStep] = useState<"type" | "push">("type");
138
+ const [bumpType, setBumpType] = useState<"patch" | "minor" | "major">("patch");
139
+
140
+ if (step === "type") {
141
+ return (
142
+ <WizardFrame title="Quick release from dev">
143
+ <InkSelect<"patch" | "minor" | "major">
144
+ message="Bump version"
145
+ options={[
146
+ { value: "patch", label: "patch (x.y.Z)" },
147
+ { value: "minor", label: "minor (x.Y.0)" },
148
+ { value: "major", label: "major (X.0.0)" },
149
+ ]}
150
+ onCancel={onCancel}
151
+ onSubmit={(t) => {
152
+ setBumpType(t);
153
+ setStep("push");
154
+ }}
155
+ />
156
+ </WizardFrame>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <WizardFrame title={`Release · up ${bumpType}`}>
162
+ <InkConfirm
163
+ message="Push after release?"
164
+ initialValue={false}
165
+ onCancel={onCancel}
166
+ onSubmit={(push) => {
167
+ onDone(["release", "up", bumpType, "-y", push ? "-p" : "-P"]);
168
+ }}
169
+ />
170
+ </WizardFrame>
171
+ );
172
+ }
173
+
127
174
  /**
128
175
  * Collects argv for `gflows sync …` entirely inside Ink.
129
176
  */
package/src/tui/hub.ts CHANGED
@@ -7,6 +7,7 @@ import { render } from "ink";
7
7
  import React from "react";
8
8
  import { dispatch } from "../dispatch.js";
9
9
  import { PromptCancelledError } from "../prompts.js";
10
+ import { runWithSoftExit, SoftExitError } from "../soft-exit.js";
10
11
  import { type HubSessionResult, HubShell } from "./HubShell.js";
11
12
  import { prepareStdinAfterInk } from "./stdin.js";
12
13
 
@@ -25,17 +26,22 @@ export async function runTuiHub(cwd: string): Promise<boolean> {
25
26
  if (!canUseTui()) return false;
26
27
 
27
28
  for (;;) {
29
+ // Ink needs a live, referenced stdin after waitEnter / Clack / prior unmount.
30
+ prepareStdinAfterInk();
28
31
  const result = await runHubSession(cwd);
29
32
  if (result.kind === "quit") break;
30
33
 
31
34
  if (result.kind === "run") {
32
- // Ink unrefs stdin on unmount — re-arm before Clack / git prompts.
33
35
  prepareStdinAfterInk();
34
36
  console.log("");
35
37
  try {
36
- await dispatch(cwd, result.argv);
38
+ // Many commands call process.exit on validation/success paths — trap them
39
+ // so the hub can show “press enter” and remount instead of dying.
40
+ await runWithSoftExit(() => dispatch(cwd, result.argv));
37
41
  } catch (err) {
38
- if (!(err instanceof PromptCancelledError)) {
42
+ if (err instanceof SoftExitError) {
43
+ // Command already printed its output / error; continue to waitEnter.
44
+ } else if (!(err instanceof PromptCancelledError)) {
39
45
  console.error("gflows:", err instanceof Error ? err.message : String(err));
40
46
  }
41
47
  }
@@ -78,15 +84,18 @@ function runHubSession(cwd: string): Promise<HubSessionResult> {
78
84
 
79
85
  /**
80
86
  * Raw stdin “press enter” (no Clack / no Ink).
81
- * Ink unrefs stdin on unmount we must ref it again or the process exits
82
- * before the user can return to the hub.
87
+ * Leaves stdin armed for the next Ink remount (does not pause/unref).
83
88
  */
84
89
  function waitEnterRaw(label: string): Promise<void> {
85
90
  return new Promise((resolve) => {
86
91
  const stdin = process.stdin;
87
92
  process.stdout.write(`${label}\n`);
88
93
  if (typeof stdin.setRawMode === "function") {
89
- stdin.setRawMode(true);
94
+ try {
95
+ stdin.setRawMode(true);
96
+ } catch {
97
+ // ignore
98
+ }
90
99
  }
91
100
  stdin.resume();
92
101
  stdin.ref();
@@ -99,20 +108,17 @@ function waitEnterRaw(label: string): Promise<void> {
99
108
  }
100
109
  }
101
110
 
102
- const cleanup = () => {
111
+ const finish = () => {
103
112
  stdin.off("data", onData);
104
- if (typeof stdin.setRawMode === "function") {
105
- stdin.setRawMode(false);
106
- }
107
- stdin.pause();
108
- stdin.unref();
113
+ // Critical: leave stdin ready for Ink remount (old code paused+unref'd → hub died).
114
+ prepareStdinAfterInk();
115
+ resolve();
109
116
  };
110
117
 
111
118
  const onData = (chunk: string | Buffer) => {
112
119
  const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
113
120
  if (s.includes("\r") || s.includes("\n") || s.includes("\x03")) {
114
- cleanup();
115
- resolve();
121
+ finish();
116
122
  }
117
123
  };
118
124
  stdin.on("data", onData);
package/src/tui/slash.ts CHANGED
@@ -20,6 +20,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
20
20
  { name: "sync", hint: "Sync with base (wizard)" },
21
21
  { name: "pr", hint: "Open pull request" },
22
22
  { name: "finish", hint: "Finish / merge (wizard)" },
23
+ { name: "release", hint: "Quick release from dev" },
23
24
  { name: "continue", hint: "Continue suspended run" },
24
25
  { name: "switch", hint: "Switch branch" },
25
26
  { name: "list", hint: "List branches" },
package/src/types.ts CHANGED
@@ -27,6 +27,7 @@ export type Command =
27
27
  | "init"
28
28
  | "start"
29
29
  | "finish"
30
+ | "release"
30
31
  | "switch"
31
32
  | "delete"
32
33
  | "list"
@@ -52,6 +53,7 @@ export const ALL_COMMANDS: Command[] = [
52
53
  "init",
53
54
  "start",
54
55
  "finish",
56
+ "release",
55
57
  "switch",
56
58
  "delete",
57
59
  "list",