copillm 0.3.0-beta.2 → 0.3.0-beta.4

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.
@@ -1,3 +1,4 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
1
2
  import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
2
3
  import { readAccountsIndex, findAccount, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
3
4
  import { addAccount, listAccountsDetailed, removeAccountAndCredential, removeAllAccounts, switchDefaultAccount } from "../../auth/accountManager.js";
@@ -11,26 +12,37 @@ import { writeCommandOutput } from "../shared/output.js";
11
12
  /**
12
13
  * Derive a friendly, path-safe account id from the GitHub login behind a token.
13
14
  * Returns null when the lookup fails or the login isn't a valid id.
15
+ *
16
+ * Multi-account routing now depends on this, so it retries a few times with a
17
+ * generous timeout: GitHub's `/user` can briefly throttle or lag right after a
18
+ * device-flow token exchange, and a single failed probe must not cause the
19
+ * caller to mis-identify (and overwrite) the wrong account.
14
20
  */
15
21
  async function deriveAccountId(token) {
16
- let identity;
17
- try {
18
- identity = await inspectGithubIdentity({ token });
19
- }
20
- catch {
21
- return null;
22
- }
23
- const login = identity?.login;
24
- if (!login) {
25
- return null;
26
- }
27
- try {
28
- assertValidAccountId(login);
29
- return login;
30
- }
31
- catch {
32
- return null;
22
+ const attempts = 3;
23
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
24
+ let identity = null;
25
+ try {
26
+ identity = await inspectGithubIdentity({ token, timeoutMs: 8_000 });
27
+ }
28
+ catch {
29
+ identity = null;
30
+ }
31
+ const login = identity?.login;
32
+ if (login) {
33
+ try {
34
+ assertValidAccountId(login);
35
+ return login;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ if (attempt < attempts) {
42
+ await sleep(400 * attempt);
43
+ }
33
44
  }
45
+ return null;
34
46
  }
35
47
  export async function runAuthLogin(opts, options) {
36
48
  if (options.forceSession) {
@@ -72,45 +84,62 @@ export async function runAuthLogin(opts, options) {
72
84
  return;
73
85
  }
74
86
  // ---- No name: auto-manage by the token's GitHub login -----------------
75
- // Deriving the login lets `copillm auth login` recognise when a *different*
76
- // GitHub account is signing in and keep both, instead of silently
77
- // overwriting the previous one. A same-account re-login refreshes in place.
87
+ // Identify the account from its GitHub login so a second `auth login` for a
88
+ // DIFFERENT account is kept alongside the first instead of overwriting it.
89
+ // The cardinal rule: never replace an existing credential unless we have
90
+ // positively confirmed it's the SAME GitHub account.
78
91
  const newLogin = await deriveAccountId(token);
92
+ const existing = await loadStoredCredential();
93
+ if (!existing) {
94
+ // Nothing stored yet.
95
+ if (index) {
96
+ // An index exists but its default account has no credential — restore it.
97
+ const targetId = newLogin ?? index.defaultAccount;
98
+ const result = await addAccount({ id: targetId, accountType, token, mode: saveMode });
99
+ emitLoginResult(opts, result, !result.isDefault);
100
+ return;
101
+ }
102
+ // Fresh single-account install: store without creating an index.
103
+ const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
104
+ writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
105
+ status: "ok",
106
+ action: "login",
107
+ credential_backend: backend
108
+ });
109
+ return;
110
+ }
111
+ // A credential already exists. We must know which GitHub account just signed
112
+ // in before we touch anything, or we risk clobbering a different account.
113
+ if (!newLogin) {
114
+ writeCommandOutput(opts, "Login failed: couldn't verify which GitHub account you signed in as (the GitHub user lookup didn't succeed). " +
115
+ "Your existing credentials were left untouched. Re-run `copillm auth login`, or name this account explicitly with `copillm auth login --as <name>`.", { status: "error", action: "login", error: "github_identity_unresolved" });
116
+ process.exitCode = 1;
117
+ return;
118
+ }
79
119
  if (index) {
80
- // Add the new login as its own account (or refresh it if already known);
81
- // when the login can't be determined, refresh the default account.
82
- const targetId = newLogin ?? index.defaultAccount;
83
- const wasKnown = findAccount(targetId) !== null;
84
- const result = await addAccount({ id: targetId, accountType, token, mode: saveMode });
120
+ // Add the new login as its own account, or refresh it in place if known.
121
+ const wasKnown = findAccount(newLogin) !== null;
122
+ const result = await addAccount({ id: newLogin, accountType, token, mode: saveMode });
85
123
  emitLoginResult(opts, result, !wasKnown && !result.isDefault);
86
124
  return;
87
125
  }
88
- // No accounts index yet. If a *different* GitHub account is signing in, move
89
- // to multi-account instead of overwriting the existing single credential.
90
- if (newLogin) {
91
- const existing = await loadStoredCredential();
92
- if (existing) {
93
- const existingLogin = await deriveAccountId(existing.token);
94
- if (existingLogin !== newLogin) {
95
- // Different (or undeterminable) account → preserve the prior login as
96
- // the default and add the new one alongside it.
97
- registerExistingCredentialAsDefault(existingLogin ?? "default", existing.accountType);
98
- const result = await addAccount({ id: newLogin, accountType, token, mode: saveMode });
99
- emitLoginResult(opts, result, !result.isDefault);
100
- return;
101
- }
102
- // Same account → fall through to an in-place single-account refresh.
103
- }
126
+ // No index yet. Compare against the existing single account.
127
+ const existingLogin = await deriveAccountId(existing.token);
128
+ if (existingLogin === newLogin) {
129
+ // Confirmed the SAME account → refresh in place, no index created.
130
+ const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
131
+ writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
132
+ status: "ok",
133
+ action: "login",
134
+ credential_backend: backend
135
+ });
136
+ return;
104
137
  }
105
- // Single-account login: no prior credential, the same account re-logging in,
106
- // or an undeterminable login. Preserve the historical behaviour exactly
107
- // legacy storage, no accounts index created.
108
- const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
109
- writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
110
- status: "ok",
111
- action: "login",
112
- credential_backend: backend
113
- });
138
+ // A different (or unverifiable) prior account transition to multi-account,
139
+ // preserving the prior login as the default and adding the new one.
140
+ registerExistingCredentialAsDefault(existingLogin ?? "default", existing.accountType);
141
+ const result = await addAccount({ id: newLogin, accountType, token, mode: saveMode });
142
+ emitLoginResult(opts, result, !result.isDefault);
114
143
  }
115
144
  function emitLoginResult(opts, result, hintSwitch) {
116
145
  const defaultSuffix = result.isDefault ? " (default)" : "";
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.3.0-beta.2"
4
+ version: "0.3.0-beta.4"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -1,9 +1,18 @@
1
1
  import { setTimeout as defaultSleep } from "node:timers/promises";
2
+ import { createHash } from "node:crypto";
2
3
  import { githubUserUrl } from "../config/upstream.js";
3
4
  import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "./upstream/retryPolicy.js";
4
5
  const CACHE_TTL_MS = 5 * 60 * 1_000;
5
6
  const DEFAULT_MAX_ATTEMPTS = 3;
6
- let cached = null;
7
+ // Cache keyed by a hash of the GitHub token. It MUST be per-token: different
8
+ // tokens identify different GitHub accounts, and a token-blind cache returns
9
+ // one account's identity for another's — which silently broke multi-account
10
+ // `auth login` (a second login appeared to be the same account and overwrote
11
+ // the first). Hashing avoids retaining raw tokens as map keys.
12
+ const cache = new Map();
13
+ function cacheKey(token) {
14
+ return createHash("sha256").update(token).digest("hex");
15
+ }
7
16
  /**
8
17
  * Fetch the GitHub user summary with bounded retries on transient failures.
9
18
  *
@@ -20,8 +29,10 @@ let cached = null;
20
29
  */
21
30
  export async function getGithubUserSummary(githubToken, options = {}) {
22
31
  const now = Date.now();
23
- if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
24
- return cached.summary;
32
+ const key = cacheKey(githubToken);
33
+ const hit = cache.get(key);
34
+ if (hit && now - hit.fetchedAt < CACHE_TTL_MS) {
35
+ return hit.summary;
25
36
  }
26
37
  const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
27
38
  const sleepImpl = options.sleepImpl ?? ((ms) => defaultSleep(ms));
@@ -60,7 +71,7 @@ export async function getGithubUserSummary(githubToken, options = {}) {
60
71
  html_url: typeof payload.html_url === "string" ? payload.html_url : null,
61
72
  plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
62
73
  };
63
- cached = { fetchedAt: Date.now(), summary };
74
+ cache.set(key, { fetchedAt: Date.now(), summary });
64
75
  return summary;
65
76
  }
66
77
  // Non-OK. 401/403/404 are terminal — fast-fail. Other retryable statuses
@@ -79,7 +90,7 @@ export async function getGithubUserSummary(githubToken, options = {}) {
79
90
  throw lastError ?? new Error("GitHub user lookup exhausted retries without error context.");
80
91
  }
81
92
  export function clearGithubUserCache() {
82
- cached = null;
93
+ cache.clear();
83
94
  }
84
95
  export class GithubUserFetchError extends Error {
85
96
  status;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.3.0-beta.2",
3
+ "version": "0.3.0-beta.4",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",