@modelprofile.com/authswitch 2.3.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +16 -2
- package/dist_ts/accounts.js +21 -3
- package/dist_ts/classes.accountlist.js +3 -2
- package/dist_ts/classes.claudecodeharness.d.ts +4 -4
- package/dist_ts/classes.claudecodeharness.js +5 -5
- package/dist_ts/classes.cli.d.ts +12 -0
- package/dist_ts/classes.cli.js +98 -9
- package/dist_ts/classes.credentialstore.d.ts +20 -7
- package/dist_ts/classes.credentialstore.js +36 -23
- package/dist_ts/classes.fileharness.d.ts +23 -3
- package/dist_ts/classes.fileharness.js +102 -16
- package/dist_ts/classes.harnessprocesses.d.ts +62 -0
- package/dist_ts/classes.harnessprocesses.js +198 -0
- package/dist_ts/classes.limits.js +37 -15
- package/dist_ts/classes.opencodeharness.d.ts +4 -5
- package/dist_ts/classes.opencodeharness.js +5 -5
- package/dist_ts/classes.operations.d.ts +9 -1
- package/dist_ts/classes.operations.js +4 -2
- package/dist_ts/classes.tui.js +48 -3
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.harness.d.ts +61 -0
- package/dist_ts/interfaces.list.d.ts +16 -1
- package/package.json +1 -1
- package/readme.md +77 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +23 -3
- package/ts/classes.accountlist.ts +2 -1
- package/ts/classes.claudecodeharness.ts +7 -6
- package/ts/classes.cli.ts +77 -9
- package/ts/classes.credentialstore.ts +41 -18
- package/ts/classes.fileharness.ts +80 -16
- package/ts/classes.harnessprocesses.ts +225 -0
- package/ts/classes.limits.ts +38 -16
- package/ts/classes.opencodeharness.ts +7 -7
- package/ts/classes.operations.ts +10 -1
- package/ts/classes.tui.ts +34 -3
- package/ts/index.ts +1 -0
- package/ts/interfaces.harness.ts +62 -0
- package/ts/interfaces.list.ts +17 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IHarnessAccount, IHarnessAccountStatus } from './interfaces.harness.js';
|
|
1
|
+
import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift } from './interfaces.harness.js';
|
|
2
2
|
/** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
|
|
3
3
|
export interface IAccountList {
|
|
4
4
|
schemaVersion: 2;
|
|
@@ -14,6 +14,8 @@ export interface IHarnessAccountList {
|
|
|
14
14
|
accounts: (IHarnessAccount & {
|
|
15
15
|
status: IHarnessAccountStatus;
|
|
16
16
|
})[];
|
|
17
|
+
/** Slots whose credential file no longer holds the account the last switch wrote. */
|
|
18
|
+
credentialDrift: IHarnessCredentialDrift[];
|
|
17
19
|
/** A state lookup failure is distinct from an empty account list. */
|
|
18
20
|
problems: string[];
|
|
19
21
|
}
|
|
@@ -67,4 +69,17 @@ export interface IActiveAccountRow {
|
|
|
67
69
|
/** Where the shown account comes from. Whether it is also saved is the `savedAt` field's answer. */
|
|
68
70
|
source: 'credential file' | 'stash only' | 'none';
|
|
69
71
|
unavailableReason: string | null;
|
|
72
|
+
/** Set when the credential file no longer holds the account the last switch wrote. */
|
|
73
|
+
drift: IActiveAccountDrift | null;
|
|
74
|
+
}
|
|
75
|
+
/** A credential file that changed underneath a switch, with both accounts named where known. */
|
|
76
|
+
export interface IActiveAccountDrift {
|
|
77
|
+
expectedAccountId: string;
|
|
78
|
+
expectedAccount: string | null;
|
|
79
|
+
currentAccountId: string | null;
|
|
80
|
+
currentAccount: string | null;
|
|
81
|
+
currentIsSaved: boolean;
|
|
82
|
+
switchedAt: string;
|
|
83
|
+
/** The sentence the table footnotes, for consumers that display one string. */
|
|
84
|
+
reason: string;
|
|
70
85
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -58,6 +58,8 @@ authswitch active # which account each provider is using right n
|
|
|
58
58
|
authswitch codex stash # save the active credential under its account email
|
|
59
59
|
authswitch codex list # Codex accounts, including an unsaved active login, with live status
|
|
60
60
|
authswitch codex use [email] # activate one; prompts when no email is given
|
|
61
|
+
authswitch claude use [email] --stop # stop running Claude Code instances first
|
|
62
|
+
authswitch claude use [email] --keep-running # switch without stopping anything
|
|
61
63
|
authswitch codex preuse <email> # send the default prompt without activating the account
|
|
62
64
|
authswitch codex current # print the account currently in use
|
|
63
65
|
authswitch codex drop <email> # forget a stash
|
|
@@ -79,16 +81,53 @@ the management TUI and human/JSON lists. With an AGL installation that supports
|
|
|
79
81
|
authswitch coordination, the CLI delegates credential changes to AGL. AGL owns
|
|
80
82
|
stopping and restarting its OpenCode runtime. Idle changes need no additional
|
|
81
83
|
restart confirmation; active work requires consent to wait for it to finish.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
Every operation, including `use` and `stash`, now works while OpenCode or Claude
|
|
85
|
+
Code is running: the credential file is written atomically, the outgoing login is
|
|
86
|
+
re-saved from disk first, and a write that races a native rewrite is detected and
|
|
87
|
+
refused rather than half-applied. Status lookups remain read-only. `preuse`
|
|
88
|
+
remains a Codex capability.
|
|
89
|
+
|
|
90
|
+
#### Running instances during a switch
|
|
91
|
+
|
|
92
|
+
A harness that keeps running through a switch still costs something: it holds the
|
|
93
|
+
previous login in memory and can write it back at its next OAuth token refresh.
|
|
94
|
+
So before `use` and before `stash` without `--keep`, authswitch lists that
|
|
95
|
+
harness's own running instances -- pid, start time and command line -- and offers
|
|
96
|
+
to stop them:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
2 Claude Code process(es) are running:
|
|
100
|
+
pid 4821 started 2026-09-16 14:02 UTC claude --session-id 9f2c
|
|
101
|
+
pid 5533 started 2026-09-16 14:02 UTC claude agents
|
|
102
|
+
? Stop these 2 Claude Code process(es) first? (y/N)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Answering yes sends `SIGTERM` to exactly those pids and waits up to ten seconds;
|
|
106
|
+
an instance that survives is reported by pid and the switch continues. Three flags
|
|
107
|
+
decide the same thing without a prompt:
|
|
108
|
+
|
|
109
|
+
| Flag | Effect |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `--stop` | stop the listed instances gracefully, then switch |
|
|
112
|
+
| `--force-stop` | the same, then `SIGKILL` the ones that ignored `SIGTERM` |
|
|
113
|
+
| `--keep-running` | switch without stopping anything |
|
|
114
|
+
|
|
115
|
+
Without a terminal and without a flag, nothing is ever signalled: the instances are
|
|
116
|
+
listed, the switch proceeds, and the command says which flag would have changed that.
|
|
117
|
+
`SIGKILL` is only ever sent for `--force-stop`. Only pids authswitch enumerated for
|
|
118
|
+
the current user in that operation are signalled -- never a name or pattern match,
|
|
119
|
+
never another user's process -- and a pid is re-verified against a fresh listing
|
|
120
|
+
before a forced kill, so a recycled pid cannot inherit it. The session that is
|
|
121
|
+
running the command is listed but never stopped: stopping it would kill the switch.
|
|
122
|
+
On Windows the instances are listed but not stopped, because its process list does
|
|
123
|
+
not identify their owner. `stash --keep` needs none of this: it writes no native
|
|
124
|
+
file and is guarded by proving the native sources unchanged around the read.
|
|
125
|
+
|
|
126
|
+
After a switch that left instances running, the command says so explicitly, because
|
|
127
|
+
one of them refreshing its token can put the previous account back. A backend
|
|
128
|
+
activation through `AuthSwitchService` gets that same caveat in its outcome, but no
|
|
129
|
+
listing and no offer: a host has no terminal to consent with, and a supervisor that
|
|
130
|
+
completed the mutation itself owns its own runtime's lifecycle.
|
|
92
131
|
|
|
93
132
|
`authswitch codex login` and `authswitch opencode login openai` use the shared
|
|
94
133
|
OpenAI device login flow. They display a verification link and code, then save
|
|
@@ -369,6 +408,22 @@ exist, `none` means neither. **Saved** is how long ago `authswitch` last saved t
|
|
|
369
408
|
login, or `not saved` — which is also the warning that a switch could not bring it
|
|
370
409
|
back. A harness with no active login keeps its row and explains itself in a note.
|
|
371
410
|
|
|
411
|
+
**Source** also answers whether the file still holds what the last switch wrote.
|
|
412
|
+
When authswitch switches a login it records that account and the hash of the
|
|
413
|
+
credential it wrote, in its own store, never in the harness file. If a later read
|
|
414
|
+
finds a different account in that slot, the row's source reads
|
|
415
|
+
`credential file (changed)` and a note explains it:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
note — Claude Code · bob@example.test: The credential file changed since the last
|
|
419
|
+
switch (likely a running instance refreshed the previous login). It now holds
|
|
420
|
+
bob@example.test, which is saved. Run authswitch claude use alice@example.test again.
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`authswitch <harness> current` prints the same sentence. The same account with a
|
|
424
|
+
rotated token is that account refreshing itself and is not reported; re-running the
|
|
425
|
+
switch re-arms the check against the file's current contents.
|
|
426
|
+
|
|
372
427
|
Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
|
|
373
428
|
Both are read-only: they never activate, save or clear a login, they need no harness
|
|
374
429
|
stopped, and every provider lookup is bounded by the same 10-second timeout and
|
|
@@ -385,16 +440,20 @@ arguments to these commands exit with 2.
|
|
|
385
440
|
`limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
|
|
386
441
|
the same rows as the tables, plus the machine-readable fields the tables condense —
|
|
387
442
|
`accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
|
|
388
|
-
`resetsIn`, the ISO `savedAt` beside the human `savedAgo`,
|
|
389
|
-
`unavailableReason` that the footnotes summarise
|
|
443
|
+
`resetsIn`, the ISO `savedAt` beside the human `savedAgo`, the per-row
|
|
444
|
+
`unavailableReason` that the footnotes summarise, and `drift`, which carries the
|
|
445
|
+
expected and current account ids, their labels, whether the current one is saved,
|
|
446
|
+
the ISO `switchedAt` of the switch that was undone, and the same sentence as `reason`. Both carry `schemaVersion: 1`, the
|
|
390
447
|
shared `generatedAt` snapshot every countdown is relative to, and `complete`, which
|
|
391
448
|
is false when any account or harness could not be read.
|
|
392
449
|
|
|
393
450
|
The exported `IAccountList` contract contains:
|
|
394
451
|
|
|
395
452
|
- `schemaVersion: 2`, `generatedAt` (ISO UTC), and `complete`.
|
|
396
|
-
- `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
|
|
397
|
-
|
|
453
|
+
- `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
|
|
454
|
+
`credentialDrift` (slots whose file no longer holds the account the last switch
|
|
455
|
+
wrote), and harness-level `problems` (distinguishing failed discovery from an
|
|
456
|
+
empty list).
|
|
398
457
|
- Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
|
|
399
458
|
`details`, and `status` containing all labelled `facts`, `problems`, and optional
|
|
400
459
|
typed `summary` fields. Missing fields remain omitted, not replaced by zero.
|
|
@@ -529,6 +588,8 @@ scripts must qualify them. Account references never resolve across harnesses.
|
|
|
529
588
|
| What | Where |
|
|
530
589
|
| --- | --- |
|
|
531
590
|
| Stashed credentials | `~/.authswitch/codex/<email>/auth.json` (mode 0600, in a 0700 directory) |
|
|
591
|
+
| Saved OpenCode/Claude logins | `~/.authswitch/<harness>/<id>.json` (mode 0600, in a 0700 directory) |
|
|
592
|
+
| Last switch per credential slot | `~/.authswitch/<harness>/switches.json` (account ids, credential hashes and timestamps; no credentials) |
|
|
532
593
|
| Stash metadata and enrollments | `~/.authswitch/codex/<email>/stash.json` |
|
|
533
594
|
| Codex' active credential | `$CODEX_HOME/auth.json`, default `~/.codex/auth.json` |
|
|
534
595
|
| Codex' remote-control enrollments | the `remote_control_enrollments` table in `$CODEX_HOME/state_<n>.sqlite` |
|
|
@@ -543,6 +604,8 @@ Account identity comes from the `id_token` inside the credential — the email c
|
|
|
543
604
|
|
|
544
605
|
**Other Codex clients keep running.** Stopping the managed app-server does not stop an editor extension that spawned its own. Close it, or expect it to keep using the credential it already loaded.
|
|
545
606
|
|
|
607
|
+
**A running OpenCode or Claude Code can undo a switch.** It holds the previous login in memory and rewrites the credential file at its next token refresh. Authswitch lists those instances, offers to stop them, warns when any keep running, and detects the overwrite afterwards — but it cannot prevent it while they run. Restart them after a switch.
|
|
608
|
+
|
|
546
609
|
**The Codex stash is keyed by email.** Two Codex workspaces with the same email
|
|
547
610
|
cannot both be saved under that key. Their account IDs are distinguished during
|
|
548
611
|
listing and identity checks, and a collision refuses to overwrite either login.
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/accounts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessState, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
1
|
+
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
2
2
|
import { plainText } from './formatting.js';
|
|
3
3
|
|
|
4
4
|
export interface IAccountRow {
|
|
@@ -41,13 +41,33 @@ export const compactUntil = (timestampArg: string | null, nowArg: number, unavai
|
|
|
41
41
|
if (remaining <= 0) return 'due';
|
|
42
42
|
return remaining < 60000 ? '<1m' : twoUnits(remaining);
|
|
43
43
|
};
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The same two-unit form for a past timestamp, for "saved 3d 4h ago".
|
|
46
|
+
*
|
|
47
|
+
* The result is always a bare duration or the caller's unavailable marker, never a phrase: callers
|
|
48
|
+
* suffix it ("... ago"), and a phrase would read as "just now ago". A span under a minute is `<1m`,
|
|
49
|
+
* exactly as `compactUntil` reports one.
|
|
50
|
+
*/
|
|
45
51
|
export const compactSince = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
|
|
46
52
|
if (timestampArg === null) return unavailableArg;
|
|
47
53
|
const elapsed = nowArg - Date.parse(timestampArg);
|
|
48
54
|
if (!Number.isFinite(elapsed)) return unavailableArg;
|
|
49
|
-
return elapsed < 60000 ? '
|
|
55
|
+
return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
|
|
50
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* One sentence for a credential file that no longer holds the account the last switch wrote.
|
|
59
|
+
*
|
|
60
|
+
* It names what the file holds now and how to put the intended account back, because the usual
|
|
61
|
+
* cause -- a harness instance that kept running through the switch and then refreshed its
|
|
62
|
+
* in-memory login -- leaves no other trace.
|
|
63
|
+
*/
|
|
64
|
+
export const credentialDriftNote = (harnessIdArg: string, driftArg: IHarnessCredentialDrift): string =>
|
|
65
|
+
'The credential file changed since the last switch (likely a running instance refreshed the previous login). '
|
|
66
|
+
+ (driftArg.currentLabel === null ? 'No login is active in that slot now. '
|
|
67
|
+
: `It now holds ${plainText(driftArg.currentLabel)}${driftArg.currentIsSaved ? ', which is saved' : ', which is not saved'}. `)
|
|
68
|
+
+ (driftArg.expectedLabel === null ? 'Switch to the intended account again.'
|
|
69
|
+
: `Run authswitch ${plainText(harnessIdArg)} use ${plainText(driftArg.expectedLabel)} again.`);
|
|
70
|
+
|
|
51
71
|
export const accountPlan = (rowArg: IAccountRow): string => {
|
|
52
72
|
const subscription = rowArg.status?.summary?.subscription;
|
|
53
73
|
return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
|
|
@@ -28,10 +28,11 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
|
|
|
28
28
|
export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAccountList> => {
|
|
29
29
|
const harnesses: IHarnessAccountList[] = [];
|
|
30
30
|
for (const harness of harnessesArg) {
|
|
31
|
-
const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], problems: [] };
|
|
31
|
+
const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], credentialDrift: [], problems: [] };
|
|
32
32
|
try {
|
|
33
33
|
const { state, rows } = await readAccountRows(harness);
|
|
34
34
|
result.saveUnavailableReason = state.saveUnavailableReason;
|
|
35
|
+
result.credentialDrift = state.credentialDrift ?? [];
|
|
35
36
|
result.accounts = rows.map(({ account, status }) => ({
|
|
36
37
|
id: account.id, label: account.label, isActive: account.isActive, isStashed: account.isStashed,
|
|
37
38
|
savedAt: account.savedAt, details: [...account.details], ...(account.slotId === undefined ? {} : { slotId: account.slotId }),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
|
|
3
|
-
import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw
|
|
3
|
+
import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
|
|
4
|
+
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
4
5
|
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
5
6
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
6
|
-
import type { IHarnessAccountStatus } from './interfaces.harness.js';
|
|
7
|
+
import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
|
|
7
8
|
|
|
8
9
|
export interface IClaudeCodeHarnessOptions {
|
|
9
10
|
configDir?: string;
|
|
@@ -12,7 +13,8 @@ export interface IClaudeCodeHarnessOptions {
|
|
|
12
13
|
env?: NodeJS.ProcessEnv;
|
|
13
14
|
platform?: NodeJS.Platform;
|
|
14
15
|
fetch?: typeof fetch;
|
|
15
|
-
|
|
16
|
+
/** Running-instance inspection, injectable for isolated installations and tests. */
|
|
17
|
+
processes?: IHarnessProcessControl;
|
|
16
18
|
/** Local settings sources to inspect for credential overrides, in increasing precedence. */
|
|
17
19
|
settingsFiles?: string[];
|
|
18
20
|
}
|
|
@@ -30,7 +32,7 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
30
32
|
private readonly env: NodeJS.ProcessEnv;
|
|
31
33
|
private readonly platform: NodeJS.Platform;
|
|
32
34
|
private readonly status: ClaudeAccountStatus;
|
|
33
|
-
|
|
35
|
+
public readonly processes: IHarnessProcessControl;
|
|
34
36
|
private readonly settingsFiles: string[];
|
|
35
37
|
|
|
36
38
|
constructor(options: IClaudeCodeHarnessOptions = {}) {
|
|
@@ -43,11 +45,10 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
43
45
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
44
46
|
this.platform = options.platform ?? process.platform;
|
|
45
47
|
this.status = new ClaudeAccountStatus(options.fetch);
|
|
46
|
-
this.
|
|
48
|
+
this.processes = options.processes ?? new HarnessProcesses('claude', { platform: this.platform });
|
|
47
49
|
this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
|
|
48
50
|
plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
|
|
49
51
|
}
|
|
50
|
-
protected beforeMutation(): void { this.checkStopped(); }
|
|
51
52
|
/** One record holds the credential and its matching metadata, so both files must be proven stable. */
|
|
52
53
|
protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
|
|
53
54
|
protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
|
package/ts/classes.cli.ts
CHANGED
|
@@ -7,15 +7,21 @@ 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
9
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
10
|
-
import { duration, orderedUsageWindows, until } from './accounts.js';
|
|
10
|
+
import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
|
|
11
|
+
import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
|
|
11
12
|
import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
|
|
12
13
|
import type { CodexSwitcher } from './classes.codexswitcher.js';
|
|
13
|
-
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';
|
|
14
15
|
import { bold, dim, green, orange, plainText, red } from './formatting.js';
|
|
15
16
|
|
|
16
17
|
const canPrompt = (): boolean =>
|
|
17
18
|
process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
|
|
18
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'];
|
|
19
25
|
/** Read-only overviews that work across every registered harness and support --json. */
|
|
20
26
|
const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
|
|
21
27
|
|
|
@@ -57,7 +63,8 @@ ${bold('Usage')}
|
|
|
57
63
|
send one prompt through that account without switching
|
|
58
64
|
authswitch <harness> stash [account/provider] [--keep]
|
|
59
65
|
save one active login; --keep leaves it active
|
|
60
|
-
authswitch <harness> use [account]
|
|
66
|
+
authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
|
|
67
|
+
activate a saved account; prompts when omitted
|
|
61
68
|
authswitch <harness> current show the active account
|
|
62
69
|
authswitch <harness> login [provider]
|
|
63
70
|
sign in and save a new account without switching
|
|
@@ -74,6 +81,13 @@ ${bold('Options')}
|
|
|
74
81
|
-i, --interactive open the guided account manager
|
|
75
82
|
--tui open the terminal management dashboard
|
|
76
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.
|
|
77
91
|
|
|
78
92
|
Preuse consumes the selected account's quota. Its default prompt is:
|
|
79
93
|
"${defaultPreusePrompt}"
|
|
@@ -107,6 +121,18 @@ ${bold('Environment')}
|
|
|
107
121
|
return 2;
|
|
108
122
|
}
|
|
109
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`);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
110
136
|
if (['-h', '--help', 'help'].includes(args[0]) || (args.length === 0 && !canPrompt())) {
|
|
111
137
|
process.stdout.write(`${this.usage()}\n`);
|
|
112
138
|
return 0;
|
|
@@ -154,9 +180,9 @@ ${bold('Environment')}
|
|
|
154
180
|
const references = args.filter(value => value !== '--keep');
|
|
155
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; }
|
|
156
182
|
const account = await this.selectActive(harness, references[0]);
|
|
157
|
-
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;
|
|
158
184
|
}
|
|
159
|
-
case 'use': return await this.commandUse(harness, args[0]);
|
|
185
|
+
case 'use': return await this.commandUse(harness, args[0], false, stopMode);
|
|
160
186
|
case 'current': this.printCurrent(harness, await harness.readState()); return 0;
|
|
161
187
|
case 'drop':
|
|
162
188
|
case 'rm': {
|
|
@@ -185,10 +211,51 @@ ${bold('Environment')}
|
|
|
185
211
|
return new AuthSwitchTui([...this.harnesses.values()], this.out, this.operations).run(harnessArg);
|
|
186
212
|
}
|
|
187
213
|
|
|
188
|
-
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);
|
|
189
216
|
return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
|
|
190
217
|
name: 'waitForIdle', type: 'confirm', message, default: false,
|
|
191
|
-
}) === 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`);
|
|
192
259
|
}
|
|
193
260
|
|
|
194
261
|
private async commandLogin(harness: IAuthHarness, providerId?: string): Promise<number> {
|
|
@@ -415,6 +482,7 @@ ${bold('Environment')}
|
|
|
415
482
|
process.stdout.write(`${dim('no account is currently active')}\n`);
|
|
416
483
|
}
|
|
417
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`);
|
|
418
486
|
}
|
|
419
487
|
|
|
420
488
|
private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
|
|
@@ -470,7 +538,7 @@ ${bold('Environment')}
|
|
|
470
538
|
return null;
|
|
471
539
|
}
|
|
472
540
|
|
|
473
|
-
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> {
|
|
474
542
|
let id: string | null = null;
|
|
475
543
|
if (referenceArg) {
|
|
476
544
|
id = await this.resolveAccount(harnessArg, referenceArg);
|
|
@@ -482,7 +550,7 @@ ${bold('Environment')}
|
|
|
482
550
|
}
|
|
483
551
|
id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
|
|
484
552
|
if (id === null) return allowBackArg ? 0 : 1;
|
|
485
|
-
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));
|
|
486
554
|
}
|
|
487
555
|
|
|
488
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
|
-
};
|