@kin-tio/cli 0.6.2 → 0.7.1
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/.env.example +4 -2
- package/CHANGELOG.md +34 -0
- package/README.md +53 -15
- package/README.zh-CN.md +34 -9
- package/bin/kintio.js +11 -1
- package/dist/daemon.js +5 -0
- package/dist/ilink.js +53 -0
- package/dist/src/cli.js +330 -13
- package/dist/src/config.js +72 -17
- package/dist/src/ilink/cli-accounts.js +74 -0
- package/dist/src/ilink/cli-login.js +563 -0
- package/dist/src/ilink/cli-start.js +60 -0
- package/dist/src/ilink/enrollment.js +24 -0
- package/dist/src/ilink/login-manager.js +102 -30
- package/dist/src/ilink/login-store.js +78 -29
- package/dist/src/ilink/qr.js +67 -3
- package/dist/src/ilink/secret-box.js +73 -0
- package/dist/src/ilink/sqlite-store.js +211 -13
- package/dist/src/mcp/ilink-login-server.js +160 -0
- package/dist/src/mcp/ipc-host.js +4 -1
- package/dist/src/mcp/ipc-protocol.js +22 -0
- package/dist/src/runtime/daemon-protocol.js +1 -0
- package/dist/src/runtime/native-daemon.js +11 -3
- package/dist/src/runtime.js +228 -92
- package/dist/src/services/codex-agent.js +57 -27
- package/dist/src/services/codex-app-server.js +17 -6
- package/dist/src/services/conversation-processor.js +8 -2
- package/dist/src/state/sqlite-store.js +151 -10
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -5,9 +5,12 @@ import path from 'node:path';
|
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import crossSpawn from 'cross-spawn';
|
|
8
|
-
import { DAEMON_STOP_TIMEOUT_MS, loadConfig, parseStartTimeout, resolveProjectRoot, WORKER_GRACEFUL_TIMEOUT_MS, } from './config.js';
|
|
8
|
+
import { DAEMON_STOP_TIMEOUT_MS, loadConfig, loadIlinkEnrollmentConfig, loadIlinkRuntimeConfig, parseStartTimeout, resolveProjectRoot, WORKER_GRACEFUL_TIMEOUT_MS, } from './config.js';
|
|
9
9
|
import { isPathInside, samePath } from './lib/path-identity.js';
|
|
10
10
|
import { assertTrustedDirectory, ensureContainedDirectory, ensurePrivateDirectory, } from './lib/private-directory.js';
|
|
11
|
+
import { runIlinkCliLogin } from './ilink/cli-login.js';
|
|
12
|
+
import { runIlinkAccountCommand } from './ilink/cli-accounts.js';
|
|
13
|
+
import { startIlinkCliRuntime } from './ilink/cli-start.js';
|
|
11
14
|
import { daemonRecordPath, readDaemonRecord, requestControl, } from './runtime/daemon-protocol.js';
|
|
12
15
|
import { acquireSingleInstanceLock, processIsAlive, SingleInstanceLockError, } from './runtime/single-instance-lock.js';
|
|
13
16
|
import { installManagedSkill } from './runtime/managed-skill.js';
|
|
@@ -22,6 +25,11 @@ Commands:
|
|
|
22
25
|
restart Restart Kintio with the current installation and config
|
|
23
26
|
status Show the background process status
|
|
24
27
|
logs Follow Kintio logs
|
|
28
|
+
ilink login [options] Connect an iLink account with a QR code
|
|
29
|
+
ilink list List enrolled iLink accounts
|
|
30
|
+
ilink start [options] Start one iLink account without Hono
|
|
31
|
+
ilink stop [options] Stop one iLink account
|
|
32
|
+
ilink delete [options] Permanently delete one iLink account and its data
|
|
25
33
|
|
|
26
34
|
Options:
|
|
27
35
|
--home <directory> Instance directory (default: ~/.kintio)
|
|
@@ -30,7 +38,132 @@ Options:
|
|
|
30
38
|
--no-follow Print logs without following
|
|
31
39
|
-h, --help Show this help
|
|
32
40
|
-v, --version Show the Kintio version
|
|
41
|
+
|
|
42
|
+
Run "kintio ilink --help" for iLink account commands.
|
|
43
|
+
`;
|
|
44
|
+
const ILINK_LOGIN_HELP = `Usage: kintio ilink login [options]
|
|
45
|
+
|
|
46
|
+
Connect one iLink account, save its encrypted credentials, and exit. This
|
|
47
|
+
command does not require setup, an environment file, Hono, or a running Kintio
|
|
48
|
+
instance. By default, the QR code is rendered directly in an interactive
|
|
49
|
+
terminal and expires after five minutes.
|
|
50
|
+
|
|
51
|
+
The PNG option is required when stdout is not an interactive terminal. Whoever
|
|
52
|
+
scans this locally issued QR receives the capabilities allowed by the host Agent
|
|
53
|
+
configuration; show it only to an authorized operator.
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--qr-output <file> Write a temporary raw QR PNG instead of terminal blocks
|
|
57
|
+
The file must be directly inside the instance directory
|
|
58
|
+
The file is removed when the login attempt ends
|
|
59
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
60
|
+
--config <file> Optional environment overrides
|
|
61
|
+
-h, --help Show this help
|
|
62
|
+
`;
|
|
63
|
+
const ILINK_START_HELP = `Usage: kintio ilink start [options]
|
|
64
|
+
|
|
65
|
+
Run iLink long polling and the host Agent in the background without starting
|
|
66
|
+
Hono or opening a TCP listener. This command does not require setup or an
|
|
67
|
+
environment file. One account is selected automatically; multiple accounts
|
|
68
|
+
require --account. Additional start commands add accounts to the same process.
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
--account <id> Provider account ID or Kintio account key
|
|
72
|
+
--foreground Keep the iLink-only Runtime attached to this terminal
|
|
73
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
74
|
+
--config <file> Optional environment overrides
|
|
75
|
+
-h, --help Show this help
|
|
76
|
+
`;
|
|
77
|
+
const ILINK_STOP_HELP = `Usage: kintio ilink stop [options]
|
|
78
|
+
|
|
79
|
+
Stop one iLink account. Stopping the last account also stops the background
|
|
80
|
+
iLink-only Runtime. One account is selected automatically; multiple
|
|
81
|
+
accounts require --account.
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
--account <id> Provider account ID or Kintio account key
|
|
85
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
86
|
+
--config <file> Optional environment overrides
|
|
87
|
+
-h, --help Show this help
|
|
88
|
+
`;
|
|
89
|
+
const ILINK_LIST_HELP = `Usage: kintio ilink list [options]
|
|
90
|
+
|
|
91
|
+
List enrolled iLink accounts and whether each account is currently running.
|
|
92
|
+
|
|
93
|
+
Options:
|
|
94
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
95
|
+
--config <file> Optional environment overrides
|
|
96
|
+
-h, --help Show this help
|
|
33
97
|
`;
|
|
98
|
+
const ILINK_DELETE_HELP = `Usage: kintio ilink delete [options]
|
|
99
|
+
|
|
100
|
+
Permanently delete one iLink account and all Kintio data scoped to it,
|
|
101
|
+
including credentials, conversations, messages, media, send records, and
|
|
102
|
+
enrollment audit records. This operation cannot be undone.
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
--account <id> Provider account ID or Kintio account key
|
|
106
|
+
--yes Confirm permanent deletion
|
|
107
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
108
|
+
--config <file> Optional environment overrides
|
|
109
|
+
-h, --help Show this help
|
|
110
|
+
`;
|
|
111
|
+
const ILINK_HELP = `Usage: kintio ilink <command>
|
|
112
|
+
|
|
113
|
+
Commands:
|
|
114
|
+
login [options] Connect an iLink account with a QR code
|
|
115
|
+
list List enrolled accounts
|
|
116
|
+
start [options] Start one account without Hono
|
|
117
|
+
stop [options] Stop one account
|
|
118
|
+
delete [options] Permanently delete one account and its data
|
|
119
|
+
|
|
120
|
+
Run "kintio ilink <command> --help" for command options.
|
|
121
|
+
`;
|
|
122
|
+
const ILINK_COMMANDS = new Set(['login', 'list', 'start', 'stop', 'delete']);
|
|
123
|
+
const COMMANDS = new Set([
|
|
124
|
+
'setup',
|
|
125
|
+
'start',
|
|
126
|
+
'run',
|
|
127
|
+
'stop',
|
|
128
|
+
'restart',
|
|
129
|
+
'status',
|
|
130
|
+
'logs',
|
|
131
|
+
'ilink',
|
|
132
|
+
]);
|
|
133
|
+
const ILINK_SIGNALS = process.platform === 'win32'
|
|
134
|
+
? ['SIGINT', 'SIGTERM']
|
|
135
|
+
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
136
|
+
function signalExitCode(signal) {
|
|
137
|
+
if (signal === 'SIGHUP')
|
|
138
|
+
return 129;
|
|
139
|
+
if (signal === 'SIGTERM')
|
|
140
|
+
return 143;
|
|
141
|
+
return 130;
|
|
142
|
+
}
|
|
143
|
+
async function runWithIlinkSignals(operation) {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
let interruptedBy;
|
|
146
|
+
const interrupt = (signal) => {
|
|
147
|
+
interruptedBy ||= signal;
|
|
148
|
+
controller.abort();
|
|
149
|
+
};
|
|
150
|
+
const listeners = ILINK_SIGNALS.map((signal) => ({
|
|
151
|
+
signal,
|
|
152
|
+
listener: () => interrupt(signal),
|
|
153
|
+
}));
|
|
154
|
+
for (const { signal, listener } of listeners)
|
|
155
|
+
process.once(signal, listener);
|
|
156
|
+
try {
|
|
157
|
+
const result = await operation(controller.signal);
|
|
158
|
+
return result === 130 && interruptedBy
|
|
159
|
+
? signalExitCode(interruptedBy)
|
|
160
|
+
: result;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
for (const { signal, listener } of listeners)
|
|
164
|
+
process.off(signal, listener);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
34
167
|
function defaultExecute(request) {
|
|
35
168
|
return new Promise((resolve, reject) => {
|
|
36
169
|
const child = crossSpawn(request.file, [...request.args], {
|
|
@@ -108,6 +241,11 @@ function runtimeDefaults() {
|
|
|
108
241
|
launchDaemon: defaultLaunchDaemon,
|
|
109
242
|
stdout: (text) => process.stdout.write(text),
|
|
110
243
|
stderr: (text) => process.stderr.write(text),
|
|
244
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
245
|
+
stdoutColumns: process.stdout.columns || 80,
|
|
246
|
+
ilinkLogin: runIlinkCliLogin,
|
|
247
|
+
ilinkAccount: runIlinkAccountCommand,
|
|
248
|
+
ilinkStart: startIlinkCliRuntime,
|
|
111
249
|
};
|
|
112
250
|
}
|
|
113
251
|
function resolveInputPath(value, cwd) {
|
|
@@ -273,7 +411,7 @@ async function probeDaemon(location) {
|
|
|
273
411
|
return undefined;
|
|
274
412
|
}
|
|
275
413
|
}
|
|
276
|
-
function assertDaemonInstance(location, packageRoot) {
|
|
414
|
+
function assertDaemonInstance(location, packageRoot, mode) {
|
|
277
415
|
const daemon = readDaemonRecord(location.home);
|
|
278
416
|
if (!daemon)
|
|
279
417
|
throw new Error('Kintio daemon record is missing');
|
|
@@ -281,6 +419,9 @@ function assertDaemonInstance(location, packageRoot) {
|
|
|
281
419
|
!samePath(daemon.packageRoot, packageRoot)) {
|
|
282
420
|
throw new Error('Kintio is running with another config or installation; use "kintio restart" to switch deliberately');
|
|
283
421
|
}
|
|
422
|
+
if (mode && daemon.mode !== mode) {
|
|
423
|
+
throw new Error(`Kintio is already running in ${daemon.mode} mode; stop it before starting ${mode} mode`);
|
|
424
|
+
}
|
|
284
425
|
}
|
|
285
426
|
async function withLifecycleLock(location, task) {
|
|
286
427
|
const dataDirectory = ensureContainedDirectory(location.home, path.join(location.home, 'data'));
|
|
@@ -336,18 +477,19 @@ async function rollbackLaunch(location, daemon) {
|
|
|
336
477
|
}
|
|
337
478
|
removeLaunchMetadata(location, daemon.pid);
|
|
338
479
|
}
|
|
339
|
-
async function
|
|
340
|
-
const environment = processEnvironment(location, runtime);
|
|
480
|
+
async function startBackgroundDaemon(location, runtime, environment, mode, restart) {
|
|
341
481
|
const timeout = parseStartTimeout(environment.KINTIO_START_TIMEOUT_MS);
|
|
342
482
|
return withLifecycleLock(location, async () => {
|
|
343
483
|
const existing = await probeDaemon(location);
|
|
344
484
|
if (existing && !restart) {
|
|
345
|
-
assertDaemonInstance(location, runtime.packageRoot);
|
|
485
|
+
assertDaemonInstance(location, runtime.packageRoot, mode);
|
|
346
486
|
if (existing.phase !== 'running') {
|
|
347
487
|
await waitUntilRunning(location, Date.now() + timeout);
|
|
348
488
|
}
|
|
349
|
-
|
|
350
|
-
|
|
489
|
+
return {
|
|
490
|
+
alreadyRunning: true,
|
|
491
|
+
pid: existing.workerPid || existing.daemonPid,
|
|
492
|
+
};
|
|
351
493
|
}
|
|
352
494
|
if (existing) {
|
|
353
495
|
await stopDaemon(location, DAEMON_STOP_TIMEOUT_MS);
|
|
@@ -357,7 +499,7 @@ async function start(location, runtime, restart) {
|
|
|
357
499
|
file: process.execPath,
|
|
358
500
|
args: [path.join(runtime.packageRoot, 'dist/daemon.js')],
|
|
359
501
|
cwd: location.home,
|
|
360
|
-
env: environment,
|
|
502
|
+
env: { ...environment, KINTIO_DAEMON_MODE: mode },
|
|
361
503
|
});
|
|
362
504
|
try {
|
|
363
505
|
await waitUntilRunning(location, deadline);
|
|
@@ -366,9 +508,40 @@ async function start(location, runtime, restart) {
|
|
|
366
508
|
await rollbackLaunch(location, daemon);
|
|
367
509
|
throw error;
|
|
368
510
|
}
|
|
369
|
-
|
|
511
|
+
const running = await requestControl(location.home, 'ping');
|
|
512
|
+
return {
|
|
513
|
+
alreadyRunning: false,
|
|
514
|
+
pid: running.workerPid || running.daemonPid,
|
|
515
|
+
};
|
|
370
516
|
});
|
|
371
517
|
}
|
|
518
|
+
async function start(location, runtime, restart) {
|
|
519
|
+
const result = await startBackgroundDaemon(location, runtime, processEnvironment(location, runtime), 'service', restart);
|
|
520
|
+
if (result.alreadyRunning) {
|
|
521
|
+
runtime.stdout(`Kintio is already running (PID ${result.pid}).\n`);
|
|
522
|
+
}
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
function ilinkDaemonEnvironment(location, runtime) {
|
|
526
|
+
const config = loadIlinkRuntimeConfig({
|
|
527
|
+
environment: { ...runtime.env },
|
|
528
|
+
envFile: location.configFile,
|
|
529
|
+
root: location.home,
|
|
530
|
+
});
|
|
531
|
+
refreshManagedSkill(config.codex.workingDirectory, runtime);
|
|
532
|
+
return {
|
|
533
|
+
...runtime.env,
|
|
534
|
+
KINTIO_HOME: location.home,
|
|
535
|
+
KINTIO_CONFIG_FILE: location.configFile,
|
|
536
|
+
NODE_ENV: 'production',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function startIlinkDaemon(location, runtime) {
|
|
540
|
+
const result = await startBackgroundDaemon(location, runtime, ilinkDaemonEnvironment(location, runtime), 'ilink', false);
|
|
541
|
+
if (!result.alreadyRunning) {
|
|
542
|
+
runtime.stdout(`Kintio iLink runtime is running in background (PID ${result.pid}).\n`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
372
545
|
async function waitUntilRunning(location, deadline) {
|
|
373
546
|
let lastError = 'daemon did not publish control state';
|
|
374
547
|
while (Date.now() < deadline) {
|
|
@@ -508,6 +681,10 @@ export async function runCli(args, overrides = {}) {
|
|
|
508
681
|
config: { type: 'string' },
|
|
509
682
|
lines: { type: 'string' },
|
|
510
683
|
'no-follow': { type: 'boolean' },
|
|
684
|
+
'qr-output': { type: 'string' },
|
|
685
|
+
account: { type: 'string' },
|
|
686
|
+
foreground: { type: 'boolean' },
|
|
687
|
+
yes: { type: 'boolean' },
|
|
511
688
|
help: { type: 'boolean', short: 'h' },
|
|
512
689
|
version: { type: 'boolean', short: 'v' },
|
|
513
690
|
},
|
|
@@ -517,18 +694,158 @@ export async function runCli(args, overrides = {}) {
|
|
|
517
694
|
return 0;
|
|
518
695
|
}
|
|
519
696
|
const command = parsed.positionals[0];
|
|
520
|
-
|
|
697
|
+
const subcommand = parsed.positionals[1];
|
|
698
|
+
if (!command) {
|
|
699
|
+
if (parsed.values['qr-output'] !== undefined) {
|
|
700
|
+
throw new Error('--qr-output is valid only for "kintio ilink login"');
|
|
701
|
+
}
|
|
521
702
|
runtime.stdout(HELP);
|
|
522
703
|
return 0;
|
|
523
704
|
}
|
|
524
|
-
if (
|
|
525
|
-
|
|
705
|
+
if (command === 'help') {
|
|
706
|
+
if (parsed.positionals.length !== 1) {
|
|
707
|
+
throw new Error(`Unexpected argument: ${subcommand}`);
|
|
708
|
+
}
|
|
709
|
+
runtime.stdout(HELP);
|
|
710
|
+
return 0;
|
|
711
|
+
}
|
|
712
|
+
if (!COMMANDS.has(command))
|
|
713
|
+
throw new Error(`Unknown command: ${command}`);
|
|
714
|
+
if (command === 'ilink') {
|
|
715
|
+
if (parsed.values.help && parsed.positionals.length === 1) {
|
|
716
|
+
runtime.stdout(ILINK_HELP);
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
if (!subcommand || !ILINK_COMMANDS.has(subcommand) ||
|
|
720
|
+
parsed.positionals.length !== 2) {
|
|
721
|
+
throw new Error('Usage: kintio ilink <login|list|start|stop|delete>');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if (parsed.positionals.length !== 1) {
|
|
725
|
+
throw new Error(`Unexpected argument: ${subcommand}`);
|
|
726
|
+
}
|
|
727
|
+
if (parsed.values.help) {
|
|
728
|
+
runtime.stdout(command !== 'ilink'
|
|
729
|
+
? HELP
|
|
730
|
+
: subcommand === 'login' ? ILINK_LOGIN_HELP
|
|
731
|
+
: subcommand === 'list' ? ILINK_LIST_HELP
|
|
732
|
+
: subcommand === 'start' ? ILINK_START_HELP
|
|
733
|
+
: subcommand === 'stop' ? ILINK_STOP_HELP
|
|
734
|
+
: ILINK_DELETE_HELP);
|
|
735
|
+
return 0;
|
|
526
736
|
}
|
|
527
737
|
if (command !== 'logs' &&
|
|
528
738
|
(parsed.values.lines !== undefined || parsed.values['no-follow'])) {
|
|
529
739
|
throw new Error('--lines and --no-follow are valid only for "kintio logs"');
|
|
530
740
|
}
|
|
741
|
+
if ((command !== 'ilink' || subcommand !== 'login') &&
|
|
742
|
+
parsed.values['qr-output'] !== undefined) {
|
|
743
|
+
throw new Error('--qr-output is valid only for "kintio ilink login"');
|
|
744
|
+
}
|
|
745
|
+
if (parsed.values['qr-output'] === '') {
|
|
746
|
+
throw new Error('--qr-output requires a non-empty file path');
|
|
747
|
+
}
|
|
748
|
+
if (parsed.values.account !== undefined &&
|
|
749
|
+
(command !== 'ilink' || !['start', 'stop', 'delete'].includes(subcommand || ''))) {
|
|
750
|
+
throw new Error('--account is valid only for "kintio ilink start|stop|delete"');
|
|
751
|
+
}
|
|
752
|
+
if (parsed.values.account === '') {
|
|
753
|
+
throw new Error('--account requires a non-empty account ID or key');
|
|
754
|
+
}
|
|
755
|
+
if (parsed.values.yes && (command !== 'ilink' || subcommand !== 'delete')) {
|
|
756
|
+
throw new Error('--yes is valid only for "kintio ilink delete"');
|
|
757
|
+
}
|
|
758
|
+
if (parsed.values.foreground && (command !== 'ilink' || subcommand !== 'start')) {
|
|
759
|
+
throw new Error('--foreground is valid only for "kintio ilink start"');
|
|
760
|
+
}
|
|
531
761
|
const location = instanceLocation(parsed.values, runtime);
|
|
762
|
+
const qrOutputPath = parsed.values['qr-output'] === undefined
|
|
763
|
+
? undefined
|
|
764
|
+
: resolveInputPath(parsed.values['qr-output'], runtime.cwd);
|
|
765
|
+
if (qrOutputPath && !samePath(path.dirname(qrOutputPath), location.home)) {
|
|
766
|
+
throw new Error('iLink QR output must be directly inside the instance directory');
|
|
767
|
+
}
|
|
768
|
+
if (command === 'ilink') {
|
|
769
|
+
if (privateFile(location.configFile, 'Kintio config')) {
|
|
770
|
+
assertTrustedDirectory(path.dirname(location.configFile), 'Kintio config directory', false);
|
|
771
|
+
}
|
|
772
|
+
prepareDirectories(location.home);
|
|
773
|
+
return await runWithIlinkSignals(async (signal) => {
|
|
774
|
+
const enrollmentConfig = loadIlinkEnrollmentConfig({
|
|
775
|
+
environment: { ...runtime.env },
|
|
776
|
+
envFile: location.configFile,
|
|
777
|
+
root: location.home,
|
|
778
|
+
});
|
|
779
|
+
if (subcommand === 'login') {
|
|
780
|
+
return await runtime.ilinkLogin({
|
|
781
|
+
config: enrollmentConfig,
|
|
782
|
+
packageRoot: runtime.packageRoot,
|
|
783
|
+
stdout: runtime.stdout,
|
|
784
|
+
stdoutIsTTY: runtime.stdoutIsTTY,
|
|
785
|
+
stdoutColumns: runtime.stdoutColumns,
|
|
786
|
+
...(qrOutputPath ? { qrOutputPath } : {}),
|
|
787
|
+
signal,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
const foreground = Boolean(parsed.values.foreground);
|
|
791
|
+
const commandResult = await runtime.ilinkAccount({
|
|
792
|
+
command: subcommand,
|
|
793
|
+
...(parsed.values.account ? { selector: parsed.values.account } : {}),
|
|
794
|
+
confirmed: Boolean(parsed.values.yes),
|
|
795
|
+
config: enrollmentConfig,
|
|
796
|
+
packageRoot: runtime.packageRoot,
|
|
797
|
+
signal,
|
|
798
|
+
stdout: runtime.stdout,
|
|
799
|
+
...(subcommand === 'start'
|
|
800
|
+
? { deferStandaloneStart: !foreground }
|
|
801
|
+
: {}),
|
|
802
|
+
});
|
|
803
|
+
if (subcommand === 'start' && commandResult.runtimeRequired) {
|
|
804
|
+
const runtimeConfig = loadIlinkRuntimeConfig({
|
|
805
|
+
environment: { ...runtime.env },
|
|
806
|
+
envFile: location.configFile,
|
|
807
|
+
root: location.home,
|
|
808
|
+
});
|
|
809
|
+
if (foreground) {
|
|
810
|
+
refreshManagedSkill(runtimeConfig.codex.workingDirectory, runtime);
|
|
811
|
+
return await runtime.ilinkStart({
|
|
812
|
+
config: runtimeConfig,
|
|
813
|
+
signal,
|
|
814
|
+
stdout: runtime.stdout,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (!commandResult.selectedAccountKey) {
|
|
818
|
+
throw new Error('iLink start did not resolve an account identity');
|
|
819
|
+
}
|
|
820
|
+
await startIlinkDaemon(location, runtime);
|
|
821
|
+
await runtime.ilinkAccount({
|
|
822
|
+
command: 'start',
|
|
823
|
+
selector: commandResult.selectedAccountKey,
|
|
824
|
+
config: enrollmentConfig,
|
|
825
|
+
packageRoot: runtime.packageRoot,
|
|
826
|
+
signal,
|
|
827
|
+
stdout: runtime.stdout,
|
|
828
|
+
});
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
if ((subcommand === 'stop' || subcommand === 'delete') &&
|
|
832
|
+
commandResult.runningCount === 0 &&
|
|
833
|
+
readDaemonRecord(location.home)?.mode === 'ilink') {
|
|
834
|
+
await withLifecycleLock(location, async () => {
|
|
835
|
+
if (readDaemonRecord(location.home)?.mode === 'ilink') {
|
|
836
|
+
try {
|
|
837
|
+
await stopDaemon(location, DAEMON_STOP_TIMEOUT_MS);
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
if (readDaemonRecord(location.home))
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return 0;
|
|
847
|
+
});
|
|
848
|
+
}
|
|
532
849
|
if (command === 'setup')
|
|
533
850
|
return setup(location, runtime);
|
|
534
851
|
if (command === 'start')
|
|
@@ -552,7 +869,7 @@ export async function runCli(args, overrides = {}) {
|
|
|
552
869
|
return 0;
|
|
553
870
|
}
|
|
554
871
|
assertDaemonInstance(location, runtime.packageRoot);
|
|
555
|
-
runtime.stdout(`Kintio is ${existing.phase} ` +
|
|
872
|
+
runtime.stdout(`Kintio is ${existing.phase} in ${readDaemonRecord(location.home)?.mode || 'service'} mode ` +
|
|
556
873
|
`(daemon PID ${existing.daemonPid}` +
|
|
557
874
|
`${existing.workerPid ? `, worker PID ${existing.workerPid}` : ''}).` +
|
|
558
875
|
`${existing.message ? ` ${existing.message}` : ''}\n`);
|
package/dist/src/config.js
CHANGED
|
@@ -124,6 +124,40 @@ function parseBoundedText(value, fallback, name, maxBytes) {
|
|
|
124
124
|
}
|
|
125
125
|
return parsed;
|
|
126
126
|
}
|
|
127
|
+
function createIlinkEnrollmentConfig(environment = process.env, root, platform = process.platform) {
|
|
128
|
+
environment = copyEnvironment(environment);
|
|
129
|
+
const home = path.resolve(root || resolveInstanceRoot(environment));
|
|
130
|
+
const storageKey = String(environment.ILINK_STORAGE_KEY || '').trim();
|
|
131
|
+
if (storageKey && !/^[A-Za-z0-9_-]{43}$/u.test(storageKey)) {
|
|
132
|
+
throw new Error('ILINK_STORAGE_KEY must be a canonical 32-byte base64url value');
|
|
133
|
+
}
|
|
134
|
+
const state = resolveStateFiles(environment, home);
|
|
135
|
+
const storageKeyFile = path.resolve(home, environment.ILINK_STORAGE_KEY_FILE ||
|
|
136
|
+
path.join(path.dirname(state.databaseFile), 'ilink-storage.key'));
|
|
137
|
+
if (platform === 'win32') {
|
|
138
|
+
for (const [name, filePath] of [
|
|
139
|
+
['KINTIO_DB_FILE', state.databaseFile],
|
|
140
|
+
['Kintio state lock', state.lockFile],
|
|
141
|
+
['ILINK_STORAGE_KEY_FILE', storageKeyFile],
|
|
142
|
+
]) {
|
|
143
|
+
if (!isPathInside(home, filePath)) {
|
|
144
|
+
throw new Error(`${name} must stay inside KINTIO_HOME on Windows`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze({
|
|
149
|
+
home,
|
|
150
|
+
state,
|
|
151
|
+
ilink: Object.freeze({
|
|
152
|
+
storageKey,
|
|
153
|
+
storageKeyFile,
|
|
154
|
+
baseUrl: environment.ILINK_BASE_URL || 'https://ilinkai.weixin.qq.com/',
|
|
155
|
+
apiTimeoutMs: parsePositiveInteger(environment.ILINK_API_TIMEOUT_MS, 15_000, 'ILINK_API_TIMEOUT_MS', 120_000),
|
|
156
|
+
longPollTimeoutMs: parsePositiveInteger(environment.ILINK_LONG_POLL_TIMEOUT_MS, 35_000, 'ILINK_LONG_POLL_TIMEOUT_MS', 120_000),
|
|
157
|
+
maxAccounts: parsePositiveInteger(environment.ILINK_MAX_ACCOUNTS, 20, 'ILINK_MAX_ACCOUNTS', 1_000),
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
127
161
|
export function createConfig(environment = process.env, root, platform = process.platform) {
|
|
128
162
|
environment = copyEnvironment(environment);
|
|
129
163
|
const instanceRoot = path.resolve(root || resolveInstanceRoot(environment));
|
|
@@ -150,22 +184,14 @@ export function createConfig(environment = process.env, root, platform = process
|
|
|
150
184
|
}
|
|
151
185
|
const ilinkEnabled = parseBoolean(environment.ILINK_ENABLED, false);
|
|
152
186
|
const codexEnabled = parseBoolean(environment.CODEX_ENABLED, apiEnabled || ilinkEnabled);
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
throw new Error('ILINK_STORAGE_KEY must be a canonical 32-byte base64url value');
|
|
156
|
-
}
|
|
157
|
-
const { databaseFile, lockFile } = resolveStateFiles(environment, instanceRoot);
|
|
187
|
+
const enrollment = createIlinkEnrollmentConfig(environment, instanceRoot, platform);
|
|
188
|
+
const { databaseFile, lockFile } = enrollment.state;
|
|
158
189
|
const codexWorkingDirectory = path.resolve(instanceRoot, environment.CODEX_WORKING_DIRECTORY ||
|
|
159
190
|
'codex-workspace');
|
|
160
|
-
const ilinkStorageKeyFile = path.resolve(instanceRoot, environment.ILINK_STORAGE_KEY_FILE ||
|
|
161
|
-
path.join(path.dirname(databaseFile), 'ilink-storage.key'));
|
|
162
191
|
const codexImageTempDirectory = path.resolve(instanceRoot, environment.CODEX_IMAGE_TMP_DIR ||
|
|
163
192
|
'data/codex-input');
|
|
164
193
|
if (platform === 'win32') {
|
|
165
194
|
for (const [name, filePath] of [
|
|
166
|
-
['KINTIO_DB_FILE', databaseFile],
|
|
167
|
-
['Kintio state lock', lockFile],
|
|
168
|
-
['ILINK_STORAGE_KEY_FILE', ilinkStorageKeyFile],
|
|
169
195
|
['CODEX_IMAGE_TMP_DIR', codexImageTempDirectory],
|
|
170
196
|
]) {
|
|
171
197
|
if (!isPathInside(instanceRoot, filePath)) {
|
|
@@ -201,12 +227,7 @@ export function createConfig(environment = process.env, root, platform = process
|
|
|
201
227
|
}),
|
|
202
228
|
ilink: Object.freeze({
|
|
203
229
|
enabled: ilinkEnabled,
|
|
204
|
-
|
|
205
|
-
storageKeyFile: ilinkStorageKeyFile,
|
|
206
|
-
baseUrl: environment.ILINK_BASE_URL || 'https://ilinkai.weixin.qq.com/',
|
|
207
|
-
apiTimeoutMs: parsePositiveInteger(environment.ILINK_API_TIMEOUT_MS, 15_000, 'ILINK_API_TIMEOUT_MS', 120_000),
|
|
208
|
-
longPollTimeoutMs: parsePositiveInteger(environment.ILINK_LONG_POLL_TIMEOUT_MS, 35_000, 'ILINK_LONG_POLL_TIMEOUT_MS', 120_000),
|
|
209
|
-
maxAccounts: parsePositiveInteger(environment.ILINK_MAX_ACCOUNTS, 20, 'ILINK_MAX_ACCOUNTS', 1_000),
|
|
230
|
+
...enrollment.ilink,
|
|
210
231
|
}),
|
|
211
232
|
state: Object.freeze({
|
|
212
233
|
databaseFile,
|
|
@@ -222,6 +243,10 @@ export function createConfig(environment = process.env, root, platform = process
|
|
|
222
243
|
});
|
|
223
244
|
}
|
|
224
245
|
export function loadConfig(options = {}) {
|
|
246
|
+
const loaded = loadConfigurationEnvironment(options);
|
|
247
|
+
return createConfig(loaded.environment, loaded.root);
|
|
248
|
+
}
|
|
249
|
+
function loadConfigurationEnvironment(options) {
|
|
225
250
|
const environment = copyEnvironment(options.environment || process.env);
|
|
226
251
|
const configuredEnvFile = options.envFile || environment.KINTIO_CONFIG_FILE;
|
|
227
252
|
const defaultRoot = path.join(path.resolve(options.homeDirectory || os.homedir()), '.kintio');
|
|
@@ -233,5 +258,35 @@ export function loadConfig(options = {}) {
|
|
|
233
258
|
const envFile = path.resolve(configuredEnvFile || path.join(initialRoot, '.env'));
|
|
234
259
|
loadEnvironmentFile(envFile, environment);
|
|
235
260
|
const root = path.resolve(options.root || environment.KINTIO_HOME || initialRoot);
|
|
236
|
-
return
|
|
261
|
+
return { environment, root };
|
|
262
|
+
}
|
|
263
|
+
export function loadIlinkEnrollmentConfig(options = {}) {
|
|
264
|
+
const loaded = loadConfigurationEnvironment(options);
|
|
265
|
+
return createIlinkEnrollmentConfig(loaded.environment, loaded.root);
|
|
266
|
+
}
|
|
267
|
+
export function loadIlinkRuntimeConfig(options = {}) {
|
|
268
|
+
const { environment, root } = loadConfigurationEnvironment(options);
|
|
269
|
+
const enrollment = createIlinkEnrollmentConfig(environment, root);
|
|
270
|
+
const workingDirectory = path.resolve(root, environment.CODEX_WORKING_DIRECTORY || 'codex-workspace');
|
|
271
|
+
const imageTempDirectory = path.resolve(root, environment.CODEX_IMAGE_TMP_DIR || 'data/codex-input');
|
|
272
|
+
if (process.platform === 'win32' && !isPathInside(root, imageTempDirectory)) {
|
|
273
|
+
throw new Error('CODEX_IMAGE_TMP_DIR must stay inside KINTIO_HOME on Windows');
|
|
274
|
+
}
|
|
275
|
+
const shutdownTimeoutMs = parsePositiveInteger(environment.SHUTDOWN_TIMEOUT_MS, 10_000, 'SHUTDOWN_TIMEOUT_MS', MAX_SHUTDOWN_TIMEOUT_MS);
|
|
276
|
+
if (shutdownTimeoutMs < 1_000) {
|
|
277
|
+
throw new Error('SHUTDOWN_TIMEOUT_MS must be at least 1000');
|
|
278
|
+
}
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
state: Object.freeze({
|
|
281
|
+
...enrollment.state,
|
|
282
|
+
shutdownTimeoutMs,
|
|
283
|
+
}),
|
|
284
|
+
ilink: Object.freeze({ enabled: true, ...enrollment.ilink }),
|
|
285
|
+
codex: Object.freeze({
|
|
286
|
+
enabled: true,
|
|
287
|
+
imageTempDirectory,
|
|
288
|
+
workingDirectory,
|
|
289
|
+
generatedImageDirectory: path.join(workingDirectory, 'generated_images'),
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
237
292
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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, deferStandaloneStart = false, }) {
|
|
26
|
+
if (!openControl && !fs.existsSync(config.state.databaseFile)) {
|
|
27
|
+
if (command === 'list') {
|
|
28
|
+
stdout('No iLink accounts enrolled.\n');
|
|
29
|
+
return { runtimeRequired: 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
|
+
? `${accounts.map((account) => account.providerAccountId).join('\n')}\n`
|
|
41
|
+
: 'No iLink accounts enrolled.\n');
|
|
42
|
+
return {
|
|
43
|
+
runtimeRequired: false,
|
|
44
|
+
runningCount: accounts.filter((account) => account.runtimeEnabled).length,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const account = selectAccount(accounts, selector, runtimeActive);
|
|
48
|
+
if (command === 'start' && control.mode === 'standalone' && deferStandaloneStart) {
|
|
49
|
+
return {
|
|
50
|
+
runtimeRequired: true,
|
|
51
|
+
runningCount: 0,
|
|
52
|
+
selectedAccountKey: account.accountKey,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (command === 'delete' && !confirmed) {
|
|
56
|
+
throw new Error(`Deleting ${JSON.stringify(account.providerAccountId)} permanently removes the account, ` +
|
|
57
|
+
'credentials, conversations, messages, media, send records, and audit records; ' +
|
|
58
|
+
'repeat with --yes');
|
|
59
|
+
}
|
|
60
|
+
const result = command === 'delete'
|
|
61
|
+
? await control.deleteAccount(account.accountKey)
|
|
62
|
+
: await control.setAccountRuntime(account.accountKey, command === 'start');
|
|
63
|
+
stdout(`${command === 'delete' ? 'Deleted' : command === 'start' ? 'Started' : 'Stopped'} ` +
|
|
64
|
+
`${JSON.stringify(account.providerAccountId)}.\n`);
|
|
65
|
+
return {
|
|
66
|
+
runtimeRequired: command === 'start' && control.mode === 'standalone',
|
|
67
|
+
runningCount: result.runningCount,
|
|
68
|
+
...(command === 'start' ? { selectedAccountKey: account.accountKey } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await control.close();
|
|
73
|
+
}
|
|
74
|
+
}
|