@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/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 start(location, runtime, restart) {
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
- runtime.stdout(`Kintio is already running (PID ${existing.workerPid || existing.daemonPid}).\n`);
350
- return 0;
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
- return 0;
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
- if (parsed.values.help || !command || command === 'help') {
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 (parsed.positionals.length !== 1) {
525
- throw new Error(`Unexpected argument: ${parsed.positionals[1]}`);
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`);
@@ -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 ilinkStorageKey = String(environment.ILINK_STORAGE_KEY || '').trim();
154
- if (ilinkStorageKey && !/^[A-Za-z0-9_-]{43}$/u.test(ilinkStorageKey)) {
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
- storageKey: ilinkStorageKey,
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 createConfig(environment, root);
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
+ }