@modelprofile.com/authswitch 2.2.0 → 3.0.0

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 (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +18 -1
  3. package/dist_ts/accounts.js +55 -1
  4. package/dist_ts/classes.accountlist.d.ts +4 -0
  5. package/dist_ts/classes.accountlist.js +20 -13
  6. package/dist_ts/classes.claudecodeharness.d.ts +4 -4
  7. package/dist_ts/classes.claudecodeharness.js +5 -5
  8. package/dist_ts/classes.cli.d.ts +17 -0
  9. package/dist_ts/classes.cli.js +128 -16
  10. package/dist_ts/classes.credentialstore.d.ts +20 -7
  11. package/dist_ts/classes.credentialstore.js +36 -23
  12. package/dist_ts/classes.fileharness.d.ts +23 -3
  13. package/dist_ts/classes.fileharness.js +102 -16
  14. package/dist_ts/classes.harnessprocesses.d.ts +62 -0
  15. package/dist_ts/classes.harnessprocesses.js +198 -0
  16. package/dist_ts/classes.limits.d.ts +24 -0
  17. package/dist_ts/classes.limits.js +200 -0
  18. package/dist_ts/classes.opencodeharness.d.ts +4 -5
  19. package/dist_ts/classes.opencodeharness.js +5 -5
  20. package/dist_ts/classes.operations.d.ts +9 -1
  21. package/dist_ts/classes.operations.js +4 -2
  22. package/dist_ts/classes.tui.js +48 -3
  23. package/dist_ts/index.d.ts +1 -0
  24. package/dist_ts/index.js +2 -1
  25. package/dist_ts/interfaces.harness.d.ts +61 -0
  26. package/dist_ts/interfaces.list.d.ts +67 -1
  27. package/package.json +2 -2
  28. package/readme.md +155 -19
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/accounts.ts +50 -1
  31. package/ts/classes.accountlist.ts +24 -11
  32. package/ts/classes.claudecodeharness.ts +7 -6
  33. package/ts/classes.cli.ts +105 -16
  34. package/ts/classes.credentialstore.ts +41 -18
  35. package/ts/classes.fileharness.ts +80 -16
  36. package/ts/classes.harnessprocesses.ts +225 -0
  37. package/ts/classes.limits.ts +209 -0
  38. package/ts/classes.opencodeharness.ts +7 -7
  39. package/ts/classes.operations.ts +10 -1
  40. package/ts/classes.tui.ts +34 -3
  41. package/ts/index.ts +1 -0
  42. package/ts/interfaces.harness.ts +62 -0
  43. package/ts/interfaces.list.ts +72 -1
package/ts/classes.cli.ts CHANGED
@@ -6,15 +6,24 @@ import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
6
6
  import { AuthSwitchTui } from './classes.tui.js';
7
7
  import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
9
- import { duration, orderedUsageWindows, until } from './accounts.js';
9
+ import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
10
+ import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
11
+ import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
10
12
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
11
13
  import type { CodexSwitcher } from './classes.codexswitcher.js';
12
- import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessState, IHarnessPreuseResult } from './interfaces.harness.js';
14
+ import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
13
15
  import { bold, dim, green, orange, plainText, red } from './formatting.js';
14
16
 
15
17
  const canPrompt = (): boolean =>
16
18
  process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
17
- const COMMANDS = ['login', 'stash', 'list', 'ls', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
19
+ const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
20
+ /** What to do about the harness's own running instances before a credential file is rewritten. */
21
+ export type TStopMode = 'stop' | 'force-stop' | 'keep-running';
22
+ const STOP_FLAGS = ['--stop', '--force-stop', '--keep-running'];
23
+ /** Commands that replace or clear a native login, and therefore offer to stop its instances. */
24
+ const STOP_COMMANDS = ['use', 'stash'];
25
+ /** Read-only overviews that work across every registered harness and support --json. */
26
+ const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
18
27
 
19
28
  /** Harness-independent command routing and interactive account management. */
20
29
  export class AuthSwitchCli {
@@ -48,11 +57,14 @@ ${bold('Usage')}
48
57
  authswitch list list all accounts with live subscription, usage and resets
49
58
  authswitch <harness> list list accounts for one harness
50
59
  authswitch list --json print all account information as JSON
60
+ authswitch limits one row per account and limit type: used % and reset countdown
61
+ authswitch active one row per provider: which account is in use, and since when
51
62
  authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
52
63
  send one prompt through that account without switching
53
64
  authswitch <harness> stash [account/provider] [--keep]
54
65
  save one active login; --keep leaves it active
55
- authswitch <harness> use [account] activate a saved account; prompts when omitted
66
+ authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
67
+ activate a saved account; prompts when omitted
56
68
  authswitch <harness> current show the active account
57
69
  authswitch <harness> login [provider]
58
70
  sign in and save a new account without switching
@@ -68,7 +80,14 @@ ${bold('Options')}
68
80
  -v, --version show the version
69
81
  -i, --interactive open the guided account manager
70
82
  --tui open the terminal management dashboard
71
- --json JSON output for list / ls only
83
+ --json JSON output for list / ls / limits / active only
84
+ --stop stop this harness's running instances before the switch
85
+ --force-stop the same, then kill instances that ignore SIGTERM
86
+ --keep-running switch without stopping anything
87
+
88
+ A switch works while OpenCode or Claude Code is running. Instances that keep running hold
89
+ the previous login in memory and can write it back at their next token refresh, so the
90
+ switch offers to stop them first; without a terminal it leaves them running and says so.
72
91
 
73
92
  Preuse consumes the selected account's quota. Its default prompt is:
74
93
  "${defaultPreusePrompt}"
@@ -97,8 +116,20 @@ ${bold('Environment')}
97
116
  args.splice(args.indexOf('--json'), 1);
98
117
  const route = [...args];
99
118
  if (this.harnesses.has(route[0])) route.shift();
100
- if (route.length !== 1 || !['list', 'ls'].includes(route[0])) {
101
- process.stderr.write('--json requires list or ls, optionally qualified by a harness.\n');
119
+ if (route.length !== 1 || !OVERVIEW_COMMANDS.includes(route[0])) {
120
+ process.stderr.write(`--json requires ${OVERVIEW_COMMANDS.join(', ')}, optionally qualified by a harness.\n`);
121
+ return 2;
122
+ }
123
+ }
124
+ const stopFlags = args.filter(value => STOP_FLAGS.includes(value));
125
+ if (stopFlags.length > 1) { process.stderr.write(`Use only one of ${STOP_FLAGS.join(', ')}.\n`); return 2; }
126
+ const stopMode = stopFlags.length ? stopFlags[0].slice(2) as TStopMode : undefined;
127
+ if (stopMode !== undefined) {
128
+ args.splice(args.indexOf(stopFlags[0]), 1);
129
+ const route = [...args];
130
+ if (this.harnesses.has(route[0])) route.shift();
131
+ if (!STOP_COMMANDS.includes(route[0]) || (route[0] === 'stash' && args.includes('--keep'))) {
132
+ process.stderr.write(`${STOP_FLAGS.join(', ')} apply to use and to stash without --keep; a save that keeps the login needs no stopped instance.\n`);
102
133
  return 2;
103
134
  }
104
135
  }
@@ -130,9 +161,12 @@ ${bold('Environment')}
130
161
  process.stderr.write(`${red('unknown harness or command')} "${plainText(first)}"\n\n${this.usage()}\n`);
131
162
  return 2;
132
163
  }
133
- if (['list', 'ls'].includes(command)) {
134
- if (args.length) { process.stderr.write('list accepts only --json; optionally put a harness before list.\n'); return 2; }
135
- return await this.commandList(harness ? [harness] : [...this.harnesses.values()], json);
164
+ if (OVERVIEW_COMMANDS.includes(command)) {
165
+ if (args.length) { process.stderr.write(`${command} accepts only --json; optionally put a harness before ${command}.\n`); return 2; }
166
+ const harnesses = harness ? [harness] : [...this.harnesses.values()];
167
+ return command === 'limits' || command === 'active'
168
+ ? await this.commandCondensed(command, harnesses, json)
169
+ : await this.commandList(harnesses, json);
136
170
  }
137
171
  if (!harness) {
138
172
  harness = await this.selectHarness();
@@ -146,9 +180,9 @@ ${bold('Environment')}
146
180
  const references = args.filter(value => value !== '--keep');
147
181
  if (references.length > 1 || references.some(value => value.startsWith('-'))) { process.stderr.write('stash accepts one active account or provider and --keep.\n'); return 2; }
148
182
  const account = await this.selectActive(harness, references[0]);
149
- return account ? this.reportOutcome(await this.mutate(harness, { harnessId: harness.id, action: 'save', keepActive: args.includes('--keep'), accountId: account.id })) : 1;
183
+ return account ? this.reportOutcome(await this.mutate(harness, { harnessId: harness.id, action: 'save', keepActive: args.includes('--keep'), accountId: account.id }, stopMode)) : 1;
150
184
  }
151
- case 'use': return await this.commandUse(harness, args[0]);
185
+ case 'use': return await this.commandUse(harness, args[0], false, stopMode);
152
186
  case 'current': this.printCurrent(harness, await harness.readState()); return 0;
153
187
  case 'drop':
154
188
  case 'rm': {
@@ -177,10 +211,51 @@ ${bold('Environment')}
177
211
  return new AuthSwitchTui([...this.harnesses.values()], this.out, this.operations).run(harnessArg);
178
212
  }
179
213
 
180
- private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<IHarnessOutcome> {
214
+ private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation, stopModeArg?: TStopMode): Promise<IHarnessOutcome> {
215
+ const replacesNativeLogin = mutation.action === 'switch' || (mutation.action === 'save' && !mutation.keepActive);
181
216
  return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
182
217
  name: 'waitForIdle', type: 'confirm', message, default: false,
183
- }) === true : undefined);
218
+ }) === true : undefined, replacesNativeLogin ? () => this.settleRunningInstances(harness, stopModeArg) : undefined);
219
+ }
220
+
221
+ /**
222
+ * Decide what happens to the harness's own running instances before its credential file changes.
223
+ *
224
+ * The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
225
+ * login either way. What a running instance still costs is durability -- it holds the previous
226
+ * login in memory and can write it back at its next refresh -- so the instances are named and
227
+ * stopping them is offered, never assumed. Without a terminal and without a flag nothing is
228
+ * signalled, and only pids enumerated here for this user are ever signalled at all.
229
+ */
230
+ private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
231
+ const control = harnessArg.processes;
232
+ if (!control) return;
233
+ let running: IHarnessProcess[];
234
+ try { running = control.list(); }
235
+ catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
236
+ if (!running.length) return;
237
+ for (const line of describeHarnessProcesses(harnessArg.label, running)) process.stdout.write(`${line}\n`);
238
+ if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
239
+ const stoppable = running.filter(item => !item.isAncestor);
240
+ if (!stoppable.length || modeArg === 'keep-running') return;
241
+ let stop = modeArg === 'stop' || modeArg === 'force-stop';
242
+ if (modeArg === undefined) {
243
+ if (!canPrompt()) { process.stdout.write(`${dim('Leaving them running. Use --stop, --force-stop or --keep-running to decide this without a terminal.')}\n`); return; }
244
+ stop = await this.out.prompts.ask({
245
+ name: 'stopHarnessProcesses', type: 'confirm',
246
+ message: `Stop these ${stoppable.length} ${harnessArg.label} process(es) first?`, default: false,
247
+ }) === true;
248
+ }
249
+ if (!stop) return;
250
+ let outcome: IHarnessStopOutcome;
251
+ // Stopping re-reads the process table; if that read fails now, the switch is still the safe part.
252
+ try { outcome = await control.stop(stoppable, { force: modeArg === 'force-stop' }); }
253
+ catch (error) {
254
+ // The reason ends the line so its own punctuation is not doubled.
255
+ process.stdout.write(`${orange(`Could not stop the running ${plainText(harnessArg.label)} process(es); leaving them running: ${plainText(error instanceof Error ? error.message : 'the process list could not be read.')}`)}\n`);
256
+ return;
257
+ }
258
+ for (const line of describeStopOutcome(harnessArg.label, outcome)) process.stdout.write(`${line}\n`);
184
259
  }
185
260
 
186
261
  private async commandLogin(harness: IAuthHarness, providerId?: string): Promise<number> {
@@ -407,6 +482,7 @@ ${bold('Environment')}
407
482
  process.stdout.write(`${dim('no account is currently active')}\n`);
408
483
  }
409
484
  if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
485
+ for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg.id, drift))}\n`);
410
486
  }
411
487
 
412
488
  private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
@@ -426,6 +502,19 @@ ${bold('Environment')}
426
502
  return outcomeArg.problems.length === 0 ? 0 : 1;
427
503
  }
428
504
 
505
+ /**
506
+ * The condensed views are projections of the same read-only account list, so a provider is queried
507
+ * once, per account, with the same bounded timeouts and per-account failure isolation as `list`.
508
+ */
509
+ private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
510
+ const list = await readAccountList(harnessesArg);
511
+ const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
512
+ if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
513
+ else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
514
+ else await new CondensedRenderer(this.out).renderActive(document);
515
+ return document.complete ? 0 : 1;
516
+ }
517
+
429
518
  private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
430
519
  const list = await readAccountList(harnessesArg);
431
520
  if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
@@ -449,7 +538,7 @@ ${bold('Environment')}
449
538
  return null;
450
539
  }
451
540
 
452
- private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false): Promise<number> {
541
+ private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
453
542
  let id: string | null = null;
454
543
  if (referenceArg) {
455
544
  id = await this.resolveAccount(harnessArg, referenceArg);
@@ -461,7 +550,7 @@ ${bold('Environment')}
461
550
  }
462
551
  id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
463
552
  if (id === null) return allowBackArg ? 0 : 1;
464
- return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }));
553
+ return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }, stopModeArg));
465
554
  }
466
555
 
467
556
  private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
@@ -38,6 +38,21 @@ export const readCredentialDocument = (file: string): { raw: string | null; data
38
38
  catch { throw new Error('Credential file is unreadable, malformed, or too large; no changes were made.'); }
39
39
  };
40
40
 
41
+ /**
42
+ * What the last switch wrote into a native credential slot.
43
+ *
44
+ * This is authswitch's own state, never part of the harness file: it records the account and the
45
+ * hash of the credential that was activated, so a later read can tell a credential file that still
46
+ * holds that account from one a running instance has overwritten with a different login. It carries
47
+ * hashes and timestamps only, never a credential.
48
+ */
49
+ export interface ISwitchRecord {
50
+ slotId: string;
51
+ accountId: string;
52
+ credentialHash: string;
53
+ switchedAt: string;
54
+ }
55
+
41
56
  export interface IStoredCredential {
42
57
  schemaVersion: 1;
43
58
  id: string;
@@ -83,6 +98,32 @@ export class CredentialStore {
83
98
  return this.read(record.id);
84
99
  }
85
100
  public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
101
+ private get switchFile(): string { return plugins.path.join(this.dir, 'switches.json'); }
102
+ /** Advisory state: an absent, malformed or partial record reports no switch, never an error. */
103
+ public switchRecords(): ISwitchRecord[] {
104
+ let data: Record<string, unknown>;
105
+ try { data = readCredentialDocument(this.switchFile).data; } catch { return []; }
106
+ if (data.schemaVersion !== 1 || !Array.isArray(data.switches)) return [];
107
+ return data.switches.filter((item: unknown): item is ISwitchRecord => {
108
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return false;
109
+ const record = item as Record<string, unknown>;
110
+ return !!credentialText(record.slotId) && /^[a-f0-9]{64}$/.test(String(record.accountId))
111
+ && /^[a-f0-9]{64}$/.test(String(record.credentialHash))
112
+ && typeof record.switchedAt === 'string' && Number.isFinite(Date.parse(record.switchedAt));
113
+ });
114
+ }
115
+ public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'>): void {
116
+ this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: new Date().toISOString() }]);
117
+ }
118
+ public clearSwitch(slotId: string): void {
119
+ const remaining = this.switchRecords().filter(item => item.slotId !== slotId);
120
+ if (remaining.length) this.writeSwitchRecords(remaining);
121
+ else plugins.fs.rmSync(this.switchFile, { force: true });
122
+ }
123
+ private writeSwitchRecords(records: ISwitchRecord[]): void {
124
+ plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
125
+ writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
126
+ }
86
127
  public locked<T>(action: () => T): T {
87
128
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
88
129
  const file = plugins.path.join(this.dir, '.lock');
@@ -93,21 +134,3 @@ export class CredentialStore {
93
134
  finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
94
135
  }
95
136
  }
96
-
97
- /**
98
- * Native harnesses hold their login in memory and rewrite it on their own refresh schedule, so a
99
- * credential written underneath a running one is destroyed by the next refresh. Operations that
100
- * replace a native login therefore require it stopped. Reading for a save that keeps the login in
101
- * place writes nothing native and is guarded by content stability instead (see FileHarness).
102
- */
103
- export const requireHarnessStopped = (binary: 'opencode' | 'claude'): void => {
104
- let names: string[];
105
- try {
106
- if (process.platform === 'win32') {
107
- names = plugins.childProcess.execFileSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => line.split('",')[0].replace(/^"/, ''));
108
- } else {
109
- names = plugins.childProcess.execFileSync('ps', ['-u', String(process.getuid!()), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => plugins.path.basename(line.trim()));
110
- }
111
- } catch { throw new Error(`Could not verify that ${binary} is stopped. No login was changed.`); }
112
- if (names.some(name => name === binary || name === `${binary}.exe`)) throw new Error(`Exit running ${binary} processes before replacing or clearing its login, then start ${binary} again. Saving with --keep does not need this.`);
113
- };
@@ -1,5 +1,5 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
2
- import { CredentialStore, type IStoredCredential } from './classes.credentialstore.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState } from './interfaces.harness.js';
2
+ import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
5
5
 
@@ -23,8 +23,8 @@ export abstract class FileHarness implements IAuthHarness {
23
23
  protected abstract readonly store: CredentialStore;
24
24
  protected abstract snapshot(): IFileHarnessSnapshot;
25
25
  protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
26
- /** Guards the operations that write a native credential file. Reads must not depend on it. */
27
- protected abstract beforeMutation(): void;
26
+ /** The harness's own running instances. Detection informs the caller; it never blocks a write. */
27
+ public abstract readonly processes: IHarnessProcessControl;
28
28
  /** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
29
29
  protected abstract nativeSources(): (string | null)[];
30
30
  protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
@@ -76,7 +76,66 @@ export abstract class FileHarness implements IAuthHarness {
76
76
  for (const account of accounts) {
77
77
  if (accounts.some(other => other.id !== account.id && other.label === account.label)) account.details.push(`Account ID: ${account.id}`);
78
78
  }
79
- return { accounts, saveUnavailableReason: snapshot.unavailable };
79
+ return { accounts, saveUnavailableReason: snapshot.unavailable, credentialDrift: snapshot.unavailable ? [] : this.credentialDrift(snapshot) };
80
+ }
81
+
82
+ /**
83
+ * Credential slots that no longer hold the account the last switch wrote.
84
+ *
85
+ * A running harness refreshes its login on its own schedule and writes the result to the same
86
+ * file, so a switch performed underneath one can be undone minutes later without any error. The
87
+ * recorded hash detects that the file changed at all; comparing the account distinguishes the
88
+ * benign case -- the switched-in account rotating its own token -- from the previous login coming
89
+ * back. Detection is advisory and never turns a read into a failure.
90
+ */
91
+ private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
92
+ let records: ISwitchRecord[];
93
+ try { records = this.store.switchRecords(); } catch { return []; }
94
+ const drift: IHarnessCredentialDrift[] = [];
95
+ for (const record of records) {
96
+ try {
97
+ const active = snapshotArg.accounts.find(account => account.slotId === record.slotId);
98
+ if (active && credentialHash(JSON.stringify(active.credential)) === record.credentialHash) continue;
99
+ const currentAccountId = active ? this.store.id(active.slotId, active.identity) : null;
100
+ if (currentAccountId !== null && currentAccountId === record.accountId) continue;
101
+ drift.push({
102
+ slotId: record.slotId, expectedAccountId: record.accountId, expectedLabel: this.savedLabel(record.accountId),
103
+ currentAccountId, currentLabel: active?.label ?? null,
104
+ currentIsSaved: currentAccountId !== null && this.savedLabel(currentAccountId) !== null,
105
+ switchedAt: record.switchedAt,
106
+ });
107
+ } catch { /* One unreadable record must not hide the rest of the state. */ }
108
+ }
109
+ return drift;
110
+ }
111
+ private savedLabel(accountIdArg: string): string | null {
112
+ try { return this.store.read(accountIdArg).label; } catch { return null; }
113
+ }
114
+ /** Instances that keep a replaced or cleared login in memory until they are restarted. */
115
+ private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
116
+ let count: number;
117
+ try { count = this.processes.list().length; }
118
+ catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
119
+ if (!count) return [];
120
+ return [`${count} ${this.label} process(es) are still running. They keep the previous login in memory until they are restarted, and a token refresh by one of them can `
121
+ + `${kindArg === 'replaced' ? 'overwrite the credential file with the previous account' : 'write the cleared login back into the credential file'}. `
122
+ + `Restart them, then check authswitch ${this.id} active.`];
123
+ }
124
+ /**
125
+ * Record what the credential file holds now, so a later overwrite is detectable.
126
+ *
127
+ * The hash is taken from the file after the write, not from the incoming credential, so it is
128
+ * exactly what a later read computes. A recording failure never invalidates a completed switch.
129
+ */
130
+ private recordActivation(slotIdArg: string, accountIdArg: string): string[] {
131
+ try {
132
+ const active = this.snapshot().accounts.find(account => account.slotId === slotIdArg);
133
+ if (!active || this.store.id(active.slotId, active.identity) !== accountIdArg) {
134
+ return [`The credential file does not hold the expected account after this operation; check authswitch ${this.id} active.`];
135
+ }
136
+ this.store.recordSwitch({ slotId: slotIdArg, accountId: accountIdArg, credentialHash: credentialHash(JSON.stringify(active.credential)) });
137
+ return [];
138
+ } catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
80
139
  }
81
140
  protected readCredential(id: string): IFileAccount {
82
141
  const active = this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
@@ -89,14 +148,14 @@ export abstract class FileHarness implements IAuthHarness {
89
148
  /** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
90
149
  public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
91
150
  return this.outcome(() => {
92
- this.beforeMutation();
93
151
  const target = this.inspect(credential, slotId);
94
152
  const snapshot = this.snapshot();
95
153
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
96
154
  const previous = snapshot.accounts.find(account => account.slotId === slotId);
97
155
  if (previous) this.store.save(previous);
98
156
  this.apply(slotId, previous, target);
99
- return `Activated ${plainText(target.label)} (${plainText(slotId)}).`;
157
+ return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`,
158
+ ...this.recordActivation(slotId, this.store.id(slotId, target.identity)), ...this.runningCaveat('replaced')];
100
159
  });
101
160
  }
102
161
  public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
@@ -106,36 +165,41 @@ export abstract class FileHarness implements IAuthHarness {
106
165
  const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
107
166
  return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
108
167
  }
109
- private outcome(action: () => string): IHarnessOutcome {
110
- try { return this.store.locked(() => ({ lines: [action()], problems: [] })); }
168
+ private outcome(action: () => string | string[]): IHarnessOutcome {
169
+ try { return this.store.locked(() => { const lines = action(); return { lines: Array.isArray(lines) ? lines : [lines], problems: [] }; }); }
111
170
  catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
112
171
  }
113
172
  public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
114
173
  return this.outcome(() => {
115
- // Keeping the login in place only reads the native files, so a running harness is no reason to refuse.
116
- if (!options.keepActive) this.beforeMutation();
117
174
  const snapshot = this.stableSnapshot();
118
175
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
119
176
  const matches = snapshot.accounts.filter(account => !options.accountId || this.store.id(account.slotId, account.identity) === options.accountId || account.slotId === options.accountId || account.label === options.accountId);
120
177
  if (matches.length !== 1) throw new Error(matches.length ? 'Several logins are active. Select the account or provider to save.' : 'No matching active login to save.');
121
178
  const active = matches[0];
122
179
  this.store.save(active);
123
- if (!options.keepActive) this.apply(active.slotId, active, undefined);
124
- return `Saved ${plainText(active.label)} (${plainText(active.slotId)})${options.keepActive ? '; login remains in place.' : `. Start ${this.label} to log in again.`}`;
180
+ if (options.keepActive) return `Saved ${plainText(active.label)} (${plainText(active.slotId)}); login remains in place.`;
181
+ this.apply(active.slotId, active, undefined);
182
+ const cleared: string[] = [];
183
+ try { this.store.clearSwitch(active.slotId); }
184
+ catch { cleared.push('The login was cleared, but its switch record could not be removed.'); }
185
+ return [`Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`, ...cleared, ...this.runningCaveat('cleared')];
125
186
  });
126
187
  }
127
188
  public switchAccount(id: string): IHarnessOutcome {
128
189
  return this.outcome(() => {
129
- this.beforeMutation();
130
190
  const target = this.saved(this.store.read(id));
131
191
  const snapshot = this.snapshot();
132
192
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
133
193
  const previous = snapshot.accounts.find(account => account.slotId === target.slotId);
134
194
  // Refresh the outgoing stash even when this account was already saved.
135
195
  if (previous) this.store.save(previous);
136
- if (previous?.identity === target.identity) return `${plainText(target.label)} is already active; its latest credential is saved.`;
196
+ // Re-running a switch for the active account re-arms drift detection against its newest bytes.
197
+ if (previous?.identity === target.identity) {
198
+ return [`${plainText(target.label)} is already active; its latest credential is saved.`, ...this.recordActivation(target.slotId, id)];
199
+ }
137
200
  this.apply(target.slotId, previous, target);
138
- return `Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`;
201
+ return [`Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`,
202
+ ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
139
203
  });
140
204
  }
141
205
  public removeAccount(id: string): IHarnessOutcome {