mergestorm 0.3.64 → 0.3.66

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.
Files changed (77) hide show
  1. package/README.md +28 -9
  2. package/dist/api.d.ts +63 -11
  3. package/dist/api.js +195 -6
  4. package/dist/automation-catalog.d.ts +5 -1
  5. package/dist/automation-catalog.js +1 -0
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +3 -2
  8. package/dist/client/index.js +3 -2
  9. package/dist/commands/account.d.ts +3 -0
  10. package/dist/commands/account.js +75 -0
  11. package/dist/commands/browse.d.ts +1 -9
  12. package/dist/commands/browse.js +10 -8
  13. package/dist/commands/dismiss.d.ts +0 -2
  14. package/dist/commands/dismiss.js +2 -2
  15. package/dist/commands/help.js +3 -2
  16. package/dist/commands/issue.d.ts +24 -6
  17. package/dist/commands/issue.js +153 -31
  18. package/dist/commands/login.d.ts +2 -1
  19. package/dist/commands/login.js +50 -14
  20. package/dist/commands/logout.js +6 -7
  21. package/dist/commands/memory.d.ts +2 -3
  22. package/dist/commands/memory.js +8 -6
  23. package/dist/commands/registry.d.ts +1 -0
  24. package/dist/commands/registry.js +35 -11
  25. package/dist/commands/review-client.d.ts +0 -3
  26. package/dist/commands/review-client.js +3 -3
  27. package/dist/commands/review.d.ts +1 -1
  28. package/dist/commands/review.js +1 -1
  29. package/dist/commands/settings.d.ts +3 -5
  30. package/dist/commands/settings.js +4 -3
  31. package/dist/commands/stack.d.ts +19 -5
  32. package/dist/commands/stack.js +78 -4
  33. package/dist/commands/whoami.js +14 -10
  34. package/dist/config.d.ts +55 -2
  35. package/dist/config.js +264 -13
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/gh.d.ts +0 -4
  38. package/dist/gh.js +4 -4
  39. package/dist/git.d.ts +1 -1
  40. package/dist/git.js +2 -2
  41. package/dist/memory-api.d.ts +1 -0
  42. package/dist/memory-api.js +2 -2
  43. package/dist/node-version.d.ts +0 -2
  44. package/dist/node-version.js +1 -1
  45. package/dist/pr-body.d.ts +4 -0
  46. package/dist/pr-body.js +15 -0
  47. package/dist/repo-identity.d.ts +2 -1
  48. package/dist/repo-identity.js +8 -4
  49. package/dist/server-publish.d.ts +1 -24
  50. package/dist/server-publish.js +9 -9
  51. package/dist/stack-blockers.d.ts +12 -38
  52. package/dist/stack-blockers.js +55 -33
  53. package/dist/stack-dto.d.ts +3 -0
  54. package/dist/stack-dto.js +15 -2
  55. package/dist/stack-meta.d.ts +2 -2
  56. package/dist/stack-meta.js +1 -1
  57. package/dist/stack-watch-loop.d.ts +2 -13
  58. package/dist/stack-watch-loop.js +12 -12
  59. package/dist/submit-pr-base.d.ts +0 -1
  60. package/dist/submit-pr-base.js +1 -1
  61. package/dist/ui/ansi.d.ts +0 -10
  62. package/dist/ui/ansi.js +2 -2
  63. package/dist/ui/banner.d.ts +3 -1
  64. package/dist/ui/banner.js +10 -3
  65. package/dist/ui/box.d.ts +0 -2
  66. package/dist/ui/box.js +1 -1
  67. package/dist/ui/prompt.d.ts +0 -11
  68. package/dist/ui/prompt.js +7 -7
  69. package/dist/ui/tabs.d.ts +2 -2
  70. package/dist/ui/tabs.js +1 -1
  71. package/dist/ui/width.d.ts +0 -9
  72. package/dist/ui/width.js +3 -3
  73. package/dist/web-api.d.ts +17 -0
  74. package/dist/web-api.js +30 -0
  75. package/package.json +1 -1
  76. package/skill/SKILL.md +4 -0
  77. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
@@ -0,0 +1,3 @@
1
+ export declare const ACCOUNT_USAGE = "usage: mergestorm account list [--json] | use <name> | remove <name>";
2
+ export declare function logoutAccount(name: string): Promise<string[]>;
3
+ export declare function cmdAccount(args: string[]): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { revokeOwnKey } from "../api.js";
2
+ import { ACCOUNT_LIMIT, apiBase, configFor, listAccounts, readStore, removeAccount, useAccount, } from "../config.js";
3
+ import { CommandError } from "../errors.js";
4
+ import { ansi } from "../ui/ansi.js";
5
+ import { present } from "../ui/present.js";
6
+ export const ACCOUNT_USAGE = "usage: mergestorm account list [--json] | use <name> | remove <name>";
7
+ export async function logoutAccount(name) {
8
+ const store = await readStore();
9
+ const cfg = configFor(store, Object.hasOwn(store.accounts, name) ? name : undefined);
10
+ if (!cfg.account)
11
+ await removeAccount(name);
12
+ cfg.apiBase = apiBase(cfg);
13
+ cfg.ignoreEnv = true;
14
+ const revoked = await revokeOwnKey(cfg, { timeoutMs: 8_000 });
15
+ const after = await removeAccount(name);
16
+ const lines = [
17
+ revoked
18
+ ? ` Logged out ${name}. Its API key is revoked.`
19
+ : ` Logged out ${name}. Its API key could not be revoked; revoke it at https://mergestorm.ai/settings#api if it should not stay valid.`,
20
+ ];
21
+ if (after.activeAccount)
22
+ lines.push(` Active account: ${after.activeAccount}`);
23
+ else
24
+ lines.push(" No accounts left. Run `mergestorm login` to sign in.");
25
+ return lines;
26
+ }
27
+ async function accountList(asJson) {
28
+ const accounts = await listAccounts();
29
+ if (asJson) {
30
+ console.log(JSON.stringify({ active: accounts.find((a) => a.active)?.name ?? null, limit: ACCOUNT_LIMIT, accounts }, null, 2));
31
+ return;
32
+ }
33
+ if (accounts.length === 0) {
34
+ await present("Accounts", [" No accounts. Run `mergestorm login` to sign in."]);
35
+ return;
36
+ }
37
+ const width = Math.max(...accounts.map((a) => a.name.length));
38
+ const lines = accounts.map((a) => {
39
+ const mark = a.active ? ansi.brightGreen("●") : ansi.dim("○");
40
+ const name = a.active ? ansi.bold(a.name.padEnd(width)) : a.name.padEnd(width);
41
+ return ` ${mark} ${name} ${ansi.dim(a.keyPrefix)} ${ansi.dim(a.apiBase)}`;
42
+ });
43
+ lines.push("", ` ${ansi.dim(`${accounts.length} of ${ACCOUNT_LIMIT} · account use <name> switches · --account <name> runs one command as another`)}`);
44
+ await present("Accounts", lines);
45
+ }
46
+ function nameArg(rest, verb) {
47
+ const name = rest.find((arg) => !arg.startsWith("-"));
48
+ if (!name) {
49
+ throw new CommandError(`mergestorm account ${verb} needs an account name.\n${ACCOUNT_USAGE}`, 1, "usage");
50
+ }
51
+ return name;
52
+ }
53
+ export async function cmdAccount(args) {
54
+ const asJson = args.includes("--json");
55
+ const [sub = "list", ...rest] = args.filter((arg) => arg !== "--json");
56
+ if (sub === "list") {
57
+ await accountList(asJson);
58
+ return;
59
+ }
60
+ if (sub === "use") {
61
+ const name = nameArg(rest, "use");
62
+ await useAccount(name);
63
+ await present("Account", [` Active account: ${name}`]);
64
+ return;
65
+ }
66
+ if (sub === "remove") {
67
+ await present("Account", await logoutAccount(nameArg(rest, "remove")));
68
+ return;
69
+ }
70
+ if (sub === "--help" || sub === "-h" || sub === "help") {
71
+ console.log(ACCOUNT_USAGE);
72
+ return;
73
+ }
74
+ throw new CommandError(`unknown account subcommand: ${sub}\n${ACCOUNT_USAGE}`, 1, "usage");
75
+ }
@@ -7,6 +7,7 @@ export declare function canBrowse(): boolean;
7
7
  /** Shared job row mapper for the Usage tab and the static `credits` panel. */
8
8
  export declare function toPanelJob(job: JobListItem): UsagePanelJob;
9
9
  export type StatusTabInput = {
10
+ account?: string | null;
10
11
  keyPrefix: string;
11
12
  keyName: string | null;
12
13
  plan: string | null;
@@ -20,19 +21,10 @@ export type StatusTabInput = {
20
21
  resetsAt?: string | null;
21
22
  now?: Date;
22
23
  };
23
- /** Short chrome note — lives on the hint row, not in the Status body. */
24
- export declare function statusReachabilityNote(input: Pick<StatusTabInput, "reachable" | "keyInvalid">): string;
25
24
  /** Status tab body: whoami + credits from `/me`. Reachability is the footer. */
26
25
  export declare function buildStatusTabLines(input: StatusTabInput): string[];
27
26
  /** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
28
27
  export declare function formatJobDetailLines(row: ReviewJobRow): string[];
29
- /** Human labels for the Config tab and the static `settings` printout. */
30
- export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
31
- export declare const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
32
- export declare const STACKS_READY_WORDS: {
33
- readonly on: "ready";
34
- readonly off: "not ready (install Mergestorm Surge)";
35
- };
36
28
  /** Config rows in a stable order: writable toggles, then connected flags. */
37
29
  export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
38
30
  /**
@@ -1,6 +1,6 @@
1
1
  import { getMe, getReview, getSettings, listJobs, patchSettings, } from "../api.js";
2
2
  import { BEARER_BOOLEAN_SETTINGS_KEYS, BEARER_SETTINGS_LABELS } from "../automation-catalog.js";
3
- import { apiBase, keyDisplay, loadConfig, resolveApiKey } from "../config.js";
3
+ import { apiBase, keyDisplay, loadConfig, resolveApiKey, settleAccountName } from "../config.js";
4
4
  import { CommandError } from "../errors.js";
5
5
  import { ansi } from "../ui/ansi.js";
6
6
  import { humanSummary } from "../ui/human-summary.js";
@@ -23,7 +23,7 @@ export function toPanelJob(job) {
23
23
  };
24
24
  }
25
25
  /** Short chrome note — lives on the hint row, not in the Status body. */
26
- export function statusReachabilityNote(input) {
26
+ function statusReachabilityNote(input) {
27
27
  if (input.keyInvalid)
28
28
  return "key revoked";
29
29
  if (!input.reachable)
@@ -50,9 +50,10 @@ function creditsValue(input) {
50
50
  }
51
51
  /** Status tab body: whoami + credits from `/me`. Reachability is the footer. */
52
52
  export function buildStatusTabLines(input) {
53
- const lines = [
54
- field("Key", `${ansi.bold(input.keyPrefix)}${input.keyName ? ` (${input.keyName})` : ""}`),
55
- ];
53
+ const lines = [];
54
+ if (input.account)
55
+ lines.push(field("Account", ansi.bold(input.account)));
56
+ lines.push(field("Key", `${ansi.bold(input.keyPrefix)}${input.keyName ? ` (${input.keyName})` : ""}`));
56
57
  if (input.plan)
57
58
  lines.push(field("Plan", input.plan));
58
59
  lines.push(field("API", input.apiBase));
@@ -120,9 +121,9 @@ export function formatJobDetailLines(row) {
120
121
  return lines;
121
122
  }
122
123
  /** Human labels for the Config tab and the static `settings` printout. */
123
- export const SETTINGS_LABELS = BEARER_SETTINGS_LABELS;
124
- export const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
125
- export const STACKS_READY_WORDS = {
124
+ const SETTINGS_LABELS = BEARER_SETTINGS_LABELS;
125
+ const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
126
+ const STACKS_READY_WORDS = {
126
127
  on: "ready",
127
128
  off: "not ready (install Mergestorm Surge)",
128
129
  };
@@ -219,6 +220,7 @@ export async function openTabsBrowser(initial) {
219
220
  }
220
221
  const data = {
221
222
  statusLines: buildStatusTabLines({
223
+ account: me ? await settleAccountName(cfg, me) : cfg.account,
222
224
  keyPrefix: me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…",
223
225
  keyName: me?.key.name ?? null,
224
226
  plan: me ? (me.plan_label_key ?? me.plan_key) : null,
@@ -1,12 +1,10 @@
1
1
  import { type PrFindingDismissInput, type PrFindingDismissOutcome, type PrFindingDismissResult } from "../api.js";
2
2
  import { type Config } from "../config.js";
3
3
  import { type StackWatchObligation } from "../stack-watch-obligation.js";
4
- export declare const DISMISS_USAGE: string;
5
4
  export type ParsedDismissArgs = PrFindingDismissInput & {
6
5
  json: boolean;
7
6
  };
8
7
  export declare function parseDismissArgs(args: string[]): ParsedDismissArgs;
9
- export declare function dismissWatch(result: PrFindingDismissResult): StackWatchObligation | null;
10
8
  export declare function formatDismissResult(result: PrFindingDismissResult, watch: StackWatchObligation | null): string;
11
9
  export type DismissDeps = {
12
10
  loadConfig?: () => Promise<Config>;
@@ -3,7 +3,7 @@ import { loadConfig } from "../config.js";
3
3
  import { CommandError, REVIEW_EXIT } from "../errors.js";
4
4
  import { stackWatchObligation } from "../stack-watch-obligation.js";
5
5
  import { parseAdoptTarget, withWatch } from "./stack.js";
6
- export const DISMISS_USAGE = [
6
+ const DISMISS_USAGE = [
7
7
  "usage: mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id>",
8
8
  " (--finding <id> [--finding <id>…] | --all) --reason <text> [--evidence <url>] [--json]",
9
9
  " mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview [--json]",
@@ -84,7 +84,7 @@ export function parseDismissArgs(args) {
84
84
  json,
85
85
  };
86
86
  }
87
- export function dismissWatch(result) {
87
+ function dismissWatch(result) {
88
88
  if (!result.stack_id)
89
89
  return null;
90
90
  return stackWatchObligation({ stackId: result.stack_id, terminal: null, unread: true, freshCursor: true });
@@ -41,9 +41,10 @@ export function buildHelpTabs() {
41
41
  "",
42
42
  rule("Account"),
43
43
  cmd("login", "sign in (--key; agents: --start)"),
44
- cmd("whoami", "key · plan · API"),
44
+ cmd("whoami", "account · key · plan · API"),
45
+ cmd("account", "list · use <name> · remove <name>"),
45
46
  cmd("settings", "automation toggles · Config tab"),
46
- cmd("logout", "forget the stored key"),
47
+ cmd("logout", "log out of the active account"),
47
48
  cmd("clear / exit", "reprint home · leave the shell"),
48
49
  ],
49
50
  },
@@ -1,23 +1,41 @@
1
- import { openStackIssue } from "../api.js";
1
+ import { openStackIssue, pollIssueReview, type IssueReviewStatus } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { parseGithubOriginRepo } from "../git.js";
4
- export declare const ISSUE_USAGE: string;
4
+ type RepoTarget = {
5
+ owner: string;
6
+ repo: string;
7
+ };
5
8
  export type ParsedIssueArgs = {
6
9
  title: string;
7
10
  body: string;
8
11
  labels: string[];
9
- repo?: {
10
- owner: string;
11
- repo: string;
12
- };
12
+ repo?: RepoTarget;
13
+ json: boolean;
14
+ wait?: boolean;
15
+ timeoutMs?: number;
16
+ };
17
+ export type ParsedIssueWaitArgs = {
18
+ number: number;
19
+ repo?: RepoTarget;
20
+ timeoutMs: number;
13
21
  json: boolean;
14
22
  };
15
23
  export declare function parseIssueCreateArgs(args: string[], readBody?: (path: string) => string): ParsedIssueArgs;
24
+ export declare function parseIssueWaitArgs(args: string[]): ParsedIssueWaitArgs;
16
25
  export type IssueDeps = {
17
26
  cwd?: string;
18
27
  loadConfig?: typeof loadConfig;
19
28
  openStackIssue?: typeof openStackIssue;
29
+ pollIssueReview?: typeof pollIssueReview;
20
30
  parseGithubOriginRepo?: typeof parseGithubOriginRepo;
21
31
  readBody?: (path: string) => string;
22
32
  };
33
+ export declare function waitForIssueReview(target: RepoTarget & {
34
+ number: number;
35
+ }, opts: {
36
+ timeoutMs: number;
37
+ json: boolean;
38
+ jsonShape?: (status: IssueReviewStatus) => unknown;
39
+ }, deps?: IssueDeps): Promise<void>;
23
40
  export declare function cmdIssue(args: string[], deps?: IssueDeps): Promise<void>;
41
+ export {};
@@ -1,18 +1,39 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { openStackIssue } from "../api.js";
2
+ import { ISSUE_REVIEW_DEFAULT_TIMEOUT_MS, IssueReviewTimeoutError, issueNotApprovedError, issueReviewLines, openStackIssue, pollIssueReview, } from "../api.js";
3
3
  import { loadConfig } from "../config.js";
4
4
  import { CommandError, REVIEW_EXIT } from "../errors.js";
5
5
  import { parseGithubOriginRepo } from "../git.js";
6
- export const ISSUE_USAGE = [
6
+ const ISSUE_USAGE = [
7
7
  "usage: mergestorm issue create --title <title> [--body <text> | --body-file <path>]",
8
- " [--label <name>]… [--repo <owner/repo>] [--json]",
8
+ " [--label <name>]… [--repo <owner/repo>] [--wait [--timeout <seconds>]] [--json]",
9
9
  " Opens a GitHub issue through Mergestorm as the Surge App. Needs no gh and no GitHub login.",
10
10
  " Uses existing labels only. Running it again with the same title returns the open issue it already made when that issue is among the newest 100 open issues; an older one, or two calls at once, can still create a duplicate.",
11
+ " --wait waits for the Vortex issue review afterwards, like issue wait.",
12
+ " mergestorm issue wait <number> [--repo <owner/repo>] [--timeout <seconds>] [--json]",
13
+ ` Waits for the Vortex review of the issue (default ${ISSUE_REVIEW_DEFAULT_TIMEOUT_MS / 1000}s). Exits 0 once the issue is approved or issue reviews are off.`,
14
+ ` Exits ${REVIEW_EXIT.failed} when the review has points to answer: reply in a comment on the issue, then wait again. Open the pull request only after approval.`,
11
15
  ].join("\n");
12
- const VALUE_FLAGS = new Set(["--title", "--body", "--body-file", "--label", "--repo"]);
16
+ const VALUE_FLAGS = new Set(["--title", "--body", "--body-file", "--label", "--repo", "--timeout"]);
13
17
  function usage(message) {
14
18
  return new CommandError(message ? `${message}\n${ISSUE_USAGE}` : ISSUE_USAGE, REVIEW_EXIT.usage, "usage");
15
19
  }
20
+ function flagValue(flag, raw) {
21
+ if (raw === undefined || raw.startsWith("--"))
22
+ throw usage(`${flag} needs a value`);
23
+ return raw;
24
+ }
25
+ function parseRepo(raw) {
26
+ const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(raw.trim());
27
+ if (!match)
28
+ throw usage("--repo needs <owner/repo>");
29
+ return { owner: match[1], repo: match[2] };
30
+ }
31
+ function parseTimeoutMs(raw) {
32
+ const seconds = Number(raw);
33
+ if (!Number.isInteger(seconds) || seconds < 1)
34
+ throw usage("--timeout needs a whole number of seconds, at least 1");
35
+ return seconds * 1000;
36
+ }
16
37
  export function parseIssueCreateArgs(args, readBody = (path) => readFileSync(path, "utf8")) {
17
38
  let title = "";
18
39
  let body;
@@ -20,33 +41,30 @@ export function parseIssueCreateArgs(args, readBody = (path) => readFileSync(pat
20
41
  let repo;
21
42
  const labels = [];
22
43
  let json = false;
23
- const value = (flag, raw) => {
24
- if (raw === undefined || raw.startsWith("--"))
25
- throw usage(`${flag} needs a value`);
26
- return raw;
27
- };
44
+ let wait = false;
45
+ let timeoutMs;
28
46
  for (let i = 0; i < args.length; i++) {
29
47
  const arg = args[i];
30
48
  if (arg === "--title")
31
- title = value(arg, args[++i]).trim();
49
+ title = flagValue(arg, args[++i]).trim();
32
50
  else if (arg === "--body")
33
- body = value(arg, args[++i]);
51
+ body = flagValue(arg, args[++i]);
34
52
  else if (arg === "--body-file")
35
- bodyFile = value(arg, args[++i]);
53
+ bodyFile = flagValue(arg, args[++i]);
36
54
  else if (arg === "--label") {
37
- const label = value(arg, args[++i]).trim();
55
+ const label = flagValue(arg, args[++i]).trim();
38
56
  if (!label)
39
57
  throw usage("--label needs a non-empty name");
40
58
  labels.push(label);
41
59
  }
42
- else if (arg === "--repo") {
43
- const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value(arg, args[++i]).trim());
44
- if (!match)
45
- throw usage("--repo needs <owner/repo>");
46
- repo = { owner: match[1], repo: match[2] };
47
- }
60
+ else if (arg === "--repo")
61
+ repo = parseRepo(flagValue(arg, args[++i]));
48
62
  else if (arg === "--json")
49
63
  json = true;
64
+ else if (arg === "--wait")
65
+ wait = true;
66
+ else if (arg === "--timeout")
67
+ timeoutMs = parseTimeoutMs(flagValue(arg, args[++i]));
50
68
  else
51
69
  throw usage(`unknown argument ${arg}`);
52
70
  }
@@ -54,6 +72,8 @@ export function parseIssueCreateArgs(args, readBody = (path) => readFileSync(pat
54
72
  throw usage("--title is required");
55
73
  if (body !== undefined && bodyFile !== undefined)
56
74
  throw usage("pass --body or --body-file, not both");
75
+ if (timeoutMs !== undefined && !wait)
76
+ throw usage("--timeout requires --wait");
57
77
  if (bodyFile !== undefined) {
58
78
  try {
59
79
  body = readBody(bodyFile);
@@ -62,12 +82,102 @@ export function parseIssueCreateArgs(args, readBody = (path) => readFileSync(pat
62
82
  throw new CommandError(`Could not read ${bodyFile}: ${err instanceof Error ? err.message : String(err)}`);
63
83
  }
64
84
  }
65
- return { title, body: body ?? "", labels, ...(repo ? { repo } : {}), json };
85
+ return {
86
+ title,
87
+ body: body ?? "",
88
+ labels,
89
+ ...(repo ? { repo } : {}),
90
+ json,
91
+ ...(wait ? { wait } : {}),
92
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
93
+ };
94
+ }
95
+ export function parseIssueWaitArgs(args) {
96
+ let number;
97
+ let repo;
98
+ let timeoutMs = ISSUE_REVIEW_DEFAULT_TIMEOUT_MS;
99
+ let json = false;
100
+ for (let i = 0; i < args.length; i++) {
101
+ const arg = args[i];
102
+ if (arg === "--repo")
103
+ repo = parseRepo(flagValue(arg, args[++i]));
104
+ else if (arg === "--timeout")
105
+ timeoutMs = parseTimeoutMs(flagValue(arg, args[++i]));
106
+ else if (arg === "--json")
107
+ json = true;
108
+ else if (!arg.startsWith("--") && number === undefined) {
109
+ const match = /^#?(\d+)$/.exec(arg.trim());
110
+ const n = match ? Number(match[1]) : NaN;
111
+ if (!Number.isSafeInteger(n) || n < 1)
112
+ throw usage(`issue wait needs an issue number, got ${arg}`);
113
+ number = n;
114
+ }
115
+ else
116
+ throw usage(`unknown argument ${arg}`);
117
+ }
118
+ if (number === undefined)
119
+ throw usage("issue wait needs an issue number");
120
+ return { number, ...(repo ? { repo } : {}), timeoutMs, json };
121
+ }
122
+ function resolveTarget(repo, deps) {
123
+ const target = repo ?? (deps.parseGithubOriginRepo ?? parseGithubOriginRepo)(deps.cwd ?? process.cwd());
124
+ if (!target) {
125
+ throw new CommandError("Could not parse GitHub owner/repo from `origin`. Pass --repo <owner/repo>.");
126
+ }
127
+ return target;
128
+ }
129
+ function asAuthExit(err) {
130
+ if (err instanceof CommandError && (err.code === "auth_invalid" || err.code === "missing_api_key")) {
131
+ return new CommandError(err.message, REVIEW_EXIT.auth, err.code);
132
+ }
133
+ return err;
134
+ }
135
+ function approvedLine(status) {
136
+ if (status.state === "off")
137
+ return `Issue reviews are off for ${status.owner}/${status.repo}; #${status.number} needs no approval.`;
138
+ const by = status.approvedBy === "human" ? "a person" : "Vortex";
139
+ return `${status.owner}/${status.repo}#${status.number} is approved by ${by}.`;
140
+ }
141
+ export async function waitForIssueReview(target, opts, deps = {}) {
142
+ const printJson = (status) => console.log(JSON.stringify(opts.jsonShape ? opts.jsonShape(status) : status, null, 2));
143
+ const progress = (line) => (opts.json ? console.error(line) : console.log(line));
144
+ let status;
145
+ try {
146
+ const cfg = await (deps.loadConfig ?? loadConfig)();
147
+ progress(`Waiting for the Vortex review of ${target.owner}/${target.repo}#${target.number} …`);
148
+ status = await (deps.pollIssueReview ?? pollIssueReview)(cfg, target, { timeoutMs: opts.timeoutMs });
149
+ }
150
+ catch (err) {
151
+ if (err instanceof IssueReviewTimeoutError) {
152
+ if (opts.json)
153
+ printJson(err.lastStatus);
154
+ else
155
+ console.log(issueReviewLines(err.lastStatus).join("\n"));
156
+ }
157
+ throw asAuthExit(err);
158
+ }
159
+ if (!status) {
160
+ throw new CommandError("This Mergestorm server does not review issues yet. Update the API, or open the pull request as before.", 1, "issue_review_unavailable");
161
+ }
162
+ if (opts.json)
163
+ printJson(status);
164
+ if (status.state === "approved" || status.state === "off") {
165
+ if (!opts.json) {
166
+ console.log(approvedLine(status));
167
+ if (status.next)
168
+ console.log(status.next);
169
+ }
170
+ return;
171
+ }
172
+ const refusal = issueNotApprovedError([status], `Vortex has not approved ${status.owner}/${status.repo}#${status.number} yet.`);
173
+ if (opts.json)
174
+ throw new CommandError(refusal.message.split("\n")[0], refusal.exitCode, refusal.code);
175
+ throw refusal;
66
176
  }
67
177
  export async function cmdIssue(args, deps = {}) {
68
178
  const sub = args[0]?.toLowerCase();
69
179
  const asksForHelp = (arg) => arg === "-h" || arg === "--help" || arg === "help";
70
- const createAsksForHelp = (rest) => {
180
+ const restAsksForHelp = (rest) => {
71
181
  for (let i = 0; i < rest.length; i++) {
72
182
  const arg = rest[i];
73
183
  if (asksForHelp(arg))
@@ -77,22 +187,34 @@ export async function cmdIssue(args, deps = {}) {
77
187
  }
78
188
  return false;
79
189
  };
80
- if (!sub || asksForHelp(sub) || (sub === "create" && createAsksForHelp(args.slice(1)))) {
190
+ if (!sub || asksForHelp(sub) || ((sub === "create" || sub === "wait") && restAsksForHelp(args.slice(1)))) {
81
191
  console.log(ISSUE_USAGE);
82
192
  return;
83
193
  }
194
+ if (sub === "wait") {
195
+ const parsed = parseIssueWaitArgs(args.slice(1));
196
+ const target = resolveTarget(parsed.repo, deps);
197
+ await waitForIssueReview({ ...target, number: parsed.number }, parsed, deps);
198
+ return;
199
+ }
84
200
  if (sub !== "create")
85
201
  throw usage(`unknown issue subcommand: ${sub}`);
86
202
  const parsed = parseIssueCreateArgs(args.slice(1), deps.readBody);
87
- const target = parsed.repo ?? (deps.parseGithubOriginRepo ?? parseGithubOriginRepo)(deps.cwd ?? process.cwd());
88
- if (!target) {
89
- throw new CommandError("Could not parse GitHub owner/repo from `origin`. Pass --repo <owner/repo>.");
90
- }
203
+ const target = resolveTarget(parsed.repo, deps);
91
204
  const issue = await (deps.openStackIssue ?? openStackIssue)({ owner: target.owner, repo: target.repo, title: parsed.title, body: parsed.body, labels: parsed.labels }, await (deps.loadConfig ?? loadConfig)());
92
- if (parsed.json) {
93
- console.log(JSON.stringify({ owner: target.owner, repo: target.repo, ...issue }, null, 2));
94
- return;
205
+ const created = { owner: target.owner, repo: target.repo, ...issue };
206
+ if (parsed.json && !parsed.wait) {
207
+ console.log(JSON.stringify(created, null, 2));
95
208
  }
96
- console.log(`${issue.created ? "Opened" : "Already open:"} ${target.owner}/${target.repo}#${issue.number} ${issue.url}`);
97
- console.log(`Put \`Fixes #${issue.number}\` in the commit or pull request body.`);
209
+ else if (!parsed.json) {
210
+ console.log(`${issue.created ? "Opened" : "Already open:"} ${target.owner}/${target.repo}#${issue.number} ${issue.url}`);
211
+ console.log(`Put \`Fixes #${issue.number}\` in the commit or pull request body.`);
212
+ }
213
+ if (!parsed.wait)
214
+ return;
215
+ await waitForIssueReview({ ...target, number: issue.number }, {
216
+ timeoutMs: parsed.timeoutMs ?? ISSUE_REVIEW_DEFAULT_TIMEOUT_MS,
217
+ json: parsed.json,
218
+ jsonShape: (review) => ({ ...created, review }),
219
+ }, deps);
98
220
  }
@@ -1,10 +1,11 @@
1
1
  import { type Config } from "../config.js";
2
- import { type MeResponse } from "../api.js";
2
+ import { type AccountIdentity, type MeResponse } from "../api.js";
3
3
  /**
4
4
  * Probe /api/v1/me with an in-memory config. Does not write disk.
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 loggedInLine(account: AccountIdentity | null | undefined): string;
8
9
  export declare function pendingLoginPath(): string;
9
10
  export interface LoginDeps {
10
11
  openBrowser: (url: string) => void;
@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
2
2
  import { platform } from "node:os";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { apiBase, configPath, loadConfig, mergestormHome, saveConfig, } from "../config.js";
6
- import { devicePost, getMe } from "../api.js";
5
+ import { accountNameFromMe, apiBase, configFor, configPath, mergestormHome, provisionalAccountName, readStore, saveAccount, } from "../config.js";
6
+ import { accountLabel, devicePost, getMe, revokeOwnKey } from "../api.js";
7
7
  import { CommandError } from "../errors.js";
8
8
  import { ansi } from "../ui/ansi.js";
9
9
  import { present } from "../ui/present.js";
@@ -40,6 +40,33 @@ export async function validateApiKey(cfg) {
40
40
  throw err;
41
41
  }
42
42
  }
43
+ async function loginConfig() {
44
+ const store = await readStore();
45
+ return configFor(store, store.activeAccount);
46
+ }
47
+ export function loggedInLine(account) {
48
+ const label = accountLabel(account);
49
+ return label ? `Logged in as ${label}.` : "Logged in.";
50
+ }
51
+ async function storeLogin(cfg, me, opts) {
52
+ const apiKey = cfg.apiKey ?? "";
53
+ const name = accountNameFromMe(me) ?? provisionalAccountName(apiKey);
54
+ const before = await readStore();
55
+ const replaced = Object.hasOwn(before.accounts, name) ? configFor(before, name) : undefined;
56
+ try {
57
+ await saveAccount(name, { apiKey, apiBase: cfg.apiBase }, { activate: true });
58
+ }
59
+ catch (err) {
60
+ if (opts.minted && err instanceof CommandError && err.code === "account_limit") {
61
+ await revokeOwnKey(cfg, { timeoutMs: 8_000 });
62
+ }
63
+ throw err;
64
+ }
65
+ if (replaced?.apiKey && replaced.apiKey !== apiKey) {
66
+ await revokeOwnKey({ ...replaced, ignoreEnv: true }, { timeoutMs: 8_000 });
67
+ }
68
+ return name;
69
+ }
43
70
  async function loginWithKey() {
44
71
  // Always use muted secret input on TTY so the key never lands in
45
72
  // question()/history echo.
@@ -47,12 +74,12 @@ async function loginWithKey() {
47
74
  if (!key.startsWith("msk_live_")) {
48
75
  throw new CommandError("Key should start with msk_live_");
49
76
  }
50
- const cfg = await loadConfig();
51
- cfg.apiKey = key;
52
- const me = await validateApiKey(cfg);
53
- await saveConfig(cfg);
77
+ const cfg = await loginConfig();
78
+ const candidate = { apiKey: key, apiBase: apiBase(cfg), ignoreEnv: true };
79
+ const me = await validateApiKey(candidate);
80
+ const name = await storeLogin(candidate, me, { minted: false });
54
81
  const prefix = me.key?.prefix?.trim() || "msk_live_…";
55
- await present("Login", [` Verified ${prefix}… · saved to ${configPath()}`]);
82
+ await present("Login", [` Logged in as ${name}. Verified ${prefix}… · saved to ${configPath()}`]);
56
83
  }
57
84
  export function pendingLoginPath() {
58
85
  return path.join(mergestormHome(), "login-pending.json");
@@ -140,12 +167,21 @@ async function pollDeviceLogin(pending, retryCommand, recovery) {
140
167
  throw new CommandError(`Login failed: network error. Check your connection and run \`${retryCommand}\` again.`);
141
168
  }
142
169
  if (poll.status === 200 && poll.body?.api_key) {
143
- const next = await loadConfig();
144
- next.apiKey = poll.body.api_key;
145
- next.apiBase = pending.apiBase;
146
- await saveConfig(next);
170
+ const minted = { apiKey: poll.body.api_key, apiBase: pending.apiBase, ignoreEnv: true };
147
171
  await clearPendingLogin(pending.deviceCode);
148
- await present("Login", [` Logged in. Key saved to ${configPath()}`]);
172
+ let me = accountLabel(poll.body?.account)
173
+ ? { account: poll.body.account }
174
+ : null;
175
+ if (!me) {
176
+ try {
177
+ me = await getMe(minted, { timeoutMs: 8_000 });
178
+ }
179
+ catch {
180
+ me = null;
181
+ }
182
+ }
183
+ const name = await storeLogin(minted, me, { minted: true });
184
+ await present("Login", [` Logged in as ${name}. Key saved to ${configPath()}`]);
149
185
  return;
150
186
  }
151
187
  const err = poll.body?.error;
@@ -177,7 +213,7 @@ async function pollDeviceLogin(pending, retryCommand, recovery) {
177
213
  throw new CommandError(`Login timed out. ${recovery}`);
178
214
  }
179
215
  async function loginStart() {
180
- const cfg = await loadConfig();
216
+ const cfg = await loginConfig();
181
217
  const base = apiBase(cfg);
182
218
  const pending = pendingFromStart(base, await startDeviceLogin(base));
183
219
  await savePendingLogin(pending);
@@ -213,7 +249,7 @@ export async function cmdLogin(args, deps = { openBrowser }) {
213
249
  await loginFinish();
214
250
  return;
215
251
  }
216
- const cfg = await loadConfig();
252
+ const cfg = await loginConfig();
217
253
  const base = apiBase(cfg);
218
254
  const pending = pendingFromStart(base, await startDeviceLogin(base));
219
255
  console.log("To sign in, open this URL and confirm the code:\n");
@@ -1,12 +1,11 @@
1
- import { clearConfig } from "../config.js";
1
+ import { loadConfig } from "../config.js";
2
2
  import { CommandError } from "../errors.js";
3
3
  import { present } from "../ui/present.js";
4
+ import { logoutAccount } from "./account.js";
4
5
  export async function cmdLogout() {
5
- try {
6
- await clearConfig();
7
- await present("Logout", [" Logged out. Removed stored API key."]);
8
- }
9
- catch (err) {
10
- throw new CommandError(`Could not remove config: ${err instanceof Error ? err.message : String(err)}`);
6
+ const cfg = await loadConfig();
7
+ if (!cfg.account) {
8
+ throw new CommandError("Not logged in. Nothing to log out of.");
11
9
  }
10
+ await present("Logout", await logoutAccount(cfg.account));
12
11
  }