@kin-tio/cli 0.6.2 → 0.7.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.
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import { openIlinkOperatorControl, } from './cli-login.js';
3
+ function choices(accounts, runtimeActive) {
4
+ return accounts.map((account, index) => ` ${index + 1}. ${JSON.stringify(account.providerAccountId)} ` +
5
+ `${account.accountKey} [` +
6
+ `${runtimeActive && account.runtimeEnabled ? 'running' : 'stopped'}]`).join('\n');
7
+ }
8
+ function selectAccount(accounts, selector, runtimeActive) {
9
+ if (accounts.length === 0) {
10
+ throw new Error('No iLink account is enrolled; run "kintio ilink login" first');
11
+ }
12
+ if (!selector) {
13
+ if (accounts.length === 1)
14
+ return accounts[0];
15
+ throw new Error(`Multiple iLink accounts are enrolled; use --account with one choice:\n` +
16
+ choices(accounts, runtimeActive));
17
+ }
18
+ const matches = accounts.filter((account) => account.accountKey === selector || account.providerAccountId === selector);
19
+ if (matches.length !== 1) {
20
+ throw new Error(`Unknown or ambiguous iLink account ${JSON.stringify(selector)}:\n` +
21
+ choices(accounts, runtimeActive));
22
+ }
23
+ return matches[0];
24
+ }
25
+ export async function runIlinkAccountCommand({ command, selector, confirmed = false, config, packageRoot, signal, stdout, openControl, }) {
26
+ if (!openControl && !fs.existsSync(config.state.databaseFile)) {
27
+ if (command === 'list') {
28
+ stdout('No iLink accounts enrolled.\n');
29
+ return { startForeground: false, runningCount: 0 };
30
+ }
31
+ throw new Error('No iLink account is enrolled; run "kintio ilink login" first');
32
+ }
33
+ const control = await (openControl?.() ||
34
+ openIlinkOperatorControl(config, packageRoot, signal));
35
+ try {
36
+ const accounts = await control.listAccounts();
37
+ const runtimeActive = control.mode === 'runtime';
38
+ if (command === 'list') {
39
+ stdout(accounts.length
40
+ ? `${choices(accounts, runtimeActive)}\n`
41
+ : 'No iLink accounts enrolled.\n');
42
+ return {
43
+ startForeground: false,
44
+ runningCount: accounts.filter((account) => account.runtimeEnabled).length,
45
+ };
46
+ }
47
+ const account = selectAccount(accounts, selector, runtimeActive);
48
+ if (command === 'delete' && !confirmed) {
49
+ throw new Error(`Deleting ${JSON.stringify(account.providerAccountId)} permanently removes the account, ` +
50
+ 'credentials, conversations, messages, media, send records, and audit records; ' +
51
+ 'repeat with --yes');
52
+ }
53
+ const result = command === 'delete'
54
+ ? await control.deleteAccount(account.accountKey)
55
+ : await control.setAccountRuntime(account.accountKey, command === 'start');
56
+ stdout(`${command === 'delete' ? 'Deleted' : command === 'start' ? 'Started' : 'Stopped'} ` +
57
+ `${JSON.stringify(account.providerAccountId)}.\n`);
58
+ return {
59
+ startForeground: command === 'start' && control.mode === 'standalone',
60
+ runningCount: result.runningCount,
61
+ };
62
+ }
63
+ finally {
64
+ await control.close();
65
+ }
66
+ }
@@ -0,0 +1,563 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { assertTrustedDirectory } from '../lib/private-directory.js';
7
+ import { findMcpDescriptorFile, operatorMcpInstanceKey, } from '../mcp/ipc-protocol.js';
8
+ import { KINTIO_VERSION } from '../version.js';
9
+ import { acquireSingleInstanceLock, SingleInstanceLockError, } from '../runtime/single-instance-lock.js';
10
+ import { StatePersistence } from '../state/persistence.js';
11
+ import { createIlinkEnrollmentService } from './enrollment.js';
12
+ import { renderIlinkQrTerminal, renderIlinkRawQrPng, } from './qr.js';
13
+ const STATUS_POLL_MS = 1_000;
14
+ const OFFER_ID = /^qo_[A-Za-z0-9_-]{1,128}$/u;
15
+ const ACCOUNT_KEY = /^ia_[0-9a-f]{40}$/u;
16
+ const LOGIN_STATUSES = new Set([
17
+ 'waiting',
18
+ 'scanned',
19
+ 'confirmed',
20
+ 'expired',
21
+ 'failed',
22
+ 'cancelled',
23
+ 'already_connected',
24
+ 'verification_required',
25
+ 'unknown',
26
+ ]);
27
+ function prepareQrOutput(filePath) {
28
+ if (!path.isAbsolute(filePath)) {
29
+ throw new Error('iLink QR output path must be absolute');
30
+ }
31
+ const parentPath = path.dirname(filePath);
32
+ let parent;
33
+ try {
34
+ parent = fs.lstatSync(parentPath);
35
+ }
36
+ catch (error) {
37
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
38
+ throw new Error(`iLink QR output parent does not exist: ${parentPath}`);
39
+ }
40
+ throw error;
41
+ }
42
+ if (!parent.isDirectory() || parent.isSymbolicLink()) {
43
+ throw new Error(`iLink QR output parent is not a regular directory: ${parentPath}`);
44
+ }
45
+ assertTrustedDirectory(parentPath, 'iLink QR output directory', true);
46
+ try {
47
+ fs.lstatSync(filePath);
48
+ throw new Error(`iLink QR output already exists: ${filePath}`);
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
52
+ throw error;
53
+ }
54
+ }
55
+ }
56
+ function writeQrOutput(filePath, png) {
57
+ let descriptor;
58
+ let output;
59
+ let created = false;
60
+ try {
61
+ descriptor = fs.openSync(filePath, 'wx', 0o600);
62
+ created = true;
63
+ const stat = fs.fstatSync(descriptor);
64
+ output = Object.freeze({
65
+ filePath,
66
+ device: stat.dev,
67
+ inode: stat.ino,
68
+ });
69
+ fs.writeFileSync(descriptor, png);
70
+ fs.fsyncSync(descriptor);
71
+ fs.closeSync(descriptor);
72
+ descriptor = undefined;
73
+ return output;
74
+ }
75
+ catch (error) {
76
+ const cleanupErrors = [];
77
+ if (descriptor !== undefined) {
78
+ try {
79
+ fs.closeSync(descriptor);
80
+ }
81
+ catch (closeError) {
82
+ cleanupErrors.push(closeError);
83
+ }
84
+ descriptor = undefined;
85
+ }
86
+ if (created) {
87
+ try {
88
+ if (output)
89
+ removeQrOutput(output);
90
+ else
91
+ fs.unlinkSync(filePath);
92
+ }
93
+ catch (cleanupError) {
94
+ cleanupErrors.push(cleanupError);
95
+ }
96
+ }
97
+ if (cleanupErrors.length) {
98
+ throw new AggregateError([error, ...cleanupErrors], 'Unable to remove incomplete iLink QR output');
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+ function removeQrOutput(output) {
104
+ let stat;
105
+ try {
106
+ stat = fs.lstatSync(output.filePath);
107
+ }
108
+ catch (error) {
109
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
110
+ return;
111
+ throw error;
112
+ }
113
+ if (!stat.isFile() || stat.isSymbolicLink() ||
114
+ stat.dev !== output.device || stat.ino !== output.inode) {
115
+ throw new Error(`Temporary iLink QR output was replaced and was not removed: ${output.filePath}`);
116
+ }
117
+ fs.unlinkSync(output.filePath);
118
+ }
119
+ function record(value) {
120
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
121
+ throw new Error('Invalid response from the Kintio runtime');
122
+ }
123
+ return value;
124
+ }
125
+ function resultError(result) {
126
+ const content = Array.isArray(result.content) ? result.content : [];
127
+ const first = content.find((item) => item && typeof item === 'object' && 'type' in item && item.type === 'text');
128
+ const message = first && typeof first === 'object' && 'text' in first
129
+ ? String(first.text || '')
130
+ : '';
131
+ throw new Error(message || 'The Kintio runtime rejected the iLink login operation');
132
+ }
133
+ function structured(result) {
134
+ const response = record(result);
135
+ if (response.isError)
136
+ resultError(response);
137
+ return record(response.structuredContent);
138
+ }
139
+ function operatorAccount(value) {
140
+ const account = record(value);
141
+ const accountKey = String(account.accountKey || '');
142
+ const providerAccountId = String(account.providerAccountId || '');
143
+ if (!ACCOUNT_KEY.test(accountKey) ||
144
+ !providerAccountId || Buffer.byteLength(providerAccountId, 'utf8') > 512 ||
145
+ typeof account.runtimeEnabled !== 'boolean') {
146
+ throw new Error('Invalid iLink account response from the Kintio runtime');
147
+ }
148
+ return Object.freeze({
149
+ accountKey: accountKey,
150
+ providerAccountId,
151
+ runtimeEnabled: account.runtimeEnabled,
152
+ });
153
+ }
154
+ function accountMutation(value) {
155
+ const runningCount = Number(value.runningCount);
156
+ if (!Number.isSafeInteger(runningCount) || runningCount < 0) {
157
+ throw new Error('Invalid iLink runtime count from the Kintio runtime');
158
+ }
159
+ return Object.freeze({
160
+ account: operatorAccount(value.account),
161
+ runningCount,
162
+ });
163
+ }
164
+ class McpIlinkOperatorControl {
165
+ mode = 'runtime';
166
+ #client;
167
+ #transport;
168
+ constructor(client, transport) {
169
+ this.#client = client;
170
+ this.#transport = transport;
171
+ }
172
+ static async connect(config, packageRoot) {
173
+ const descriptorFile = findMcpDescriptorFile(path.dirname(config.state.lockFile), operatorMcpInstanceKey(config.state.lockFile));
174
+ if (!fs.existsSync(descriptorFile)) {
175
+ throw new Error('Kintio runtime has no local iLink operator control');
176
+ }
177
+ const sourceRelay = path.join(packageRoot, 'mcp-relay.ts');
178
+ const relayFile = fs.existsSync(sourceRelay)
179
+ ? sourceRelay
180
+ : path.join(packageRoot, 'dist/mcp-relay.js');
181
+ const transport = new StdioClientTransport({
182
+ command: process.execPath,
183
+ args: [
184
+ relayFile,
185
+ '--descriptor',
186
+ descriptorFile,
187
+ '--route',
188
+ 'operator',
189
+ ],
190
+ stderr: 'pipe',
191
+ });
192
+ const stderr = transport.stderr;
193
+ if (stderr && 'resume' in stderr && typeof stderr.resume === 'function')
194
+ stderr.resume();
195
+ const client = new Client({ name: 'kintio-cli', version: KINTIO_VERSION });
196
+ try {
197
+ await client.connect(transport);
198
+ return new McpIlinkOperatorControl(client, transport);
199
+ }
200
+ catch (error) {
201
+ try {
202
+ await transport.close();
203
+ }
204
+ catch { }
205
+ throw new Error('Kintio runtime is not available for iLink operator control', {
206
+ cause: error,
207
+ });
208
+ }
209
+ }
210
+ async begin(signal) {
211
+ const value = structured(await this.#client.callTool({ name: 'begin_login', arguments: {} }, undefined, { signal, timeout: 30_000 }));
212
+ const offerId = String(value.offerId || '');
213
+ const qrContent = String(value.qrContent || '');
214
+ const expiresAt = Number(value.expiresAt || 0);
215
+ if (!OFFER_ID.test(offerId) || !qrContent ||
216
+ Buffer.byteLength(qrContent, 'utf8') > 2_048 ||
217
+ !Number.isSafeInteger(expiresAt) || expiresAt <= 0)
218
+ throw new Error('Invalid iLink login offer from the Kintio runtime');
219
+ return { offerId, qrContent, expiresAt };
220
+ }
221
+ async status(offerId, signal) {
222
+ const value = structured(await this.#client.callTool({ name: 'login_status', arguments: { offerId } }, undefined, { signal, timeout: 5_000 }));
223
+ const status = String(value.status || '');
224
+ if (!LOGIN_STATUSES.has(status)) {
225
+ throw new Error('Invalid iLink login status from the Kintio runtime');
226
+ }
227
+ return { status };
228
+ }
229
+ async cancel(offerId) {
230
+ const value = structured(await this.#client.callTool({ name: 'cancel_login', arguments: { offerId } }, undefined, { timeout: 5_000 }));
231
+ if (typeof value.cancelled !== 'boolean') {
232
+ throw new Error('Invalid iLink cancellation response from the Kintio runtime');
233
+ }
234
+ return value.cancelled;
235
+ }
236
+ async listAccounts() {
237
+ const value = structured(await this.#client.callTool({ name: 'list_accounts', arguments: {} }, undefined, { timeout: 5_000 }));
238
+ if (!Array.isArray(value.accounts) || value.accounts.length > 1_000) {
239
+ throw new Error('Invalid iLink account list from the Kintio runtime');
240
+ }
241
+ return Object.freeze(value.accounts.map(operatorAccount));
242
+ }
243
+ async setAccountRuntime(accountKey, enabled) {
244
+ return accountMutation(structured(await this.#client.callTool({
245
+ name: enabled ? 'start_account' : 'stop_account',
246
+ arguments: { accountKey },
247
+ }, undefined, { timeout: 10_000 })));
248
+ }
249
+ async deleteAccount(accountKey) {
250
+ return accountMutation(structured(await this.#client.callTool({ name: 'delete_account', arguments: { accountKey } }, undefined, { timeout: 10_000 })));
251
+ }
252
+ async close() {
253
+ try {
254
+ await this.#client.close();
255
+ }
256
+ catch { }
257
+ try {
258
+ await this.#transport.close();
259
+ }
260
+ catch { }
261
+ }
262
+ }
263
+ class LocalIlinkOperatorControl {
264
+ mode = 'standalone';
265
+ #persistence;
266
+ #lock;
267
+ #config;
268
+ #accounts;
269
+ #enrollment;
270
+ #enrollmentStarted;
271
+ #closed = false;
272
+ constructor(persistence, lock, config) {
273
+ this.#persistence = persistence;
274
+ this.#lock = lock;
275
+ this.#config = config;
276
+ this.#accounts = persistence.createIlinkStore();
277
+ }
278
+ static async open(config) {
279
+ const lock = acquireSingleInstanceLock({
280
+ filePath: config.state.lockFile,
281
+ hasActiveDatabaseOwner: () => StatePersistence.hasActiveWriter(config.state.databaseFile),
282
+ });
283
+ let persistence;
284
+ try {
285
+ persistence = new StatePersistence({ filePath: config.state.databaseFile });
286
+ return new LocalIlinkOperatorControl(persistence, lock, config);
287
+ }
288
+ catch (error) {
289
+ const cleanupErrors = [];
290
+ try {
291
+ persistence?.close();
292
+ }
293
+ catch (cleanupError) {
294
+ cleanupErrors.push(cleanupError);
295
+ }
296
+ if (!persistence || persistence.closed) {
297
+ try {
298
+ if (!lock.release())
299
+ cleanupErrors.push(new Error('iLink operator lock was not released'));
300
+ }
301
+ catch (cleanupError) {
302
+ cleanupErrors.push(cleanupError);
303
+ }
304
+ }
305
+ else {
306
+ cleanupErrors.push(new Error('iLink state stayed open; its instance lock was retained'));
307
+ }
308
+ if (cleanupErrors.length) {
309
+ throw new AggregateError([error, ...cleanupErrors], 'Standalone iLink login initialization and cleanup both failed');
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+ async #startEnrollment() {
315
+ this.#enrollment ||= createIlinkEnrollmentService({
316
+ persistence: this.#persistence,
317
+ config: this.#config.ilink,
318
+ });
319
+ this.#enrollmentStarted ||= this.#enrollment.manager.start();
320
+ await this.#enrollmentStarted;
321
+ return this.#enrollment;
322
+ }
323
+ async begin(signal) {
324
+ const enrollment = await this.#startEnrollment();
325
+ return enrollment.manager.offer({ kind: 'terminal' }, signal ? { signal } : {});
326
+ }
327
+ status(offerId) {
328
+ if (!this.#enrollment)
329
+ throw new Error('No iLink login is active');
330
+ return Promise.resolve(this.#enrollment.manager.status(offerId));
331
+ }
332
+ cancel(offerId) {
333
+ return Promise.resolve(this.#enrollment?.manager.cancel(offerId) || false);
334
+ }
335
+ listAccounts() {
336
+ return Promise.resolve(Object.freeze(this.#accounts.listActiveAccounts().map((account) => ({
337
+ accountKey: account.accountKey,
338
+ providerAccountId: account.providerAccountId,
339
+ runtimeEnabled: account.runtimeEnabled,
340
+ }))));
341
+ }
342
+ setAccountRuntime(accountKey, enabled) {
343
+ const account = enabled
344
+ ? this.#accounts.selectRuntimeAccount(accountKey)
345
+ : this.#accounts.setRuntimeEnabled(accountKey, false);
346
+ return Promise.resolve({
347
+ account: {
348
+ accountKey: account.accountKey,
349
+ providerAccountId: account.providerAccountId,
350
+ runtimeEnabled: account.runtimeEnabled,
351
+ },
352
+ runningCount: this.#accounts.listRuntimeAccountsWithSecrets().length,
353
+ });
354
+ }
355
+ deleteAccount(accountKey) {
356
+ const account = this.#accounts.deleteAccountCompletely(accountKey);
357
+ return Promise.resolve({
358
+ account: {
359
+ accountKey: account.accountKey,
360
+ providerAccountId: account.providerAccountId,
361
+ runtimeEnabled: account.runtimeEnabled,
362
+ },
363
+ runningCount: this.#accounts.listRuntimeAccountsWithSecrets().length,
364
+ });
365
+ }
366
+ async close() {
367
+ if (this.#closed)
368
+ return;
369
+ const errors = [];
370
+ if (this.#enrollment) {
371
+ try {
372
+ await this.#enrollment.manager.close();
373
+ }
374
+ catch (error) {
375
+ errors.push(error);
376
+ }
377
+ }
378
+ try {
379
+ this.#persistence.core.checkpoint('TRUNCATE');
380
+ }
381
+ catch (error) {
382
+ errors.push(error);
383
+ }
384
+ try {
385
+ this.#persistence.close();
386
+ }
387
+ catch (error) {
388
+ errors.push(error);
389
+ }
390
+ if (this.#persistence.closed) {
391
+ try {
392
+ if (!this.#lock.release())
393
+ errors.push(new Error('iLink operator lock was not released'));
394
+ }
395
+ catch (error) {
396
+ errors.push(error);
397
+ }
398
+ }
399
+ else {
400
+ errors.push(new Error('iLink state stayed open; its instance lock was retained'));
401
+ }
402
+ this.#closed = this.#persistence.closed;
403
+ if (errors.length) {
404
+ throw new AggregateError(errors, 'Standalone iLink login cleanup failed');
405
+ }
406
+ }
407
+ }
408
+ export async function openIlinkOperatorControl(config, packageRoot, signal) {
409
+ try {
410
+ return await McpIlinkOperatorControl.connect(config, packageRoot);
411
+ }
412
+ catch (ipcError) {
413
+ try {
414
+ return await LocalIlinkOperatorControl.open(config);
415
+ }
416
+ catch (localError) {
417
+ if (!(localError instanceof SingleInstanceLockError))
418
+ throw localError;
419
+ if (localError.owner?.pid !== process.pid) {
420
+ const deadline = Date.now() + 5_000;
421
+ while (!signal.aborted && Date.now() < deadline) {
422
+ await delay(100, undefined, { signal });
423
+ try {
424
+ return await McpIlinkOperatorControl.connect(config, packageRoot);
425
+ }
426
+ catch { }
427
+ }
428
+ }
429
+ throw new Error('This Kintio instance is running, but its private iLink operator control is unavailable', { cause: ipcError });
430
+ }
431
+ }
432
+ }
433
+ function defaultSleep(milliseconds, signal) {
434
+ return delay(milliseconds, undefined, { signal });
435
+ }
436
+ function terminalMessage(status) {
437
+ switch (status) {
438
+ case 'confirmed': return 'iLink login succeeded.\n';
439
+ case 'expired': return 'iLink login QR code expired.\n';
440
+ case 'cancelled': return 'iLink login was cancelled.\n';
441
+ case 'already_connected':
442
+ return 'The iLink account is already connected; host authorization is confirmed.\n';
443
+ case 'verification_required':
444
+ return 'This iLink login requires verification that the CLI does not support.\n';
445
+ case 'failed': return 'iLink login failed.\n';
446
+ case 'unknown': return 'The iLink login session is no longer available.\n';
447
+ }
448
+ }
449
+ function aborted(error, signal) {
450
+ return signal.aborted || (error instanceof Error && error.name === 'AbortError');
451
+ }
452
+ async function cancelQuietly(control, offerId) {
453
+ try {
454
+ await control.cancel(offerId);
455
+ }
456
+ catch { }
457
+ }
458
+ async function cancelOrReadFinal(control, offerId) {
459
+ try {
460
+ if (await control.cancel(offerId))
461
+ return 'cancelled';
462
+ }
463
+ catch { }
464
+ try {
465
+ return (await control.status(offerId, new AbortController().signal)).status;
466
+ }
467
+ catch {
468
+ return undefined;
469
+ }
470
+ }
471
+ function loginSucceeded(status) {
472
+ return status === 'confirmed' || status === 'already_connected';
473
+ }
474
+ export async function runIlinkCliLogin(options) {
475
+ if (!options.stdoutIsTTY && !options.qrOutputPath) {
476
+ throw new Error('iLink login requires an interactive terminal, or use --qr-output <file>');
477
+ }
478
+ if (options.qrOutputPath)
479
+ prepareQrOutput(options.qrOutputPath);
480
+ const clock = options.clock || Date.now;
481
+ const sleep = options.sleep || defaultSleep;
482
+ const openControl = options.openControl || (() => openIlinkOperatorControl(options.config, options.packageRoot, options.signal));
483
+ let control;
484
+ let offerId = '';
485
+ let qrOutput;
486
+ try {
487
+ control = await openControl();
488
+ const offer = await control.begin(options.signal);
489
+ offerId = offer.offerId;
490
+ if (options.qrOutputPath) {
491
+ qrOutput = writeQrOutput(options.qrOutputPath, await renderIlinkRawQrPng(offer.qrContent));
492
+ options.stdout(`Temporary QR image: ${JSON.stringify(options.qrOutputPath)}\n` +
493
+ 'Scan it with WeChat within 5 minutes. The file will be removed when login ends.\n' +
494
+ 'Waiting for scan...\n');
495
+ }
496
+ else {
497
+ const qr = renderIlinkQrTerminal(offer.qrContent);
498
+ if (options.stdoutColumns < qr.columns) {
499
+ await cancelQuietly(control, offerId);
500
+ offerId = '';
501
+ throw new Error(`Terminal is too narrow for this QR code; ${qr.columns} columns are required`);
502
+ }
503
+ options.stdout(`Scan this QR code with WeChat within 5 minutes:\n\n${qr.text}\n` +
504
+ 'Waiting for scan...\n');
505
+ }
506
+ let lastStatus = 'waiting';
507
+ while (true) {
508
+ const current = await control.status(offerId, options.signal);
509
+ if (current.status === 'scanned' && lastStatus !== 'scanned') {
510
+ options.stdout('QR scanned. Confirm the login in WeChat.\n');
511
+ }
512
+ if (current.status !== 'waiting' && current.status !== 'scanned') {
513
+ options.stdout(terminalMessage(current.status));
514
+ offerId = '';
515
+ return loginSucceeded(current.status) ? 0 : 1;
516
+ }
517
+ lastStatus = current.status;
518
+ if (clock() >= offer.expiresAt) {
519
+ const finalStatus = await cancelOrReadFinal(control, offerId);
520
+ offerId = '';
521
+ if (finalStatus && finalStatus !== 'cancelled') {
522
+ options.stdout(terminalMessage(finalStatus === 'waiting' || finalStatus === 'scanned'
523
+ ? 'expired'
524
+ : finalStatus));
525
+ return loginSucceeded(finalStatus) ? 0 : 1;
526
+ }
527
+ options.stdout(terminalMessage('expired'));
528
+ return 1;
529
+ }
530
+ await sleep(STATUS_POLL_MS, options.signal);
531
+ }
532
+ }
533
+ catch (error) {
534
+ if (!aborted(error, options.signal)) {
535
+ const finalStatus = offerId
536
+ ? await cancelOrReadFinal(control, offerId)
537
+ : undefined;
538
+ if (finalStatus && loginSucceeded(finalStatus)) {
539
+ options.stdout(terminalMessage(finalStatus));
540
+ return 0;
541
+ }
542
+ throw error;
543
+ }
544
+ const finalStatus = offerId
545
+ ? await cancelOrReadFinal(control, offerId)
546
+ : undefined;
547
+ if (finalStatus && loginSucceeded(finalStatus)) {
548
+ options.stdout(terminalMessage(finalStatus));
549
+ return 0;
550
+ }
551
+ options.stdout('iLink login was cancelled.\n');
552
+ return 130;
553
+ }
554
+ finally {
555
+ try {
556
+ if (qrOutput)
557
+ removeQrOutput(qrOutput);
558
+ }
559
+ finally {
560
+ await control?.close();
561
+ }
562
+ }
563
+ }
@@ -0,0 +1,57 @@
1
+ import { FORCE_ABORT_TIMEOUT_MS, } from '../config.js';
2
+ import { createRuntime, } from '../runtime.js';
3
+ function waitForAbort(signal) {
4
+ if (signal.aborted)
5
+ return Promise.resolve();
6
+ return new Promise((resolve) => {
7
+ signal.addEventListener('abort', () => resolve(), { once: true });
8
+ });
9
+ }
10
+ async function closeRuntime(runtime, timeoutMs) {
11
+ runtime.stopAccepting();
12
+ let timeout;
13
+ const timedOut = new Promise((_resolve, reject) => {
14
+ timeout = setTimeout(() => reject(new Error('Graceful iLink shutdown timed out')), timeoutMs);
15
+ });
16
+ try {
17
+ await Promise.race([runtime.close(), timedOut]);
18
+ }
19
+ catch (error) {
20
+ let forceTimer;
21
+ await Promise.race([
22
+ runtime.abort().catch(() => undefined),
23
+ new Promise((resolve) => {
24
+ forceTimer = setTimeout(resolve, FORCE_ABORT_TIMEOUT_MS);
25
+ }),
26
+ ]);
27
+ clearTimeout(forceTimer);
28
+ throw error;
29
+ }
30
+ finally {
31
+ clearTimeout(timeout);
32
+ }
33
+ }
34
+ export async function startIlinkCliRuntime(options) {
35
+ if (options.signal.aborted)
36
+ return 130;
37
+ const create = options.create || createRuntime;
38
+ let requestStop;
39
+ const stopRequested = new Promise((resolve) => { requestStop = resolve; });
40
+ const runtime = await create({
41
+ config: options.config,
42
+ ...(options.logger ? { logger: options.logger } : {}),
43
+ onIlinkStopRequested: requestStop,
44
+ });
45
+ try {
46
+ await runtime.start();
47
+ options.stdout('Kintio iLink runtime is active. Press Ctrl-C to stop.\n');
48
+ const reason = await Promise.race([
49
+ waitForAbort(options.signal).then(() => 'signal'),
50
+ stopRequested.then(() => 'account-stop'),
51
+ ]);
52
+ return reason === 'signal' ? 130 : 0;
53
+ }
54
+ finally {
55
+ await closeRuntime(runtime, options.config.state.shutdownTimeoutMs);
56
+ }
57
+ }