mergestorm 0.3.49 → 0.3.51

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 CHANGED
@@ -45,7 +45,7 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.49 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.51 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -76,6 +76,8 @@ One-shot subcommands work the same inside the shell and from argv (scripts/CI):
76
76
  mergestorm Interactive shell (TTY only)
77
77
  mergestorm shell Explicit shell entry
78
78
  mergestorm login Sign in via browser; stores an API key in ~/.mergestorm/config.json
79
+ mergestorm login --start Print a sign-in URL and code, then exit (agents)
80
+ mergestorm login --finish Store the API key once the code from --start is approved
79
81
  mergestorm login --key Paste an existing API key instead (headless/CI)
80
82
  mergestorm logout Remove the stored API key
81
83
  mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)
@@ -148,6 +150,8 @@ Runs a browser device-authorization flow: prints a short code, opens `https://me
148
150
 
149
151
  For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [Settings → API](https://mergestorm.ai/settings#api)). On a TTY the paste is hidden (not echoed); the CLI verifies the key with the API before saving.
150
152
 
153
+ For an agent, which cannot sit in a prompt while a person signs in, use two steps. `mergestorm login --start` prints the URL and the short code and exits. Give both to the account owner, who opens [mergestorm.ai/cli/auth](https://mergestorm.ai/cli/auth) on any device, signs in, and approves the code. Then `mergestorm login --finish` stores the API key. The code lasts 10 minutes, and `--finish` can be run again if it is interrupted before the approval.
154
+
151
155
  ### `logout`
152
156
 
153
157
  Removes `~/.mergestorm/config.json` so the stored key is no longer used.
@@ -9,11 +9,11 @@ export declare const BEARER_SETTINGS: readonly [{
9
9
  }, {
10
10
  readonly key: "auto_resolve_conflicts_enabled";
11
11
  readonly flag: "--auto-resolve-conflicts";
12
- readonly label: "Auto-resolve merge conflicts";
12
+ readonly label: "Auto-resolve merge conflicts (alpha)";
13
13
  }, {
14
14
  readonly key: "auto_fix_ci_enabled";
15
15
  readonly flag: "--auto-fix-ci";
16
- readonly label: "Auto-fix failing CI";
16
+ readonly label: "Auto-fix failing CI (alpha)";
17
17
  }, {
18
18
  readonly key: "vortex_show_thinking_traces";
19
19
  readonly flag: "--vortex-thinking";
@@ -1,8 +1,8 @@
1
1
  export const BEARER_SETTINGS = [
2
2
  { key: "auto_review_enabled", flag: "--auto-review", label: "Auto review" },
3
3
  { key: "auto_patch_enabled", flag: "--auto-patch", label: "Auto patch" },
4
- { key: "auto_resolve_conflicts_enabled", flag: "--auto-resolve-conflicts", label: "Auto-resolve merge conflicts" },
5
- { key: "auto_fix_ci_enabled", flag: "--auto-fix-ci", label: "Auto-fix failing CI" },
4
+ { key: "auto_resolve_conflicts_enabled", flag: "--auto-resolve-conflicts", label: "Auto-resolve merge conflicts (alpha)" },
5
+ { key: "auto_fix_ci_enabled", flag: "--auto-fix-ci", label: "Auto-fix failing CI (alpha)" },
6
6
  { key: "vortex_show_thinking_traces", flag: "--vortex-thinking", label: "Vortex thinking traces" },
7
7
  { key: "repo_overview_enabled", flag: "--repo-overview", label: "Repo overview" },
8
8
  { key: "cyclone_skip_ci_enabled", flag: "--cyclone-skip-ci", label: "Skip CI on Cyclone commits" },
@@ -40,7 +40,7 @@ export function buildHelpTabs() {
40
40
  cmd("thread <slug>", "jobs in a review thread"),
41
41
  "",
42
42
  rule("Account"),
43
- cmd("login", "browser sign-in (or login --key)"),
43
+ cmd("login", "sign in (--key; agents: --start)"),
44
44
  cmd("whoami", "key · plan · API"),
45
45
  cmd("settings", "automation toggles · Config tab"),
46
46
  cmd("logout", "forget the stored key"),
@@ -5,4 +5,5 @@ import { type MeResponse } from "../api.js";
5
5
  * Throws CommandError when the key must not be saved.
6
6
  */
7
7
  export declare function validateApiKey(cfg: Config): Promise<MeResponse>;
8
+ export declare function pendingLoginPath(): string;
8
9
  export declare function cmdLogin(args: string[]): Promise<void>;
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { platform } from "node:os";
3
- import { apiBase, configPath, loadConfig, saveConfig } from "../config.js";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { apiBase, configPath, loadConfig, mergestormHome, saveConfig, } from "../config.js";
4
6
  import { devicePost, getMe } from "../api.js";
5
7
  import { CommandError } from "../errors.js";
6
8
  import { ansi } from "../ui/ansi.js";
@@ -52,13 +54,40 @@ async function loginWithKey() {
52
54
  const prefix = me.key?.prefix?.trim() || "msk_live_…";
53
55
  await present("Login", [` Verified ${prefix}… · saved to ${configPath()}`]);
54
56
  }
55
- export async function cmdLogin(args) {
56
- if (args.includes("--key")) {
57
- await loginWithKey();
58
- return;
57
+ export function pendingLoginPath() {
58
+ return path.join(mergestormHome(), "login-pending.json");
59
+ }
60
+ async function savePendingLogin(pending) {
61
+ await mkdir(mergestormHome(), { recursive: true });
62
+ await writeFile(pendingLoginPath(), JSON.stringify(pending, null, 2) + "\n", {
63
+ encoding: "utf8",
64
+ mode: 0o600,
65
+ });
66
+ }
67
+ async function loadPendingLogin() {
68
+ try {
69
+ const parsed = JSON.parse(await readFile(pendingLoginPath(), "utf8"));
70
+ if (typeof parsed.deviceCode !== "string" ||
71
+ typeof parsed.userCode !== "string" ||
72
+ typeof parsed.openUrl !== "string" ||
73
+ typeof parsed.apiBase !== "string" ||
74
+ typeof parsed.pollMs !== "number" ||
75
+ typeof parsed.expiresAt !== "number") {
76
+ return null;
77
+ }
78
+ return parsed;
59
79
  }
60
- const cfg = await loadConfig();
61
- const base = apiBase(cfg);
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ async function clearPendingLogin(deviceCode) {
85
+ const pending = await loadPendingLogin();
86
+ if (pending?.deviceCode !== deviceCode)
87
+ return;
88
+ await rm(pendingLoginPath(), { force: true });
89
+ }
90
+ async function startDeviceLogin(base) {
62
91
  let start;
63
92
  let attempt = 0;
64
93
  do {
@@ -87,51 +116,110 @@ export async function cmdLogin(args) {
87
116
  throw new CommandError(`Could not start device login (HTTP ${start.status}). The API may be unreachable or misconfigured.\n` +
88
117
  `Run \`mergestorm login --key\` to paste an existing API key instead.`);
89
118
  }
90
- const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in, } = start.body;
91
- const openUrl = verification_uri_complete || verification_uri;
92
- console.log("To sign in, open this URL and confirm the code:\n");
93
- console.log(` ${ansi.brightGreen(openUrl)}`);
94
- console.log(`\n Code: ${ansi.bold(user_code)}\n`);
95
- openBrowser(openUrl);
96
- console.log("Waiting for approval…");
97
- const pollMs = Math.max(interval || 5, 2) * 1000;
98
- const deadline = Date.now() + (expires_in || 600) * 1000;
99
- while (Date.now() < deadline) {
100
- await new Promise((r) => setTimeout(r, pollMs));
119
+ return start.body;
120
+ }
121
+ function pendingFromStart(base, start) {
122
+ return {
123
+ deviceCode: start.device_code,
124
+ userCode: start.user_code,
125
+ openUrl: start.verification_uri_complete || start.verification_uri,
126
+ apiBase: base,
127
+ pollMs: Math.max(start.interval || 5, 2) * 1000,
128
+ expiresAt: Date.now() + (start.expires_in || 600) * 1000,
129
+ };
130
+ }
131
+ async function pollDeviceLogin(pending, retryCommand, recovery) {
132
+ while (Date.now() < pending.expiresAt) {
101
133
  let poll;
102
134
  try {
103
- poll = await devicePost(base, "/api/v1/cli/device/token", { device_code });
135
+ poll = await devicePost(pending.apiBase, "/api/v1/cli/device/token", {
136
+ device_code: pending.deviceCode,
137
+ });
104
138
  }
105
139
  catch {
106
- throw new CommandError("Login failed: network error. Check your connection and run `mergestorm login` again.");
140
+ throw new CommandError(`Login failed: network error. Check your connection and run \`${retryCommand}\` again.`);
107
141
  }
108
142
  if (poll.status === 200 && poll.body?.api_key) {
109
143
  const next = await loadConfig();
110
144
  next.apiKey = poll.body.api_key;
145
+ next.apiBase = pending.apiBase;
111
146
  await saveConfig(next);
147
+ await clearPendingLogin(pending.deviceCode);
112
148
  await present("Login", [` Logged in. Key saved to ${configPath()}`]);
113
149
  return;
114
150
  }
115
151
  const err = poll.body?.error;
116
152
  if (err === "authorization_pending") {
117
153
  process.stdout.write(".");
154
+ await new Promise((r) => setTimeout(r, pending.pollMs));
118
155
  continue;
119
156
  }
120
157
  if (err === "slow_down" || err === "rate_limited") {
121
158
  const backoff = Number(poll.body?.retry_after_seconds);
122
- await new Promise((r) => setTimeout(r, Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : pollMs));
159
+ await new Promise((r) => setTimeout(r, Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : pending.pollMs));
123
160
  continue;
124
161
  }
125
162
  if (err === "key_limit_reached") {
126
163
  throw new CommandError("You have reached the max active API keys. Revoke one at https://mergestorm.ai/settings#api and try again.");
127
164
  }
165
+ if (err === "expired_token" || err === "already_redeemed" || err === "access_denied") {
166
+ await clearPendingLogin(pending.deviceCode);
167
+ }
128
168
  if (err === "expired_token") {
129
- throw new CommandError("Login code expired. Run `mergestorm login` again.");
169
+ throw new CommandError(`Login code expired. ${recovery}`);
130
170
  }
131
171
  if (err === "already_redeemed") {
132
- throw new CommandError("This code was already used. Run `mergestorm login` again.");
172
+ throw new CommandError(`This code was already used. ${recovery}`);
133
173
  }
134
174
  throw new CommandError(`Login failed: ${typeof err === "string" ? err : JSON.stringify(poll.body)}`);
135
175
  }
136
- throw new CommandError("Login timed out. Run `mergestorm login` again.");
176
+ await clearPendingLogin(pending.deviceCode);
177
+ throw new CommandError(`Login timed out. ${recovery}`);
178
+ }
179
+ async function loginStart() {
180
+ const cfg = await loadConfig();
181
+ const base = apiBase(cfg);
182
+ const pending = pendingFromStart(base, await startDeviceLogin(base));
183
+ await savePendingLogin(pending);
184
+ const minutes = Math.max(1, Math.round((pending.expiresAt - Date.now()) / 60_000));
185
+ console.log("Ask the account owner to open this URL, sign in, and approve the code:\n");
186
+ console.log(` ${pending.openUrl}`);
187
+ console.log(`\n Code: ${pending.userCode}\n`);
188
+ console.log(`The code expires in ${minutes} minutes.`);
189
+ console.log("After they approve, run `mergestorm login --finish` to store the API key.");
190
+ }
191
+ async function loginFinish() {
192
+ const pending = await loadPendingLogin();
193
+ if (!pending) {
194
+ throw new CommandError("No login is waiting. Run `mergestorm login --start` first.");
195
+ }
196
+ if (Date.now() >= pending.expiresAt) {
197
+ await clearPendingLogin(pending.deviceCode);
198
+ throw new CommandError("Login code expired. Run `mergestorm login --start` again.");
199
+ }
200
+ console.log(`Waiting for approval of code ${pending.userCode} at ${pending.openUrl}`);
201
+ await pollDeviceLogin(pending, "mergestorm login --finish", "Run `mergestorm login --start`, then `mergestorm login --finish`.");
202
+ }
203
+ export async function cmdLogin(args) {
204
+ if (args.includes("--key")) {
205
+ await loginWithKey();
206
+ return;
207
+ }
208
+ if (args.includes("--start")) {
209
+ await loginStart();
210
+ return;
211
+ }
212
+ if (args.includes("--finish")) {
213
+ await loginFinish();
214
+ return;
215
+ }
216
+ const cfg = await loadConfig();
217
+ const base = apiBase(cfg);
218
+ const pending = pendingFromStart(base, await startDeviceLogin(base));
219
+ console.log("To sign in, open this URL and confirm the code:\n");
220
+ console.log(` ${ansi.brightGreen(pending.openUrl)}`);
221
+ console.log(`\n Code: ${ansi.bold(pending.userCode)}\n`);
222
+ openBrowser(pending.openUrl);
223
+ console.log("Waiting for approval…");
224
+ await pollDeviceLogin(pending, "mergestorm login", "Run `mergestorm login` again.");
137
225
  }
@@ -25,6 +25,8 @@ export const COMMAND_REGISTRY = [
25
25
  summary: "Sign in via browser (or paste a key)",
26
26
  usage: [
27
27
  "mergestorm login Sign in via browser and store an API key",
28
+ "mergestorm login --start Print a sign-in URL and code, then exit (agents)",
29
+ "mergestorm login --finish Store the API key once the code from --start is approved",
28
30
  "mergestorm login --key Paste an existing API key instead (headless/CI)",
29
31
  ],
30
32
  async run(args, ctx) {
@@ -63,11 +63,14 @@ function coreReviewDoneAtHead(layer) {
63
63
  return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
64
64
  }
65
65
  export const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
66
- function vortexChangesRequestedAtHead(layer) {
66
+ function vortexFindingsRestAtHead(layer) {
67
67
  const review = layer.vortexReview;
68
68
  if (review && !(review.status === "done" && sameHead(review.head_sha, layer.headSha)))
69
69
  return false;
70
- return layer.vortexStatus === "findings" && layer.reviewStatus === "changes_requested";
70
+ return layer.vortexStatus === "findings" || layer.vortexStatus === "throttled";
71
+ }
72
+ function vortexChangesRequestedAtHead(layer) {
73
+ return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
71
74
  }
72
75
  function cycloneHandoffAtHead(stack, layer) {
73
76
  if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
@@ -149,9 +152,24 @@ function ciHandoffNote(stack, layer) {
149
152
  }
150
153
  return null;
151
154
  }
155
+ const CYCLONE_HELD_FINDINGS_REASONS = [
156
+ "failed",
157
+ "left_for_human",
158
+ "infra_failure",
159
+ "pr_state_unavailable",
160
+ "vortex_patch_cap",
161
+ "patch_job_lost",
162
+ ];
163
+ function vortexImportantFindingsHeldAtHead(layer) {
164
+ return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "reviewed" &&
165
+ sameHead(layer.cycloneHandoff?.headSha, layer.headSha) &&
166
+ CYCLONE_HELD_FINDINGS_REASONS.includes(layer.cycloneHandoff.reason);
167
+ }
152
168
  function vortexFindingsLeftForPerson(stack, layer) {
153
- return vortexChangesRequestedAtHead(layer) && !layerAgentsBusy(layer).cyclone &&
154
- cycloneHandoffAtHead(stack, layer) !== null;
169
+ if (layerAgentsBusy(layer).cyclone)
170
+ return false;
171
+ return vortexImportantFindingsHeldAtHead(layer) ||
172
+ (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer) !== null);
155
173
  }
156
174
  function quotaSkippedReview(layer) {
157
175
  const review = layer.vortexReview;
@@ -776,7 +794,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
776
794
  branch: layer.branch,
777
795
  handoff,
778
796
  findings,
779
- steps: `Vortex requested changes on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff)})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
797
+ steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff)})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
780
798
  };
781
799
  }
782
800
  if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
package/dist/ui/banner.js CHANGED
@@ -185,6 +185,12 @@ const WHATS_NEW = {
185
185
  "0.3.49": [
186
186
  "Submit retries a stalled stacks check",
187
187
  ],
188
+ "0.3.50": [
189
+ "Agent sign-in: login --start, --finish",
190
+ ],
191
+ "0.3.51": [
192
+ "Watch names findings held on a throttled head",
193
+ ],
188
194
  };
189
195
  function densityFromVariant(variant) {
190
196
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.49",
3
+ "version": "0.3.51",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",