mergestorm 0.3.63 → 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 (79) hide show
  1. package/README.md +30 -10
  2. package/dist/api.d.ts +68 -11
  3. package/dist/api.js +206 -8
  4. package/dist/automation-catalog.d.ts +17 -3
  5. package/dist/automation-catalog.js +4 -1
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +6 -4
  8. package/dist/client/index.js +6 -4
  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 +29 -0
  22. package/dist/commands/memory.js +154 -0
  23. package/dist/commands/registry.d.ts +1 -0
  24. package/dist/commands/registry.js +52 -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 -4
  30. package/dist/commands/settings.js +51 -5
  31. package/dist/commands/stack.d.ts +20 -5
  32. package/dist/commands/stack.js +106 -7
  33. package/dist/commands/whoami.js +14 -10
  34. package/dist/config.d.ts +56 -2
  35. package/dist/config.js +271 -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 +74 -0
  42. package/dist/memory-api.js +69 -0
  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 +5 -26
  50. package/dist/server-publish.js +47 -40
  51. package/dist/stack-blockers.d.ts +15 -36
  52. package/dist/stack-blockers.js +69 -33
  53. package/dist/stack-dto.d.ts +4 -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 +35 -14
  59. package/dist/stack-watch.d.ts +2 -1
  60. package/dist/stack-watch.js +6 -2
  61. package/dist/submit-pr-base.d.ts +0 -1
  62. package/dist/submit-pr-base.js +1 -1
  63. package/dist/ui/ansi.d.ts +0 -10
  64. package/dist/ui/ansi.js +2 -2
  65. package/dist/ui/banner.d.ts +3 -1
  66. package/dist/ui/banner.js +13 -3
  67. package/dist/ui/box.d.ts +0 -2
  68. package/dist/ui/box.js +1 -1
  69. package/dist/ui/prompt.d.ts +0 -11
  70. package/dist/ui/prompt.js +7 -7
  71. package/dist/ui/tabs.d.ts +2 -2
  72. package/dist/ui/tabs.js +1 -1
  73. package/dist/ui/width.d.ts +0 -9
  74. package/dist/ui/width.js +3 -3
  75. package/dist/web-api.d.ts +17 -0
  76. package/dist/web-api.js +30 -0
  77. package/package.json +3 -1
  78. package/skill/SKILL.md +4 -0
  79. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
@@ -1,6 +1,6 @@
1
- import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
1
+ import { type StackRepairHint } from "../stack-blockers.js";
2
2
  import { type StackWatchObligation } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getStackPublication, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getIssueReview, getStackPublication, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { createPrs, findOpenPrNumber } from "../gh.js";
6
6
  import { parseGithubOriginRepo } from "../git.js";
@@ -11,9 +11,7 @@ import { gitTopLevel } from "../repo-identity.js";
11
11
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
12
12
  import { pollStackWatch } from "../stack-watch.js";
13
13
  import { runStackWatch } from "../stack-watch-loop.js";
14
- export declare function stackHeldLine(held: StackHeldBlocker | null | undefined): string[];
15
14
  export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
16
- export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
17
15
  export declare function openStackWatch(stackId: string, opts?: {
18
16
  unread?: boolean;
19
17
  }): StackWatchObligation;
@@ -128,6 +126,22 @@ export type StackCreateDeps = {
128
126
  };
129
127
  export declare function cmdStackCreate(args: string[], deps?: StackCreateDeps): Promise<void>;
130
128
  /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
129
+ export declare function issueReviewsEnabledFor(cfg: Awaited<ReturnType<typeof loadConfig>>, owner: string, repo: string): Promise<boolean>;
130
+ export type IssueGateLayer = {
131
+ branch: string;
132
+ texts: string[];
133
+ };
134
+ export type IssueGateDeps = {
135
+ getIssueReview?: typeof getIssueReview;
136
+ issueReviewsEnabled?: typeof issueReviewsEnabledFor;
137
+ };
138
+ export declare function assertLinkedIssuesApproved(input: {
139
+ cfg: Awaited<ReturnType<typeof loadConfig>>;
140
+ owner: string;
141
+ repo: string;
142
+ layers: readonly IssueGateLayer[];
143
+ warn: (line: string) => void;
144
+ }, deps?: IssueGateDeps): Promise<void>;
131
145
  export type StackSubmitDeps = {
132
146
  cwd?: string;
133
147
  gitTopLevel?: (cwd?: string) => string;
@@ -155,7 +169,7 @@ export type StackSubmitDeps = {
155
169
  adoptStack?: typeof adoptStack;
156
170
  ensureUpperPark?: typeof ensureUpperPark;
157
171
  env?: NodeJS.ProcessEnv;
158
- };
172
+ } & IssueGateDeps;
159
173
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
160
174
  export declare function parseStackPublishArgs(args: string[]): {
161
175
  branch?: string;
@@ -180,6 +194,7 @@ export declare function cmdStackWatch(args: string[], deps?: {
180
194
  loadConfig?: typeof loadConfig;
181
195
  runStackWatch?: typeof runStackWatch;
182
196
  signal?: AbortSignal;
197
+ writeMarker?: (line: string) => void;
183
198
  }): Promise<void>;
184
199
  export declare function cmdStackStatus(args: string[], deps?: {
185
200
  loadConfig?: typeof loadConfig;
@@ -1,17 +1,17 @@
1
1
  import { cycloneStatusWords, stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
2
2
  import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getStackPublication, landNextStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, restackStack, setStackPolicy, } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, getIssueReview, getSettings, getStackPublication, issueNotApprovedError, landNextStack, listRepoSettings, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, restackStack, setStackPolicy, } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
6
6
  import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
7
7
  import { parseGithubOriginRepo } from "../git.js";
8
8
  import { branchExists, commitsAheadOf, createBranchFrom, createBranchFromHead, trunkStartRef, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
9
- import { buildPrBodyFromCommit } from "../pr-body.js";
9
+ import { buildPrBodyFromCommit, linkedIssueNumbers } from "../pr-body.js";
10
10
  import { publishBranchServerSide, assertExplicitStacking, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
11
11
  import { gitTopLevel } from "../repo-identity.js";
12
12
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
13
13
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
14
- import { runStackWatch, STACK_WATCH_EXIT } from "../stack-watch-loop.js";
14
+ import { runStackWatch, STACK_WATCH_EXIT, stackWatchFailedLine } from "../stack-watch-loop.js";
15
15
  import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
16
16
  import { ansi } from "../ui/ansi.js";
17
17
  import { runLineTabsBrowser } from "../ui/line-tabs.js";
@@ -19,7 +19,7 @@ import { present } from "../ui/present.js";
19
19
  import { canBrowse } from "./browse.js";
20
20
  import { isTransientReviewPollError } from "./review-client.js";
21
21
  import { openHelpBrowser } from "./help.js";
22
- export function stackHeldLine(held) {
22
+ function stackHeldLine(held) {
23
23
  if (!held)
24
24
  return [];
25
25
  const agents = held.waitingOn.map((agent) => agent === "cyclone" ? "Cyclone" : "Vortex").join(" and ");
@@ -32,7 +32,7 @@ export function stackRepairLine(repair) {
32
32
  const note = "note" in repair && repair.note ? ` ${repair.note}` : "";
33
33
  return [`Repair #${repair.prNumber} ${repair.kind}${files}: ${repair.steps}${note}`];
34
34
  }
35
- export function stackWatchTextLines(watch) {
35
+ function stackWatchTextLines(watch) {
36
36
  if (!watch)
37
37
  return [];
38
38
  if (watch.done)
@@ -62,7 +62,8 @@ const STACK_USAGE = `usage:
62
62
  mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]
63
63
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
64
64
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
65
- error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
65
+ error twice, or 15 minutes with no stack read: timeouts, 5xx and rate limits, which it retries with backoff).
66
+ It also prints MS-WATCH ATTENTION failed when it cannot start (bad flags, no config), so it never exits without an MS-WATCH line unless you stop it; prints MS-WATCH LANDED
66
67
  and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
67
68
  An ATTENTION line for a merge queue bounce ends with restart="mg stack watch <id> --bounce-id <id> --after-finished-at <iso>":
68
69
  restart with that command (plus --head <sha> after a push) so a bounce you already handled is not reported again.
@@ -679,6 +680,65 @@ export async function cmdStackCreate(args, deps = {}) {
679
680
  created.push(ansi.dim(" authoring state: managed automatically outside the repository"));
680
681
  await present("Stack", created);
681
682
  }
683
+ /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
684
+ export async function issueReviewsEnabledFor(cfg, owner, repo) {
685
+ const repos = await listRepoSettings(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
686
+ const row = repos.find((r) => r.owner.toLowerCase() === owner.toLowerCase() && r.repo.toLowerCase() === repo.toLowerCase());
687
+ if (typeof row?.issue_review_enabled === "boolean")
688
+ return row.issue_review_enabled;
689
+ const settings = await getSettings(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
690
+ if (!settings)
691
+ throw new CommandError("Could not read the account settings");
692
+ return settings.issue_review_enabled === true;
693
+ }
694
+ export async function assertLinkedIssuesApproved(input, deps = {}) {
695
+ const { cfg, owner, repo, layers, warn } = input;
696
+ if (layers.length === 0)
697
+ return;
698
+ const getIssueReviewFn = deps.getIssueReview ?? getIssueReview;
699
+ const unverified = (err) => {
700
+ if (err instanceof CommandError && (err.code === "auth_invalid" || err.code === "missing_api_key" || err.code === "rate_limited"))
701
+ return err;
702
+ return new CommandError(`Could not verify the Vortex reviews of the linked issues (${err instanceof Error ? err.message : String(err)}); ` +
703
+ "refusing to submit. Retry when the API is reachable.");
704
+ };
705
+ const linked = layers.map((layer) => ({ branch: layer.branch, numbers: linkedIssueNumbers({ owner, repo }, ...layer.texts) }));
706
+ const statuses = [];
707
+ for (const number of [...new Set(linked.flatMap((layer) => layer.numbers))].sort((a, b) => a - b)) {
708
+ let status;
709
+ try {
710
+ status = await getIssueReviewFn(cfg, { owner, repo, number }, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
711
+ }
712
+ catch (err) {
713
+ throw unverified(err);
714
+ }
715
+ if (!status) {
716
+ warn(ansi.dim(" This Mergestorm server does not check issue reviews yet; submitting without the issue gate."));
717
+ continue;
718
+ }
719
+ statuses.push(status);
720
+ }
721
+ const unlinked = linked.filter((layer) => layer.numbers.length === 0);
722
+ if (unlinked.length > 0) {
723
+ let enabled;
724
+ try {
725
+ enabled = await (deps.issueReviewsEnabled ?? issueReviewsEnabledFor)(cfg, owner, repo);
726
+ }
727
+ catch (err) {
728
+ throw unverified(err);
729
+ }
730
+ if (enabled) {
731
+ throw new CommandError([
732
+ `Issue reviews are on for ${owner}/${repo}, and ${unlinked.map((layer) => layer.branch).join(", ")} links no issue.`,
733
+ "Open one with `mergestorm issue create --title <problem> --wait`, answer the Vortex review until it is approved,",
734
+ "then add `Fixes #N` to the tip commit message and submit again.",
735
+ ].join("\n"), REVIEW_EXIT.failed, "issue_not_approved");
736
+ }
737
+ }
738
+ const blocking = statuses.filter((status) => status.state !== "approved" && status.state !== "off");
739
+ if (blocking.length > 0)
740
+ throw issueNotApprovedError(blocking);
741
+ }
682
742
  export async function cmdStackSubmit(args, deps = {}) {
683
743
  const { extend, autoLand, autoReview, autoPatch, autoResolveConflicts, autoFixCi, rest, serverSide, asJson } = parseStackSubmitArgs(args);
684
744
  const api = rest ? "rest" : githubApiFromEnv(deps.env ?? process.env);
@@ -802,6 +862,21 @@ export async function cmdStackSubmit(args, deps = {}) {
802
862
  for (const plan of plans) {
803
863
  existingPrs.set(plan.branch, await opener.findOpen(plan.branch));
804
864
  }
865
+ await assertLinkedIssuesApproved({
866
+ cfg: await loadConfigFn(),
867
+ owner,
868
+ repo,
869
+ layers: plans
870
+ .filter((plan) => existingPrs.get(plan.branch) == null)
871
+ .map((plan) => {
872
+ if (!branchExistsFn(plan.branch, cwd)) {
873
+ throw new CommandError(`Stack layer branch missing locally: ${plan.branch}`);
874
+ }
875
+ const message = tipCommitMessageFn(plan.branch, cwd);
876
+ return { branch: plan.branch, texts: [message, buildPrBodyFn(message)] };
877
+ }),
878
+ warn: progress,
879
+ }, deps);
805
880
  const newHeads = plans
806
881
  .filter((plan) => existingPrs.get(plan.branch) == null)
807
882
  .map((plan) => plan.branch);
@@ -1213,7 +1288,7 @@ export async function cmdStackWait(args, deps = {}) {
1213
1288
  throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
1214
1289
  }
1215
1290
  }
1216
- export async function cmdStackWatch(args, deps = {}) {
1291
+ async function startStackWatch(args, deps) {
1217
1292
  const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]";
1218
1293
  const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
1219
1294
  const positional = [];
@@ -1286,6 +1361,30 @@ export async function cmdStackWatch(args, deps = {}) {
1286
1361
  ...(afterFinishedAt ? { afterFinishedAt } : {}),
1287
1362
  } } : {}),
1288
1363
  });
1364
+ return { stackId, result };
1365
+ }
1366
+ export async function cmdStackWatch(args, deps = {}) {
1367
+ let started;
1368
+ try {
1369
+ started = await startStackWatch(args, deps);
1370
+ }
1371
+ catch (err) {
1372
+ const valueFlags = new Set(["--until", "--ignore", "--max", "--head", "--bounce-id", "--after-finished-at"]);
1373
+ let json = false;
1374
+ for (let i = 0; i < args.length; i++) {
1375
+ if (args[i] === "--json")
1376
+ json = true;
1377
+ else if (valueFlags.has(args[i]))
1378
+ i++;
1379
+ }
1380
+ const marker = deps.writeMarker ?? (json ? console.error : console.log);
1381
+ const stackId = args.find((arg, i) => UUID_RE.test(arg.trim()) && !valueFlags.has(args[i - 1]))?.trim() ?? "none";
1382
+ marker(stackWatchFailedLine(stackId, err instanceof Error ? err.message : String(err)));
1383
+ if (json)
1384
+ console.log(JSON.stringify({ stackId, outcome: "failed" }));
1385
+ throw err;
1386
+ }
1387
+ const { stackId, result } = started;
1289
1388
  if (result.exitCode === STACK_WATCH_EXIT.landed)
1290
1389
  return;
1291
1390
  const reason = result.outcome === "timeout"
@@ -1,9 +1,8 @@
1
- import { getMe } from "../api.js";
2
- import { apiBase, configPath, loadConfig, resolveApiKey } from "../config.js";
1
+ import { accountLabel, getMe } from "../api.js";
2
+ import { apiBase, configPath, listAccounts, loadConfig, resolveApiKey, settleAccountName } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
4
  import { ansi } from "../ui/ansi.js";
5
5
  import { present } from "../ui/present.js";
6
- import { canBrowse, openTabsBrowser } from "./browse.js";
7
6
  export async function cmdWhoami(args, opts = {}) {
8
7
  const asJson = args.includes("--json");
9
8
  const cfg = await loadConfig();
@@ -11,15 +10,17 @@ export async function cmdWhoami(args, opts = {}) {
11
10
  if (!key) {
12
11
  throw new CommandError("Not logged in. Run `mergestorm login` (agents: `mergestorm login --start`, then `--finish`).");
13
12
  }
14
- if (!asJson && canBrowse()) {
15
- await openTabsBrowser("status");
16
- return;
17
- }
18
13
  const me = await getMe(cfg);
19
14
  if (!me) {
20
15
  throw new CommandError("Live account details unavailable. Could not verify account with /me.");
21
16
  }
17
+ const account = (await settleAccountName(cfg, me)) ?? accountLabel(me.account);
18
+ const accounts = (await listAccounts()).map((a) => a.name);
22
19
  const payload = {
20
+ account,
21
+ accounts,
22
+ github_login: me.account?.github_login ?? null,
23
+ email: me.account?.email ?? null,
23
24
  key_prefix: me.key.prefix,
24
25
  key_name: me.key.name ?? null,
25
26
  plan_key: me.plan_key ?? null,
@@ -37,9 +38,12 @@ export async function cmdWhoami(args, opts = {}) {
37
38
  console.log(text);
38
39
  return;
39
40
  }
40
- const lines = [
41
- ` Key ${ansi.bold(String(payload.key_prefix))}${payload.key_name ? ` (${payload.key_name})` : ""}`,
42
- ];
41
+ const lines = [` Account ${account ? ansi.bold(account) : "unknown (API too old to say)"}`];
42
+ lines.push(` Key ${ansi.bold(String(payload.key_prefix))}${payload.key_name ? ` (${payload.key_name})` : ""}`);
43
+ const others = accounts.filter((name) => name !== account);
44
+ if (others.length > 0) {
45
+ lines.push(` Others ${others.join(", ")} ${ansi.dim("· mergestorm account use <name>")}`);
46
+ }
43
47
  if (payload.plan_key) {
44
48
  lines.push(` Plan ${payload.plan_label_key ?? payload.plan_key}`);
45
49
  }
package/dist/config.d.ts CHANGED
@@ -1,15 +1,69 @@
1
1
  export declare const DEFAULT_API = "https://api.mergestorm.ai";
2
+ export declare const ACCOUNT_LIMIT = 3;
2
3
  export type Config = {
3
4
  apiKey?: string;
4
5
  apiBase?: string;
5
6
  ignoreEnv?: boolean;
7
+ account?: string;
8
+ };
9
+ export type StoredAccount = {
10
+ apiKey: string;
11
+ apiBase?: string;
12
+ };
13
+ export type AccountStore = {
14
+ accounts: Record<string, StoredAccount>;
15
+ activeAccount?: string;
16
+ apiBase?: string;
17
+ ignoreEnv?: boolean;
18
+ };
19
+ type StoreFile = AccountStore & {
20
+ apiKey?: string;
21
+ };
22
+ export type AccountSummary = {
23
+ name: string;
24
+ active: boolean;
25
+ keyPrefix: string;
26
+ apiBase: string;
27
+ };
28
+ export type NamedMe = {
29
+ account?: {
30
+ github_login?: string | null;
31
+ email?: string | null;
32
+ } | null;
6
33
  };
7
34
  export declare function mergestormHome(env?: NodeJS.ProcessEnv): string;
8
35
  export declare function configPath(): string;
36
+ export declare function provisionalAccountName(apiKey: string): string;
37
+ export declare function accountNameFromMe(me: NamedMe | null | undefined): string | null;
38
+ export declare function serializeStore(store: AccountStore): StoreFile;
39
+ export declare function migrateStore(raw: unknown): {
40
+ store: AccountStore;
41
+ migrated: boolean;
42
+ };
43
+ export declare function writeStore(store: AccountStore): Promise<void>;
44
+ export declare function readStore(): Promise<AccountStore>;
45
+ export declare function withAccount<T>(name: string | undefined, fn: () => Promise<T>): Promise<T>;
46
+ export declare function requestedAccount(store: AccountStore, env?: NodeJS.ProcessEnv): string | undefined;
47
+ export declare function selectAccountName(store: AccountStore, requested: string | undefined): string | undefined;
48
+ export declare function configFor(store: AccountStore, name: string | undefined): Config;
9
49
  export declare function loadConfig(): Promise<Config>;
10
- export declare function saveConfig(cfg: Config): Promise<void>;
11
- export declare function clearConfig(): Promise<void>;
50
+ export declare function accountLimitMessage(store: AccountStore): string;
51
+ export declare function saveAccount(name: string, account: StoredAccount, opts?: {
52
+ activate?: boolean;
53
+ }): Promise<AccountStore>;
54
+ export declare function useAccount(name: string): Promise<AccountStore>;
55
+ export declare function removeAccount(name: string): Promise<AccountStore>;
56
+ export declare function renameAccount(from: string, to: string): Promise<AccountStore>;
57
+ export declare function summarizeAccounts(store: AccountStore): AccountSummary[];
58
+ export declare function listAccounts(): Promise<AccountSummary[]>;
59
+ export declare function settleAccountName(cfg: Config, me: NamedMe | null | undefined): Promise<string | undefined>;
60
+ export declare function splitAccountFlag(args: readonly string[]): {
61
+ account?: string;
62
+ args: string[];
63
+ };
12
64
  export declare function apiBase(cfg: Config): string;
65
+ export declare function resolveChatId(cfg: Config): string | undefined;
13
66
  export declare function resolveApiKey(cfg: Config): string | undefined;
14
67
  /** Short display form of a stored key (prefix only). */
15
68
  export declare function keyDisplay(key: string | undefined): string | null;
69
+ export {};
package/dist/config.js CHANGED
@@ -1,7 +1,10 @@
1
- import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
+ import { CommandError } from "./errors.js";
4
5
  export const DEFAULT_API = "https://api.mergestorm.ai";
6
+ export const ACCOUNT_LIMIT = 3;
7
+ const KEY_PREFIX_LENGTH = "msk_live_".length + 4;
5
8
  export function mergestormHome(env = process.env) {
6
9
  const override = env.MERGESTORM_HOME?.trim();
7
10
  return override ? path.resolve(override) : path.join(homedir(), ".mergestorm");
@@ -9,30 +12,285 @@ export function mergestormHome(env = process.env) {
9
12
  export function configPath() {
10
13
  return path.join(mergestormHome(), "config.json");
11
14
  }
12
- export async function loadConfig() {
13
- try {
14
- const raw = await readFile(configPath(), "utf8");
15
- return JSON.parse(raw);
15
+ export function provisionalAccountName(apiKey) {
16
+ return apiKey.slice(0, KEY_PREFIX_LENGTH);
17
+ }
18
+ export function accountNameFromMe(me) {
19
+ const login = me?.account?.github_login?.trim();
20
+ if (login)
21
+ return login;
22
+ const email = me?.account?.email?.trim();
23
+ return email || null;
24
+ }
25
+ function cleanString(value) {
26
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
27
+ }
28
+ function compact(store) {
29
+ const out = { accounts: store.accounts };
30
+ if (store.activeAccount && store.accounts[store.activeAccount])
31
+ out.activeAccount = store.activeAccount;
32
+ if (store.apiBase)
33
+ out.apiBase = store.apiBase;
34
+ if (store.ignoreEnv)
35
+ out.ignoreEnv = true;
36
+ return out;
37
+ }
38
+ function storedAccount(apiKey, apiBase) {
39
+ return apiBase ? { apiKey, apiBase } : { apiKey };
40
+ }
41
+ export function serializeStore(store) {
42
+ const out = compact(store);
43
+ const active = out.activeAccount ? out.accounts[out.activeAccount] : undefined;
44
+ if (!active)
45
+ return out;
46
+ out.apiKey = active.apiKey;
47
+ if (active.apiBase)
48
+ out.apiBase = active.apiBase;
49
+ else
50
+ delete out.apiBase;
51
+ return out;
52
+ }
53
+ export function migrateStore(raw) {
54
+ const obj = raw && typeof raw === "object" ? raw : {};
55
+ const ignoreEnv = obj.ignoreEnv === true ? true : undefined;
56
+ const apiBase = cleanString(obj.apiBase);
57
+ const apiKey = cleanString(obj.apiKey);
58
+ if (obj.accounts && typeof obj.accounts === "object") {
59
+ const accounts = {};
60
+ for (const [name, value] of Object.entries(obj.accounts)) {
61
+ const entry = value && typeof value === "object" ? value : {};
62
+ const key = cleanString(entry.apiKey);
63
+ if (!key)
64
+ continue;
65
+ accounts[name] = storedAccount(key, cleanString(entry.apiBase));
66
+ }
67
+ const requested = cleanString(obj.activeAccount);
68
+ let activeAccount = requested && Object.hasOwn(accounts, requested) ? requested : Object.keys(accounts)[0];
69
+ let migrated = false;
70
+ if (apiKey) {
71
+ const owner = Object.keys(accounts).find((name) => accounts[name].apiKey === apiKey);
72
+ if (!owner) {
73
+ if (Object.keys(accounts).length < ACCOUNT_LIMIT) {
74
+ const name = provisionalAccountName(apiKey);
75
+ accounts[name] = storedAccount(apiKey, apiBase);
76
+ activeAccount = name;
77
+ migrated = true;
78
+ }
79
+ }
80
+ else if (owner !== activeAccount) {
81
+ activeAccount = owner;
82
+ migrated = true;
83
+ }
84
+ }
85
+ else if (activeAccount) {
86
+ migrated = true;
87
+ }
88
+ const store = compact({ accounts, activeAccount, apiBase: activeAccount ? undefined : apiBase, ignoreEnv });
89
+ return { store, migrated };
16
90
  }
17
- catch {
18
- return {};
91
+ if (!apiKey) {
92
+ return { store: compact({ accounts: {}, apiBase, ignoreEnv }), migrated: Object.keys(obj).length > 0 };
19
93
  }
94
+ const name = provisionalAccountName(apiKey);
95
+ return {
96
+ store: compact({ accounts: { [name]: storedAccount(apiKey, apiBase) }, activeAccount: name, ignoreEnv }),
97
+ migrated: true,
98
+ };
20
99
  }
21
- export async function saveConfig(cfg) {
22
- const dir = path.dirname(configPath());
23
- await mkdir(dir, { recursive: true });
24
- await writeFile(configPath(), JSON.stringify(cfg, null, 2) + "\n", {
100
+ export async function writeStore(store) {
101
+ const file = configPath();
102
+ await mkdir(path.dirname(file), { recursive: true });
103
+ await writeFile(file, JSON.stringify(serializeStore(store), null, 2) + "\n", {
25
104
  encoding: "utf8",
26
105
  mode: 0o600,
27
106
  });
28
107
  }
29
- export async function clearConfig() {
30
- await rm(configPath(), { force: true });
108
+ export async function readStore() {
109
+ let raw;
110
+ try {
111
+ raw = JSON.parse(await readFile(configPath(), "utf8"));
112
+ }
113
+ catch {
114
+ return { accounts: {} };
115
+ }
116
+ const { store, migrated } = migrateStore(raw);
117
+ if (migrated) {
118
+ try {
119
+ await writeStore(store);
120
+ }
121
+ catch {
122
+ return store;
123
+ }
124
+ }
125
+ return store;
126
+ }
127
+ function accountNames(store) {
128
+ return Object.keys(store.accounts);
129
+ }
130
+ function unknownAccountMessage(name, store) {
131
+ const names = accountNames(store);
132
+ if (names.length === 0) {
133
+ return `No account named "${name}". Nobody is logged in; run \`mergestorm login\`.`;
134
+ }
135
+ return `No account named "${name}". Accounts: ${names.join(", ")}.`;
136
+ }
137
+ let accountOverride;
138
+ export async function withAccount(name, fn) {
139
+ if (!name)
140
+ return fn();
141
+ const previous = accountOverride;
142
+ accountOverride = name;
143
+ try {
144
+ return await fn();
145
+ }
146
+ finally {
147
+ accountOverride = previous;
148
+ }
149
+ }
150
+ export function requestedAccount(store, env = process.env) {
151
+ if (accountOverride)
152
+ return accountOverride;
153
+ if (store.ignoreEnv)
154
+ return undefined;
155
+ return env.MERGESTORM_ACCOUNT?.trim() || undefined;
156
+ }
157
+ export function selectAccountName(store, requested) {
158
+ if (!requested)
159
+ return store.activeAccount;
160
+ if (Object.hasOwn(store.accounts, requested))
161
+ return requested;
162
+ throw new CommandError(unknownAccountMessage(requested, store));
163
+ }
164
+ export function configFor(store, name) {
165
+ const account = name ? store.accounts[name] : undefined;
166
+ const cfg = {};
167
+ if (account && name) {
168
+ cfg.apiKey = account.apiKey;
169
+ cfg.account = name;
170
+ }
171
+ const base = account?.apiBase ?? store.apiBase;
172
+ if (base)
173
+ cfg.apiBase = base;
174
+ if (store.ignoreEnv)
175
+ cfg.ignoreEnv = true;
176
+ return cfg;
177
+ }
178
+ export async function loadConfig() {
179
+ const store = await readStore();
180
+ return configFor(store, selectAccountName(store, requestedAccount(store)));
181
+ }
182
+ export function accountLimitMessage(store) {
183
+ return `Already ${ACCOUNT_LIMIT} accounts: ${accountNames(store).join(", ")}. Remove one with \`mergestorm account remove <name>\` first.`;
184
+ }
185
+ export async function saveAccount(name, account, opts = {}) {
186
+ const store = await readStore();
187
+ if (!Object.hasOwn(store.accounts, name) && accountNames(store).length >= ACCOUNT_LIMIT) {
188
+ throw new CommandError(accountLimitMessage(store), 1, "account_limit");
189
+ }
190
+ store.accounts[name] = storedAccount(account.apiKey, account.apiBase);
191
+ if (opts.activate || !store.activeAccount)
192
+ store.activeAccount = name;
193
+ await writeStore(store);
194
+ return store;
195
+ }
196
+ export async function useAccount(name) {
197
+ const store = await readStore();
198
+ if (!Object.hasOwn(store.accounts, name))
199
+ throw new CommandError(unknownAccountMessage(name, store));
200
+ store.activeAccount = name;
201
+ await writeStore(store);
202
+ return store;
203
+ }
204
+ export async function removeAccount(name) {
205
+ const store = await readStore();
206
+ if (!Object.hasOwn(store.accounts, name))
207
+ throw new CommandError(unknownAccountMessage(name, store));
208
+ delete store.accounts[name];
209
+ if (store.activeAccount === name)
210
+ store.activeAccount = accountNames(store)[0];
211
+ await writeStore(store);
212
+ return store;
213
+ }
214
+ export async function renameAccount(from, to) {
215
+ const store = await readStore();
216
+ const account = store.accounts[from];
217
+ if (!account || from === to)
218
+ return store;
219
+ if (Object.hasOwn(store.accounts, to)) {
220
+ throw new CommandError(`An account named "${to}" already exists, so ${from} keeps its name.`);
221
+ }
222
+ delete store.accounts[from];
223
+ store.accounts[to] = account;
224
+ if (store.activeAccount === from)
225
+ store.activeAccount = to;
226
+ await writeStore(store);
227
+ return store;
228
+ }
229
+ export function summarizeAccounts(store) {
230
+ return Object.entries(store.accounts).map(([name, account]) => ({
231
+ name,
232
+ active: name === store.activeAccount,
233
+ keyPrefix: provisionalAccountName(account.apiKey),
234
+ apiBase: (account.apiBase ?? store.apiBase ?? DEFAULT_API).replace(/\/$/, ""),
235
+ }));
236
+ }
237
+ export async function listAccounts() {
238
+ return summarizeAccounts(await readStore());
239
+ }
240
+ export async function settleAccountName(cfg, me) {
241
+ if (!cfg.account || !cfg.apiKey || resolveApiKey(cfg) !== cfg.apiKey)
242
+ return cfg.account;
243
+ if (cfg.account !== provisionalAccountName(cfg.apiKey))
244
+ return cfg.account;
245
+ const name = accountNameFromMe(me);
246
+ if (!name || name === cfg.account)
247
+ return cfg.account;
248
+ try {
249
+ await renameAccount(cfg.account, name);
250
+ }
251
+ catch {
252
+ return cfg.account;
253
+ }
254
+ return name;
255
+ }
256
+ export function splitAccountFlag(args) {
257
+ const rest = [];
258
+ let account;
259
+ for (let i = 0; i < args.length; i += 1) {
260
+ const arg = args[i];
261
+ let value;
262
+ if (arg === "--account") {
263
+ value = args[i + 1];
264
+ if (value === undefined || value.startsWith("-"))
265
+ value = "";
266
+ else
267
+ i += 1;
268
+ }
269
+ else if (arg.startsWith("--account=")) {
270
+ value = arg.slice("--account=".length);
271
+ }
272
+ else {
273
+ rest.push(arg);
274
+ continue;
275
+ }
276
+ if (!value.trim()) {
277
+ throw new CommandError("--account needs an account name. `mergestorm account list` shows them.", 1, "usage");
278
+ }
279
+ account = value.trim();
280
+ }
281
+ return account ? { account, args: rest } : { args: rest };
31
282
  }
32
283
  export function apiBase(cfg) {
33
284
  const fromEnv = cfg.ignoreEnv ? undefined : process.env.MERGESTORM_API_URL?.trim();
34
285
  return (fromEnv || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
35
286
  }
287
+ const CHAT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
288
+ export function resolveChatId(cfg) {
289
+ if (cfg.ignoreEnv)
290
+ return undefined;
291
+ const chatId = process.env.MERGESTORM_CHAT_ID?.trim() ?? "";
292
+ return CHAT_ID.test(chatId) ? chatId.toLowerCase() : undefined;
293
+ }
36
294
  export function resolveApiKey(cfg) {
37
295
  if (cfg.ignoreEnv)
38
296
  return cfg.apiKey;
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "account_limit" | "surge_other_account" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "git_failed" | "unknown_label" | "invalid_input" | "memory_off" | "memory_full" | "memory_similar" | "memory_unavailable" | "web_unavailable" | "issue_not_approved" | "issue_review_unavailable";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;