mergestorm 0.3.7 → 0.3.8
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/README.md +20 -38
- package/dist/api.d.ts +16 -4
- package/dist/api.js +98 -17
- package/dist/cli.js +20 -68
- package/dist/commands/login.d.ts +8 -2
- package/dist/commands/login.js +32 -20
- package/dist/commands/registry.d.ts +32 -0
- package/dist/commands/registry.js +219 -0
- package/dist/commands/review.d.ts +14 -5
- package/dist/commands/review.js +97 -31
- package/dist/commands/stack.d.ts +27 -0
- package/dist/commands/stack.js +52 -20
- package/dist/commands/status.js +2 -13
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +6 -1
- package/dist/git-stack.d.ts +5 -0
- package/dist/git-stack.js +12 -0
- package/dist/git.d.ts +6 -1
- package/dist/git.js +31 -5
- package/dist/node-version.d.ts +11 -0
- package/dist/node-version.js +19 -0
- package/dist/pr-body.d.ts +1 -1
- package/dist/pr-body.js +1 -1
- package/dist/repo-identity.js +22 -3
- package/dist/shell.d.ts +2 -2
- package/dist/shell.js +14 -70
- package/dist/stack-meta.js +4 -8
- package/dist/ui/banner.js +38 -6
- package/dist/ui/box.d.ts +8 -1
- package/dist/ui/box.js +15 -3
- package/dist/ui/findings.d.ts +28 -0
- package/dist/ui/findings.js +16 -0
- package/dist/ui/prompt.js +15 -8
- package/dist/ui/secret-input.d.ts +5 -0
- package/dist/ui/secret-input.js +73 -0
- package/package.json +13 -2
package/dist/errors.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/** Thrown by command handlers; one-shot mode maps `exitCode` onto process.exitCode. */
|
|
2
2
|
export class CommandError extends Error {
|
|
3
3
|
exitCode;
|
|
4
|
-
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, exitCode = 1, code) {
|
|
5
6
|
super(message);
|
|
6
7
|
this.name = "CommandError";
|
|
7
8
|
this.exitCode = exitCode;
|
|
9
|
+
this.code = code;
|
|
8
10
|
}
|
|
9
11
|
}
|
|
12
|
+
export function isCommandErrorCode(err, code) {
|
|
13
|
+
return err instanceof CommandError && err.code === code;
|
|
14
|
+
}
|
|
10
15
|
/** Review/status poll aborted by Ctrl+C — shell returns to the prompt. */
|
|
11
16
|
export class DetachedError extends Error {
|
|
12
17
|
jobId;
|
package/dist/git-stack.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export declare function createBranchFromHead(name: string, cwd?: string): void;
|
|
|
18
18
|
/** Slugify a free-form name into a safe branch segment. */
|
|
19
19
|
export declare function slugifyBranchName(raw: string): string;
|
|
20
20
|
export declare function defaultLayerBranchName(explicit?: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* How many commits are on `head` that are not reachable from `base`
|
|
23
|
+
* (`git rev-list --count base..head`). Used by stack submit to refuse empty layers.
|
|
24
|
+
*/
|
|
25
|
+
export declare function commitsAheadOf(base: string, head: string, cwd?: string): number;
|
|
21
26
|
/** Push branch to origin and set upstream. */
|
|
22
27
|
export declare function pushBranch(branch: string, cwd?: string): void;
|
|
23
28
|
/** First line of the tip commit on `branch` (for PR title). */
|
package/dist/git-stack.js
CHANGED
|
@@ -95,6 +95,18 @@ export function defaultLayerBranchName(explicit) {
|
|
|
95
95
|
}
|
|
96
96
|
return `ms/${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* How many commits are on `head` that are not reachable from `base`
|
|
100
|
+
* (`git rev-list --count base..head`). Used by stack submit to refuse empty layers.
|
|
101
|
+
*/
|
|
102
|
+
export function commitsAheadOf(base, head, cwd = process.cwd()) {
|
|
103
|
+
const out = git(["rev-list", "--count", `${base}..${head}`], cwd).trim();
|
|
104
|
+
const n = Number.parseInt(out, 10);
|
|
105
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
106
|
+
throw new Error(`Could not count commits on ${head} ahead of ${base}`);
|
|
107
|
+
}
|
|
108
|
+
return n;
|
|
109
|
+
}
|
|
98
110
|
/** Push branch to origin and set upstream. */
|
|
99
111
|
export function pushBranch(branch, cwd = process.cwd()) {
|
|
100
112
|
try {
|
package/dist/git.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export declare function git(args: string[], cwd?: string): string;
|
|
2
|
-
|
|
2
|
+
/** Parse `origin` remote into GitHub owner/repo when possible. */
|
|
3
|
+
export declare function parseGithubOriginRepo(cwd?: string): {
|
|
4
|
+
owner: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function collectChangedFiles(base: string, head: string, cwd?: string): Promise<{
|
|
3
8
|
path: string;
|
|
4
9
|
content: string;
|
|
5
10
|
}[]>;
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { readFile, stat } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
export function git(args, cwd = process.cwd()) {
|
|
@@ -13,22 +14,47 @@ export function git(args, cwd = process.cwd()) {
|
|
|
13
14
|
throw new Error(`git ${args.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/** Parse `origin` remote into GitHub owner/repo when possible. */
|
|
18
|
+
export function parseGithubOriginRepo(cwd = process.cwd()) {
|
|
19
|
+
try {
|
|
20
|
+
const url = git(["remote", "get-url", "origin"], cwd).trim();
|
|
21
|
+
if (!url)
|
|
22
|
+
return null;
|
|
23
|
+
const m = url.match(/^(?:https?:\/\/|git@)?github\.com[:/]([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i);
|
|
24
|
+
if (!m?.[1] || !m[2])
|
|
25
|
+
return null;
|
|
26
|
+
return { owner: m[1], repo: m[2] };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Repo-root absolute path for the current working tree.
|
|
34
|
+
* Kept local (not imported from repo-identity) to avoid a git ↔ repo-identity cycle.
|
|
35
|
+
*/
|
|
36
|
+
function repoRoot(cwd = process.cwd()) {
|
|
37
|
+
return realpathSync(git(["rev-parse", "--show-toplevel"], cwd).trim());
|
|
38
|
+
}
|
|
39
|
+
export async function collectChangedFiles(base, head, cwd = process.cwd()) {
|
|
40
|
+
const root = repoRoot(cwd);
|
|
41
|
+
const names = git(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd)
|
|
18
42
|
.split("\n")
|
|
19
43
|
.map((s) => s.trim())
|
|
20
44
|
.filter(Boolean);
|
|
21
45
|
const files = [];
|
|
22
46
|
for (const name of names.slice(0, 40)) {
|
|
47
|
+
const abs = path.join(root, name);
|
|
23
48
|
try {
|
|
24
|
-
const st = await stat(
|
|
49
|
+
const st = await stat(abs);
|
|
25
50
|
if (!st.isFile() || st.size > 400_000)
|
|
26
51
|
continue;
|
|
27
|
-
const content = await readFile(
|
|
52
|
+
const content = await readFile(abs, "utf8");
|
|
28
53
|
files.push({ path: name, content });
|
|
29
54
|
}
|
|
30
55
|
catch {
|
|
31
|
-
// deleted or unreadable
|
|
56
|
+
// deleted or unreadable — do not silently drop without a hint
|
|
57
|
+
console.warn(`Warning: could not read ${name} for review upload (resolved from repo root); skipping file contents`);
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
return files;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Mergestorm CLI requires Node 22+ (matches package.json engines). */
|
|
2
|
+
export declare const MIN_NODE_MAJOR = 22;
|
|
3
|
+
/** Parse major version from a Node version string (e.g. "22.22.3" → 22). */
|
|
4
|
+
export declare function nodeMajor(version?: string): number;
|
|
5
|
+
/**
|
|
6
|
+
* Fatal message when the runtime is too old. Kept pure for unit tests —
|
|
7
|
+
* callers print + exit.
|
|
8
|
+
*/
|
|
9
|
+
export declare function unsupportedNodeMessage(version?: string, minMajor?: number): string;
|
|
10
|
+
/** True when this process may run the CLI. */
|
|
11
|
+
export declare function isSupportedNode(version?: string, minMajor?: number): boolean;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Mergestorm CLI requires Node 22+ (matches package.json engines). */
|
|
2
|
+
export const MIN_NODE_MAJOR = 22;
|
|
3
|
+
/** Parse major version from a Node version string (e.g. "22.22.3" → 22). */
|
|
4
|
+
export function nodeMajor(version = process.versions.node) {
|
|
5
|
+
const major = Number.parseInt(version.split(".")[0] ?? "", 10);
|
|
6
|
+
return Number.isFinite(major) ? major : 0;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Fatal message when the runtime is too old. Kept pure for unit tests —
|
|
10
|
+
* callers print + exit.
|
|
11
|
+
*/
|
|
12
|
+
export function unsupportedNodeMessage(version = process.versions.node, minMajor = MIN_NODE_MAJOR) {
|
|
13
|
+
return (`mergestorm requires Node.js ${minMajor}+ (found v${version}). ` +
|
|
14
|
+
`Upgrade Node, then re-run: https://nodejs.org/`);
|
|
15
|
+
}
|
|
16
|
+
/** True when this process may run the CLI. */
|
|
17
|
+
export function isSupportedNode(version = process.versions.node, minMajor = MIN_NODE_MAJOR) {
|
|
18
|
+
return nodeMajor(version) >= minMajor;
|
|
19
|
+
}
|
package/dist/pr-body.d.ts
CHANGED
package/dist/pr-body.js
CHANGED
package/dist/repo-identity.js
CHANGED
|
@@ -5,18 +5,37 @@ import { mergestormHome } from "./config.js";
|
|
|
5
5
|
import { CommandError } from "./errors.js";
|
|
6
6
|
import { git } from "./git.js";
|
|
7
7
|
export const LEGACY_STACK_META_REL = path.join(".mergestorm", "stack.json");
|
|
8
|
+
/** Classify git "not a repo" failures once at the boundary; callers match on code. */
|
|
9
|
+
function rethrowIfNotARepo(err) {
|
|
10
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
11
|
+
if (/not a git repository/i.test(detail)) {
|
|
12
|
+
throw new CommandError(detail, 1, "not_a_repo");
|
|
13
|
+
}
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
8
16
|
export function gitTopLevel(cwd = process.cwd()) {
|
|
9
|
-
|
|
17
|
+
try {
|
|
18
|
+
return realpathSync(git(["rev-parse", "--show-toplevel"], cwd).trim());
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
rethrowIfNotARepo(err);
|
|
22
|
+
}
|
|
10
23
|
}
|
|
11
24
|
export function gitCommonDir(cwd = process.cwd()) {
|
|
12
|
-
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = git(["rev-parse", "--git-common-dir"], cwd).trim();
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
rethrowIfNotARepo(err);
|
|
31
|
+
}
|
|
13
32
|
const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
14
33
|
try {
|
|
15
34
|
return realpathSync(absolute);
|
|
16
35
|
}
|
|
17
36
|
catch (err) {
|
|
18
37
|
if (err?.code === "ENOENT") {
|
|
19
|
-
throw new CommandError(`git common directory is missing (${absolute}); this is often a linked worktree whose main repository was deleted. Run \`mg stack reset --force\` to clear local pre-submit state
|
|
38
|
+
throw new CommandError(`git common directory is missing (${absolute}); this is often a linked worktree whose main repository was deleted. Run \`mg stack reset --force\` to clear local pre-submit state.`, 1, "not_a_repo");
|
|
20
39
|
}
|
|
21
40
|
throw err;
|
|
22
41
|
}
|
package/dist/shell.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const COMMANDS: CommandSpec[];
|
|
1
|
+
/** Shell autocomplete + `/help` — derived from the command registry (STRUCT-01). */
|
|
2
|
+
export declare const COMMANDS: import("./ui/prompt.js").CommandSpec[];
|
|
3
3
|
/** Strips an optional leading "/" so both "review" and "/review" work. */
|
|
4
4
|
export declare function parseLine(line: string): {
|
|
5
5
|
cmd: string;
|
package/dist/shell.js
CHANGED
|
@@ -1,38 +1,13 @@
|
|
|
1
1
|
import { stdout as output } from "node:process";
|
|
2
2
|
import { loadConfig, resolveApiKey } from "./config.js";
|
|
3
3
|
import { CommandError, DetachedError } from "./errors.js";
|
|
4
|
-
import {
|
|
5
|
-
import { cmdCredits } from "./commands/credits.js";
|
|
6
|
-
import { cmdJobs, cmdThread } from "./commands/jobs.js";
|
|
7
|
-
import { cmdLogin } from "./commands/login.js";
|
|
8
|
-
import { cmdLogout } from "./commands/logout.js";
|
|
9
|
-
import { cmdReview } from "./commands/review.js";
|
|
10
|
-
import { cmdStack } from "./commands/stack.js";
|
|
11
|
-
import { cmdStatus } from "./commands/status.js";
|
|
12
|
-
import { cmdWhoami } from "./commands/whoami.js";
|
|
4
|
+
import { dispatchCommand, shellCommandSpecs, } from "./commands/registry.js";
|
|
13
5
|
import { ansi } from "./ui/ansi.js";
|
|
14
6
|
import { printBannerHeader, shellPrompt } from "./ui/banner.js";
|
|
15
7
|
import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ui/ctrl-c-exit.js";
|
|
16
8
|
import { askLine, PromptClosedError } from "./ui/prompt.js";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ name: "login", summary: "Sign in via browser (or paste a key)" },
|
|
20
|
-
{ name: "logout", summary: "Remove the stored API key" },
|
|
21
|
-
{ name: "review", summary: "Review git diff (default main...HEAD)" },
|
|
22
|
-
{ name: "status", summary: "Show a review job" },
|
|
23
|
-
{ name: "credits", summary: "Credit balance with usage bars" },
|
|
24
|
-
{ name: "usage", summary: "Credit balance with usage bars" },
|
|
25
|
-
{ name: "jobs", summary: "Recent review jobs (default 10)" },
|
|
26
|
-
{ name: "branches", summary: "Pick a recently reviewed branch" },
|
|
27
|
-
{ name: "chains", summary: "Alias for branches" },
|
|
28
|
-
{ name: "chain", summary: "Show a branch review-chain timeline" },
|
|
29
|
-
{ name: "whoami", summary: "Key + plan + API base" },
|
|
30
|
-
{ name: "thread", summary: "Jobs in a review thread" },
|
|
31
|
-
{ name: "stack", summary: "Stacks: create, submit, list, adopt, restack, land" },
|
|
32
|
-
{ name: "clear", summary: "Clear screen and reprint banner" },
|
|
33
|
-
{ name: "exit", summary: "Leave the shell" },
|
|
34
|
-
{ name: "quit", summary: "Leave the shell" },
|
|
35
|
-
];
|
|
9
|
+
/** Shell autocomplete + `/help` — derived from the command registry (STRUCT-01). */
|
|
10
|
+
export const COMMANDS = shellCommandSpecs();
|
|
36
11
|
function printHelp() {
|
|
37
12
|
const rows = COMMANDS.map((c) => ` ${ansi.green("/" + c.name.padEnd(10))} ${c.summary}`).join("\n");
|
|
38
13
|
console.log(`
|
|
@@ -125,48 +100,17 @@ export async function runShell() {
|
|
|
125
100
|
}
|
|
126
101
|
busyAbort = new AbortController();
|
|
127
102
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
else if (cmd === "status") {
|
|
140
|
-
if (!args[0]) {
|
|
141
|
-
throw new CommandError('usage: status <job_id>');
|
|
142
|
-
}
|
|
143
|
-
await cmdStatus(args[0], { format: "pretty", signal: busyAbort.signal });
|
|
144
|
-
}
|
|
145
|
-
else if (cmd === "credits" || cmd === "usage") {
|
|
146
|
-
await cmdCredits(args);
|
|
147
|
-
}
|
|
148
|
-
else if (cmd === "jobs") {
|
|
149
|
-
await cmdJobs(args);
|
|
150
|
-
}
|
|
151
|
-
else if (cmd === "branches" || cmd === "chains") {
|
|
152
|
-
await cmdBranches(args);
|
|
153
|
-
}
|
|
154
|
-
else if (cmd === "chain") {
|
|
155
|
-
await cmdChain(args);
|
|
156
|
-
}
|
|
157
|
-
else if (cmd === "whoami") {
|
|
158
|
-
await cmdWhoami(args);
|
|
159
|
-
}
|
|
160
|
-
else if (cmd === "thread") {
|
|
161
|
-
if (!args[0]) {
|
|
162
|
-
throw new CommandError("usage: thread <slug>");
|
|
163
|
-
}
|
|
164
|
-
await cmdThread(args[0], args.slice(1));
|
|
165
|
-
}
|
|
166
|
-
else if (cmd === "stack") {
|
|
167
|
-
await cmdStack(args);
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
103
|
+
const ok = await dispatchCommand(cmd, args, {
|
|
104
|
+
mode: "shell",
|
|
105
|
+
signal: busyAbort.signal,
|
|
106
|
+
afterAuth: async () => {
|
|
107
|
+
try {
|
|
108
|
+
await printBannerHeader();
|
|
109
|
+
}
|
|
110
|
+
catch { /* banner is best-effort */ }
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (!ok) {
|
|
170
114
|
console.error(ansi.dim(`unknown command "${cmd}" -- type help`));
|
|
171
115
|
}
|
|
172
116
|
}
|
package/dist/stack-meta.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile, } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { CommandError } from "./errors.js";
|
|
8
|
+
import { CommandError, isCommandErrorCode } from "./errors.js";
|
|
9
9
|
import { git } from "./git.js";
|
|
10
10
|
import { cliStacksRoot, gitTopLevel, LEGACY_STACK_META_REL, legacyStackMetaPath, repoStateDir, stackMetaPath as resolveStackMetaPath, } from "./repo-identity.js";
|
|
11
11
|
/** Exported only for legacy migration/tests. New state never uses this path. */
|
|
@@ -200,14 +200,10 @@ export async function resetStackMeta(cwd = process.cwd(), stacksRoot = cliStacks
|
|
|
200
200
|
legacyFile = legacyStackMetaPath(cwd);
|
|
201
201
|
}
|
|
202
202
|
catch (err) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (err instanceof Error &&
|
|
207
|
-
(err.message.includes("not a git repository") ||
|
|
208
|
-
err.message.includes("git common directory is missing"))) {
|
|
203
|
+
// Outside a worktree / deleted main repo: reset is a no-op (nothing to clear).
|
|
204
|
+
// Match on CommandError.code — do not treat it as a Node errno.
|
|
205
|
+
if (isCommandErrorCode(err, "not_a_repo"))
|
|
209
206
|
return;
|
|
210
|
-
}
|
|
211
207
|
throw err;
|
|
212
208
|
}
|
|
213
209
|
await rm(stateFile, { force: true });
|
package/dist/ui/banner.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getMe } from "../api.js";
|
|
2
|
+
import { CommandError } from "../errors.js";
|
|
2
3
|
import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
|
|
3
4
|
import { cliVersion } from "../version.js";
|
|
4
5
|
import { ansi } from "./ansi.js";
|
|
5
|
-
import { frameWidth, padVisible, roundedBox, sideBySide, visibleWidth, } from "./box.js";
|
|
6
|
+
import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, visibleWidth, } from "./box.js";
|
|
6
7
|
import { TORNADO_LOGO } from "./logo.js";
|
|
7
8
|
import { usageBar } from "./usage.js";
|
|
8
9
|
const WORDMARK = `
|
|
@@ -36,7 +37,7 @@ const WHATS_NEW = {
|
|
|
36
37
|
"`mg` short alias — same binary as `mergestorm`",
|
|
37
38
|
],
|
|
38
39
|
"0.3.3": [
|
|
39
|
-
"`stack list` / `adopt` / `restack` / `land` / `auto-
|
|
40
|
+
"`stack list` / `adopt` / `restack` / `land` / `auto-promote`",
|
|
40
41
|
],
|
|
41
42
|
"0.3.4": [
|
|
42
43
|
"`stack create` / `submit` — author stacks with `mg` (not Graphite)",
|
|
@@ -54,14 +55,25 @@ const WHATS_NEW = {
|
|
|
54
55
|
"MIT license + public source at github.com/marginsystems/mergestorm-cli",
|
|
55
56
|
"npm repository/bugs point at the public CLI repo",
|
|
56
57
|
],
|
|
58
|
+
"0.3.8": [
|
|
59
|
+
"Command registry + CommandError.code (no message-regex control flow)",
|
|
60
|
+
"cmdStackSubmit unit-tested; shared findings renderer; origin parse in git.ts",
|
|
61
|
+
"Review poll retries + status <job_id> recovery; empty-submit / narrow-TTY fixes",
|
|
62
|
+
"login --key muted + validated; API timeouts; Node 22 hard-fail; --version",
|
|
63
|
+
],
|
|
57
64
|
};
|
|
58
|
-
function formatStatusLines(me, key, barWidth) {
|
|
65
|
+
function formatStatusLines(me, key, barWidth, keyInvalid = false) {
|
|
59
66
|
if (!key) {
|
|
60
67
|
return [
|
|
61
68
|
`${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in`,
|
|
62
69
|
` or ${ansi.green("login --key")} to paste an API key`,
|
|
63
70
|
];
|
|
64
71
|
}
|
|
72
|
+
if (keyInvalid) {
|
|
73
|
+
return [
|
|
74
|
+
`${ansi.dim("○")} API key invalid or revoked — type ${ansi.green("login")} to sign in again`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
65
77
|
const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
|
|
66
78
|
if (!me) {
|
|
67
79
|
return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
|
|
@@ -76,7 +88,7 @@ function formatStatusLines(me, key, barWidth) {
|
|
|
76
88
|
function tipsLines() {
|
|
77
89
|
return [
|
|
78
90
|
ansi.dim("Tips for getting started"),
|
|
79
|
-
` ${ansi.green("review")} review main
|
|
91
|
+
` ${ansi.green("review")} review origin/HEAD or main`,
|
|
80
92
|
` ${ansi.green("stack")} create → submit → restack → land`,
|
|
81
93
|
` ${ansi.green("/help")} list all commands`,
|
|
82
94
|
];
|
|
@@ -99,8 +111,20 @@ export async function printBannerHeader() {
|
|
|
99
111
|
const cfg = await loadConfig();
|
|
100
112
|
const key = resolveApiKey(cfg);
|
|
101
113
|
let me = null;
|
|
114
|
+
let keyInvalid = false;
|
|
102
115
|
if (key) {
|
|
103
|
-
|
|
116
|
+
try {
|
|
117
|
+
// Short probe so a hung API cannot stall shell startup for the full request budget.
|
|
118
|
+
me = await getMe(cfg, { timeoutMs: 8_000 });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof CommandError) {
|
|
122
|
+
keyInvalid = true;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
104
128
|
}
|
|
105
129
|
const version = cliVersion();
|
|
106
130
|
const outer = frameWidth();
|
|
@@ -114,7 +138,7 @@ export async function printBannerHeader() {
|
|
|
114
138
|
const leftCol = [
|
|
115
139
|
...logoLines,
|
|
116
140
|
"",
|
|
117
|
-
...formatStatusLines(me, key, barWidth),
|
|
141
|
+
...formatStatusLines(me, key, barWidth, keyInvalid),
|
|
118
142
|
];
|
|
119
143
|
const rightCol = [
|
|
120
144
|
...tipsLines(),
|
|
@@ -124,6 +148,14 @@ export async function printBannerHeader() {
|
|
|
124
148
|
// Clip right column lines that overflow the right pane.
|
|
125
149
|
const rightClipped = rightCol.map((line) => visibleWidth(line) <= rightColW ? line : padVisible(line, rightColW));
|
|
126
150
|
const rows = sideBySide(leftCol, rightClipped, leftColW, 2);
|
|
151
|
+
if (!preferBoxedUi()) {
|
|
152
|
+
// Narrow panes — plain lines beat wrapped box frames.
|
|
153
|
+
console.log(ansi.boldGreen(`mergestorm v${version}`));
|
|
154
|
+
for (const line of rows)
|
|
155
|
+
console.log(line);
|
|
156
|
+
console.log("");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
127
159
|
const panel = roundedBox(rows, {
|
|
128
160
|
title: `mergestorm v${version}`,
|
|
129
161
|
width: outer,
|
package/dist/ui/box.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
export declare function visibleWidth(str: string): number;
|
|
2
|
+
/** Below this width, boxed chrome wraps on real TTYs — prefer plain output. */
|
|
3
|
+
export declare const MIN_BOXED_COLUMNS = 48;
|
|
2
4
|
/** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
|
|
3
5
|
export declare function terminalColumns(fallback?: number): number;
|
|
6
|
+
/**
|
|
7
|
+
* Whether rounded/full-bleed boxes fit without wrapping.
|
|
8
|
+
* Non-TTY (unknown columns) keeps boxes for tests and CI logs.
|
|
9
|
+
*/
|
|
10
|
+
export declare function preferBoxedUi(): boolean;
|
|
4
11
|
/**
|
|
5
12
|
* Outer box width for Claude-style full-bleed frames.
|
|
6
|
-
*
|
|
13
|
+
* Never wider than the terminal (minus 1 so the right border doesn't wrap).
|
|
7
14
|
*/
|
|
8
15
|
export declare function frameWidth(fallback?: number): number;
|
|
9
16
|
export interface RoundedBoxOptions {
|
package/dist/ui/box.js
CHANGED
|
@@ -4,20 +4,32 @@ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
|
4
4
|
export function visibleWidth(str) {
|
|
5
5
|
return str.replace(ANSI_PATTERN, "").length;
|
|
6
6
|
}
|
|
7
|
+
/** Below this width, boxed chrome wraps on real TTYs — prefer plain output. */
|
|
8
|
+
export const MIN_BOXED_COLUMNS = 48;
|
|
7
9
|
/** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
|
|
8
10
|
export function terminalColumns(fallback = 80) {
|
|
9
11
|
const cols = stdout.columns;
|
|
10
|
-
if (typeof cols === "number" && Number.isFinite(cols) && cols >=
|
|
12
|
+
if (typeof cols === "number" && Number.isFinite(cols) && cols >= 1) {
|
|
11
13
|
return cols;
|
|
12
14
|
}
|
|
13
15
|
return fallback;
|
|
14
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether rounded/full-bleed boxes fit without wrapping.
|
|
19
|
+
* Non-TTY (unknown columns) keeps boxes for tests and CI logs.
|
|
20
|
+
*/
|
|
21
|
+
export function preferBoxedUi() {
|
|
22
|
+
const cols = stdout.columns;
|
|
23
|
+
if (typeof cols !== "number" || !Number.isFinite(cols))
|
|
24
|
+
return true;
|
|
25
|
+
return cols >= MIN_BOXED_COLUMNS;
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Outer box width for Claude-style full-bleed frames.
|
|
17
|
-
*
|
|
29
|
+
* Never wider than the terminal (minus 1 so the right border doesn't wrap).
|
|
18
30
|
*/
|
|
19
31
|
export function frameWidth(fallback = 80) {
|
|
20
|
-
return Math.max(
|
|
32
|
+
return Math.max(20, terminalColumns(fallback) - 1);
|
|
21
33
|
}
|
|
22
34
|
const ROUNDED = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
23
35
|
const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared review-job findings payload (API may send snake or camel for off-diff). */
|
|
2
|
+
export type ReviewFindings = {
|
|
3
|
+
inline?: {
|
|
4
|
+
path: string;
|
|
5
|
+
line: number;
|
|
6
|
+
severity: string;
|
|
7
|
+
body: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
}[];
|
|
10
|
+
off_diff?: {
|
|
11
|
+
path?: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
severity: string;
|
|
14
|
+
body: string;
|
|
15
|
+
}[];
|
|
16
|
+
offDiff?: {
|
|
17
|
+
path?: string;
|
|
18
|
+
line?: number;
|
|
19
|
+
severity: string;
|
|
20
|
+
body: string;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
export type RenderFindingsOptions = {
|
|
24
|
+
/** When true and both lists are empty, print the empty message. */
|
|
25
|
+
emptyMessage?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
|
|
28
|
+
export declare function renderFindings(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
|
|
2
|
+
export function renderFindings(findings, opts = {}) {
|
|
3
|
+
const inline = findings?.inline ?? [];
|
|
4
|
+
const off = findings?.off_diff ?? findings?.offDiff ?? [];
|
|
5
|
+
for (const c of inline) {
|
|
6
|
+
console.log(`\n[${c.severity}] ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
|
|
7
|
+
console.log(c.body);
|
|
8
|
+
}
|
|
9
|
+
for (const c of off) {
|
|
10
|
+
console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
|
|
11
|
+
console.log(c.body);
|
|
12
|
+
}
|
|
13
|
+
if (opts.emptyMessage && inline.length === 0 && off.length === 0) {
|
|
14
|
+
console.log("\nNo findings.");
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/ui/prompt.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as readline from "node:readline";
|
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { ansi } from "./ansi.js";
|
|
5
|
-
import { frameWidth, roundedBox, visibleWidth } from "./box.js";
|
|
5
|
+
import { frameWidth, preferBoxedUi, roundedBox, visibleWidth } from "./box.js";
|
|
6
6
|
import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ctrl-c-exit.js";
|
|
7
7
|
/** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
|
|
8
8
|
export class PromptClosedError extends Error {
|
|
@@ -46,8 +46,10 @@ export async function askLine(opts) {
|
|
|
46
46
|
readline.emitKeypressEvents(input);
|
|
47
47
|
input.setRawMode(true);
|
|
48
48
|
input.resume();
|
|
49
|
+
const boxed = preferBoxedUi();
|
|
49
50
|
// Row (0-based) of the "› buffer" content line among the rows we draw each frame.
|
|
50
|
-
|
|
51
|
+
// Boxed: optional prompt + top border + content. Plain: optional prompt + content.
|
|
52
|
+
const inputRow = (opts.prompt ? 1 : 0) + (boxed ? 1 : 0);
|
|
51
53
|
function currentDropdown() {
|
|
52
54
|
if (!buffer.startsWith("/"))
|
|
53
55
|
return [];
|
|
@@ -61,11 +63,15 @@ export async function askLine(opts) {
|
|
|
61
63
|
const rows = [];
|
|
62
64
|
if (opts.prompt)
|
|
63
65
|
rows.push(ansi.dim(opts.prompt));
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
width: frameWidth()
|
|
68
|
-
}
|
|
66
|
+
const inputLine = `${ansi.green("›")} ${buffer}`;
|
|
67
|
+
if (boxed) {
|
|
68
|
+
// Full terminal width input frame, not content-sized.
|
|
69
|
+
rows.push(...roundedBox([inputLine], { padding: 1, width: frameWidth() }));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Plain prompt on narrow TTYs (avoids wrapped borders).
|
|
73
|
+
rows.push(inputLine);
|
|
74
|
+
}
|
|
69
75
|
const dropdown = currentDropdown();
|
|
70
76
|
if (dropdown.length) {
|
|
71
77
|
if (dropdownIndex >= dropdown.length)
|
|
@@ -81,7 +87,8 @@ export async function askLine(opts) {
|
|
|
81
87
|
output.write(rows.join("\r\n") + "\r\n");
|
|
82
88
|
linesDrawn = rows.length;
|
|
83
89
|
const prefixWidth = visibleWidth(`${ansi.green("›")} `);
|
|
84
|
-
|
|
90
|
+
// Boxed: border col + padding; plain: column 1.
|
|
91
|
+
const targetCol = (boxed ? 1 + 1 : 0) + prefixWidth + cursor + 1;
|
|
85
92
|
output.write(`${up(linesDrawn - inputRow)}${col(targetCol)}`);
|
|
86
93
|
}
|
|
87
94
|
function cleanup() {
|