@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +18 -1
- package/dist_ts/accounts.js +55 -1
- package/dist_ts/classes.accountlist.d.ts +4 -0
- package/dist_ts/classes.accountlist.js +20 -13
- package/dist_ts/classes.claudecodeharness.d.ts +4 -4
- package/dist_ts/classes.claudecodeharness.js +5 -5
- package/dist_ts/classes.cli.d.ts +17 -0
- package/dist_ts/classes.cli.js +128 -16
- 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.d.ts +24 -0
- package/dist_ts/classes.limits.js +200 -0
- 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 +67 -1
- package/package.json +2 -2
- package/readme.md +155 -19
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +50 -1
- package/ts/classes.accountlist.ts +24 -11
- package/ts/classes.claudecodeharness.ts +7 -6
- package/ts/classes.cli.ts +105 -16
- 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 +209 -0
- 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 +72 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IHarnessProcess, IHarnessProcessControl, IHarnessStopOutcome } from './interfaces.harness.js';
|
|
3
|
+
import { plainText } from './formatting.js';
|
|
4
|
+
|
|
5
|
+
export type THarnessBinary = 'opencode' | 'claude';
|
|
6
|
+
/** A raw same-user process row. The enumerator never filters; identity matching belongs here. */
|
|
7
|
+
export interface IEnumeratedProcess {
|
|
8
|
+
pid: number;
|
|
9
|
+
parentPid: number | null;
|
|
10
|
+
/** Executable name as the platform reports it. Truncated by the platform on Linux. */
|
|
11
|
+
command: string;
|
|
12
|
+
/** argv[0] as the process reports it; a native installer may run a version-named file. */
|
|
13
|
+
executable: string;
|
|
14
|
+
commandLine: string;
|
|
15
|
+
startedAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IHarnessProcessesOptions {
|
|
19
|
+
/** Same-user process table. Injected by tests and by hosts that own their own inspection. */
|
|
20
|
+
enumerate?: () => IEnumeratedProcess[];
|
|
21
|
+
/** Signal delivery, restricted by this class to pids it enumerated itself. */
|
|
22
|
+
signal?: (pidArg: number, signalArg: 'SIGTERM' | 'SIGKILL') => void;
|
|
23
|
+
/** Liveness probe for a pid that was signalled. */
|
|
24
|
+
alive?: (pidArg: number) => boolean;
|
|
25
|
+
sleep?: (millisecondsArg: number) => Promise<void>;
|
|
26
|
+
/** Default bounded wait for a graceful stop. Callers may shorten it per operation. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
platform?: NodeJS.Platform;
|
|
29
|
+
/** This command's own pid, for the ancestor chain that must never be signalled. */
|
|
30
|
+
selfPid?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a harness binary is recognised in a process table.
|
|
35
|
+
*
|
|
36
|
+
* `comm` is not enough. Claude Code's native installer runs the CLI from a file named after its
|
|
37
|
+
* version (`~/.local/share/claude/versions/2.1.273`) and its remote agent from
|
|
38
|
+
* `~/.claude/remote/ccd-cli/<version>`, so the kernel reports a version number as the command name
|
|
39
|
+
* while argv[0] carries the real identity. Matching argv[0] and its install path finds the
|
|
40
|
+
* processes that actually hold a login; matching only `comm` finds the launcher and misses every
|
|
41
|
+
* session. Verified against Claude Code 2.1.260-2.1.273 and OpenCode 1.18.15 on Linux.
|
|
42
|
+
*/
|
|
43
|
+
const executablePatterns: Record<THarnessBinary, RegExp[]> = {
|
|
44
|
+
claude: [/(?:^|[/\\])claude(?:\.exe)?$/i, /[/\\]claude[/\\]versions[/\\][^/\\]+$/, /[/\\]\.claude[/\\]remote[/\\]ccd-cli[/\\][^/\\]+$/],
|
|
45
|
+
opencode: [/(?:^|[/\\])opencode(?:\.exe)?$/i],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const MAX_COMMAND_LINE = 200;
|
|
49
|
+
const POSIX_FIELDS = 'pid=,ppid=,uid=,lstart=,comm=,args=';
|
|
50
|
+
|
|
51
|
+
/** A bounded read of the current user's process table. Never a pattern-based kill list. */
|
|
52
|
+
const enumeratePosix = (): IEnumeratedProcess[] => {
|
|
53
|
+
const uid = process.getuid!();
|
|
54
|
+
const output = plugins.childProcess.execFileSync('ps', ['-u', String(uid), '-o', POSIX_FIELDS], {
|
|
55
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' }, windowsHide: true,
|
|
56
|
+
});
|
|
57
|
+
const processes: IEnumeratedProcess[] = [];
|
|
58
|
+
for (const line of output.split('\n')) {
|
|
59
|
+
// pid ppid uid, then the five fixed tokens of lstart, then comm and the full argument vector.
|
|
60
|
+
const parts = line.trim().split(/\s+/);
|
|
61
|
+
if (parts.length < 9) continue;
|
|
62
|
+
const [pid, parentPid, owner] = parts.slice(0, 3).map(Number);
|
|
63
|
+
if (!Number.isInteger(pid) || pid <= 0 || owner !== uid) continue;
|
|
64
|
+
const started = Date.parse(parts.slice(3, 8).join(' '));
|
|
65
|
+
const args = parts.slice(9);
|
|
66
|
+
processes.push({
|
|
67
|
+
pid, parentPid: Number.isInteger(parentPid) ? parentPid : null, command: parts[8],
|
|
68
|
+
executable: args[0] ?? parts[8], commandLine: (args.length ? args.join(' ') : parts[8]).slice(0, MAX_COMMAND_LINE),
|
|
69
|
+
startedAt: Number.isFinite(started) ? new Date(started).toISOString() : null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return processes;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Windows reports an image name and a pid, with no owner, parent or start time in this view. */
|
|
76
|
+
const enumerateWindows = (): IEnumeratedProcess[] => {
|
|
77
|
+
const output = plugins.childProcess.execFileSync('tasklist', ['/FO', 'CSV', '/NH'], {
|
|
78
|
+
encoding: 'utf8', timeout: 5000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
|
|
79
|
+
});
|
|
80
|
+
const processes: IEnumeratedProcess[] = [];
|
|
81
|
+
for (const line of output.split('\n')) {
|
|
82
|
+
const columns = line.trim().replace(/^"/, '').replace(/"$/, '').split('","');
|
|
83
|
+
const pid = Number(columns[1]);
|
|
84
|
+
if (columns.length < 2 || !Number.isInteger(pid) || pid <= 0) continue;
|
|
85
|
+
processes.push({ pid, parentPid: null, command: columns[0], executable: columns[0], commandLine: columns[0], startedAt: null });
|
|
86
|
+
}
|
|
87
|
+
return processes;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Running instances of one harness binary, and a bounded, consent-driven way to stop them.
|
|
92
|
+
*
|
|
93
|
+
* Every signal target is a pid this class enumerated for the current user in this operation, and
|
|
94
|
+
* the ancestors of the running command are never signalled: stopping the session that invoked
|
|
95
|
+
* authswitch would kill the switch itself. `SIGKILL` is only ever sent after an explicit force
|
|
96
|
+
* request, and only to a pid re-verified against a fresh listing, so a recycled pid cannot inherit
|
|
97
|
+
* a kill meant for the process that already exited.
|
|
98
|
+
*/
|
|
99
|
+
export class HarnessProcesses implements IHarnessProcessControl {
|
|
100
|
+
private readonly enumerate: () => IEnumeratedProcess[];
|
|
101
|
+
private readonly signal: (pidArg: number, signalArg: 'SIGTERM' | 'SIGKILL') => void;
|
|
102
|
+
private readonly alive: (pidArg: number) => boolean;
|
|
103
|
+
private readonly sleep: (millisecondsArg: number) => Promise<void>;
|
|
104
|
+
private readonly platform: NodeJS.Platform;
|
|
105
|
+
private readonly selfPid: number;
|
|
106
|
+
private readonly defaultTimeout: number;
|
|
107
|
+
|
|
108
|
+
constructor(public readonly binary: THarnessBinary, optionsArg: IHarnessProcessesOptions = {}) {
|
|
109
|
+
this.platform = optionsArg.platform ?? process.platform;
|
|
110
|
+
this.enumerate = optionsArg.enumerate ?? (this.platform === 'win32' ? enumerateWindows : enumeratePosix);
|
|
111
|
+
this.signal = optionsArg.signal ?? ((pid, signal) => { process.kill(pid, signal); });
|
|
112
|
+
this.alive = optionsArg.alive ?? (pid => {
|
|
113
|
+
try { process.kill(pid, 0); return true; }
|
|
114
|
+
// EPERM means the process exists but is not ours; it is then never a stop target either.
|
|
115
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; }
|
|
116
|
+
});
|
|
117
|
+
this.sleep = optionsArg.sleep ?? (milliseconds => new Promise<void>(resolve => { setTimeout(resolve, milliseconds); }));
|
|
118
|
+
this.selfPid = optionsArg.selfPid ?? process.pid;
|
|
119
|
+
this.defaultTimeout = optionsArg.timeoutMs ?? 10_000;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Windows' process list carries no owner, so an enumerated pid cannot be proven to be ours. */
|
|
123
|
+
public get stopUnavailableReason(): string | null {
|
|
124
|
+
return this.platform === 'win32'
|
|
125
|
+
? 'On Windows authswitch lists running instances but does not stop them: the process list does not identify their owner. Close them yourself.'
|
|
126
|
+
: null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public list(): IHarnessProcess[] {
|
|
130
|
+
const table = this.enumerate();
|
|
131
|
+
const ancestors = this.ancestorPids(table);
|
|
132
|
+
const patterns = executablePatterns[this.binary];
|
|
133
|
+
return table
|
|
134
|
+
.filter(item => patterns.some(pattern => pattern.test(item.executable) || pattern.test(item.command)))
|
|
135
|
+
.filter(item => item.pid !== this.selfPid)
|
|
136
|
+
.map(item => ({
|
|
137
|
+
pid: item.pid, command: plugins.path.basename(item.command), commandLine: plainText(item.commandLine),
|
|
138
|
+
startedAt: item.startedAt, isAncestor: ancestors.has(item.pid),
|
|
139
|
+
}))
|
|
140
|
+
.sort((left, right) => left.pid - right.pid);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The chain from this command up to init, which owns the terminal this command is running in. */
|
|
144
|
+
private ancestorPids(tableArg: IEnumeratedProcess[]): Set<number> {
|
|
145
|
+
const parents = new Map(tableArg.map(item => [item.pid, item.parentPid]));
|
|
146
|
+
const ancestors = new Set<number>();
|
|
147
|
+
let current = parents.get(this.selfPid) ?? (this.selfPid === process.pid ? process.ppid : null);
|
|
148
|
+
// The table is finite and each step moves to a distinct pid, so the walk terminates.
|
|
149
|
+
while (current !== null && current !== undefined && current > 1 && !ancestors.has(current)) {
|
|
150
|
+
ancestors.add(current);
|
|
151
|
+
current = parents.get(current) ?? null;
|
|
152
|
+
}
|
|
153
|
+
return ancestors;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public async stop(processesArg: readonly IHarnessProcess[], optionsArg: { force?: boolean; timeoutMs?: number } = {}): Promise<IHarnessStopOutcome> {
|
|
157
|
+
const force = optionsArg.force === true;
|
|
158
|
+
const timeout = Math.max(0, Math.min(optionsArg.timeoutMs ?? this.defaultTimeout, 120_000));
|
|
159
|
+
if (this.stopUnavailableReason) return { stopped: [], survivors: [...processesArg], forced: [] };
|
|
160
|
+
const listed = new Map(this.list().map(item => [item.pid, item]));
|
|
161
|
+
// Only a pid enumerated in this operation, still present, still not an ancestor, is a target.
|
|
162
|
+
const targets = processesArg.filter(item => {
|
|
163
|
+
const current = listed.get(item.pid);
|
|
164
|
+
return current !== undefined && !current.isAncestor && current.startedAt === item.startedAt;
|
|
165
|
+
});
|
|
166
|
+
// Anything the caller passed that is not a valid target is reported, never signalled.
|
|
167
|
+
const skipped = processesArg.filter(item => !targets.includes(item) && this.alive(item.pid));
|
|
168
|
+
let remaining = this.deliver(targets, 'SIGTERM');
|
|
169
|
+
remaining = await this.awaitExit(remaining, timeout);
|
|
170
|
+
const forced: IHarnessProcess[] = [];
|
|
171
|
+
if (force && remaining.length) {
|
|
172
|
+
// Re-verify against a fresh listing: a pid that exited may already belong to another process.
|
|
173
|
+
const fresh = new Map(this.list().map(item => [item.pid, item]));
|
|
174
|
+
const verified = remaining.filter(item => fresh.get(item.pid)?.startedAt === item.startedAt && !fresh.get(item.pid)!.isAncestor);
|
|
175
|
+
forced.push(...verified);
|
|
176
|
+
remaining = [...this.deliver(verified, 'SIGKILL'), ...remaining.filter(item => !verified.includes(item))];
|
|
177
|
+
remaining = await this.awaitExit(remaining, Math.min(timeout, 2000));
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
stopped: processesArg.filter(item => !remaining.includes(item) && !skipped.includes(item)),
|
|
181
|
+
survivors: [...remaining, ...skipped],
|
|
182
|
+
forced,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private deliver(targetsArg: readonly IHarnessProcess[], signalArg: 'SIGTERM' | 'SIGKILL'): IHarnessProcess[] {
|
|
187
|
+
const remaining: IHarnessProcess[] = [];
|
|
188
|
+
for (const target of targetsArg) {
|
|
189
|
+
try { this.signal(target.pid, signalArg); remaining.push(target); }
|
|
190
|
+
catch (error) {
|
|
191
|
+
// ESRCH is the process we wanted gone; anything else stays reported as still running.
|
|
192
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') remaining.push(target);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return remaining;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async awaitExit(processesArg: IHarnessProcess[], timeoutArg: number): Promise<IHarnessProcess[]> {
|
|
199
|
+
let remaining = processesArg.filter(item => this.alive(item.pid));
|
|
200
|
+
const deadline = Date.now() + timeoutArg;
|
|
201
|
+
while (remaining.length && Date.now() < deadline) {
|
|
202
|
+
await this.sleep(Math.min(250, Math.max(1, deadline - Date.now())));
|
|
203
|
+
remaining = remaining.filter(item => this.alive(item.pid));
|
|
204
|
+
}
|
|
205
|
+
return remaining;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const started = (processArg: IHarnessProcess): string =>
|
|
210
|
+
processArg.startedAt === null ? 'start time unavailable' : `started ${processArg.startedAt.replace('T', ' ').slice(0, 16)} UTC`;
|
|
211
|
+
|
|
212
|
+
/** Plain-text description of the instances found, for a terminal, a TUI log or a confirmation. */
|
|
213
|
+
export const describeHarnessProcesses = (labelArg: string, processesArg: readonly IHarnessProcess[]): string[] => [
|
|
214
|
+
`${processesArg.length} ${plainText(labelArg)} process(es) are running:`,
|
|
215
|
+
...processesArg.map(item => ` pid ${item.pid} ${started(item)} ${item.commandLine}`
|
|
216
|
+
+ (item.isAncestor ? ' [runs this command; it is never stopped from inside itself]' : '')),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** Plain-text report of what a stop achieved, including every instance that survived it. */
|
|
220
|
+
export const describeStopOutcome = (labelArg: string, outcomeArg: IHarnessStopOutcome): string[] => {
|
|
221
|
+
const lines: string[] = [];
|
|
222
|
+
if (outcomeArg.stopped.length) lines.push(`Stopped ${outcomeArg.stopped.length} ${plainText(labelArg)} process(es)${outcomeArg.forced.length ? `, ${outcomeArg.forced.length} of them with SIGKILL` : ''}.`);
|
|
223
|
+
for (const survivor of outcomeArg.survivors) lines.push(`pid ${survivor.pid} did not exit and is still running: ${survivor.commandLine}`);
|
|
224
|
+
return lines;
|
|
225
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
|
|
3
|
+
import { consoleHeading, consoleTable } from './classes.accountlist.js';
|
|
4
|
+
import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
|
|
5
|
+
import type { IHarnessCredentialDrift } from './interfaces.harness.js';
|
|
6
|
+
import { dim, plainText } from './formatting.js';
|
|
7
|
+
|
|
8
|
+
type TListedAccount = IHarnessAccountList['accounts'][number];
|
|
9
|
+
|
|
10
|
+
const UNAVAILABLE = 'n/a';
|
|
11
|
+
const NONE = 'none';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provider naming stays data-driven.
|
|
15
|
+
*
|
|
16
|
+
* A harness that owns exactly one credential slot is its own provider; one that owns several (an
|
|
17
|
+
* OpenCode installation with an OpenAI and an Anthropic login) has to say which slot a row is about.
|
|
18
|
+
*/
|
|
19
|
+
const providerName = (harnessArg: IHarnessAccountList, accountArg?: TListedAccount): string => {
|
|
20
|
+
const slots = new Set(harnessArg.accounts.map(account => account.slotId).filter((slot): slot is string => slot !== undefined));
|
|
21
|
+
return accountArg?.slotId !== undefined && slots.size > 1
|
|
22
|
+
? `${plainText(harnessArg.label)} / ${plainText(accountArg.slotId)}`
|
|
23
|
+
: plainText(harnessArg.label);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Why an account contributes no numbers.
|
|
28
|
+
*
|
|
29
|
+
* The provider's own explanation is preferred over ours, and a failed lookup is never presented as a
|
|
30
|
+
* missing capability: the two have different fixes.
|
|
31
|
+
*/
|
|
32
|
+
const missingLimitReason = (accountArg: TListedAccount): string => {
|
|
33
|
+
if (accountArg.status.problems.length) return accountArg.status.problems.map(plainText).join(' ');
|
|
34
|
+
const availability = accountArg.status.facts.filter(fact => fact.section === 'Availability').map(fact => plainText(fact.value));
|
|
35
|
+
if (accountArg.status.summary?.usageWindows === undefined) {
|
|
36
|
+
return availability[0] ?? 'This login does not expose a usage or quota API.';
|
|
37
|
+
}
|
|
38
|
+
return 'The provider reported no usage window for this account.';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount) => ({
|
|
42
|
+
harnessId: harnessArg.id,
|
|
43
|
+
provider: providerName(harnessArg, accountArg),
|
|
44
|
+
account: plainText(accountArg.label),
|
|
45
|
+
accountId: accountArg.id,
|
|
46
|
+
...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
|
|
50
|
+
harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
|
|
51
|
+
|
|
52
|
+
/** Provider then account, keeping each account's windows in the order the adapter ranked them. */
|
|
53
|
+
const byProviderThenAccount = <TRow extends { provider: string; account: string | null }>(rowsArg: TRow[]): TRow[] =>
|
|
54
|
+
[...rowsArg].sort((left, right) => left.provider.localeCompare(right.provider)
|
|
55
|
+
|| (left.account ?? '').localeCompare(right.account ?? ''));
|
|
56
|
+
|
|
57
|
+
const noteFor = (rowArg: { provider: string; account: string | null; unavailableReason: string | null }): string | null =>
|
|
58
|
+
rowArg.unavailableReason === null ? null
|
|
59
|
+
: `${rowArg.provider}${rowArg.account === null ? '' : ` · ${rowArg.account}`}: ${rowArg.unavailableReason}`;
|
|
60
|
+
|
|
61
|
+
const collectNotes = (rowsArg: { provider: string; account: string | null; unavailableReason: string | null }[]): string[] =>
|
|
62
|
+
[...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
|
|
63
|
+
|
|
64
|
+
/** The drift a switch record proves, carried into the row document with both accounts named. */
|
|
65
|
+
const activeDrift = (harnessArg: IHarnessAccountList, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
|
|
66
|
+
expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
|
|
67
|
+
currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
|
|
68
|
+
currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(harnessArg.id, driftArg),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One row per account and limit type, projected from the account list.
|
|
73
|
+
*
|
|
74
|
+
* Nothing here talks to a provider: the list is already the read-only, per-account-isolated lookup,
|
|
75
|
+
* so a single failing account costs its own rows a number and nothing else.
|
|
76
|
+
*/
|
|
77
|
+
export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
78
|
+
const now = Date.parse(listArg.generatedAt);
|
|
79
|
+
const rows: IAccountLimitRow[] = [];
|
|
80
|
+
for (const harness of listArg.harnesses) {
|
|
81
|
+
const problem = harnessProblem(harness);
|
|
82
|
+
const empty = {
|
|
83
|
+
limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE,
|
|
84
|
+
} as const;
|
|
85
|
+
if (problem !== null) {
|
|
86
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!harness.accounts.length) {
|
|
90
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty,
|
|
91
|
+
unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
for (const account of harness.accounts) {
|
|
95
|
+
const windows = orderedUsageWindows(account.status.summary?.usageWindows);
|
|
96
|
+
if (!windows.length) {
|
|
97
|
+
rows.push({ ...accountKeys(harness, account), isActive: account.isActive, ...empty, unavailableReason: missingLimitReason(account) });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const window of windows) {
|
|
101
|
+
rows.push({
|
|
102
|
+
...accountKeys(harness, account), isActive: account.isActive,
|
|
103
|
+
limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
|
|
104
|
+
usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
|
|
105
|
+
unavailableReason: null,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const limits = byProviderThenAccount(rows);
|
|
111
|
+
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: collectNotes(limits) };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One row per active credential slot.
|
|
116
|
+
*
|
|
117
|
+
* A harness with no active login still gets a row: "nothing is active here" is an answer, and
|
|
118
|
+
* silently dropping the provider would read as "this provider does not exist".
|
|
119
|
+
*/
|
|
120
|
+
export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
121
|
+
const now = Date.parse(listArg.generatedAt);
|
|
122
|
+
const rows: IActiveAccountRow[] = [];
|
|
123
|
+
const unattached: string[] = [];
|
|
124
|
+
for (const harness of listArg.harnesses) {
|
|
125
|
+
const problem = harnessProblem(harness);
|
|
126
|
+
const drifts = [...harness.credentialDrift];
|
|
127
|
+
const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
|
|
128
|
+
const index = drifts.findIndex(item => item.slotId === slotIdArg);
|
|
129
|
+
return index < 0 ? null : activeDrift(harness, drifts.splice(index, 1)[0]);
|
|
130
|
+
};
|
|
131
|
+
if (problem !== null) {
|
|
132
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const active = harness.accounts.filter(account => account.isActive);
|
|
136
|
+
if (!active.length) {
|
|
137
|
+
const stashed = harness.accounts.filter(account => account.isStashed);
|
|
138
|
+
rows.push({
|
|
139
|
+
harnessId: harness.id, provider: providerName(harness), account: null, accountId: null,
|
|
140
|
+
savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
|
|
141
|
+
unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
|
|
142
|
+
: stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
|
|
143
|
+
: `No login is active and none is saved. ${plainText(harness.loginHint)}`,
|
|
144
|
+
drift: drifts.length ? activeDrift(harness, drifts.shift()!) : null,
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
for (const account of active) {
|
|
148
|
+
rows.push({
|
|
149
|
+
...accountKeys(harness, account),
|
|
150
|
+
savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
|
|
151
|
+
source: 'credential file',
|
|
152
|
+
unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
|
|
153
|
+
drift: driftFor(account.slotId),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// A drifted slot with no row of its own still has to be reported, not dropped.
|
|
158
|
+
for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(harness.id, drift)}`);
|
|
159
|
+
}
|
|
160
|
+
const active = byProviderThenAccount(rows);
|
|
161
|
+
const driftNotes = active.filter(row => row.drift !== null)
|
|
162
|
+
.map(row => `${row.provider}${row.account === null ? '' : ` · ${row.account}`}: ${row.drift!.reason}`);
|
|
163
|
+
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
|
|
167
|
+
|
|
168
|
+
/** Condensed presentation for `authswitch limits` and `authswitch active`. */
|
|
169
|
+
export class CondensedRenderer {
|
|
170
|
+
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
171
|
+
|
|
172
|
+
private notes(notesArg: string[], prefixArg: string): void {
|
|
173
|
+
if (!notesArg.length) return;
|
|
174
|
+
process.stdout.write('\n');
|
|
175
|
+
for (const note of notesArg) process.stdout.write(`${dim(`${prefixArg}${note}`)}\n`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
|
|
179
|
+
consoleHeading('Account limits');
|
|
180
|
+
if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
181
|
+
await consoleTable(this.out, limitsArg.limits, [
|
|
182
|
+
{ key: 'provider', title: 'Provider', value: row => row.provider },
|
|
183
|
+
{ key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
|
|
184
|
+
{ key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
|
|
185
|
+
{ key: 'used', title: 'Used %', value: percent, align: 'right',
|
|
186
|
+
style: row => ({ foreground: row.usedPercent === null ? 'orange' : row.usedPercent >= 100 ? 'red' : undefined }) },
|
|
187
|
+
{ key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
|
|
188
|
+
]);
|
|
189
|
+
this.notes(limitsArg.notes, 'n/a — ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public async renderActive(activeArg: IActiveAccounts): Promise<void> {
|
|
193
|
+
consoleHeading('Active accounts');
|
|
194
|
+
if (!activeArg.active.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
195
|
+
await consoleTable(this.out, activeArg.active, [
|
|
196
|
+
{ key: 'provider', title: 'Provider', value: row => row.provider },
|
|
197
|
+
{ key: 'account', title: 'Account', value: row => row.account ?? NONE,
|
|
198
|
+
style: row => ({ bold: row.account !== null, foreground: row.account === null ? 'orange' : undefined }) },
|
|
199
|
+
// The absolute timestamp stays in --json; one screen is worth more here than one more column.
|
|
200
|
+
// "ago" belongs to a duration only; the unavailable marker is not one.
|
|
201
|
+
{ key: 'saved', title: 'Saved', value: row => row.savedAt === null ? (row.account === null ? UNAVAILABLE : 'not saved')
|
|
202
|
+
: row.savedAgo === UNAVAILABLE ? UNAVAILABLE : `${row.savedAgo} ago`, align: 'right',
|
|
203
|
+
style: row => ({ foreground: row.savedAt === null && row.account !== null ? 'orange' : undefined }) },
|
|
204
|
+
{ key: 'source', title: 'Source', value: row => row.drift === null ? row.source : `${row.source} (changed)`,
|
|
205
|
+
style: row => ({ foreground: row.drift !== null ? 'red' : row.source === 'credential file' ? 'green' : 'orange' }) },
|
|
206
|
+
]);
|
|
207
|
+
this.notes(activeArg.notes, 'note — ');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -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, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw
|
|
3
|
+
import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
|
|
4
|
+
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
4
5
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
5
6
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
6
|
-
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
|
|
7
|
+
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl } from './interfaces.harness.js';
|
|
7
8
|
import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
|
|
8
9
|
import { plainText } from './formatting.js';
|
|
9
10
|
|
|
@@ -12,8 +13,8 @@ export interface IOpenCodeHarnessOptions {
|
|
|
12
13
|
stashRoot?: string;
|
|
13
14
|
env?: NodeJS.ProcessEnv;
|
|
14
15
|
fetch?: typeof fetch;
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/** Running-instance inspection, injectable for isolated installations and tests. */
|
|
17
|
+
processes?: IHarnessProcessControl;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
|
|
@@ -26,7 +27,7 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
26
27
|
private readonly file: string;
|
|
27
28
|
private readonly env: NodeJS.ProcessEnv;
|
|
28
29
|
private readonly status: CodexAccountStatus;
|
|
29
|
-
|
|
30
|
+
public readonly processes: IHarnessProcessControl;
|
|
30
31
|
private readonly fetcher?: typeof fetch;
|
|
31
32
|
|
|
32
33
|
constructor(options: IOpenCodeHarnessOptions = {}) {
|
|
@@ -36,9 +37,8 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
36
37
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
37
38
|
this.status = new CodexAccountStatus(options.fetch);
|
|
38
39
|
this.fetcher = options.fetch;
|
|
39
|
-
this.
|
|
40
|
+
this.processes = options.processes ?? new HarnessProcesses('opencode');
|
|
40
41
|
}
|
|
41
|
-
protected beforeMutation(): void { this.checkStopped(); }
|
|
42
42
|
/** Every provider entry lives in one file, so its bytes are the whole stability proof. */
|
|
43
43
|
protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file)]; }
|
|
44
44
|
/** Public identity only, for reconciling a host's OpenAI association after native refresh/restart. */
|
package/ts/classes.operations.ts
CHANGED
|
@@ -19,6 +19,14 @@ export type TAuthSwitchCoordinationResult =
|
|
|
19
19
|
| { status: 'complete'; outcome: IHarnessOutcome };
|
|
20
20
|
export type TAuthSwitchCoordinator = (request: IAuthSwitchCoordinationRequest) => Promise<TAuthSwitchCoordinationResult>;
|
|
21
21
|
export type TAuthSwitchConfirmation = (message: string) => Promise<boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* Runs immediately before a mutation this process performs itself.
|
|
24
|
+
*
|
|
25
|
+
* It never runs when a supervisor completed the mutation: a client must not stop a process whose
|
|
26
|
+
* lifecycle another manager owns, and stopping it after that manager already restarted it would
|
|
27
|
+
* split one transaction across two owners.
|
|
28
|
+
*/
|
|
29
|
+
export type TAuthSwitchLocalPreparation = () => Promise<void>;
|
|
22
30
|
|
|
23
31
|
export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): string => plugins.crypto.createHash('sha256').update(JSON.stringify({
|
|
24
32
|
home: plugins.os.homedir(),
|
|
@@ -56,7 +64,7 @@ export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAu
|
|
|
56
64
|
/** One mutation path for command, guide and TUI. Supervisors own their complete restart transaction. */
|
|
57
65
|
export class AuthSwitchOperations {
|
|
58
66
|
constructor(private readonly coordinator?: TAuthSwitchCoordinator) {}
|
|
59
|
-
public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation): Promise<IHarnessOutcome> {
|
|
67
|
+
public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation, prepareLocal?: TAuthSwitchLocalPreparation): Promise<IHarnessOutcome> {
|
|
60
68
|
assertAuthSwitchMutation(mutation);
|
|
61
69
|
if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
|
|
62
70
|
if (this.coordinator && mutation.action !== 'remove') {
|
|
@@ -69,6 +77,7 @@ export class AuthSwitchOperations {
|
|
|
69
77
|
if (result.status === 'complete') return result.outcome;
|
|
70
78
|
if (result.status === 'busy') return { lines: [], problems: [result.message] };
|
|
71
79
|
}
|
|
80
|
+
if (prepareLocal) await prepareLocal();
|
|
72
81
|
return runAuthSwitchMutation(harness, mutation);
|
|
73
82
|
}
|
|
74
83
|
}
|
package/ts/classes.tui.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import type { IAuthHarness, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
|
|
2
|
+
import type { IAuthHarness, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
|
|
3
3
|
import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usagePeriod, until, readAccountRows, type IAccountRow } from './accounts.js';
|
|
4
4
|
import { plainText } from './formatting.js';
|
|
5
5
|
import { AuthSwitchOperations } from './classes.operations.js';
|
|
6
|
+
import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
|
|
6
7
|
|
|
7
8
|
/** Owns account-management actions; terminal rendering and interaction belong to smartconsole. */
|
|
8
9
|
export class AuthSwitchTui {
|
|
@@ -59,6 +60,34 @@ export class AuthSwitchTui {
|
|
|
59
60
|
catch { footer.setText(`Operation failed. Run authswitch ${harness.id} doctor for diagnostics.`); }
|
|
60
61
|
updateDetails();
|
|
61
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Name the harness's running instances before a credential write, and stop them only on consent.
|
|
65
|
+
*
|
|
66
|
+
* The dashboard makes the same offer the command line does, with the same rule: only pids
|
|
67
|
+
* enumerated for this user are signalled, the session running this dashboard is never one of
|
|
68
|
+
* them, and declining still performs the switch.
|
|
69
|
+
*/
|
|
70
|
+
const settleInstances = (screen: plugins.smartconsole.ITuiContext) => async (): Promise<void> => {
|
|
71
|
+
const control = harness.processes;
|
|
72
|
+
if (!control) return;
|
|
73
|
+
let running: IHarnessProcess[];
|
|
74
|
+
try { running = control.list(); }
|
|
75
|
+
catch { ui.logs.append(`Could not check for running ${plainText(harness.label)} processes.`); return; }
|
|
76
|
+
if (!running.length) return;
|
|
77
|
+
for (const line of describeHarnessProcesses(harness.label, running)) ui.logs.append(line);
|
|
78
|
+
if (control.stopUnavailableReason !== null) { ui.logs.append(control.stopUnavailableReason); return; }
|
|
79
|
+
const stoppable = running.filter(item => !item.isAncestor);
|
|
80
|
+
if (!stoppable.length || screen.signal.aborted) return;
|
|
81
|
+
if (!await screen.confirm(`Stop these ${stoppable.length} ${plainText(harness.label)} process(es) first?`, { confirmLabel: 'Stop them' })) return;
|
|
82
|
+
let outcome: IHarnessStopOutcome;
|
|
83
|
+
// Stopping re-reads the process table; if that read fails now, the switch is still the safe part.
|
|
84
|
+
try { outcome = await control.stop(stoppable); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
ui.logs.append(`Could not stop the running ${plainText(harness.label)} process(es); leaving them running: ${plainText(error instanceof Error ? error.message : 'the process list could not be read.')}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const line of describeStopOutcome(harness.label, outcome)) ui.logs.append(line);
|
|
90
|
+
};
|
|
62
91
|
const save = async (screen: plugins.smartconsole.ITuiContext, keepActiveArg: boolean): Promise<void> => {
|
|
63
92
|
const latest = await harness.readState();
|
|
64
93
|
const activeAccounts = latest.accounts.filter(account => account.isActive);
|
|
@@ -67,7 +96,8 @@ export class AuthSwitchTui {
|
|
|
67
96
|
if (!active || latest.saveUnavailableReason) { footer.setText(latest.saveUnavailableReason ?? 'No active login to save.'); return; }
|
|
68
97
|
if (!await screen.confirm(`Save ${plainText(active.label)}${keepActiveArg ? ' and keep it active' : ' and clear the active login'}?`)) return;
|
|
69
98
|
if (screen.signal.aborted) return;
|
|
70
|
-
const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: keepActiveArg, accountId: active.id },
|
|
99
|
+
const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: keepActiveArg, accountId: active.id },
|
|
100
|
+
message => screen.confirm(message), keepActiveArg ? undefined : settleInstances(screen));
|
|
71
101
|
await refresh(screen); showOutcome(outcome);
|
|
72
102
|
};
|
|
73
103
|
const switchSelected = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
|
|
@@ -87,7 +117,8 @@ export class AuthSwitchTui {
|
|
|
87
117
|
}
|
|
88
118
|
if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}?`, { confirmLabel: 'Switch' })) return;
|
|
89
119
|
if (screen.signal.aborted) return;
|
|
90
|
-
const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id },
|
|
120
|
+
const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id },
|
|
121
|
+
message => screen.confirm(message), settleInstances(screen));
|
|
91
122
|
await refresh(screen); showOutcome(outcome);
|
|
92
123
|
};
|
|
93
124
|
const remove = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
|
package/ts/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './interfaces.list.js';
|
|
|
4
4
|
export * from './classes.codexharness.js';
|
|
5
5
|
export * from './classes.opencodeharness.js';
|
|
6
6
|
export * from './classes.claudecodeharness.js';
|
|
7
|
+
export * from './classes.harnessprocesses.js';
|
|
7
8
|
export * from './classes.claudestatus.js';
|
|
8
9
|
export * from './classes.codexstatus.js';
|
|
9
10
|
export * from './classes.codexpreuse.js';
|