gflows 1.0.3 → 1.1.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 +4 -1
- package/CHANGELOG.md +13 -0
- package/README.md +3 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/commands/bump.ts +2 -33
- package/src/commands/completion.ts +3 -0
- package/src/commands/help.ts +2 -1
- package/src/commands/info.ts +33 -0
- package/src/commands/mcp.ts +12 -1
- package/src/commands/schema.ts +3 -0
- package/src/interactive.ts +1 -0
- package/src/packages.ts +78 -0
- package/src/repo-inspect.ts +465 -0
- package/src/tui/HubHome.tsx +2 -1
- package/src/tui/HubShell.tsx +8 -1
- package/src/tui/slash.ts +1 -0
- package/src/tui/views.tsx +42 -1
- package/src/types.ts +2 -0
package/AGENTS.md
CHANGED
|
@@ -9,7 +9,7 @@ gflows is a **local Git branching workflow CLI** (main + dev + typed short-lived
|
|
|
9
9
|
3. **Do not finish empty branches** — if there are no commits beyond the merge target, finish exits `2`. Commit first.
|
|
10
10
|
4. **Conflicts** — resolve files, then `gflows continue`. Or `gflows abort` / `gflows undo`.
|
|
11
11
|
5. **Discover the API** — run `gflows schema` (JSON) or read this file + README.
|
|
12
|
-
6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/list/start/sync/finish/schema tools.
|
|
12
|
+
6. **MCP** — `gflows mcp` (stdio JSON-RPC) exposes status/doctor/info/list/start/sync/finish/schema tools.
|
|
13
13
|
7. **Hub / viz** — bare `gflows` (TTY) opens an Ink fullscreen hub (`/` commands). Prompts use Clack. `gflows viz` prints the scrollback status panel.
|
|
14
14
|
|
|
15
15
|
## Exit codes
|
|
@@ -51,6 +51,9 @@ gflows doctor --json
|
|
|
51
51
|
gflows continue
|
|
52
52
|
gflows abort
|
|
53
53
|
gflows undo
|
|
54
|
+
|
|
55
|
+
# Repo shape / stacks
|
|
56
|
+
gflows info --json
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
## Branch merge targets
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2026-07-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `gflows info` — describe repo layout (monolith vs monorepo), package versions, and detected frontend / fullstack / backend stacks (`--json` supported)
|
|
15
|
+
- Hub `/info` read-only screen; MCP tool `gflows_info`
|
|
16
|
+
|
|
17
|
+
## [1.0.4] - 2026-07-25
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Hub switch flow typecheck when HEAD is detached (`current` may be null)
|
|
22
|
+
|
|
10
23
|
## [1.0.3] - 2026-07-25
|
|
11
24
|
|
|
12
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -231,6 +231,7 @@ Hub: `g` → select the action. Typed short form assumes `alias g=gflows`.
|
|
|
231
231
|
| `gflows abort` | — |
|
|
232
232
|
| `gflows undo` | — |
|
|
233
233
|
| `gflows doctor` | — |
|
|
234
|
+
| `gflows info` | — |
|
|
234
235
|
|
|
235
236
|
|
|
236
237
|
|
|
@@ -274,6 +275,7 @@ Bare `gflows` / `g` in a **TTY** opens the Ink hub (see [Way 1](#way-1--hub-reco
|
|
|
274
275
|
| `status` `-t` | Current branch flow info |
|
|
275
276
|
| `viz` | Scrollback visual map |
|
|
276
277
|
| `doctor` | Health checks |
|
|
278
|
+
| `info` | Layout, versions, frontend/backend stacks |
|
|
277
279
|
| `config` | `get` / `set` `.gflows.json` |
|
|
278
280
|
| `bump` `-U` | Version bump/rollback in package files |
|
|
279
281
|
| `continue` / `abort` / `undo` | Recovery |
|
|
@@ -395,6 +397,7 @@ Rules of thumb:
|
|
|
395
397
|
| `gflows finish feature -y -P` | `g -F -f -y -P` |
|
|
396
398
|
| `gflows status --json` | `g -t --json` |
|
|
397
399
|
| `gflows doctor --json` | — |
|
|
400
|
+
| `gflows info --json` | — |
|
|
398
401
|
|
|
399
402
|
**Exit codes:** `0` ok · `1` usage/validation · `2` git/system (conflict, dirty tree, empty finish, …)
|
|
400
403
|
|
package/llms.txt
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
- `gflows viz`: scrollback status panel — lifecycle, branches, recommended next step
|
|
15
15
|
- `gflows init` / `start` / `finish` / `sync` / `pr`
|
|
16
16
|
- `gflows continue` / `undo` / `abort` — recovery after conflicts
|
|
17
|
-
- `gflows doctor` / `config` / `status --json` / `mcp`
|
|
17
|
+
- `gflows doctor` / `info` / `config` / `status --json` / `mcp`
|
|
18
18
|
|
|
19
19
|
## Package
|
|
20
20
|
|
package/package.json
CHANGED
package/src/commands/bump.ts
CHANGED
|
@@ -5,47 +5,16 @@
|
|
|
5
5
|
* @module commands/bump
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync,
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
10
|
import { EXIT_OK, EXIT_USER } from "../constants.js";
|
|
11
11
|
import { InvalidVersionError } from "../errors.js";
|
|
12
12
|
import { hint, success } from "../out.js";
|
|
13
|
+
import { findPackageRoots, PACKAGE_JSON } from "../packages.js";
|
|
13
14
|
import type { BumpDirection, BumpType, ParsedArgs } from "../types.js";
|
|
14
15
|
|
|
15
|
-
const PACKAGE_JSON = "package.json";
|
|
16
16
|
const JSR_JSON = "jsr.json";
|
|
17
17
|
|
|
18
|
-
/** Directory names to skip when discovering package roots (monorepo). */
|
|
19
|
-
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Recursively finds all directories under `root` that contain a package.json.
|
|
23
|
-
* Skips node_modules and .git.
|
|
24
|
-
*/
|
|
25
|
-
function findPackageRoots(root: string): string[] {
|
|
26
|
-
const acc: string[] = [];
|
|
27
|
-
if (!existsSync(root) || !statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
28
|
-
return acc;
|
|
29
|
-
}
|
|
30
|
-
if (existsSync(join(root, PACKAGE_JSON))) {
|
|
31
|
-
acc.push(root);
|
|
32
|
-
}
|
|
33
|
-
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
|
34
|
-
try {
|
|
35
|
-
entries = readdirSync(root, { withFileTypes: true }) as Array<{
|
|
36
|
-
isDirectory(): boolean;
|
|
37
|
-
name: string;
|
|
38
|
-
}>;
|
|
39
|
-
} catch {
|
|
40
|
-
return acc;
|
|
41
|
-
}
|
|
42
|
-
for (const e of entries) {
|
|
43
|
-
if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
44
|
-
acc.push(...findPackageRoots(join(root, e.name)));
|
|
45
|
-
}
|
|
46
|
-
return acc;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
18
|
/** Semver triplet. */
|
|
50
19
|
interface Semver {
|
|
51
20
|
major: number;
|
|
@@ -20,6 +20,7 @@ const COMMANDS = [
|
|
|
20
20
|
"list",
|
|
21
21
|
"bump",
|
|
22
22
|
"doctor",
|
|
23
|
+
"info",
|
|
23
24
|
"config",
|
|
24
25
|
"schema",
|
|
25
26
|
"continue",
|
|
@@ -260,6 +261,8 @@ complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}"
|
|
|
260
261
|
-a "list" -d "List branches by type"
|
|
261
262
|
complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
|
|
262
263
|
-a "bump" -d "Bump or rollback version"
|
|
264
|
+
complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
|
|
265
|
+
-a "info" -d "Repo layout, versions, and stacks"
|
|
263
266
|
complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
|
|
264
267
|
-a "completion" -d "Print shell completion script"
|
|
265
268
|
complete -c gflows -f -n "not __fish_seen_subcommand_from ${COMMANDS.join(" ")}" \\
|
package/src/commands/help.ts
CHANGED
|
@@ -27,6 +27,7 @@ Commands:
|
|
|
27
27
|
status, -t Show current branch flow info
|
|
28
28
|
viz Visual branch map + flow (also shown in interactive hub)
|
|
29
29
|
doctor Repo health checks
|
|
30
|
+
info Repo layout, versions, and stacks
|
|
30
31
|
config get/set .gflows.json
|
|
31
32
|
bump, -U Bump or rollback package version
|
|
32
33
|
continue Resume after merge conflict
|
|
@@ -53,7 +54,7 @@ Common flags:
|
|
|
53
54
|
-v, --verbose Verbose output
|
|
54
55
|
-q, --quiet Minimal output
|
|
55
56
|
-C, --path <dir> Run as if in <dir>
|
|
56
|
-
--json Machine-readable output (status/list/doctor/config)
|
|
57
|
+
--json Machine-readable output (status/list/doctor/info/config)
|
|
57
58
|
|
|
58
59
|
Init: --script-alias <name> Add package.json script (e.g. g → "gflows")
|
|
59
60
|
--no-script-alias Never add a script alias
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Info: describe repo layout, package versions, and detected stacks.
|
|
3
|
+
* @module commands/info
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveRepoRoot } from "../git.js";
|
|
7
|
+
import { collectInfoReport, formatInfoReport } from "../repo-inspect.js";
|
|
8
|
+
import type { ParsedArgs } from "../types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Collects repo info without printing (CLI + hub).
|
|
12
|
+
*/
|
|
13
|
+
export async function collectInfo(cwd: string) {
|
|
14
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
15
|
+
return collectInfoReport(repoRoot);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs the info command (human or JSON).
|
|
20
|
+
*/
|
|
21
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
22
|
+
const report = await collectInfo(args.cwd);
|
|
23
|
+
|
|
24
|
+
if (args.json) {
|
|
25
|
+
console.log(JSON.stringify(report, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = formatInfoReport(report);
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
console.log(line);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -31,6 +31,14 @@ const TOOLS = [
|
|
|
31
31
|
properties: { path: { type: "string" } },
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
name: "gflows_info",
|
|
36
|
+
description: "Describe repo layout (monolith/monorepo), package versions, and stacks.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { path: { type: "string" } },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
34
42
|
{
|
|
35
43
|
name: "gflows_list",
|
|
36
44
|
description: "List workflow branches.",
|
|
@@ -123,6 +131,9 @@ async function runTool(
|
|
|
123
131
|
case "gflows_doctor":
|
|
124
132
|
argv.push("doctor", "--json");
|
|
125
133
|
break;
|
|
134
|
+
case "gflows_info":
|
|
135
|
+
argv.push("info", "--json");
|
|
136
|
+
break;
|
|
126
137
|
case "gflows_list":
|
|
127
138
|
argv.push("list", "-q");
|
|
128
139
|
if (typeof args.type === "string") argv.push(args.type);
|
|
@@ -174,7 +185,7 @@ async function runTool(
|
|
|
174
185
|
*/
|
|
175
186
|
export async function run(_args: ParsedArgs): Promise<void> {
|
|
176
187
|
console.error(
|
|
177
|
-
"gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, list, start, sync, finish, schema.",
|
|
188
|
+
"gflows mcp: listening on stdio (JSON-RPC). Tools: status, doctor, info, list, start, sync, finish, schema.",
|
|
178
189
|
);
|
|
179
190
|
|
|
180
191
|
const decoder = new TextDecoder();
|
package/src/commands/schema.ts
CHANGED
|
@@ -60,6 +60,8 @@ export async function run(_args: ParsedArgs): Promise<void> {
|
|
|
60
60
|
sync: ["--rebase", "--force"],
|
|
61
61
|
list: ["-r/--include-remote", "--json"],
|
|
62
62
|
status: ["--json"],
|
|
63
|
+
info: ["--json"],
|
|
64
|
+
doctor: ["--json"],
|
|
63
65
|
},
|
|
64
66
|
agentNotes: [
|
|
65
67
|
"Always pass explicit flags in non-TTY (never assume interactive hub).",
|
|
@@ -84,6 +86,7 @@ function commandMeta(command: string): { summary: string; interactiveOk: boolean
|
|
|
84
86
|
pr: { summary: "Open PR/MR via gh/glab", interactiveOk: true },
|
|
85
87
|
viz: { summary: "Visual branch map and flow diagram", interactiveOk: true },
|
|
86
88
|
doctor: { summary: "Repo health checks", interactiveOk: false },
|
|
89
|
+
info: { summary: "Repo layout, versions, and stacks", interactiveOk: false },
|
|
87
90
|
config: { summary: "Get/set .gflows.json", interactiveOk: true },
|
|
88
91
|
schema: { summary: "Machine-readable command catalog", interactiveOk: false },
|
|
89
92
|
continue: { summary: "Resume suspended operation", interactiveOk: false },
|
package/src/interactive.ts
CHANGED
|
@@ -86,6 +86,7 @@ function buildLegacyOptions(
|
|
|
86
86
|
{ value: "list", label: "List branches" },
|
|
87
87
|
{ value: "viz", label: "Refresh map" },
|
|
88
88
|
{ value: "doctor", label: "Doctor (check setup)" },
|
|
89
|
+
{ value: "info", label: "Info (layout & stacks)" },
|
|
89
90
|
{ value: "config", label: "Config" },
|
|
90
91
|
{ value: "bump", label: "Bump version" },
|
|
91
92
|
{ value: "continue", label: "Continue suspended operation" },
|
package/src/packages.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared package.json discovery for monorepo-aware commands (bump, info).
|
|
3
|
+
* @module packages
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Filename for Node/Bun package manifests. */
|
|
10
|
+
export const PACKAGE_JSON = "package.json";
|
|
11
|
+
|
|
12
|
+
/** Directory names to skip when discovering package roots. */
|
|
13
|
+
const SKIP_DIRS = new Set([
|
|
14
|
+
"node_modules",
|
|
15
|
+
".git",
|
|
16
|
+
".next",
|
|
17
|
+
".nuxt",
|
|
18
|
+
".turbo",
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
"coverage",
|
|
22
|
+
".cache",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively finds all directories under `root` that contain a package.json.
|
|
27
|
+
* Skips node_modules, .git, and common build/cache dirs.
|
|
28
|
+
*/
|
|
29
|
+
export function findPackageRoots(root: string): string[] {
|
|
30
|
+
const acc: string[] = [];
|
|
31
|
+
if (!existsSync(root) || !statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
32
|
+
return acc;
|
|
33
|
+
}
|
|
34
|
+
if (existsSync(join(root, PACKAGE_JSON))) {
|
|
35
|
+
acc.push(root);
|
|
36
|
+
}
|
|
37
|
+
let entries: Array<{ isDirectory(): boolean; name: string }>;
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(root, { withFileTypes: true }) as Array<{
|
|
40
|
+
isDirectory(): boolean;
|
|
41
|
+
name: string;
|
|
42
|
+
}>;
|
|
43
|
+
} catch {
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
for (const e of entries) {
|
|
47
|
+
if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
|
|
48
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
49
|
+
acc.push(...findPackageRoots(join(root, e.name)));
|
|
50
|
+
}
|
|
51
|
+
return acc;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal package.json fields used by inspectors. */
|
|
55
|
+
export interface PackageJsonFields {
|
|
56
|
+
name?: string;
|
|
57
|
+
version?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
private?: boolean;
|
|
60
|
+
workspaces?: string[] | { packages?: string[] };
|
|
61
|
+
dependencies?: Record<string, string>;
|
|
62
|
+
devDependencies?: Record<string, string>;
|
|
63
|
+
peerDependencies?: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads and parses package.json from a directory. Returns null if missing or invalid.
|
|
68
|
+
*/
|
|
69
|
+
export function readPackageJson(dir: string): PackageJsonFields | null {
|
|
70
|
+
const path = join(dir, PACKAGE_JSON);
|
|
71
|
+
if (!existsSync(path)) return null;
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(path, "utf-8");
|
|
74
|
+
return JSON.parse(raw) as PackageJsonFields;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo inspection for `gflows info`: layout, versions, and stack detection.
|
|
3
|
+
* @module repo-inspect
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { resolveConfig } from "./config.js";
|
|
9
|
+
import { getCurrentBranch, runGit } from "./git.js";
|
|
10
|
+
import { findPackageRoots, type PackageJsonFields, readPackageJson } from "./packages.js";
|
|
11
|
+
|
|
12
|
+
/** Layout classification. */
|
|
13
|
+
export type RepoLayout = "monolith" | "monorepo";
|
|
14
|
+
|
|
15
|
+
/** One detected stack entry. */
|
|
16
|
+
export interface StackHit {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
packages: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One package.json summary row. */
|
|
23
|
+
export interface PackageVersionRow {
|
|
24
|
+
path: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
version: string | null;
|
|
27
|
+
private: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structured report for CLI, hub, and tests. */
|
|
31
|
+
export interface InfoReport {
|
|
32
|
+
root: string;
|
|
33
|
+
layout: RepoLayout;
|
|
34
|
+
layoutReasons: string[];
|
|
35
|
+
name: string | null;
|
|
36
|
+
version: string | null;
|
|
37
|
+
description: string | null;
|
|
38
|
+
packages: PackageVersionRow[];
|
|
39
|
+
stacks: {
|
|
40
|
+
frontend: StackHit[];
|
|
41
|
+
fullstack: StackHit[];
|
|
42
|
+
backend: StackHit[];
|
|
43
|
+
};
|
|
44
|
+
packageManager: string | null;
|
|
45
|
+
remoteUrl: string | null;
|
|
46
|
+
branch: string | null;
|
|
47
|
+
gflows: { main: string; dev: string; remote: string };
|
|
48
|
+
runtimes: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type StackKind = "frontend" | "fullstack" | "backend";
|
|
52
|
+
|
|
53
|
+
interface StackRule {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
kind: StackKind;
|
|
57
|
+
keys: string[];
|
|
58
|
+
/** When set, all keys must be present (AND). Otherwise any key matches (OR). */
|
|
59
|
+
requireAll?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STACK_RULES: StackRule[] = [
|
|
63
|
+
// Frontend
|
|
64
|
+
{ id: "react", name: "React", kind: "frontend", keys: ["react"] },
|
|
65
|
+
{ id: "vue", name: "Vue", kind: "frontend", keys: ["vue"] },
|
|
66
|
+
{ id: "angular", name: "Angular", kind: "frontend", keys: ["@angular/core"] },
|
|
67
|
+
{ id: "svelte", name: "Svelte", kind: "frontend", keys: ["svelte"] },
|
|
68
|
+
{ id: "solid", name: "Solid", kind: "frontend", keys: ["solid-js"] },
|
|
69
|
+
{ id: "qwik", name: "Qwik", kind: "frontend", keys: ["@builder.io/qwik"] },
|
|
70
|
+
{ id: "preact", name: "Preact", kind: "frontend", keys: ["preact"] },
|
|
71
|
+
{ id: "alpine", name: "Alpine.js", kind: "frontend", keys: ["alpinejs"] },
|
|
72
|
+
{ id: "lit", name: "Lit", kind: "frontend", keys: ["lit", "lit-element"] },
|
|
73
|
+
{ id: "expo", name: "Expo", kind: "frontend", keys: ["expo"] },
|
|
74
|
+
{ id: "react-native", name: "React Native", kind: "frontend", keys: ["react-native"] },
|
|
75
|
+
// Fullstack
|
|
76
|
+
{ id: "next", name: "Next.js", kind: "fullstack", keys: ["next"] },
|
|
77
|
+
{ id: "nuxt", name: "Nuxt", kind: "fullstack", keys: ["nuxt", "nuxt3"] },
|
|
78
|
+
{ id: "sveltekit", name: "SvelteKit", kind: "fullstack", keys: ["@sveltejs/kit"] },
|
|
79
|
+
{ id: "astro", name: "Astro", kind: "fullstack", keys: ["astro"] },
|
|
80
|
+
{
|
|
81
|
+
id: "remix",
|
|
82
|
+
name: "Remix",
|
|
83
|
+
kind: "fullstack",
|
|
84
|
+
keys: ["remix", "@remix-run/node", "@remix-run/react", "@remix-run/serve"],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "react-router",
|
|
88
|
+
name: "React Router (framework)",
|
|
89
|
+
kind: "fullstack",
|
|
90
|
+
keys: ["@react-router/node", "@react-router/dev", "@react-router/serve"],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "tanstack-start",
|
|
94
|
+
name: "TanStack Start",
|
|
95
|
+
kind: "fullstack",
|
|
96
|
+
keys: ["@tanstack/react-start", "@tanstack/solid-start"],
|
|
97
|
+
},
|
|
98
|
+
{ id: "solidstart", name: "SolidStart", kind: "fullstack", keys: ["@solidjs/start"] },
|
|
99
|
+
{
|
|
100
|
+
id: "analog",
|
|
101
|
+
name: "Analog",
|
|
102
|
+
kind: "fullstack",
|
|
103
|
+
keys: ["@analogjs/platform", "@analogjs/vite-plugin-angular"],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "redwood",
|
|
107
|
+
name: "RedwoodJS",
|
|
108
|
+
kind: "fullstack",
|
|
109
|
+
keys: ["@redwoodjs/core", "@redwoodjs/api"],
|
|
110
|
+
},
|
|
111
|
+
{ id: "blitz", name: "Blitz", kind: "fullstack", keys: ["blitz"] },
|
|
112
|
+
{ id: "quasar", name: "Quasar", kind: "fullstack", keys: ["quasar"] },
|
|
113
|
+
{ id: "gatsby", name: "Gatsby", kind: "fullstack", keys: ["gatsby"] },
|
|
114
|
+
// Backend
|
|
115
|
+
{ id: "express", name: "Express", kind: "backend", keys: ["express"] },
|
|
116
|
+
{ id: "nestjs", name: "NestJS", kind: "backend", keys: ["@nestjs/core"] },
|
|
117
|
+
{ id: "fastify", name: "Fastify", kind: "backend", keys: ["fastify"] },
|
|
118
|
+
{ id: "hono", name: "Hono", kind: "backend", keys: ["hono"] },
|
|
119
|
+
{ id: "elysia", name: "Elysia", kind: "backend", keys: ["elysia"] },
|
|
120
|
+
{ id: "koa", name: "Koa", kind: "backend", keys: ["koa"] },
|
|
121
|
+
{ id: "oak", name: "Oak", kind: "backend", keys: ["@oak/oak", "oak"] },
|
|
122
|
+
{ id: "hapi", name: "Hapi", kind: "backend", keys: ["@hapi/hapi"] },
|
|
123
|
+
{ id: "restify", name: "Restify", kind: "backend", keys: ["restify"] },
|
|
124
|
+
{ id: "trpc", name: "tRPC", kind: "backend", keys: ["@trpc/server"] },
|
|
125
|
+
{ id: "nitro", name: "Nitro", kind: "backend", keys: ["nitropack", "nitro"] },
|
|
126
|
+
{ id: "encore", name: "Encore.ts", kind: "backend", keys: ["encore.dev"] },
|
|
127
|
+
{ id: "adonis", name: "AdonisJS", kind: "backend", keys: ["@adonisjs/core"] },
|
|
128
|
+
{ id: "feathers", name: "Feathers", kind: "backend", keys: ["@feathersjs/feathers"] },
|
|
129
|
+
{ id: "socketio", name: "Socket.IO", kind: "backend", keys: ["socket.io"] },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const WORKSPACE_MARKER_FILES = [
|
|
133
|
+
"pnpm-workspace.yaml",
|
|
134
|
+
"lerna.json",
|
|
135
|
+
"nx.json",
|
|
136
|
+
"turbo.json",
|
|
137
|
+
"rush.json",
|
|
138
|
+
"go.work",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Collects direct dependency names from a package.json.
|
|
143
|
+
*/
|
|
144
|
+
function directDepNames(pkg: PackageJsonFields): Set<string> {
|
|
145
|
+
const names = new Set<string>();
|
|
146
|
+
for (const block of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) {
|
|
147
|
+
if (!block) continue;
|
|
148
|
+
for (const key of Object.keys(block)) names.add(key);
|
|
149
|
+
}
|
|
150
|
+
return names;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Detects package manager from lockfiles at repo root.
|
|
155
|
+
*/
|
|
156
|
+
function detectPackageManager(root: string): string | null {
|
|
157
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
|
|
158
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
159
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
160
|
+
if (existsSync(join(root, "package-lock.json"))) return "npm";
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Detects monorepo vs monolith and returns reasons.
|
|
166
|
+
*/
|
|
167
|
+
function detectLayout(
|
|
168
|
+
root: string,
|
|
169
|
+
rootPkg: PackageJsonFields | null,
|
|
170
|
+
packageRoots: string[],
|
|
171
|
+
): { layout: RepoLayout; reasons: string[] } {
|
|
172
|
+
const reasons: string[] = [];
|
|
173
|
+
|
|
174
|
+
if (rootPkg?.workspaces !== undefined) {
|
|
175
|
+
reasons.push("package.json workspaces");
|
|
176
|
+
}
|
|
177
|
+
for (const file of WORKSPACE_MARKER_FILES) {
|
|
178
|
+
if (existsSync(join(root, file))) {
|
|
179
|
+
reasons.push(file);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (existsSync(join(root, "Cargo.toml"))) {
|
|
183
|
+
try {
|
|
184
|
+
const cargo = readFileSync(join(root, "Cargo.toml"), "utf-8");
|
|
185
|
+
if (/\[workspace\]/.test(cargo)) reasons.push("Cargo.toml [workspace]");
|
|
186
|
+
} catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (packageRoots.length > 1) {
|
|
191
|
+
reasons.push(`${packageRoots.length} package.json files`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (reasons.length > 0) {
|
|
195
|
+
return { layout: "monorepo", reasons };
|
|
196
|
+
}
|
|
197
|
+
return { layout: "monolith", reasons: ["single package root"] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Matches stack rules against direct deps of one package.
|
|
202
|
+
*/
|
|
203
|
+
function matchStacks(
|
|
204
|
+
depNames: Set<string>,
|
|
205
|
+
packageLabel: string,
|
|
206
|
+
acc: Map<string, StackHit>,
|
|
207
|
+
): void {
|
|
208
|
+
for (const rule of STACK_RULES) {
|
|
209
|
+
const hit = rule.requireAll
|
|
210
|
+
? rule.keys.every((k) => depNames.has(k))
|
|
211
|
+
: rule.keys.some((k) => depNames.has(k));
|
|
212
|
+
if (!hit) continue;
|
|
213
|
+
const existing = acc.get(rule.id);
|
|
214
|
+
if (existing) {
|
|
215
|
+
if (!existing.packages.includes(packageLabel)) {
|
|
216
|
+
existing.packages.push(packageLabel);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
acc.set(rule.id, {
|
|
220
|
+
id: rule.id,
|
|
221
|
+
name: rule.name,
|
|
222
|
+
packages: [packageLabel],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* When NestJS is present, drop Express/Fastify if they only appear as Nest platforms.
|
|
230
|
+
*/
|
|
231
|
+
function suppressNestPlatformNoise(hits: Map<string, StackHit>): void {
|
|
232
|
+
if (!hits.has("nestjs")) return;
|
|
233
|
+
for (const id of ["express", "fastify"] as const) {
|
|
234
|
+
const nest = hits.get("nestjs");
|
|
235
|
+
const platform = hits.get(id);
|
|
236
|
+
if (!nest || !platform) continue;
|
|
237
|
+
const onlyNestPkgs = platform.packages.every((p) => nest.packages.includes(p));
|
|
238
|
+
if (onlyNestPkgs) hits.delete(id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Detects non-JS runtimes / frameworks from root manifests.
|
|
244
|
+
*/
|
|
245
|
+
function detectNonJsRuntimes(root: string): string[] {
|
|
246
|
+
const out: string[] = [];
|
|
247
|
+
if (existsSync(join(root, "go.mod"))) out.push("Go");
|
|
248
|
+
if (existsSync(join(root, "Cargo.toml"))) out.push("Rust");
|
|
249
|
+
if (
|
|
250
|
+
existsSync(join(root, "pyproject.toml")) ||
|
|
251
|
+
existsSync(join(root, "requirements.txt")) ||
|
|
252
|
+
existsSync(join(root, "Pipfile"))
|
|
253
|
+
) {
|
|
254
|
+
out.push("Python");
|
|
255
|
+
for (const file of ["pyproject.toml", "requirements.txt", "Pipfile"] as const) {
|
|
256
|
+
const path = join(root, file);
|
|
257
|
+
if (!existsSync(path)) continue;
|
|
258
|
+
try {
|
|
259
|
+
const text = readFileSync(path, "utf-8").toLowerCase();
|
|
260
|
+
if (text.includes("django")) out.push("Django");
|
|
261
|
+
if (text.includes("flask")) out.push("Flask");
|
|
262
|
+
if (text.includes("fastapi")) out.push("FastAPI");
|
|
263
|
+
} catch {
|
|
264
|
+
/* ignore */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (existsSync(join(root, "Gemfile"))) {
|
|
269
|
+
out.push("Ruby");
|
|
270
|
+
try {
|
|
271
|
+
const gem = readFileSync(join(root, "Gemfile"), "utf-8").toLowerCase();
|
|
272
|
+
if (/\brails\b/.test(gem)) out.push("Rails");
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (existsSync(join(root, "composer.json"))) {
|
|
278
|
+
out.push("PHP");
|
|
279
|
+
try {
|
|
280
|
+
const composer = readFileSync(join(root, "composer.json"), "utf-8");
|
|
281
|
+
if (composer.includes("laravel/framework")) out.push("Laravel");
|
|
282
|
+
} catch {
|
|
283
|
+
/* ignore */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (hasFileWithExt(root, ".csproj") || hasFileWithExt(root, ".sln")) {
|
|
287
|
+
out.push(".NET");
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
existsSync(join(root, "pom.xml")) ||
|
|
291
|
+
existsSync(join(root, "build.gradle")) ||
|
|
292
|
+
existsSync(join(root, "build.gradle.kts"))
|
|
293
|
+
) {
|
|
294
|
+
out.push("JVM");
|
|
295
|
+
}
|
|
296
|
+
return [...new Set(out)];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Returns true if any file in `dir` (non-recursive) ends with `ext`.
|
|
301
|
+
*/
|
|
302
|
+
function hasFileWithExt(dir: string, ext: string): boolean {
|
|
303
|
+
try {
|
|
304
|
+
const entries = readdirSync(dir);
|
|
305
|
+
return entries.some((name) => name.endsWith(ext));
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Formats package path relative to root (`.` for root).
|
|
313
|
+
*/
|
|
314
|
+
function packageLabel(root: string, dir: string): string {
|
|
315
|
+
const rel = relative(root, dir);
|
|
316
|
+
return rel === "" ? "." : rel;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Collects a full info report for a git/repo root directory.
|
|
321
|
+
*/
|
|
322
|
+
export async function collectInfoReport(repoRoot: string): Promise<InfoReport> {
|
|
323
|
+
const rootPkg = readPackageJson(repoRoot);
|
|
324
|
+
let packageRoots = findPackageRoots(repoRoot);
|
|
325
|
+
packageRoots = [...packageRoots].sort((a, b) => {
|
|
326
|
+
if (a === repoRoot && b !== repoRoot) return -1;
|
|
327
|
+
if (a !== repoRoot && b === repoRoot) return 1;
|
|
328
|
+
return a.localeCompare(b);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const { layout, reasons } = detectLayout(repoRoot, rootPkg, packageRoots);
|
|
332
|
+
|
|
333
|
+
const packages: PackageVersionRow[] = [];
|
|
334
|
+
const stackAcc = new Map<string, StackHit>();
|
|
335
|
+
const kindById = new Map(STACK_RULES.map((r) => [r.id, r.kind]));
|
|
336
|
+
|
|
337
|
+
for (const dir of packageRoots) {
|
|
338
|
+
const pkg = readPackageJson(dir);
|
|
339
|
+
const label = packageLabel(repoRoot, dir);
|
|
340
|
+
packages.push({
|
|
341
|
+
path: label,
|
|
342
|
+
name: typeof pkg?.name === "string" ? pkg.name : null,
|
|
343
|
+
version: typeof pkg?.version === "string" ? pkg.version : null,
|
|
344
|
+
private: pkg?.private === true,
|
|
345
|
+
});
|
|
346
|
+
if (pkg) {
|
|
347
|
+
matchStacks(directDepNames(pkg), label, stackAcc);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
suppressNestPlatformNoise(stackAcc);
|
|
352
|
+
|
|
353
|
+
const frontend: StackHit[] = [];
|
|
354
|
+
const fullstack: StackHit[] = [];
|
|
355
|
+
const backend: StackHit[] = [];
|
|
356
|
+
for (const hit of stackAcc.values()) {
|
|
357
|
+
const kind = kindById.get(hit.id);
|
|
358
|
+
if (kind === "frontend") frontend.push(hit);
|
|
359
|
+
else if (kind === "fullstack") fullstack.push(hit);
|
|
360
|
+
else if (kind === "backend") backend.push(hit);
|
|
361
|
+
}
|
|
362
|
+
const byName = (a: StackHit, b: StackHit) => a.name.localeCompare(b.name);
|
|
363
|
+
frontend.sort(byName);
|
|
364
|
+
fullstack.sort(byName);
|
|
365
|
+
backend.sort(byName);
|
|
366
|
+
|
|
367
|
+
const gflows = resolveConfig(repoRoot, {}, {});
|
|
368
|
+
let remoteUrl: string | null = null;
|
|
369
|
+
try {
|
|
370
|
+
const remoteResult = await runGit(["remote", "get-url", gflows.remote], {
|
|
371
|
+
cwd: repoRoot,
|
|
372
|
+
verbose: false,
|
|
373
|
+
});
|
|
374
|
+
const url = remoteResult.stdout.trim();
|
|
375
|
+
if (url) remoteUrl = url;
|
|
376
|
+
} catch {
|
|
377
|
+
remoteUrl = null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let branch: string | null = null;
|
|
381
|
+
try {
|
|
382
|
+
branch = await getCurrentBranch(repoRoot, { verbose: false });
|
|
383
|
+
} catch {
|
|
384
|
+
branch = null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
root: repoRoot,
|
|
389
|
+
layout,
|
|
390
|
+
layoutReasons: reasons,
|
|
391
|
+
name: typeof rootPkg?.name === "string" ? rootPkg.name : null,
|
|
392
|
+
version: typeof rootPkg?.version === "string" ? rootPkg.version : null,
|
|
393
|
+
description: typeof rootPkg?.description === "string" ? rootPkg.description : null,
|
|
394
|
+
packages,
|
|
395
|
+
stacks: { frontend, fullstack, backend },
|
|
396
|
+
packageManager: detectPackageManager(repoRoot),
|
|
397
|
+
remoteUrl,
|
|
398
|
+
branch,
|
|
399
|
+
gflows: { main: gflows.main, dev: gflows.dev, remote: gflows.remote },
|
|
400
|
+
runtimes: detectNonJsRuntimes(repoRoot),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Formats an InfoReport as human-readable lines.
|
|
406
|
+
*/
|
|
407
|
+
export function formatInfoReport(report: InfoReport): string[] {
|
|
408
|
+
const lines: string[] = [];
|
|
409
|
+
const title = report.name ?? (relative(process.cwd(), report.root) || report.root);
|
|
410
|
+
lines.push(`Repo: ${title} (${report.layout})`);
|
|
411
|
+
if (report.version) {
|
|
412
|
+
lines.push(`Version: ${report.version} (root)`);
|
|
413
|
+
} else if (report.packages.length === 0) {
|
|
414
|
+
lines.push("Version: (no package.json)");
|
|
415
|
+
} else {
|
|
416
|
+
lines.push("Version: (none at root)");
|
|
417
|
+
}
|
|
418
|
+
if (report.description) {
|
|
419
|
+
lines.push(`Description: ${report.description}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const nested = report.packages.filter((p) => p.path !== ".");
|
|
423
|
+
if (nested.length > 0) {
|
|
424
|
+
lines.push("Packages:");
|
|
425
|
+
for (const p of nested) {
|
|
426
|
+
const ver = p.version ?? "—";
|
|
427
|
+
const nm = p.name ? ` ${p.name}` : "";
|
|
428
|
+
lines.push(` ${p.path.padEnd(24)} ${ver}${nm}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const fmt = (hits: StackHit[]) => hits.map((h) => h.name).join(", ");
|
|
433
|
+
if (report.stacks.frontend.length > 0) {
|
|
434
|
+
lines.push(`Frontend: ${fmt(report.stacks.frontend)}`);
|
|
435
|
+
}
|
|
436
|
+
if (report.stacks.fullstack.length > 0) {
|
|
437
|
+
lines.push(`Fullstack: ${fmt(report.stacks.fullstack)}`);
|
|
438
|
+
}
|
|
439
|
+
if (report.stacks.backend.length > 0) {
|
|
440
|
+
lines.push(`Backend: ${fmt(report.stacks.backend)}`);
|
|
441
|
+
}
|
|
442
|
+
if (
|
|
443
|
+
report.stacks.frontend.length === 0 &&
|
|
444
|
+
report.stacks.fullstack.length === 0 &&
|
|
445
|
+
report.stacks.backend.length === 0 &&
|
|
446
|
+
report.runtimes.length === 0
|
|
447
|
+
) {
|
|
448
|
+
lines.push("Stacks: (none detected)");
|
|
449
|
+
}
|
|
450
|
+
if (report.runtimes.length > 0) {
|
|
451
|
+
lines.push(`Runtimes: ${report.runtimes.join(", ")}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const meta: string[] = [];
|
|
455
|
+
if (report.packageManager) meta.push(report.packageManager);
|
|
456
|
+
meta.push(`${report.gflows.main}/${report.gflows.dev}`);
|
|
457
|
+
if (report.branch) meta.push(`branch ${report.branch}`);
|
|
458
|
+
if (meta.length > 0) {
|
|
459
|
+
lines.push(`PM: ${meta.join(" · ")}`);
|
|
460
|
+
}
|
|
461
|
+
if (report.remoteUrl) {
|
|
462
|
+
lines.push(`Remote: ${report.remoteUrl}`);
|
|
463
|
+
}
|
|
464
|
+
return lines;
|
|
465
|
+
}
|
package/src/tui/HubHome.tsx
CHANGED
|
@@ -289,7 +289,7 @@ export function HubHome({
|
|
|
289
289
|
) : (
|
|
290
290
|
<Text color={MUTED}>
|
|
291
291
|
{showHelp
|
|
292
|
-
? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
|
|
292
|
+
? "/init /start /sync /pr /finish /doctor /info /help · esc clear · ctrl+c quit"
|
|
293
293
|
: "? for shortcuts · / for command menu"}
|
|
294
294
|
</Text>
|
|
295
295
|
)}
|
|
@@ -376,6 +376,7 @@ function buildActions(snap: VizSnapshot | null, recommended: RecommendAction | n
|
|
|
376
376
|
push("switch", "Switch branch", "/switch");
|
|
377
377
|
push("list", "List branches", "/list");
|
|
378
378
|
push("doctor", "Doctor", "/doctor");
|
|
379
|
+
push("info", "Info", "/info");
|
|
379
380
|
push("help", "Help", "/help");
|
|
380
381
|
push("quit", "Quit", "q");
|
|
381
382
|
|
package/src/tui/HubShell.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { HubHome } from "./HubHome.js";
|
|
19
19
|
import { WizardFrame } from "./prompts.js";
|
|
20
20
|
import { SLASH_COMMANDS } from "./slash.js";
|
|
21
|
-
import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
|
|
21
|
+
import { ConfigView, DoctorView, HelpView, InfoView, StatusView, VersionView } from "./views.js";
|
|
22
22
|
|
|
23
23
|
const ACCENT = "#E88C4A";
|
|
24
24
|
const MUTED = "#8A8A8A";
|
|
@@ -37,6 +37,7 @@ type Screen =
|
|
|
37
37
|
| { id: "init" }
|
|
38
38
|
| { id: "bump" }
|
|
39
39
|
| { id: "doctor" }
|
|
40
|
+
| { id: "info" }
|
|
40
41
|
| { id: "help" }
|
|
41
42
|
| { id: "status" }
|
|
42
43
|
| { id: "config" }
|
|
@@ -104,6 +105,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
104
105
|
case "doctor":
|
|
105
106
|
setScreen({ id: "doctor" });
|
|
106
107
|
return true;
|
|
108
|
+
case "info":
|
|
109
|
+
setScreen({ id: "info" });
|
|
110
|
+
return true;
|
|
107
111
|
case "help":
|
|
108
112
|
setScreen({ id: "help" });
|
|
109
113
|
return true;
|
|
@@ -231,6 +235,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
231
235
|
if (screen.id === "doctor") {
|
|
232
236
|
return <DoctorView cwd={cwd} onDone={cancelWizard} />;
|
|
233
237
|
}
|
|
238
|
+
if (screen.id === "info") {
|
|
239
|
+
return <InfoView cwd={cwd} onDone={cancelWizard} />;
|
|
240
|
+
}
|
|
234
241
|
if (screen.id === "help") {
|
|
235
242
|
return <HelpView onDone={cancelWizard} />;
|
|
236
243
|
}
|
package/src/tui/slash.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
|
|
|
24
24
|
{ name: "switch", hint: "Switch branch" },
|
|
25
25
|
{ name: "list", hint: "List branches" },
|
|
26
26
|
{ name: "doctor", hint: "Doctor checks" },
|
|
27
|
+
{ name: "info", hint: "Repo layout & stacks" },
|
|
27
28
|
{ name: "help", hint: "Show help" },
|
|
28
29
|
{ name: "status", hint: "Repo status" },
|
|
29
30
|
{ name: "config", hint: "Show config" },
|
package/src/tui/views.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read-only Ink screens that stay inside the hub (doctor / help / status / config).
|
|
2
|
+
* Read-only Ink screens that stay inside the hub (doctor / info / help / status / config).
|
|
3
3
|
* @module tui/views
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -7,9 +7,11 @@ import type React from "react";
|
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { collectDoctorReport } from "../commands/doctor.js";
|
|
9
9
|
import { getHelpText } from "../commands/help.js";
|
|
10
|
+
import { collectInfo } from "../commands/info.js";
|
|
10
11
|
import { collectStatusLines } from "../commands/status-lines.js";
|
|
11
12
|
import { resolveConfig } from "../config.js";
|
|
12
13
|
import { resolveRepoRoot } from "../git.js";
|
|
14
|
+
import { formatInfoReport } from "../repo-inspect.js";
|
|
13
15
|
import { getVersion } from "../version.js";
|
|
14
16
|
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
15
17
|
|
|
@@ -60,6 +62,45 @@ export function DoctorView({
|
|
|
60
62
|
return <HubScrollPanel title="Doctor" lines={lines} error={error} onDone={onDone} />;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Repo layout / versions / stacks inside the hub.
|
|
67
|
+
*/
|
|
68
|
+
export function InfoView({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
|
|
69
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
let cancelled = false;
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const report = await collectInfo(cwd);
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
const rows: HubPanelLine[] = formatInfoReport(report).map((text, i) => ({
|
|
79
|
+
id: `i${i}`,
|
|
80
|
+
text: text.length === 0 ? " " : text,
|
|
81
|
+
tone:
|
|
82
|
+
text.startsWith("Repo:") ||
|
|
83
|
+
text.startsWith("Frontend:") ||
|
|
84
|
+
text.startsWith("Fullstack:")
|
|
85
|
+
? "accent"
|
|
86
|
+
: "default",
|
|
87
|
+
}));
|
|
88
|
+
setLines(rows);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (!cancelled) {
|
|
91
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
92
|
+
setLines([]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [cwd]);
|
|
100
|
+
|
|
101
|
+
return <HubScrollPanel title="Info" lines={lines} error={error} onDone={onDone} />;
|
|
102
|
+
}
|
|
103
|
+
|
|
63
104
|
/**
|
|
64
105
|
* Scrollable help inside the hub.
|
|
65
106
|
*/
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type Command =
|
|
|
38
38
|
| "sync"
|
|
39
39
|
| "pr"
|
|
40
40
|
| "doctor"
|
|
41
|
+
| "info"
|
|
41
42
|
| "config"
|
|
42
43
|
| "schema"
|
|
43
44
|
| "continue"
|
|
@@ -59,6 +60,7 @@ export const ALL_COMMANDS: Command[] = [
|
|
|
59
60
|
"pr",
|
|
60
61
|
"viz",
|
|
61
62
|
"doctor",
|
|
63
|
+
"info",
|
|
62
64
|
"config",
|
|
63
65
|
"schema",
|
|
64
66
|
"continue",
|