copillm 0.2.9 → 0.3.0-beta.1

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.
@@ -2,19 +2,72 @@ import fs from "node:fs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { z } from "zod";
4
4
  import { ensureAppHome } from "../config/config.js";
5
- import { credentialsPath, credentialsReadPath } from "../config/home.js";
5
+ import { accountCredentialsPath, accountCredentialsReadPath, credentialsPath, credentialsReadPath } from "../config/home.js";
6
6
  import { writeFileSecureAtomic } from "../config/fsSecurity.js";
7
+ import { assertValidAccountId, findAccount, readAccountsIndex, upsertAccount } from "./accounts.js";
7
8
  const SERVICE = "copillm";
8
- const ACCOUNT = "github-oauth-token";
9
+ // The legacy keychain account string. Single-account installs (and the default
10
+ // account on multi-account installs) keep using this exact key so upgrading to
11
+ // a multi-account-aware build never invalidates an existing login. Additional
12
+ // accounts are namespaced as `${LEGACY_ACCOUNT}:<id>`.
13
+ const LEGACY_ACCOUNT = "github-oauth-token";
14
+ // Map key for the default account's in-memory session credential. Chosen so it
15
+ // can't collide with a real account id (those can't contain ':').
16
+ const DEFAULT_SESSION_KEY = "::default::";
17
+ function legacyStorage() {
18
+ return {
19
+ keychainAccount: LEGACY_ACCOUNT,
20
+ filePath: credentialsPath(),
21
+ fileReadPath: credentialsReadPath(),
22
+ sessionKey: DEFAULT_SESSION_KEY
23
+ };
24
+ }
25
+ function namespacedStorage(accountId) {
26
+ assertValidAccountId(accountId);
27
+ return {
28
+ keychainAccount: `${LEGACY_ACCOUNT}:${accountId}`,
29
+ filePath: accountCredentialsPath(accountId),
30
+ fileReadPath: accountCredentialsReadPath(accountId),
31
+ sessionKey: accountId
32
+ };
33
+ }
34
+ function storageForScheme(accountId, scheme) {
35
+ return scheme === "legacy" ? legacyStorage() : namespacedStorage(accountId);
36
+ }
37
+ /**
38
+ * Resolve the storage for a specific account id. A registered account uses the
39
+ * scheme recorded in its index entry (so the default/legacy account keeps the
40
+ * legacy keys even when addressed by id). An unregistered id is assumed to be a
41
+ * not-yet-persisted named account and gets namespaced storage.
42
+ */
43
+ function storageForAccountId(accountId) {
44
+ const record = findAccount(accountId);
45
+ return record ? storageForScheme(record.id, record.storage) : namespacedStorage(accountId);
46
+ }
47
+ /**
48
+ * Storage for the *default* account. With no accounts index this is the legacy
49
+ * single-account storage — the path every existing install takes — so the
50
+ * exported `loadStoredCredential` / `saveStoredCredential` / etc. behave
51
+ * identically to the pre-multi-account build.
52
+ */
53
+ function defaultAccountStorage() {
54
+ const index = readAccountsIndex();
55
+ if (!index) {
56
+ return legacyStorage();
57
+ }
58
+ const record = index.accounts.find((account) => account.id === index.defaultAccount);
59
+ return record ? storageForScheme(record.id, record.storage) : legacyStorage();
60
+ }
9
61
  const FileCredentialSchema = z.object({
10
62
  version: z.literal(1),
11
63
  github_token: z.string().min(1),
12
64
  account_type: z.enum(["individual", "business", "enterprise"]),
13
65
  saved_at: z.string().min(1)
14
66
  });
15
- // Module-level in-memory credential. Only populated when SaveMode === "session".
16
- // Never persisted; cleared on clearStoredCredential() and on process exit.
17
- let sessionCredential = null;
67
+ // Module-level in-memory credentials, keyed by account session key. Only
68
+ // populated when SaveMode === "session". Never persisted; cleared on
69
+ // clearStoredCredential() and on process exit.
70
+ const sessionCredentials = new Map();
18
71
  function forceSessionBackend() {
19
72
  return process.env.COPILLM_FORCE_SESSION_BACKEND === "1";
20
73
  }
@@ -97,18 +150,17 @@ async function resolveKeyring() {
97
150
  }
98
151
  return { keyring: null, reason: "keyring module is unavailable on this machine" };
99
152
  }
100
- function parseCredentialFile() {
101
- const path = credentialsReadPath();
102
- const raw = readFileSync(path, "utf8");
153
+ function parseCredentialFile(readPath) {
154
+ const raw = readFileSync(readPath, "utf8");
103
155
  let parsedJson;
104
156
  try {
105
157
  parsedJson = JSON.parse(raw);
106
158
  }
107
159
  catch (error) {
108
160
  if (error instanceof Error) {
109
- throw new Error(`Credential file exists but contains invalid JSON at ${path}: ${error.message}`);
161
+ throw new Error(`Credential file exists but contains invalid JSON at ${readPath}: ${error.message}`);
110
162
  }
111
- throw new Error(`Credential file exists but contains invalid JSON at ${path}.`);
163
+ throw new Error(`Credential file exists but contains invalid JSON at ${readPath}.`);
112
164
  }
113
165
  try {
114
166
  const parsed = FileCredentialSchema.parse(parsedJson);
@@ -116,12 +168,12 @@ function parseCredentialFile() {
116
168
  }
117
169
  catch (error) {
118
170
  if (error instanceof Error) {
119
- throw new Error(`Credential file exists but is invalid at ${path}: ${error.message}`);
171
+ throw new Error(`Credential file exists but is invalid at ${readPath}: ${error.message}`);
120
172
  }
121
- throw new Error(`Credential file exists but is invalid at ${path}.`);
173
+ throw new Error(`Credential file exists but is invalid at ${readPath}.`);
122
174
  }
123
175
  }
124
- function writeCredentialFile(token, accountType) {
176
+ function writeCredentialFile(writePath, token, accountType) {
125
177
  ensureAppHome();
126
178
  const payload = {
127
179
  version: 1,
@@ -129,7 +181,7 @@ function writeCredentialFile(token, accountType) {
129
181
  account_type: accountType,
130
182
  saved_at: new Date().toISOString()
131
183
  };
132
- writeFileSecureAtomic(credentialsPath(), JSON.stringify(payload, null, 2), 0o600);
184
+ writeFileSecureAtomic(writePath, JSON.stringify(payload, null, 2), 0o600);
133
185
  }
134
186
  function canUsePlaintextFallback() {
135
187
  if (forceSessionBackend()) {
@@ -152,11 +204,11 @@ function isMissingKeyringError(error) {
152
204
  * this helper to avoid accidentally pulling the secret into a code path that
153
205
  * might log or print it.
154
206
  */
155
- export async function inspectStoredCredential() {
156
- if (sessionCredential) {
207
+ async function inspectStorage(storage) {
208
+ if (sessionCredentials.has(storage.sessionKey)) {
157
209
  return { stored: true, backend: "session" };
158
210
  }
159
- if (fs.existsSync(credentialsReadPath())) {
211
+ if (fs.existsSync(storage.fileReadPath)) {
160
212
  return { stored: true, backend: "file" };
161
213
  }
162
214
  const { keyring } = await resolveKeyring();
@@ -164,7 +216,7 @@ export async function inspectStoredCredential() {
164
216
  return { stored: false, backend: null };
165
217
  }
166
218
  try {
167
- const token = await keyring.getPassword(SERVICE, ACCOUNT);
219
+ const token = await keyring.getPassword(SERVICE, storage.keychainAccount);
168
220
  if (token) {
169
221
  return { stored: true, backend: "keyring" };
170
222
  }
@@ -177,12 +229,13 @@ export async function inspectStoredCredential() {
177
229
  throw new Error("Failed to read token from OS keychain.");
178
230
  }
179
231
  }
180
- export async function loadStoredCredential() {
181
- if (sessionCredential) {
182
- return { token: sessionCredential.token, accountType: sessionCredential.accountType, source: "session" };
232
+ async function loadStorage(storage) {
233
+ const session = sessionCredentials.get(storage.sessionKey);
234
+ if (session) {
235
+ return { token: session.token, accountType: session.accountType, source: "session" };
183
236
  }
184
- if (fs.existsSync(credentialsReadPath())) {
185
- const parsed = parseCredentialFile();
237
+ if (fs.existsSync(storage.fileReadPath)) {
238
+ const parsed = parseCredentialFile(storage.fileReadPath);
186
239
  return { token: parsed.token, accountType: parsed.accountType, source: "file" };
187
240
  }
188
241
  const { keyring } = await resolveKeyring();
@@ -191,7 +244,7 @@ export async function loadStoredCredential() {
191
244
  }
192
245
  let token;
193
246
  try {
194
- token = await keyring.getPassword(SERVICE, ACCOUNT);
247
+ token = await keyring.getPassword(SERVICE, storage.keychainAccount);
195
248
  }
196
249
  catch (error) {
197
250
  if (error instanceof Error) {
@@ -204,33 +257,13 @@ export async function loadStoredCredential() {
204
257
  }
205
258
  return { token, accountType: "individual", source: "keyring" };
206
259
  }
207
- /**
208
- * Coalesced inspect + load for status surfaces. Returns the same fields
209
- * `inspectStoredCredential` exposes (`stored` + `backend`) AND the
210
- * `token` but performs only ONE backend probe.
211
- *
212
- * Previously, `auth status` with user-lookup enabled did:
213
- * 1. `inspectStoredCredential` → one `keyring.getPassword` (backend probe)
214
- * 2. `loadStoredCredential` (inside `inspectGithubIdentity`) → another
215
- * `keyring.getPassword` (full token read)
216
- *
217
- * On macOS, each call is its own keychain audit-log entry and (on a
218
- * misconfigured system) its own permission prompt. This helper folds both
219
- * into a single backend probe + single read.
220
- *
221
- * SECURITY: callers MUST treat the `token` field as sensitive — do not log,
222
- * print, or persist it. The status JSON output and `formatHumanAuthStatusLine`
223
- * only consume `backend` (and the upstream identity summary returned by
224
- * `inspectGithubIdentity`), never `token` directly. Enforced at the call
225
- * site (`tests/integration/authStatusCli.test.ts` runs a substring-leak
226
- * guard on the printed output).
227
- */
228
- export async function loadStoredCredentialForStatus() {
229
- if (sessionCredential) {
230
- return { stored: true, backend: "session", token: sessionCredential.token };
260
+ async function loadStorageForStatus(storage) {
261
+ const session = sessionCredentials.get(storage.sessionKey);
262
+ if (session) {
263
+ return { stored: true, backend: "session", token: session.token };
231
264
  }
232
- if (fs.existsSync(credentialsReadPath())) {
233
- const parsed = parseCredentialFile();
265
+ if (fs.existsSync(storage.fileReadPath)) {
266
+ const parsed = parseCredentialFile(storage.fileReadPath);
234
267
  return { stored: true, backend: "file", token: parsed.token };
235
268
  }
236
269
  const { keyring } = await resolveKeyring();
@@ -238,7 +271,7 @@ export async function loadStoredCredentialForStatus() {
238
271
  return { stored: false, backend: null, token: null };
239
272
  }
240
273
  try {
241
- const token = await keyring.getPassword(SERVICE, ACCOUNT);
274
+ const token = await keyring.getPassword(SERVICE, storage.keychainAccount);
242
275
  if (token) {
243
276
  return { stored: true, backend: "keyring", token };
244
277
  }
@@ -251,21 +284,20 @@ export async function loadStoredCredentialForStatus() {
251
284
  throw new Error("Failed to read token from OS keychain.");
252
285
  }
253
286
  }
254
- export async function saveStoredCredential(token, accountType, options = {}) {
255
- const mode = options.mode ?? "auto";
287
+ async function saveStorage(storage, token, accountType, mode) {
256
288
  if (mode === "session") {
257
- sessionCredential = { token, accountType };
289
+ sessionCredentials.set(storage.sessionKey, { token, accountType });
258
290
  return "session";
259
291
  }
260
- if (fs.existsSync(credentialsReadPath())) {
261
- parseCredentialFile();
262
- writeCredentialFile(token, accountType);
292
+ if (fs.existsSync(storage.fileReadPath)) {
293
+ parseCredentialFile(storage.fileReadPath);
294
+ writeCredentialFile(storage.filePath, token, accountType);
263
295
  return "file";
264
296
  }
265
297
  const { keyring, reason } = await resolveKeyring();
266
298
  if (keyring) {
267
299
  try {
268
- await keyring.setPassword(SERVICE, ACCOUNT, token);
300
+ await keyring.setPassword(SERVICE, storage.keychainAccount, token);
269
301
  return "keyring";
270
302
  }
271
303
  catch (error) {
@@ -278,15 +310,14 @@ export async function saveStoredCredential(token, accountType, options = {}) {
278
310
  if (!canUsePlaintextFallback()) {
279
311
  throw new Error(`OS keychain backend unavailable (${reason ?? "unknown reason"}). Plaintext fallback is disabled in non-interactive mode; set COPILLM_ALLOW_PLAINTEXT_CREDENTIALS=1 to allow it.`);
280
312
  }
281
- writeCredentialFile(token, accountType);
313
+ writeCredentialFile(storage.filePath, token, accountType);
282
314
  return "file";
283
315
  }
284
- export async function clearStoredCredential() {
316
+ async function clearStorage(storage) {
285
317
  // Always clear in-memory session token first; it shadows other backends.
286
- const hadSession = sessionCredential !== null;
287
- sessionCredential = null;
288
- const readablePath = credentialsReadPath();
289
- const canonicalPath = credentialsPath();
318
+ const hadSession = sessionCredentials.delete(storage.sessionKey);
319
+ const readablePath = storage.fileReadPath;
320
+ const canonicalPath = storage.filePath;
290
321
  if (fs.existsSync(readablePath)) {
291
322
  fs.unlinkSync(readablePath);
292
323
  if (readablePath !== canonicalPath && fs.existsSync(canonicalPath)) {
@@ -297,7 +328,7 @@ export async function clearStoredCredential() {
297
328
  const { keyring, reason } = await resolveKeyring();
298
329
  if (keyring) {
299
330
  try {
300
- const removed = await keyring.deletePassword(SERVICE, ACCOUNT);
331
+ const removed = await keyring.deletePassword(SERVICE, storage.keychainAccount);
301
332
  return { backend: "keyring", removed: removed || hadSession };
302
333
  }
303
334
  catch (error) {
@@ -312,8 +343,106 @@ export async function clearStoredCredential() {
312
343
  }
313
344
  throw new Error(`No credential backend available to clear credentials (${reason ?? "unknown reason"}).`);
314
345
  }
315
- // Test seam: forcibly clear the in-memory session credential. Not exported via
346
+ // ---------------------------------------------------------------------------
347
+ // Default-account surface. With no accounts index these operate on the legacy
348
+ // single-account storage, so behaviour is identical to the pre-multi-account
349
+ // build. With an index they target whichever account is currently the default.
350
+ // ---------------------------------------------------------------------------
351
+ export async function inspectStoredCredential() {
352
+ return inspectStorage(defaultAccountStorage());
353
+ }
354
+ export async function loadStoredCredential() {
355
+ return loadStorage(defaultAccountStorage());
356
+ }
357
+ /**
358
+ * Coalesced inspect + load for status surfaces. Returns the same fields
359
+ * `inspectStoredCredential` exposes (`stored` + `backend`) AND the
360
+ * `token` — but performs only ONE backend probe.
361
+ *
362
+ * Previously, `auth status` with user-lookup enabled did:
363
+ * 1. `inspectStoredCredential` → one `keyring.getPassword` (backend probe)
364
+ * 2. `loadStoredCredential` (inside `inspectGithubIdentity`) → another
365
+ * `keyring.getPassword` (full token read)
366
+ *
367
+ * On macOS, each call is its own keychain audit-log entry and (on a
368
+ * misconfigured system) its own permission prompt. This helper folds both
369
+ * into a single backend probe + single read.
370
+ *
371
+ * SECURITY: callers MUST treat the `token` field as sensitive — do not log,
372
+ * print, or persist it. The status JSON output and `formatHumanAuthStatusLine`
373
+ * only consume `backend` (and the upstream identity summary returned by
374
+ * `inspectGithubIdentity`), never `token` directly. Enforced at the call
375
+ * site (`tests/integration/authStatusCli.test.ts` runs a substring-leak
376
+ * guard on the printed output).
377
+ */
378
+ export async function loadStoredCredentialForStatus() {
379
+ return loadStorageForStatus(defaultAccountStorage());
380
+ }
381
+ export async function saveStoredCredential(token, accountType, options = {}) {
382
+ return saveStorage(defaultAccountStorage(), token, accountType, options.mode ?? "auto");
383
+ }
384
+ export async function clearStoredCredential() {
385
+ return clearStorage(defaultAccountStorage());
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // Account-scoped surface. These address a specific account by id regardless of
389
+ // which one is the default. A registered account uses the storage scheme from
390
+ // its index entry (so the legacy/default account keeps the legacy keys); an
391
+ // unregistered id is treated as a not-yet-persisted namespaced account.
392
+ // ---------------------------------------------------------------------------
393
+ export async function inspectStoredCredentialForAccount(accountId) {
394
+ return inspectStorage(storageForAccountId(accountId));
395
+ }
396
+ export async function loadStoredCredentialForAccount(accountId) {
397
+ const loaded = await loadStorage(storageForAccountId(accountId));
398
+ if (!loaded) {
399
+ return loaded;
400
+ }
401
+ // The keychain backend can't store the account type, so `loadStorage`
402
+ // defaults it to "individual". The index records the real type per account —
403
+ // prefer it so model-discovery base-URL selection stays correct.
404
+ if (loaded.source === "keyring") {
405
+ const record = findAccount(accountId);
406
+ if (record) {
407
+ return { ...loaded, accountType: record.accountType };
408
+ }
409
+ }
410
+ return loaded;
411
+ }
412
+ export async function saveStoredCredentialForAccount(accountId, token, accountType, options = {}) {
413
+ return saveStorage(storageForAccountId(accountId), token, accountType, options.mode ?? "auto");
414
+ }
415
+ export async function clearStoredCredentialForAccount(accountId) {
416
+ return clearStorage(storageForAccountId(accountId));
417
+ }
418
+ /**
419
+ * Materialize the accounts index from a pre-existing single-account install.
420
+ * Registers the current legacy credential as the default account with
421
+ * `storage: "legacy"` so its token is **not moved** — the keychain entry and
422
+ * `credentials.json` stay exactly where they are. Idempotent: if the index
423
+ * already exists it is returned unchanged. Use this before adding a second
424
+ * account so the original login is represented without any invalidation risk.
425
+ */
426
+ export function registerExistingCredentialAsDefault(accountId, accountType) {
427
+ assertValidAccountId(accountId);
428
+ const existing = readAccountsIndex();
429
+ if (existing) {
430
+ const current = existing.accounts.find((account) => account.id === existing.defaultAccount);
431
+ if (current) {
432
+ return current;
433
+ }
434
+ }
435
+ const record = {
436
+ id: accountId,
437
+ accountType,
438
+ storage: "legacy",
439
+ addedAt: new Date().toISOString()
440
+ };
441
+ upsertAccount(record);
442
+ return record;
443
+ }
444
+ // Test seam: forcibly clear all in-memory session credentials. Not exported via
316
445
  // the package surface for end users — only used by unit tests.
317
446
  export function __resetSessionCredentialForTests() {
318
- sessionCredential = null;
447
+ sessionCredentials.clear();
319
448
  }
@@ -3,8 +3,9 @@ import { claudeConfigDir, piAgentDir } from "../config/home.js";
3
3
  export function buildClaudeEnvBundle(input) {
4
4
  const defaults = input.defaults ?? computeAnthropicDefaults(readModelIdsFromCache());
5
5
  const enableGateway = input.enableGatewayDiscovery !== false;
6
+ const prefix = input.pathPrefix ?? "";
6
7
  const env = {
7
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}/anthropic`,
8
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${input.port}${prefix}/anthropic`,
8
9
  ANTHROPIC_AUTH_TOKEN: input.callerSecret ?? "copillm-local",
9
10
  // Point Claude at a copillm-owned config home so copillm-launched Claude
10
11
  // never reads/writes the user's real ~/.claude (and dev mode isolates it).
@@ -1,26 +1,93 @@
1
- import { clearStoredCredential, saveStoredCredential } from "../../auth/credentials.js";
1
+ import { clearStoredCredential, loadStoredCredential, loadStoredCredentialForAccount, registerExistingCredentialAsDefault, saveStoredCredential } from "../../auth/credentials.js";
2
+ import { readAccountsIndex, assertValidAccountId, InvalidAccountIdError, UnknownAccountError } from "../../auth/accounts.js";
3
+ import { addAccount, listAccountsDetailed, removeAccountAndCredential, removeAllAccounts, switchDefaultAccount } from "../../auth/accountManager.js";
2
4
  import { loginViaDeviceFlow } from "../../auth/deviceFlow.js";
5
+ import { inspectGithubIdentity } from "../../auth/githubIdentity.js";
3
6
  import { loadConfig } from "../../config/config.js";
4
7
  import { inspectLock, releaseLock } from "../../server/lock.js";
5
8
  import { stopByPid } from "../daemon/lifecycle.js";
6
- import { describeBackend } from "../shared/backends.js";
9
+ import { describeBackend, formatHumanAuthStatusLine } from "../shared/backends.js";
7
10
  import { writeCommandOutput } from "../shared/output.js";
11
+ /**
12
+ * Derive a friendly, path-safe account id from the GitHub login behind a token.
13
+ * Returns null when the lookup fails or the login isn't a valid id.
14
+ */
15
+ 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;
33
+ }
34
+ }
8
35
  export async function runAuthLogin(opts, options) {
9
36
  if (options.forceSession) {
10
37
  process.env.COPILLM_FORCE_SESSION_BACKEND = "1";
11
38
  }
12
39
  const config = loadConfig();
40
+ const accountType = opts.accountType ?? config.accountType;
41
+ const namedRequested = typeof opts.as === "string" && opts.as.trim().length > 0;
42
+ if (namedRequested) {
43
+ try {
44
+ assertValidAccountId(opts.as.trim());
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
48
+ writeCommandOutput(opts, `Login failed: ${message}`, { status: "error", action: "login", error: message });
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ }
13
53
  const token = await loginViaDeviceFlow();
14
54
  const saveMode = options.forceSession ? "session" : "auto";
15
- const backend = await saveStoredCredential(token, config.accountType, { mode: saveMode });
16
- writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
55
+ 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) {
59
+ const backend = await saveStoredCredential(token, accountType, { mode: saveMode });
60
+ writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
61
+ status: "ok",
62
+ action: "login",
63
+ credential_backend: backend
64
+ });
65
+ return;
66
+ }
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
+ }
78
+ }
79
+ const result = await addAccount({ id: accountId, accountType, token, mode: saveMode });
80
+ const defaultSuffix = result.isDefault ? " (default)" : "";
81
+ writeCommandOutput(opts, `Login succeeded for account "${result.id}"${defaultSuffix}. Credentials stored via ${describeBackend(result.backend)}.`, {
17
82
  status: "ok",
18
83
  action: "login",
19
- credential_backend: backend
84
+ account: result.id,
85
+ account_type: result.accountType,
86
+ is_default: result.isDefault,
87
+ credential_backend: result.backend
20
88
  });
21
89
  }
22
- export async function runAuthLogout(opts) {
23
- const result = await clearStoredCredential();
90
+ async function stopRunningDaemon() {
24
91
  const lockState = inspectLock();
25
92
  if (lockState.state === "running") {
26
93
  await stopByPid(lockState.lock.pid);
@@ -28,11 +95,141 @@ export async function runAuthLogout(opts) {
28
95
  else if (lockState.state === "stale") {
29
96
  releaseLock();
30
97
  }
98
+ }
99
+ export async function runAuthLogout(opts) {
100
+ // Stopping the daemon is always part of logout — its in-memory bearers are
101
+ // derived from the credentials we're clearing.
102
+ if (opts.all) {
103
+ const result = await removeAllAccounts();
104
+ await stopRunningDaemon();
105
+ writeCommandOutput(opts, `Logged out of all accounts (${result.clearedCount} credential(s) cleared).`, {
106
+ status: "ok",
107
+ action: "logout",
108
+ scope: "all",
109
+ cleared_count: result.clearedCount,
110
+ removed_accounts: result.removedAccountIds
111
+ });
112
+ return;
113
+ }
114
+ const index = readAccountsIndex();
115
+ // Single-account install (no index) and no explicit target: preserve the
116
+ // historical single-account logout behaviour.
117
+ if (!index && !opts.account) {
118
+ const result = await clearStoredCredential();
119
+ await stopRunningDaemon();
120
+ const credentialStatus = result.removed ? "removed" : "not present";
121
+ writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
122
+ status: "ok",
123
+ action: "logout",
124
+ credential_backend: result.backend,
125
+ credential_removed: result.removed
126
+ });
127
+ return;
128
+ }
129
+ if (opts.account) {
130
+ try {
131
+ assertValidAccountId(opts.account);
132
+ }
133
+ catch (error) {
134
+ const message = error instanceof InvalidAccountIdError ? error.message : "invalid account id";
135
+ writeCommandOutput(opts, `Logout failed: ${message}`, { status: "error", action: "logout", error: message });
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ }
140
+ const targetId = opts.account ?? index.defaultAccount;
141
+ const result = await removeAccountAndCredential(targetId);
142
+ await stopRunningDaemon();
31
143
  const credentialStatus = result.removed ? "removed" : "not present";
32
- writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
144
+ const tail = result.indexDeleted
145
+ ? " No accounts remain."
146
+ : result.newDefault
147
+ ? ` Default is now "${result.newDefault}".`
148
+ : "";
149
+ writeCommandOutput(opts, `Logged out of account "${result.id}". Credentials ${credentialStatus} from ${describeBackend(result.backend)}.${tail}`, {
33
150
  status: "ok",
34
151
  action: "logout",
152
+ account: result.id,
35
153
  credential_backend: result.backend,
36
- credential_removed: result.removed
154
+ credential_removed: result.removed,
155
+ new_default: result.newDefault,
156
+ index_deleted: result.indexDeleted
37
157
  });
38
158
  }
159
+ export async function runAuthSwitch(opts, accountId) {
160
+ try {
161
+ assertValidAccountId(accountId);
162
+ const index = switchDefaultAccount(accountId);
163
+ writeCommandOutput(opts, `Default account is now "${index.defaultAccount}".`, {
164
+ status: "ok",
165
+ action: "switch",
166
+ default_account: index.defaultAccount
167
+ });
168
+ }
169
+ catch (error) {
170
+ const message = error instanceof UnknownAccountError
171
+ ? `Unknown account "${accountId}". Run \`copillm auth status\` to list accounts.`
172
+ : error instanceof InvalidAccountIdError
173
+ ? error.message
174
+ : error instanceof Error
175
+ ? error.message
176
+ : "switch failed";
177
+ writeCommandOutput(opts, `Switch failed: ${message}`, { status: "error", action: "switch", error: message });
178
+ process.exitCode = 1;
179
+ }
180
+ }
181
+ /**
182
+ * Multi-account `auth status` listing (used when an accounts index exists).
183
+ * Returns whether any account has a stored credential so the caller can pick
184
+ * the process exit code. Never prints a token.
185
+ */
186
+ export async function runAuthStatusList(opts) {
187
+ const wantUser = opts.user !== false;
188
+ const listing = await listAccountsDetailed();
189
+ const anyStored = listing.accounts.some((account) => account.stored);
190
+ const enriched = await Promise.all(listing.accounts.map(async (account) => {
191
+ let login = null;
192
+ let name = null;
193
+ if (wantUser && account.stored) {
194
+ try {
195
+ const credential = await loadStoredCredentialForAccount(account.id);
196
+ if (credential) {
197
+ const identity = await inspectGithubIdentity({ token: credential.token });
198
+ login = identity?.login ?? null;
199
+ name = identity?.name ?? null;
200
+ }
201
+ }
202
+ catch {
203
+ login = null;
204
+ name = null;
205
+ }
206
+ }
207
+ return { ...account, login, name };
208
+ }));
209
+ if (opts.json) {
210
+ process.stdout.write(JSON.stringify({
211
+ status: anyStored ? "logged_in" : "logged_out",
212
+ default: listing.defaultAccount,
213
+ accounts: enriched.map((account) => ({
214
+ id: account.id,
215
+ account_type: account.accountType,
216
+ storage: account.storage,
217
+ default: account.isDefault,
218
+ stored: account.stored,
219
+ backend: account.backend,
220
+ user: account.login ? { login: account.login, name: account.name } : null
221
+ }))
222
+ }, null, 2) + "\n");
223
+ return { anyStored };
224
+ }
225
+ process.stdout.write(`copillm — ${enriched.length} account(s)\n`);
226
+ for (const account of enriched) {
227
+ const marker = account.isDefault ? "*" : " ";
228
+ const who = account.login ? ` @${account.login}` : "";
229
+ const state = account.stored
230
+ ? formatHumanAuthStatusLine(account.backend, account.login ? { login: account.login, name: account.name } : null)
231
+ : "no credential";
232
+ process.stdout.write(`${marker} ${account.id} [${account.accountType}]${who} — ${state}\n`);
233
+ }
234
+ return { anyStored };
235
+ }