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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive hub entry — prefers Ink TUI, falls back to Clack menu.
|
|
3
|
+
* @module interactive
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveConfig } from "./config.js";
|
|
7
|
+
import { dispatch } from "./dispatch.js";
|
|
8
|
+
import { classifyBranch, formatMergeTarget, getBaseBranchName } from "./flow.js";
|
|
9
|
+
import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "./git.js";
|
|
10
|
+
import { hint } from "./out.js";
|
|
11
|
+
import { confirmPrompt, inputPrompt, selectPrompt } from "./prompts.js";
|
|
12
|
+
import { type RecommendAction, recommend } from "./recommend.js";
|
|
13
|
+
import { readActiveRun } from "./run-state.js";
|
|
14
|
+
import type { BranchType } from "./types.js";
|
|
15
|
+
import { ansi, paint, rule } from "./ui.js";
|
|
16
|
+
import { printViz, type VizSnapshot } from "./viz.js";
|
|
17
|
+
|
|
18
|
+
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
19
|
+
|
|
20
|
+
export { dispatch } from "./dispatch.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs the interactive hub until the user quits.
|
|
24
|
+
* Uses the Ink fullscreen TUI when stdin/stdout are TTYs; otherwise Clack menu.
|
|
25
|
+
*/
|
|
26
|
+
export async function runHub(cwd: string): Promise<void> {
|
|
27
|
+
const { runTuiHub } = await import("./tui/hub.js");
|
|
28
|
+
const usedTui = await runTuiHub(cwd);
|
|
29
|
+
if (!usedTui) {
|
|
30
|
+
await runLegacyHub(cwd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Legacy Clack select hub (when Ink/TUI cannot run).
|
|
36
|
+
*/
|
|
37
|
+
async function runLegacyHub(cwd: string): Promise<void> {
|
|
38
|
+
for (;;) {
|
|
39
|
+
let snap: VizSnapshot | null = null;
|
|
40
|
+
try {
|
|
41
|
+
snap = await printViz(cwd);
|
|
42
|
+
} catch {
|
|
43
|
+
await printContextFallback(cwd);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rec = snap ? recommend(snap) : null;
|
|
47
|
+
const options = buildLegacyOptions(rec?.action ?? null, snap);
|
|
48
|
+
const action = await selectPrompt({
|
|
49
|
+
message: rec ? `Recommended · ${rec.label}` : "What do you want to do?",
|
|
50
|
+
options,
|
|
51
|
+
initialValue: options[0]?.value,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (action === "quit") {
|
|
55
|
+
console.log(paint(ansi.dim, "Bye."));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await runHubAction(cwd, action);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error("gflows:", err instanceof Error ? err.message : String(err));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log("");
|
|
66
|
+
console.log(rule(56));
|
|
67
|
+
const again = await confirmPrompt({ message: "Back to menu?", initialValue: true });
|
|
68
|
+
if (!again) break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildLegacyOptions(
|
|
73
|
+
recommended: RecommendAction | null,
|
|
74
|
+
snap: VizSnapshot | null,
|
|
75
|
+
): Array<{ value: string; label: string; hint?: string }> {
|
|
76
|
+
const items: Array<{ value: string; label: string; hint?: string }> = [];
|
|
77
|
+
if (snap?.needsInit) {
|
|
78
|
+
items.push({ value: "init", label: "Initialize repo", hint: "gflows init" });
|
|
79
|
+
}
|
|
80
|
+
items.push(
|
|
81
|
+
{ value: "start", label: "Start new work" },
|
|
82
|
+
{ value: "sync", label: "Sync this branch with base" },
|
|
83
|
+
{ value: "pr", label: "Open a pull request" },
|
|
84
|
+
{ value: "finish", label: "Finish / merge this branch" },
|
|
85
|
+
{ value: "switch", label: "Switch branch" },
|
|
86
|
+
{ value: "list", label: "List branches" },
|
|
87
|
+
{ value: "viz", label: "Refresh map" },
|
|
88
|
+
{ value: "doctor", label: "Doctor (check setup)" },
|
|
89
|
+
{ value: "config", label: "Config" },
|
|
90
|
+
{ value: "bump", label: "Bump version" },
|
|
91
|
+
{ value: "continue", label: "Continue suspended operation" },
|
|
92
|
+
{ value: "help", label: "Help" },
|
|
93
|
+
{ value: "quit", label: "Quit" },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const prefer = recommended && recommended !== "commit" ? recommended : null;
|
|
97
|
+
if (prefer) {
|
|
98
|
+
const idx = items.findIndex((a) => a.value === prefer);
|
|
99
|
+
if (idx >= 0) {
|
|
100
|
+
const [item] = items.splice(idx, 1);
|
|
101
|
+
if (item) {
|
|
102
|
+
items.unshift({ ...item, label: `★ ${item.label}` });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return items;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runHubAction(cwd: string, action: string): Promise<void> {
|
|
110
|
+
if (action === "viz") {
|
|
111
|
+
await printViz(cwd);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (action === "start") {
|
|
115
|
+
await promptStart(cwd);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (action === "finish") {
|
|
119
|
+
const push = await confirmPrompt({ message: "Push after finish?", initialValue: false });
|
|
120
|
+
await dispatch(cwd, ["finish", "-y", push ? "-p" : "-P"]);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (action === "sync") {
|
|
124
|
+
const rebase = await confirmPrompt({
|
|
125
|
+
message: "Rebase onto base (instead of merge)?",
|
|
126
|
+
initialValue: false,
|
|
127
|
+
});
|
|
128
|
+
await dispatch(cwd, ["sync", ...(rebase ? ["--rebase"] : []), "--force"]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await dispatch(cwd, [action]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fallback text context when viz cannot run (e.g. not a repo). */
|
|
135
|
+
async function printContextFallback(cwd: string): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
const root = await resolveRepoRoot(cwd);
|
|
138
|
+
const config = resolveConfig(root, {}, {});
|
|
139
|
+
const current = await getCurrentBranch(root, {});
|
|
140
|
+
const active = readActiveRun(root);
|
|
141
|
+
console.log("");
|
|
142
|
+
console.log(paint(ansi.cyan + ansi.bold, "╭─ gflows ──────────────────────────────╮"));
|
|
143
|
+
if (active) {
|
|
144
|
+
console.log(`│ Suspended: ${active.command} — use Continue, or gflows abort`);
|
|
145
|
+
}
|
|
146
|
+
if (!current) {
|
|
147
|
+
console.log("│ HEAD: detached");
|
|
148
|
+
console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const kind = classifyBranch(current, config);
|
|
152
|
+
console.log(`│ Branch: ${current}`);
|
|
153
|
+
if (kind === "main" || kind === "dev") {
|
|
154
|
+
console.log(`│ Type: long-lived (${kind})`);
|
|
155
|
+
console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
|
|
156
|
+
hint("Start new work from the menu, or switch to a workflow branch.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (kind === null) {
|
|
160
|
+
console.log("│ Type: unknown");
|
|
161
|
+
console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const base = getBaseBranchName(kind, false, config);
|
|
165
|
+
const { ahead, behind } = await getAheadBehind(root, base, current, {});
|
|
166
|
+
const meta = formatMergeTarget(
|
|
167
|
+
kind === "release" || kind === "hotfix" ? "main-then-dev" : "dev",
|
|
168
|
+
config,
|
|
169
|
+
);
|
|
170
|
+
console.log(`│ Type: ${kind} · base: ${base} · +${ahead}/-${behind}`);
|
|
171
|
+
console.log(`│ Merge: ${meta}`);
|
|
172
|
+
console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
|
|
173
|
+
} catch {
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(paint(ansi.yellow, "╭─ gflows ──────────────────────────────╮"));
|
|
176
|
+
console.log("│ Not a git repo here");
|
|
177
|
+
console.log(paint(ansi.dim, "│ → cd into a repo, or git init + gflows init"));
|
|
178
|
+
console.log(paint(ansi.dim, "╰────────────────────────────────────────╯"));
|
|
179
|
+
console.log("");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function promptStart(cwd: string): Promise<void> {
|
|
184
|
+
const { type, name } = await promptStartArgs();
|
|
185
|
+
const push = await confirmPrompt({ message: "Push after create?", initialValue: false });
|
|
186
|
+
const argv = ["start", type, name, push ? "-p" : "-P"];
|
|
187
|
+
if (type === "bugfix") {
|
|
188
|
+
const fromMain = await confirmPrompt({
|
|
189
|
+
message: "Base from main (production fix)?",
|
|
190
|
+
initialValue: false,
|
|
191
|
+
});
|
|
192
|
+
if (fromMain) argv.push("-o", "main");
|
|
193
|
+
}
|
|
194
|
+
await dispatch(cwd, argv);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Prompts for start type/name when missing (TTY).
|
|
199
|
+
*/
|
|
200
|
+
export async function promptStartArgs(): Promise<{ type: BranchType; name: string }> {
|
|
201
|
+
const type = await selectPrompt({
|
|
202
|
+
message: "Branch type",
|
|
203
|
+
options: BRANCH_TYPES.map((t) => ({ value: t, label: t })),
|
|
204
|
+
});
|
|
205
|
+
const name = await inputPrompt({
|
|
206
|
+
message: type === "release" || type === "hotfix" ? "Version (vX.Y.Z)" : "Branch name",
|
|
207
|
+
});
|
|
208
|
+
return { type, name };
|
|
209
|
+
}
|
package/src/out.ts
CHANGED
|
@@ -30,14 +30,21 @@ export function success(message: string): void {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
|
|
33
|
+
* Whether stderr is a TTY and can safely use ANSI color codes for hints.
|
|
34
|
+
*/
|
|
35
|
+
function isStderrColorCapable(): boolean {
|
|
36
|
+
return typeof process.stderr.isTTY === "boolean" && process.stderr.isTTY;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prints a hint line to stderr (dim when TTY). Keeps stdout clean for scripting (`list`, pipes).
|
|
41
|
+
* Omit when --quiet to keep output minimal.
|
|
35
42
|
*
|
|
36
43
|
* @param message - One-line hint (e.g. "Run gflows start feature <name> to create a branch").
|
|
37
44
|
*/
|
|
38
45
|
export function hint(message: string): void {
|
|
39
|
-
const line =
|
|
40
|
-
console.
|
|
46
|
+
const line = isStderrColorCapable() ? `${DIM}Hint: ${message}${RESET}` : `Hint: ${message}`;
|
|
47
|
+
console.error(line);
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
const BANNER_INNER_WIDTH = 42;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for optional package.json script aliases (e.g. "g": "gflows").
|
|
3
|
+
* @module package-scripts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_JSON = "package.json";
|
|
10
|
+
const SCRIPT_VALUE = "gflows";
|
|
11
|
+
|
|
12
|
+
/** Result of attempting to add a script alias. */
|
|
13
|
+
export type ScriptAliasResult =
|
|
14
|
+
| { status: "added"; name: string }
|
|
15
|
+
| { status: "unchanged"; name: string }
|
|
16
|
+
| { status: "conflict"; name: string; existing: string }
|
|
17
|
+
| { status: "no-package" }
|
|
18
|
+
| { status: "invalid"; name: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates a package.json script name (npm/bun safe subset).
|
|
22
|
+
*/
|
|
23
|
+
export function isValidScriptAliasName(name: string): boolean {
|
|
24
|
+
return /^[a-z][a-z0-9:_-]*$/i.test(name) && name.length <= 64;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ensures `scripts[name] = "gflows"` in the nearest package.json under repoRoot.
|
|
29
|
+
* Does not overwrite a different existing script value.
|
|
30
|
+
*/
|
|
31
|
+
export function ensureGflowsScriptAlias(
|
|
32
|
+
repoRoot: string,
|
|
33
|
+
name: string,
|
|
34
|
+
options?: { dryRun?: boolean },
|
|
35
|
+
): ScriptAliasResult {
|
|
36
|
+
if (!isValidScriptAliasName(name)) {
|
|
37
|
+
return { status: "invalid", name };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pkgPath = join(repoRoot, PACKAGE_JSON);
|
|
41
|
+
if (!existsSync(pkgPath)) {
|
|
42
|
+
return { status: "no-package" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let pkg: Record<string, unknown>;
|
|
46
|
+
try {
|
|
47
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
return { status: "no-package" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const scripts =
|
|
53
|
+
pkg.scripts && typeof pkg.scripts === "object" && !Array.isArray(pkg.scripts)
|
|
54
|
+
? { ...(pkg.scripts as Record<string, unknown>) }
|
|
55
|
+
: {};
|
|
56
|
+
|
|
57
|
+
const existing = scripts[name];
|
|
58
|
+
if (typeof existing === "string") {
|
|
59
|
+
if (existing === SCRIPT_VALUE || existing.trim() === SCRIPT_VALUE) {
|
|
60
|
+
return { status: "unchanged", name };
|
|
61
|
+
}
|
|
62
|
+
return { status: "conflict", name, existing };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options?.dryRun) {
|
|
66
|
+
return { status: "added", name };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
scripts[name] = SCRIPT_VALUE;
|
|
70
|
+
pkg.scripts = scripts;
|
|
71
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
|
|
72
|
+
return { status: "added", name };
|
|
73
|
+
}
|