@modelprofile.com/authswitch 2.3.0 → 3.1.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 +28 -6
- package/dist_ts/accounts.js +40 -7
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.claudecodeharness.d.ts +4 -4
- package/dist_ts/classes.claudecodeharness.js +5 -5
- package/dist_ts/classes.claudestatus.js +94 -18
- 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 +45 -19
- 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 +51 -5
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.harness.d.ts +79 -8
- package/dist_ts/interfaces.list.d.ts +20 -1
- package/package.json +2 -2
- package/readme.md +137 -31
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +44 -10
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.claudecodeharness.ts +7 -6
- package/ts/classes.claudestatus.ts +102 -14
- 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 +49 -20
- package/ts/classes.opencodeharness.ts +7 -7
- package/ts/classes.operations.ts +10 -1
- package/ts/classes.tui.ts +39 -7
- package/ts/index.ts +1 -0
- package/ts/interfaces.harness.ts +82 -8
- package/ts/interfaces.list.ts +21 -1
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
|
-
};
|
|
@@ -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
|
-
/**
|
|
27
|
-
|
|
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:
|
|
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 (
|
|
124
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
+
};
|