mergestorm 0.3.49 → 0.3.50

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.50 │
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) {
package/dist/ui/banner.js CHANGED
@@ -185,6 +185,9 @@ 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
+ ],
188
191
  };
189
192
  function densityFromVariant(variant) {
190
193
  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.50",
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",