gflows 0.1.18 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +78 -0
- package/CHANGELOG.md +48 -0
- package/README.md +279 -505
- package/llms.txt +22 -0
- package/package.json +29 -23
- package/src/cli.ts +21 -367
- package/src/commands/abort.ts +39 -0
- package/src/commands/bump.ts +10 -10
- package/src/commands/completion.ts +14 -4
- package/src/commands/config.ts +123 -0
- package/src/commands/continue.ts +40 -0
- package/src/commands/delete.ts +4 -4
- package/src/commands/doctor.ts +143 -0
- package/src/commands/finish.ts +363 -137
- package/src/commands/help.ts +29 -21
- package/src/commands/init.ts +99 -18
- package/src/commands/list.ts +6 -1
- package/src/commands/mcp.ts +239 -0
- package/src/commands/pr.ts +120 -0
- package/src/commands/schema.ts +99 -0
- package/src/commands/start.ts +33 -31
- package/src/commands/status.ts +70 -51
- package/src/commands/switch.ts +14 -19
- package/src/commands/sync.ts +160 -0
- package/src/commands/undo.ts +78 -0
- package/src/commands/version.ts +3 -15
- package/src/commands/viz.ts +18 -0
- package/src/dispatch.ts +21 -0
- package/src/errors.ts +55 -12
- package/src/flow.ts +157 -0
- package/src/git.ts +135 -8
- package/src/index.ts +24 -1
- package/src/interactive.ts +209 -0
- package/src/out.ts +11 -4
- package/src/package-scripts.ts +73 -0
- package/src/parse.ts +430 -0
- package/src/prompts.ts +89 -0
- package/src/recommend.ts +132 -0
- package/src/run-state.ts +165 -0
- package/src/tui/HubHome.tsx +391 -0
- package/src/tui/HubShell.tsx +193 -0
- package/src/tui/flows.tsx +229 -0
- package/src/tui/hub.ts +114 -0
- package/src/tui/prompts.tsx +207 -0
- package/src/tui/slash.ts +63 -0
- package/src/types.ts +62 -3
- package/src/ui.ts +132 -0
- package/src/version.ts +24 -0
- package/src/viz.ts +294 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Ink hub shell: home map + in-app wizards (no Clack drop-out for prompts).
|
|
3
|
+
* @module tui/HubShell
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { FinishFlow, ListFlow, StartFlow, SyncFlow } from "./flows.js";
|
|
10
|
+
import { HubHome } from "./HubHome.js";
|
|
11
|
+
import { WizardFrame } from "./prompts.js";
|
|
12
|
+
import { SLASH_COMMANDS } from "./slash.js";
|
|
13
|
+
|
|
14
|
+
const ACCENT = "#E88C4A";
|
|
15
|
+
const MUTED = "#8A8A8A";
|
|
16
|
+
const FG = "#E6E6E6";
|
|
17
|
+
|
|
18
|
+
/** Outcome of one hub session (Ink unmounts after this). */
|
|
19
|
+
export type HubSessionResult = { kind: "quit" } | { kind: "run"; argv: string[] };
|
|
20
|
+
|
|
21
|
+
type Screen =
|
|
22
|
+
| { id: "home"; flash?: string }
|
|
23
|
+
| { id: "start" }
|
|
24
|
+
| { id: "finish" }
|
|
25
|
+
| { id: "sync" }
|
|
26
|
+
| { id: "list" }
|
|
27
|
+
| { id: "notice"; message: string };
|
|
28
|
+
|
|
29
|
+
const DISPATCH_COMMANDS = new Set(
|
|
30
|
+
SLASH_COMMANDS.map((c) => c.name).filter(
|
|
31
|
+
(name) => !["start", "finish", "sync", "list", "viz", "quit", "exit", "q"].includes(name),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Props for the hub shell.
|
|
37
|
+
*/
|
|
38
|
+
export interface HubShellProps {
|
|
39
|
+
cwd: string;
|
|
40
|
+
onDone: (result: HubSessionResult) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fullscreen hub with embedded wizards.
|
|
45
|
+
*/
|
|
46
|
+
export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
47
|
+
const { exit } = useApp();
|
|
48
|
+
const [screen, setScreen] = useState<Screen>({ id: "home" });
|
|
49
|
+
|
|
50
|
+
const finish = (result: HubSessionResult) => {
|
|
51
|
+
onDone(result);
|
|
52
|
+
exit();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const cancelWizard = () => setScreen({ id: "home" });
|
|
56
|
+
const runArgv = (argv: string[]) => finish({ kind: "run", argv });
|
|
57
|
+
|
|
58
|
+
const handleSlash = (raw: string) => {
|
|
59
|
+
const parts = raw.slice(1).trim().split(/\s+/);
|
|
60
|
+
const cmd = (parts[0] ?? "").toLowerCase();
|
|
61
|
+
const rest = parts.slice(1);
|
|
62
|
+
|
|
63
|
+
if (!cmd || cmd === "quit" || cmd === "exit" || cmd === "q") {
|
|
64
|
+
finish({ kind: "quit" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cmd === "start") {
|
|
69
|
+
if (rest.length >= 2) {
|
|
70
|
+
const type = rest[0] ?? "feature";
|
|
71
|
+
const name = rest[1] ?? "";
|
|
72
|
+
runArgv(["start", type, name, "-P", ...rest.slice(2)]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setScreen({ id: "start" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (cmd === "finish") {
|
|
79
|
+
if (rest.length > 0) {
|
|
80
|
+
runArgv(["finish", "-y", "-P", ...rest]);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setScreen({ id: "finish" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (cmd === "sync") {
|
|
87
|
+
if (rest.length > 0) {
|
|
88
|
+
runArgv(["sync", "--force", ...rest]);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setScreen({ id: "sync" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (cmd === "list") {
|
|
95
|
+
setScreen({ id: "list" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (cmd === "viz") {
|
|
99
|
+
setScreen({ id: "home", flash: "Branch map is shown on this screen." });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!DISPATCH_COMMANDS.has(cmd)) {
|
|
104
|
+
setScreen({
|
|
105
|
+
id: "notice",
|
|
106
|
+
message: `Unknown /${cmd} — type / for command hints.`,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
runArgv([cmd, ...rest]);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleHome = (action: string) => {
|
|
115
|
+
if (action === "quit") {
|
|
116
|
+
finish({ kind: "quit" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (action === "start") {
|
|
120
|
+
setScreen({ id: "start" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (action === "finish") {
|
|
124
|
+
setScreen({ id: "finish" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (action === "sync") {
|
|
128
|
+
setScreen({ id: "sync" });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (action === "list") {
|
|
132
|
+
setScreen({ id: "list" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
runArgv([action]);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (screen.id === "start") {
|
|
139
|
+
return <StartFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
140
|
+
}
|
|
141
|
+
if (screen.id === "finish") {
|
|
142
|
+
return <FinishFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
143
|
+
}
|
|
144
|
+
if (screen.id === "sync") {
|
|
145
|
+
return <SyncFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
146
|
+
}
|
|
147
|
+
if (screen.id === "list") {
|
|
148
|
+
return <ListFlow cwd={cwd} onDone={cancelWizard} />;
|
|
149
|
+
}
|
|
150
|
+
if (screen.id === "notice") {
|
|
151
|
+
return (
|
|
152
|
+
<PressEnter
|
|
153
|
+
title="Notice"
|
|
154
|
+
message={screen.message}
|
|
155
|
+
onDone={() => setScreen({ id: "home" })}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<HubHome
|
|
162
|
+
cwd={cwd}
|
|
163
|
+
flash={screen.flash}
|
|
164
|
+
onAction={handleHome}
|
|
165
|
+
onSlash={handleSlash}
|
|
166
|
+
onQuit={() => finish({ kind: "quit" })}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function PressEnter({
|
|
172
|
+
title,
|
|
173
|
+
message,
|
|
174
|
+
onDone,
|
|
175
|
+
}: {
|
|
176
|
+
title: string;
|
|
177
|
+
message: string;
|
|
178
|
+
onDone: () => void;
|
|
179
|
+
}): React.ReactElement {
|
|
180
|
+
useInput((_ch, key) => {
|
|
181
|
+
if (key.return || key.escape) onDone();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<WizardFrame title={title}>
|
|
186
|
+
<Text color={FG}>{message}</Text>
|
|
187
|
+
<Box marginTop={1}>
|
|
188
|
+
<Text color={MUTED}>Press enter to continue</Text>
|
|
189
|
+
<Text color={ACCENT}> █</Text>
|
|
190
|
+
</Box>
|
|
191
|
+
</WizardFrame>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-step Ink wizards for hub actions (start / finish / sync / list).
|
|
3
|
+
* @module tui/flows
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useInput } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import type { BranchType } from "../types.js";
|
|
10
|
+
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
11
|
+
import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
|
|
12
|
+
|
|
13
|
+
const MUTED = "#8A8A8A";
|
|
14
|
+
const FG = "#E6E6E6";
|
|
15
|
+
const GREEN = "#78C88C";
|
|
16
|
+
const ACCENT = "#E88C4A";
|
|
17
|
+
|
|
18
|
+
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Collects argv for `gflows start …` entirely inside Ink.
|
|
22
|
+
*/
|
|
23
|
+
export function StartFlow({
|
|
24
|
+
onDone,
|
|
25
|
+
onCancel,
|
|
26
|
+
}: {
|
|
27
|
+
onDone: (argv: string[]) => void;
|
|
28
|
+
onCancel: () => void;
|
|
29
|
+
}): React.ReactElement {
|
|
30
|
+
const [step, setStep] = useState<"type" | "name" | "push" | "fromMain">("type");
|
|
31
|
+
const [type, setType] = useState<BranchType>("feature");
|
|
32
|
+
const [name, setName] = useState("");
|
|
33
|
+
const [push, setPush] = useState(false);
|
|
34
|
+
|
|
35
|
+
if (step === "type") {
|
|
36
|
+
return (
|
|
37
|
+
<WizardFrame title="Start new work">
|
|
38
|
+
<InkSelect
|
|
39
|
+
message="Branch type"
|
|
40
|
+
options={BRANCH_TYPES.map((t) => ({ value: t, label: t }))}
|
|
41
|
+
onCancel={onCancel}
|
|
42
|
+
onSubmit={(t) => {
|
|
43
|
+
setType(t);
|
|
44
|
+
setStep("name");
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
</WizardFrame>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (step === "name") {
|
|
52
|
+
return (
|
|
53
|
+
<WizardFrame title={`Start · ${type}`}>
|
|
54
|
+
<InkText
|
|
55
|
+
message={type === "release" || type === "hotfix" ? "Version (vX.Y.Z)" : "Branch name"}
|
|
56
|
+
placeholder={type === "release" || type === "hotfix" ? "v1.0.0" : "my-thing"}
|
|
57
|
+
onCancel={onCancel}
|
|
58
|
+
onSubmit={(n) => {
|
|
59
|
+
setName(n);
|
|
60
|
+
setStep("push");
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</WizardFrame>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (step === "push") {
|
|
68
|
+
return (
|
|
69
|
+
<WizardFrame title={`Start · ${type}/${name}`}>
|
|
70
|
+
<InkConfirm
|
|
71
|
+
message="Push after create?"
|
|
72
|
+
initialValue={false}
|
|
73
|
+
onCancel={onCancel}
|
|
74
|
+
onSubmit={(p) => {
|
|
75
|
+
setPush(p);
|
|
76
|
+
if (type === "bugfix") setStep("fromMain");
|
|
77
|
+
else onDone(["start", type, name, p ? "-p" : "-P"]);
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</WizardFrame>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// fromMain
|
|
85
|
+
return (
|
|
86
|
+
<WizardFrame title={`Start · bugfix/${name}`}>
|
|
87
|
+
<InkConfirm
|
|
88
|
+
message="Base from main (production fix)?"
|
|
89
|
+
initialValue={false}
|
|
90
|
+
onCancel={onCancel}
|
|
91
|
+
onSubmit={(fromMain) => {
|
|
92
|
+
const argv = ["start", type, name, push ? "-p" : "-P"];
|
|
93
|
+
if (fromMain) argv.push("-o", "main");
|
|
94
|
+
onDone(argv);
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
</WizardFrame>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Collects argv for `gflows finish …` entirely inside Ink.
|
|
103
|
+
*/
|
|
104
|
+
export function FinishFlow({
|
|
105
|
+
onDone,
|
|
106
|
+
onCancel,
|
|
107
|
+
}: {
|
|
108
|
+
onDone: (argv: string[]) => void;
|
|
109
|
+
onCancel: () => void;
|
|
110
|
+
}): React.ReactElement {
|
|
111
|
+
return (
|
|
112
|
+
<WizardFrame title="Finish / merge branch">
|
|
113
|
+
<InkConfirm
|
|
114
|
+
message="Push after finish?"
|
|
115
|
+
initialValue={false}
|
|
116
|
+
onCancel={onCancel}
|
|
117
|
+
onSubmit={(push) => {
|
|
118
|
+
onDone(["finish", "-y", push ? "-p" : "-P"]);
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</WizardFrame>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Collects argv for `gflows sync …` entirely inside Ink.
|
|
127
|
+
*/
|
|
128
|
+
export function SyncFlow({
|
|
129
|
+
onDone,
|
|
130
|
+
onCancel,
|
|
131
|
+
}: {
|
|
132
|
+
onDone: (argv: string[]) => void;
|
|
133
|
+
onCancel: () => void;
|
|
134
|
+
}): React.ReactElement {
|
|
135
|
+
return (
|
|
136
|
+
<WizardFrame title="Sync with base">
|
|
137
|
+
<InkConfirm
|
|
138
|
+
message="Rebase onto base (instead of merge)?"
|
|
139
|
+
initialValue={false}
|
|
140
|
+
onCancel={onCancel}
|
|
141
|
+
onSubmit={(rebase) => {
|
|
142
|
+
onDone(["sync", ...(rebase ? ["--rebase"] : []), "--force"]);
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
</WizardFrame>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Styled branch list inside the hub (no drop-out to plain stdout).
|
|
151
|
+
*/
|
|
152
|
+
export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
|
|
153
|
+
const [lines, setLines] = useState<string[] | null>(null);
|
|
154
|
+
const [error, setError] = useState<string | null>(null);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
void (async () => {
|
|
159
|
+
try {
|
|
160
|
+
const snap = await collectVizSnapshot(cwd);
|
|
161
|
+
if (!cancelled) setLines(formatListLines(snap));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (!cancelled) {
|
|
164
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
165
|
+
setLines([]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
return () => {
|
|
170
|
+
cancelled = true;
|
|
171
|
+
};
|
|
172
|
+
}, [cwd]);
|
|
173
|
+
|
|
174
|
+
useInput((_ch, key) => {
|
|
175
|
+
if (key.return || key.escape || (key.ctrl && _ch === "c")) onDone();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<WizardFrame title="Branches">
|
|
180
|
+
{lines === null ? (
|
|
181
|
+
<Text color={MUTED}>Loading…</Text>
|
|
182
|
+
) : error ? (
|
|
183
|
+
<Text color={FG}>{error}</Text>
|
|
184
|
+
) : (
|
|
185
|
+
<Box flexDirection="column">
|
|
186
|
+
{lines.map((line) => {
|
|
187
|
+
const current = line.includes("●");
|
|
188
|
+
return (
|
|
189
|
+
<Text key={line} color={current ? GREEN : FG} bold={current}>
|
|
190
|
+
{line}
|
|
191
|
+
</Text>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</Box>
|
|
195
|
+
)}
|
|
196
|
+
<Box marginTop={1}>
|
|
197
|
+
<Text color={MUTED}>enter / esc return to hub</Text>
|
|
198
|
+
<Text color={ACCENT}> █</Text>
|
|
199
|
+
</Box>
|
|
200
|
+
</WizardFrame>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Formats a viz snapshot into hub list rows.
|
|
206
|
+
*/
|
|
207
|
+
function formatListLines(snap: VizSnapshot): string[] {
|
|
208
|
+
const lines: string[] = [];
|
|
209
|
+
if (snap.suspended) lines.push(`⚠ suspended: ${snap.suspended}`);
|
|
210
|
+
for (const row of snap.rows) {
|
|
211
|
+
if (row.type !== "main" && row.type !== "dev") continue;
|
|
212
|
+
const mark = row.current ? "●" : "○";
|
|
213
|
+
lines.push(`${mark} ${row.name} [${row.type}]`);
|
|
214
|
+
}
|
|
215
|
+
const workflow = snap.rows.filter((r) => r.type !== "main" && r.type !== "dev");
|
|
216
|
+
if (workflow.length === 0) {
|
|
217
|
+
lines.push(" (no workflow branches)");
|
|
218
|
+
} else {
|
|
219
|
+
for (let i = 0; i < workflow.length; i++) {
|
|
220
|
+
const row = workflow[i];
|
|
221
|
+
if (!row) continue;
|
|
222
|
+
const elbow = i === workflow.length - 1 ? "└─" : "├─";
|
|
223
|
+
const mark = row.current ? "●" : "○";
|
|
224
|
+
const ab = row.ahead === 0 && row.behind === 0 ? "synced" : `+${row.ahead}/-${row.behind}`;
|
|
225
|
+
lines.push(`${elbow} ${mark} ${row.name} (${row.type}) ${ab}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return lines;
|
|
229
|
+
}
|
package/src/tui/hub.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink hub runner — wizards stay in Ink; only git dispatch drops to the main screen.
|
|
3
|
+
* @module tui/hub
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { render } from "ink";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { dispatch } from "../dispatch.js";
|
|
9
|
+
import { type HubSessionResult, HubShell } from "./HubShell.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether stdin/stdout can host the Ink hub.
|
|
13
|
+
*/
|
|
14
|
+
export function canUseTui(): boolean {
|
|
15
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs the Ink fullscreen hub until quit.
|
|
20
|
+
* @returns true if the TUI ran, false if caller should use the legacy menu.
|
|
21
|
+
*/
|
|
22
|
+
export async function runTuiHub(cwd: string): Promise<boolean> {
|
|
23
|
+
if (!canUseTui()) return false;
|
|
24
|
+
|
|
25
|
+
for (;;) {
|
|
26
|
+
const result = await runHubSession(cwd);
|
|
27
|
+
if (result.kind === "quit") break;
|
|
28
|
+
|
|
29
|
+
if (result.kind === "run") {
|
|
30
|
+
console.log("");
|
|
31
|
+
try {
|
|
32
|
+
await dispatch(cwd, result.argv);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error("gflows:", err instanceof Error ? err.message : String(err));
|
|
35
|
+
}
|
|
36
|
+
console.log("");
|
|
37
|
+
await waitEnterRaw("Press enter to return to hub…");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runHubSession(cwd: string): Promise<HubSessionResult> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
const done = (result: HubSessionResult) => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
resolve(result);
|
|
51
|
+
};
|
|
52
|
+
const instance = render(
|
|
53
|
+
React.createElement(HubShell, {
|
|
54
|
+
cwd,
|
|
55
|
+
onDone: (result) => {
|
|
56
|
+
done(result);
|
|
57
|
+
instance.unmount();
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
{
|
|
61
|
+
// Keep hub off the primary scrollback so command output isn't stranded
|
|
62
|
+
// under a full-height cleared frame when we drop out for dispatch.
|
|
63
|
+
alternateScreen: true,
|
|
64
|
+
exitOnCtrlC: false,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
void instance.waitUntilExit().then(() => {
|
|
68
|
+
done({ kind: "quit" });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Raw stdin “press enter” (no Clack / no Ink).
|
|
75
|
+
* Ink unrefs stdin on unmount — we must ref it again or the process exits
|
|
76
|
+
* before the user can return to the hub.
|
|
77
|
+
*/
|
|
78
|
+
function waitEnterRaw(label: string): Promise<void> {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const stdin = process.stdin;
|
|
81
|
+
process.stdout.write(`${label}\n`);
|
|
82
|
+
if (typeof stdin.setRawMode === "function") {
|
|
83
|
+
stdin.setRawMode(true);
|
|
84
|
+
}
|
|
85
|
+
stdin.resume();
|
|
86
|
+
stdin.ref();
|
|
87
|
+
|
|
88
|
+
// Drop buffered Enter from the hub key that launched this command.
|
|
89
|
+
if (typeof stdin.read === "function") {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const pending = stdin.read();
|
|
92
|
+
if (pending === null) break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
stdin.off("data", onData);
|
|
98
|
+
if (typeof stdin.setRawMode === "function") {
|
|
99
|
+
stdin.setRawMode(false);
|
|
100
|
+
}
|
|
101
|
+
stdin.pause();
|
|
102
|
+
stdin.unref();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const onData = (chunk: string | Buffer) => {
|
|
106
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
107
|
+
if (s.includes("\r") || s.includes("\n") || s.includes("\x03")) {
|
|
108
|
+
cleanup();
|
|
109
|
+
resolve();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
stdin.on("data", onData);
|
|
113
|
+
});
|
|
114
|
+
}
|