gflows 0.1.18 → 1.0.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.
- package/AGENTS.md +78 -0
- package/CHANGELOG.md +40 -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 +238 -0
- package/src/commands/pr.ts +120 -0
- package/src/commands/schema.ts +98 -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 +343 -0
- package/src/tui/HubShell.tsx +186 -0
- package/src/tui/flows.tsx +140 -0
- package/src/tui/hub.ts +89 -0
- package/src/tui/prompts.tsx +207 -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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-step Ink wizards for hub actions (start / finish / sync).
|
|
3
|
+
* @module tui/flows
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type React from "react";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import type { BranchType } from "../types.js";
|
|
9
|
+
import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
|
|
10
|
+
|
|
11
|
+
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collects argv for `gflows start …` entirely inside Ink.
|
|
15
|
+
*/
|
|
16
|
+
export function StartFlow({
|
|
17
|
+
onDone,
|
|
18
|
+
onCancel,
|
|
19
|
+
}: {
|
|
20
|
+
onDone: (argv: string[]) => void;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
}): React.ReactElement {
|
|
23
|
+
const [step, setStep] = useState<"type" | "name" | "push" | "fromMain">("type");
|
|
24
|
+
const [type, setType] = useState<BranchType>("feature");
|
|
25
|
+
const [name, setName] = useState("");
|
|
26
|
+
const [push, setPush] = useState(false);
|
|
27
|
+
|
|
28
|
+
if (step === "type") {
|
|
29
|
+
return (
|
|
30
|
+
<WizardFrame title="Start new work">
|
|
31
|
+
<InkSelect
|
|
32
|
+
message="Branch type"
|
|
33
|
+
options={BRANCH_TYPES.map((t) => ({ value: t, label: t }))}
|
|
34
|
+
onCancel={onCancel}
|
|
35
|
+
onSubmit={(t) => {
|
|
36
|
+
setType(t);
|
|
37
|
+
setStep("name");
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
</WizardFrame>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (step === "name") {
|
|
45
|
+
return (
|
|
46
|
+
<WizardFrame title={`Start · ${type}`}>
|
|
47
|
+
<InkText
|
|
48
|
+
message={type === "release" || type === "hotfix" ? "Version (vX.Y.Z)" : "Branch name"}
|
|
49
|
+
placeholder={type === "release" || type === "hotfix" ? "v1.0.0" : "my-thing"}
|
|
50
|
+
onCancel={onCancel}
|
|
51
|
+
onSubmit={(n) => {
|
|
52
|
+
setName(n);
|
|
53
|
+
setStep("push");
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</WizardFrame>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (step === "push") {
|
|
61
|
+
return (
|
|
62
|
+
<WizardFrame title={`Start · ${type}/${name}`}>
|
|
63
|
+
<InkConfirm
|
|
64
|
+
message="Push after create?"
|
|
65
|
+
initialValue={false}
|
|
66
|
+
onCancel={onCancel}
|
|
67
|
+
onSubmit={(p) => {
|
|
68
|
+
setPush(p);
|
|
69
|
+
if (type === "bugfix") setStep("fromMain");
|
|
70
|
+
else onDone(["start", type, name, p ? "-p" : "-P"]);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
</WizardFrame>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// fromMain
|
|
78
|
+
return (
|
|
79
|
+
<WizardFrame title={`Start · bugfix/${name}`}>
|
|
80
|
+
<InkConfirm
|
|
81
|
+
message="Base from main (production fix)?"
|
|
82
|
+
initialValue={false}
|
|
83
|
+
onCancel={onCancel}
|
|
84
|
+
onSubmit={(fromMain) => {
|
|
85
|
+
const argv = ["start", type, name, push ? "-p" : "-P"];
|
|
86
|
+
if (fromMain) argv.push("-o", "main");
|
|
87
|
+
onDone(argv);
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
</WizardFrame>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Collects argv for `gflows finish …` entirely inside Ink.
|
|
96
|
+
*/
|
|
97
|
+
export function FinishFlow({
|
|
98
|
+
onDone,
|
|
99
|
+
onCancel,
|
|
100
|
+
}: {
|
|
101
|
+
onDone: (argv: string[]) => void;
|
|
102
|
+
onCancel: () => void;
|
|
103
|
+
}): React.ReactElement {
|
|
104
|
+
return (
|
|
105
|
+
<WizardFrame title="Finish / merge branch">
|
|
106
|
+
<InkConfirm
|
|
107
|
+
message="Push after finish?"
|
|
108
|
+
initialValue={false}
|
|
109
|
+
onCancel={onCancel}
|
|
110
|
+
onSubmit={(push) => {
|
|
111
|
+
onDone(["finish", "-y", push ? "-p" : "-P"]);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</WizardFrame>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Collects argv for `gflows sync …` entirely inside Ink.
|
|
120
|
+
*/
|
|
121
|
+
export function SyncFlow({
|
|
122
|
+
onDone,
|
|
123
|
+
onCancel,
|
|
124
|
+
}: {
|
|
125
|
+
onDone: (argv: string[]) => void;
|
|
126
|
+
onCancel: () => void;
|
|
127
|
+
}): React.ReactElement {
|
|
128
|
+
return (
|
|
129
|
+
<WizardFrame title="Sync with base">
|
|
130
|
+
<InkConfirm
|
|
131
|
+
message="Rebase onto base (instead of merge)?"
|
|
132
|
+
initialValue={false}
|
|
133
|
+
onCancel={onCancel}
|
|
134
|
+
onSubmit={(rebase) => {
|
|
135
|
+
onDone(["sync", ...(rebase ? ["--rebase"] : []), "--force"]);
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
</WizardFrame>
|
|
139
|
+
);
|
|
140
|
+
}
|
package/src/tui/hub.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
void instance.waitUntilExit().then(() => {
|
|
62
|
+
done({ kind: "quit" });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Raw stdin “press enter” (no Clack / no Ink).
|
|
69
|
+
*/
|
|
70
|
+
function waitEnterRaw(label: string): Promise<void> {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
process.stdout.write(`${label}\n`);
|
|
73
|
+
if (typeof process.stdin.setRawMode === "function") {
|
|
74
|
+
process.stdin.setRawMode(true);
|
|
75
|
+
}
|
|
76
|
+
process.stdin.resume();
|
|
77
|
+
const onData = (chunk: string | Buffer) => {
|
|
78
|
+
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
79
|
+
if (s === "\r" || s === "\n" || s === "\x03") {
|
|
80
|
+
process.stdin.off("data", onData);
|
|
81
|
+
if (typeof process.stdin.setRawMode === "function") {
|
|
82
|
+
process.stdin.setRawMode(false);
|
|
83
|
+
}
|
|
84
|
+
resolve();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
process.stdin.on("data", onData);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink-native prompts used inside the hub (no Clack drop-out).
|
|
3
|
+
* @module tui/prompts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useInput } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
|
|
10
|
+
const ACCENT = "#E88C4A";
|
|
11
|
+
const MUTED = "#8A8A8A";
|
|
12
|
+
const FG = "#E6E6E6";
|
|
13
|
+
|
|
14
|
+
/** Option for {@link InkSelect}. */
|
|
15
|
+
export interface InkSelectOption<T extends string> {
|
|
16
|
+
value: T;
|
|
17
|
+
label: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Arrow-key select list.
|
|
23
|
+
*/
|
|
24
|
+
export function InkSelect<T extends string>({
|
|
25
|
+
message,
|
|
26
|
+
options,
|
|
27
|
+
initialIndex = 0,
|
|
28
|
+
onSubmit,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: {
|
|
31
|
+
message: string;
|
|
32
|
+
options: InkSelectOption<T>[];
|
|
33
|
+
initialIndex?: number;
|
|
34
|
+
onSubmit: (value: T) => void;
|
|
35
|
+
onCancel?: () => void;
|
|
36
|
+
}): React.ReactElement {
|
|
37
|
+
const [selected, setSelected] = useState(
|
|
38
|
+
Math.min(Math.max(0, initialIndex), Math.max(0, options.length - 1)),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
useInput((ch, key) => {
|
|
42
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
43
|
+
onCancel?.();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.upArrow) {
|
|
47
|
+
setSelected((i) => Math.max(0, i - 1));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (key.downArrow) {
|
|
51
|
+
setSelected((i) => Math.min(options.length - 1, i + 1));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (key.return) {
|
|
55
|
+
const opt = options[selected];
|
|
56
|
+
if (opt) onSubmit(opt.value);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box flexDirection="column">
|
|
62
|
+
<Text bold color={FG}>
|
|
63
|
+
{message}
|
|
64
|
+
</Text>
|
|
65
|
+
{options.map((opt, i) => {
|
|
66
|
+
const active = i === selected;
|
|
67
|
+
return (
|
|
68
|
+
<Text key={opt.value} color={active ? FG : MUTED} bold={active}>
|
|
69
|
+
{active ? <Text color={ACCENT}>❯ </Text> : " "}
|
|
70
|
+
{opt.label}
|
|
71
|
+
{opt.hint ? <Text color={MUTED}>{` ${opt.hint}`}</Text> : null}
|
|
72
|
+
</Text>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
<Text color={MUTED}>↑↓ · enter · esc cancel</Text>
|
|
76
|
+
</Box>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Yes/no confirm (←/→ or y/n).
|
|
82
|
+
*/
|
|
83
|
+
export function InkConfirm({
|
|
84
|
+
message,
|
|
85
|
+
initialValue = true,
|
|
86
|
+
onSubmit,
|
|
87
|
+
onCancel,
|
|
88
|
+
}: {
|
|
89
|
+
message: string;
|
|
90
|
+
initialValue?: boolean;
|
|
91
|
+
onSubmit: (value: boolean) => void;
|
|
92
|
+
onCancel?: () => void;
|
|
93
|
+
}): React.ReactElement {
|
|
94
|
+
const [value, setValue] = useState(initialValue);
|
|
95
|
+
|
|
96
|
+
useInput((ch, key) => {
|
|
97
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
98
|
+
onCancel?.();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.leftArrow || key.rightArrow || ch === " ") {
|
|
102
|
+
setValue((v) => !v);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (ch === "y" || ch === "Y") {
|
|
106
|
+
onSubmit(true);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "n" || ch === "N") {
|
|
110
|
+
onSubmit(false);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.return) onSubmit(value);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Box flexDirection="column">
|
|
118
|
+
<Text bold color={FG}>
|
|
119
|
+
{message}
|
|
120
|
+
</Text>
|
|
121
|
+
<Text>
|
|
122
|
+
<Text color={value ? ACCENT : MUTED} bold={value}>
|
|
123
|
+
{value ? "●" : "○"} Yes
|
|
124
|
+
</Text>
|
|
125
|
+
{" "}
|
|
126
|
+
<Text color={!value ? ACCENT : MUTED} bold={!value}>
|
|
127
|
+
{!value ? "●" : "○"} No
|
|
128
|
+
</Text>
|
|
129
|
+
</Text>
|
|
130
|
+
<Text color={MUTED}>←→ / y n · enter · esc cancel</Text>
|
|
131
|
+
</Box>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Single-line text input.
|
|
137
|
+
*/
|
|
138
|
+
export function InkText({
|
|
139
|
+
message,
|
|
140
|
+
placeholder,
|
|
141
|
+
initialValue = "",
|
|
142
|
+
onSubmit,
|
|
143
|
+
onCancel,
|
|
144
|
+
}: {
|
|
145
|
+
message: string;
|
|
146
|
+
placeholder?: string;
|
|
147
|
+
initialValue?: string;
|
|
148
|
+
onSubmit: (value: string) => void;
|
|
149
|
+
onCancel?: () => void;
|
|
150
|
+
}): React.ReactElement {
|
|
151
|
+
const [value, setValue] = useState(initialValue);
|
|
152
|
+
|
|
153
|
+
useInput((ch, key) => {
|
|
154
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
155
|
+
onCancel?.();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key.return) {
|
|
159
|
+
const out = value.trim() || (placeholder ?? "");
|
|
160
|
+
if (out) onSubmit(out);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (key.backspace || key.delete) {
|
|
164
|
+
setValue((s) => [...s].slice(0, -1).join(""));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (ch && !key.ctrl && !key.meta && ch >= " ") {
|
|
168
|
+
setValue((s) => s + ch);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Box flexDirection="column">
|
|
174
|
+
<Text bold color={FG}>
|
|
175
|
+
{message}
|
|
176
|
+
</Text>
|
|
177
|
+
<Text>
|
|
178
|
+
<Text color={ACCENT}>❯ </Text>
|
|
179
|
+
{value || <Text color={MUTED}>{placeholder ?? ""}</Text>}
|
|
180
|
+
<Text color={ACCENT}>█</Text>
|
|
181
|
+
</Text>
|
|
182
|
+
<Text color={MUTED}>type · enter · esc cancel</Text>
|
|
183
|
+
</Box>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Framed wizard chrome around a prompt step.
|
|
189
|
+
*/
|
|
190
|
+
export function WizardFrame({
|
|
191
|
+
title,
|
|
192
|
+
children,
|
|
193
|
+
}: {
|
|
194
|
+
title: string;
|
|
195
|
+
children: React.ReactNode;
|
|
196
|
+
}): React.ReactElement {
|
|
197
|
+
return (
|
|
198
|
+
<Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} paddingY={0}>
|
|
199
|
+
<Text bold color={ACCENT}>
|
|
200
|
+
{title}
|
|
201
|
+
</Text>
|
|
202
|
+
<Box marginTop={1} flexDirection="column">
|
|
203
|
+
{children}
|
|
204
|
+
</Box>
|
|
205
|
+
</Box>
|
|
206
|
+
);
|
|
207
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -34,7 +34,42 @@ export type Command =
|
|
|
34
34
|
| "completion"
|
|
35
35
|
| "status"
|
|
36
36
|
| "help"
|
|
37
|
-
| "version"
|
|
37
|
+
| "version"
|
|
38
|
+
| "sync"
|
|
39
|
+
| "pr"
|
|
40
|
+
| "doctor"
|
|
41
|
+
| "config"
|
|
42
|
+
| "schema"
|
|
43
|
+
| "continue"
|
|
44
|
+
| "undo"
|
|
45
|
+
| "abort"
|
|
46
|
+
| "mcp"
|
|
47
|
+
| "viz";
|
|
48
|
+
|
|
49
|
+
/** All CLI commands in display order. */
|
|
50
|
+
export const ALL_COMMANDS: Command[] = [
|
|
51
|
+
"init",
|
|
52
|
+
"start",
|
|
53
|
+
"finish",
|
|
54
|
+
"switch",
|
|
55
|
+
"delete",
|
|
56
|
+
"list",
|
|
57
|
+
"bump",
|
|
58
|
+
"sync",
|
|
59
|
+
"pr",
|
|
60
|
+
"viz",
|
|
61
|
+
"doctor",
|
|
62
|
+
"config",
|
|
63
|
+
"schema",
|
|
64
|
+
"continue",
|
|
65
|
+
"undo",
|
|
66
|
+
"abort",
|
|
67
|
+
"completion",
|
|
68
|
+
"status",
|
|
69
|
+
"help",
|
|
70
|
+
"version",
|
|
71
|
+
"mcp",
|
|
72
|
+
];
|
|
38
73
|
|
|
39
74
|
/** Branch prefix overrides per type (e.g. "feature" -> "feature/"). */
|
|
40
75
|
export interface BranchPrefixes {
|
|
@@ -75,6 +110,9 @@ export type BumpDirection = "up" | "down";
|
|
|
75
110
|
/** Bump type (semver segment). */
|
|
76
111
|
export type BumpType = "patch" | "minor" | "major";
|
|
77
112
|
|
|
113
|
+
/** Config subcommand for `gflows config`. */
|
|
114
|
+
export type ConfigAction = "get" | "set";
|
|
115
|
+
|
|
78
116
|
/** Parsed CLI arguments after resolving command, type, name, and flags. */
|
|
79
117
|
export interface ParsedArgs {
|
|
80
118
|
command: Command;
|
|
@@ -92,6 +130,12 @@ export interface ParsedArgs {
|
|
|
92
130
|
bumpDirection?: BumpDirection;
|
|
93
131
|
/** Bump type (patch | minor | major). */
|
|
94
132
|
bumpType?: BumpType;
|
|
133
|
+
/** Config get/set action. */
|
|
134
|
+
configAction?: ConfigAction;
|
|
135
|
+
/** Config key (main, dev, remote, or prefixes.<type>). */
|
|
136
|
+
configKey?: string;
|
|
137
|
+
/** Config value for set. */
|
|
138
|
+
configValue?: string;
|
|
95
139
|
// Common flags
|
|
96
140
|
push: boolean;
|
|
97
141
|
noPush: boolean;
|
|
@@ -107,7 +151,9 @@ export interface ParsedArgs {
|
|
|
107
151
|
quiet: boolean;
|
|
108
152
|
force: boolean;
|
|
109
153
|
path: string | undefined;
|
|
110
|
-
|
|
154
|
+
/** Raw --from / -o value (base branch override). */
|
|
155
|
+
from: string | undefined;
|
|
156
|
+
/** True when --from points at main (configured or literal). */
|
|
111
157
|
fromMain: boolean;
|
|
112
158
|
// finish
|
|
113
159
|
noFf: boolean;
|
|
@@ -117,8 +163,21 @@ export interface ParsedArgs {
|
|
|
117
163
|
noTag: boolean;
|
|
118
164
|
tagMessage: string | undefined;
|
|
119
165
|
message: string | undefined;
|
|
120
|
-
|
|
166
|
+
squash: boolean;
|
|
167
|
+
preview: boolean;
|
|
168
|
+
bumpOnFinish: boolean;
|
|
169
|
+
// list / status
|
|
121
170
|
includeRemote: boolean;
|
|
171
|
+
json: boolean;
|
|
172
|
+
// sync
|
|
173
|
+
rebase: boolean;
|
|
122
174
|
// switch: explicit mode when uncommitted (overrides prompt)
|
|
123
175
|
switchMode?: "restore" | "clean" | "cancel" | "move" | "destroy";
|
|
176
|
+
/**
|
|
177
|
+
* Optional package.json script name to wire to `gflows` during init
|
|
178
|
+
* (e.g. `"g"` → `bun run g -- start feature x`). Undefined = ask (TTY) or skip.
|
|
179
|
+
*/
|
|
180
|
+
scriptAlias: string | undefined;
|
|
181
|
+
/** When true, never add a package.json script alias during init. */
|
|
182
|
+
noScriptAlias: boolean;
|
|
124
183
|
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modern terminal chrome shared by viz and the interactive hub.
|
|
3
|
+
* Lightweight ANSI panels (Claude Code / Codex–style), no TUI framework.
|
|
4
|
+
* @module ui
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const RESET = "\x1b[0m";
|
|
8
|
+
const BOLD = "\x1b[1m";
|
|
9
|
+
const DIM = "\x1b[2m";
|
|
10
|
+
const CYAN = "\x1b[36m";
|
|
11
|
+
const GREEN = "\x1b[32m";
|
|
12
|
+
const YELLOW = "\x1b[33m";
|
|
13
|
+
const MAGENTA = "\x1b[35m";
|
|
14
|
+
const BLUE = "\x1b[34m";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether stdout can use ANSI color.
|
|
18
|
+
*/
|
|
19
|
+
export function colorEnabled(): boolean {
|
|
20
|
+
if (process.env.NO_COLOR) return false;
|
|
21
|
+
return typeof process.stdout.isTTY === "boolean" && process.stdout.isTTY;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wraps text in an ANSI code when color is enabled.
|
|
26
|
+
*/
|
|
27
|
+
export function paint(code: string, text: string): string {
|
|
28
|
+
return colorEnabled() ? `${code}${text}${RESET}` : text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shared ANSI codes for callers that compose styles. */
|
|
32
|
+
export const ansi = {
|
|
33
|
+
reset: RESET,
|
|
34
|
+
bold: BOLD,
|
|
35
|
+
dim: DIM,
|
|
36
|
+
cyan: CYAN,
|
|
37
|
+
green: GREEN,
|
|
38
|
+
yellow: YELLOW,
|
|
39
|
+
magenta: MAGENTA,
|
|
40
|
+
blue: BLUE,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
const ESC = "\u001b";
|
|
44
|
+
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
|
|
45
|
+
const ANSI_PREFIX_RE = new RegExp(`^${ESC}\\[[0-9;]*m`);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Visible length of a string ignoring ANSI escape sequences.
|
|
49
|
+
*/
|
|
50
|
+
export function visibleLength(text: string): number {
|
|
51
|
+
return text.replace(ANSI_RE, "").length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pads or soft-truncates a line to a target visible width.
|
|
56
|
+
*/
|
|
57
|
+
export function padVisible(text: string, width: number): string {
|
|
58
|
+
const len = visibleLength(text);
|
|
59
|
+
if (len === width) return text;
|
|
60
|
+
if (len < width) return text + " ".repeat(width - len);
|
|
61
|
+
let out = "";
|
|
62
|
+
let vis = 0;
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < text.length && vis < width - 1) {
|
|
65
|
+
if (text[i] === ESC) {
|
|
66
|
+
const m = text.slice(i).match(ANSI_PREFIX_RE);
|
|
67
|
+
if (m) {
|
|
68
|
+
out += m[0];
|
|
69
|
+
i += m[0].length;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
out += text[i];
|
|
74
|
+
vis++;
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
return `${out}…`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Renders a rounded status panel (title bar + body rows).
|
|
82
|
+
*/
|
|
83
|
+
export function renderPanel(title: string, body: string[], width = 56): string[] {
|
|
84
|
+
const inner = Math.max(24, width - 2);
|
|
85
|
+
const labeled = `─ ${title} `;
|
|
86
|
+
const fill = Math.max(0, inner - visibleLength(labeled));
|
|
87
|
+
const top = paint(CYAN + BOLD, `╭${labeled}${"─".repeat(fill)}╮`);
|
|
88
|
+
const bottom = paint(DIM, `╰${"─".repeat(inner)}╯`);
|
|
89
|
+
const rows = body.map((line) => {
|
|
90
|
+
const content = padVisible(` ${line}`, inner);
|
|
91
|
+
return `${paint(DIM, "│")}${content}${paint(DIM, "│")}`;
|
|
92
|
+
});
|
|
93
|
+
return [top, ...rows, bottom];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Section header.
|
|
98
|
+
*/
|
|
99
|
+
export function section(title: string): string {
|
|
100
|
+
return paint(BOLD, title);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Dim horizontal rule.
|
|
105
|
+
*/
|
|
106
|
+
export function rule(width = 56): string {
|
|
107
|
+
return paint(DIM, "─".repeat(width));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Accent arrow for recommended next step.
|
|
112
|
+
*/
|
|
113
|
+
export function recommendLine(label: string): string {
|
|
114
|
+
return `${paint(GREEN + BOLD, "→")} ${paint(BOLD, label)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Chip / badge text.
|
|
119
|
+
*/
|
|
120
|
+
export function chip(text: string, tone: "ok" | "warn" | "info" | "muted" = "muted"): string {
|
|
121
|
+
const code = tone === "ok" ? GREEN : tone === "warn" ? YELLOW : tone === "info" ? CYAN : DIM;
|
|
122
|
+
return paint(code, `[${text}]`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Keyboard hint footer.
|
|
127
|
+
*/
|
|
128
|
+
export function keyHints(pairs: Array<[string, string]>): string {
|
|
129
|
+
return pairs
|
|
130
|
+
.map(([key, label]) => `${paint(BOLD, key)} ${paint(DIM, label)}`)
|
|
131
|
+
.join(paint(DIM, " · "));
|
|
132
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package version helper for CLI chrome and schema.
|
|
3
|
+
* @module version
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads the installed/package version from package.json.
|
|
14
|
+
*/
|
|
15
|
+
export function getVersion(): string {
|
|
16
|
+
try {
|
|
17
|
+
const pkgPath = join(__dirname, "..", "package.json");
|
|
18
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
19
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
20
|
+
return pkg.version ?? "0.0.0";
|
|
21
|
+
} catch {
|
|
22
|
+
return "0.0.0";
|
|
23
|
+
}
|
|
24
|
+
}
|