copillm 0.3.0-beta.1 → 0.3.0-beta.3

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,5 +1,6 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
1
2
  import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
2
- import { readAccountsIndex, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
3
+ import { readAccountsIndex, findAccount, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
3
4
  import { addAccount, listAccountsDetailed, removeAccountAndCredential, removeAllAccounts, switchDefaultAccount } from "../../auth/accountManager.js";
4
5
  import { loginViaDeviceFlow } from "../../auth/deviceFlow.js";
5
6
  import { inspectGithubIdentity } from "../../auth/githubIdentity.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) {
@@ -53,9 +65,41 @@ export async function runAuthLogin(opts, options) {
53
65
  const token = await loginViaDeviceFlow();
54
66
  const saveMode = options.forceSession ? "session" : "auto";
55
67
  const index = readAccountsIndex();
56
- // Pure single-account login: no index and no explicit name. Preserve the
57
- // historical behaviour exactly — legacy storage, no accounts index created.
58
- if (!index && !namedRequested) {
68
+ // ---- Explicit name (`--as`) -------------------------------------------
69
+ if (namedRequested) {
70
+ const accountId = opts.as.trim();
71
+ // First time we materialize the index via an explicit name: preserve any
72
+ // pre-existing single account as the default so its token isn't clobbered.
73
+ if (!index) {
74
+ const existing = await loadStoredCredential();
75
+ if (existing) {
76
+ const existingId = (await deriveAccountId(existing.token)) ?? "default";
77
+ if (existingId !== accountId) {
78
+ registerExistingCredentialAsDefault(existingId, existing.accountType);
79
+ }
80
+ }
81
+ }
82
+ const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
83
+ emitLoginResult(opts, result, false);
84
+ return;
85
+ }
86
+ // ---- No name: auto-manage by the token's GitHub login -----------------
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.
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.
59
103
  const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
60
104
  writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
61
105
  status: "ok",
@@ -64,21 +108,45 @@ export async function runAuthLogin(opts, options) {
64
108
  });
65
109
  return;
66
110
  }
67
- const accountId = namedRequested ? opts.as.trim() : index.defaultAccount;
68
- // First time we materialize the index via an explicit name: preserve any
69
- // pre-existing single account as the default so its token isn't clobbered.
70
- if (!index && namedRequested) {
71
- const existing = await loadStoredCredential();
72
- if (existing) {
73
- const existingId = (await deriveAccountId(existing.token)) ?? "default";
74
- if (existingId !== accountId) {
75
- registerExistingCredentialAsDefault(existingId, existing.accountType);
76
- }
77
- }
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
+ }
119
+ if (index) {
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 });
123
+ emitLoginResult(opts, result, !wasKnown && !result.isDefault);
124
+ return;
78
125
  }
79
- const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
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;
137
+ }
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);
143
+ }
144
+ function emitLoginResult(opts, result, hintSwitch) {
80
145
  const defaultSuffix = result.isDefault ? " (default)" : "";
81
- writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.`, {
146
+ const switchHint = hintSwitch
147
+ ? ` It is not the default — run \`copillm auth switch ${result.id}\` to make it so.`
148
+ : "";
149
+ writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.${switchHint}`, {
82
150
  status: "ok",
83
151
  action: "login",
84
152
  account: result.id,
@@ -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.1"
4
+ version: "0.3.0-beta.3"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.3",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",