@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.
package/.env.example CHANGED
@@ -28,8 +28,10 @@ WECOM_AUTH_CONFIRMATION=Code accepted. You can continue the conversation.
28
28
  # WECOM_MCP_OBSERVE_MS=5000
29
29
  # SHUTDOWN_TIMEOUT_MS=10000
30
30
 
31
- # Optional Weixin iLink channel. Generate a 32-byte base64url key and keep it in
32
- # the deployment secret store; Bot and context tokens are encrypted with it.
31
+ # Enable Weixin iLink inside the combined `kintio start` runtime. Standalone
32
+ # `kintio ilink login` and `kintio ilink start` do not require this flag.
33
+ # Generate a 32-byte base64url key and keep it in the deployment secret store;
34
+ # Bot and context tokens are encrypted with it.
33
35
  ILINK_ENABLED=false
34
36
  # Prefer a deployment-secret value. If omitted, the service creates a private
35
37
  # 0600 key file beside SQLite; never register that key with a chat Agent.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ This file records important user-visible changes after the first public release.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.7.0
8
+
9
+ - Added `kintio ilink login`, which reuses the iLink enrollment state machine
10
+ while rendering its five-minute QR code directly in an interactive terminal;
11
+ no WeChat KF conversation or Agent turn is required. Accounts
12
+ enrolled locally receive host-level Agent access and inherit the host runtime
13
+ configuration, while remotely offered iLink accounts remain restricted
14
+ ([#54](https://github.com/Gkxie/kintio/issues/54)).
15
+ - Added an explicit `--qr-output <file>` view for `kintio ilink login`, allowing
16
+ graphical and non-terminal callers to consume a temporary raw PNG directly
17
+ from the QR payload without parsing ANSI terminal output. The file is created
18
+ exclusively and removed when the login attempt ends
19
+ ([#57](https://github.com/Gkxie/kintio/issues/57)).
20
+ - Made iLink a standalone lifecycle: `kintio ilink login` now initializes and
21
+ persists an account without setup, an environment file, Hono, or a running
22
+ Worker, while safely delegating to a running instance when present;
23
+ `kintio ilink start` starts polling and the host Agent in the foreground without
24
+ a public HTTP listener. The iLink Runtime configuration no longer contains a
25
+ synthetic WeChat KF adapter
26
+ ([#59](https://github.com/Gkxie/kintio/issues/59)).
27
+ - Added per-account `kintio ilink list`, `start`, `stop`, and confirmed `delete`
28
+ lifecycle commands. One Runtime can reconcile multiple selected listeners;
29
+ complete deletion atomically purges the selected account and all Kintio data
30
+ scoped to it while preserving unrelated accounts and channels
31
+ ([#60](https://github.com/Gkxie/kintio/issues/60)).
32
+
7
33
  ## 0.6.2
8
34
 
9
35
  - Added a custom Kintio wordmark and its circular-safe TIO avatar to the English
package/README.md CHANGED
@@ -67,33 +67,67 @@ Prerequisites:
67
67
 
68
68
  ```bash
69
69
  npm install --global @kin-tio/cli
70
- kintio setup
71
70
  codex login status
72
71
  ```
73
72
 
74
- `kintio setup` creates a private instance under `~/.kintio`, installs the bundled Agent
75
- skill, and writes the channel configuration template. POSIX systems use mode `0600`;
76
- Windows keeps the instance inside the current user's profile. No adapter is enabled
77
- by default. Follow the [setup guide](https://github.com/Gkxie/kintio/blob/master/docs/setup.md)
78
- and edit `~/.kintio/.env` to configure one adapter:
73
+ For an iLink-only instance, no setup file or public HTTP listener is required:
79
74
 
80
- - For WeChat KF API, set its callback token, EncodingAESKey, CorpID, and secret. A temporary
81
- `WECOM_AUTH_TRIGGER` can authorize the first user without knowing their
82
- `external_userid` in advance.
83
- - For an existing Weixin iLink binding, set `ILINK_ENABLED=true`. Creating a new binding
84
- currently starts from an authorized WeChat KF conversation.
75
+ ```bash
76
+ kintio ilink login
77
+ kintio ilink start
78
+ ```
85
79
 
86
- Start Kintio:
80
+ `ilink login` performs one encrypted enrollment, starts no listener, and exits. `ilink start` then runs provider
81
+ polling and the host Agent in the foreground without Hono or a TCP listener. Both commands
82
+ use `~/.kintio` by default and accept `--home`. With multiple accounts, use `ilink list`
83
+ and pass the displayed provider ID or account key through `--account`. Repeated `start`
84
+ commands add accounts to the live runtime; `stop` removes one.
85
+
86
+ For a callback-based adapter, create and edit the deployment configuration instead:
87
87
 
88
88
  ```bash
89
+ kintio setup
89
90
  kintio start
90
91
  kintio status
91
92
  kintio logs --lines 100
92
93
  ```
93
94
 
94
- After startup, confirm that `kintio logs` contains `Hono server is listening on port 8888`.
95
- Complete the callback or binding checks described in the setup guide before sending traffic.
96
- Use `kintio run` when a foreground process is preferable to the native daemon.
95
+ `kintio setup` creates a private instance under `~/.kintio`, installs the managed Agent
96
+ skill, and writes the channel configuration template. Follow the
97
+ [setup guide](https://github.com/Gkxie/kintio/blob/master/docs/setup.md):
98
+
99
+ - For WeChat KF API, set its callback token, EncodingAESKey, CorpID, and secret. A temporary
100
+ `WECOM_AUTH_TRIGGER` can authorize the first user without knowing their
101
+ `external_userid` in advance.
102
+ - A combined callback + iLink deployment may additionally set `ILINK_ENABLED=true`.
103
+
104
+ For a graphical or non-terminal caller, select a temporary raw PNG instead of ANSI blocks:
105
+
106
+ ```bash
107
+ kintio ilink login --qr-output ~/.kintio/ilink-login.png
108
+ ```
109
+
110
+ The target must be directly inside the selected Kintio instance directory and must not
111
+ already exist. Kintio removes the PNG when login succeeds, expires, is cancelled, or fails;
112
+ the QR payload is never printed. Without `--qr-output`, the command
113
+ requires an interactive terminal. Both forms stop waiting after five minutes and never
114
+ start an Agent turn. The resulting iLink identity represents
115
+ the local operator and inherits the host Agent configuration without Kintio's untrusted-
116
+ channel capability restrictions. Show this QR code only to someone authorized to control
117
+ the host Agent. Run `kintio ilink start` after enrollment to process messages without Hono.
118
+
119
+ To permanently remove an account and every Kintio record scoped to it, use:
120
+
121
+ ```bash
122
+ kintio ilink delete --account <provider-id-or-account-key> --yes
123
+ ```
124
+
125
+ The explicit confirmation is mandatory. Credentials, conversations, messages, media,
126
+ delivery records, and enrollment audit rows for that account are deleted atomically.
127
+
128
+ For callback deployments, confirm that `kintio logs` contains
129
+ `Hono server is listening on port 8888`. Use `kintio run` when a foreground process is
130
+ preferable to the native daemon.
97
131
  Existing source-based deployments can keep their current state after the one-time
98
132
  process-manager migration described in the setup guide.
99
133
 
@@ -111,6 +145,9 @@ Source builds and contributor setup are documented in
111
145
  Hono route; every action still requires a short-lived conversation capability.
112
146
  - Project-level Agent capability restrictions are not an operating-system sandbox. Use a
113
147
  dedicated system account and additional isolation appropriate to the Agent's real powers.
148
+ - An iLink account enrolled by `kintio ilink login` is explicitly host-authorized; its owner
149
+ receives the capabilities allowed by the host Agent configuration. Accounts enrolled from
150
+ a remote adapter remain restricted, and chat input cannot change this persisted trust level.
114
151
  - A provider accepting an outbound request does not prove that a client displayed it;
115
152
  uncertain outcomes remain explicit to avoid duplicate delivery.
116
153
 
package/README.zh-CN.md CHANGED
@@ -43,25 +43,49 @@
43
43
 
44
44
  ```bash
45
45
  npm install --global @kin-tio/cli
46
- kintio setup
47
46
  codex login status
48
47
  ```
49
48
 
50
- `kintio setup` 会在 `~/.kintio` 创建私有实例目录、安装内置 Agent Skill,并生成渠道配置
51
- 模板 `.env`。macOS/Linux 使用 `0600`,Windows 则限定在当前用户目录的 ACL
52
- 边界内。默认不启用任何适配器;请按英文
53
- [部署指南](https://github.com/Gkxie/kintio/blob/master/docs/setup.md)配置 WeChat KF API,或为已有 Weixin iLink 绑定设置
54
- `ILINK_ENABLED=true`。
49
+ iLink 可以完全独立使用,不需要 `setup`、`.env` 或公网 HTTP:
55
50
 
56
51
  ```bash
52
+ kintio ilink login
53
+ kintio ilink start
54
+ ```
55
+
56
+ `ilink login` 完成一次扫码、加密保存凭据后退出,不会自行启动监听;`ilink start` 不启动 Hono 或 TCP 端口,
57
+ 只以前台方式运行 iLink 长轮询和宿主 Agent。两者默认使用 `~/.kintio`。
58
+ 存在多个账号时,先用 `kintio ilink list` 查看账号,再通过 `--account` 指定
59
+ `start`、`stop` 或 `delete` 的目标;正在运行时可继续执行 `start` 增加监听账号。
60
+
61
+ 需要部署公网回调渠道时,再使用:
62
+
63
+ ```bash
64
+ kintio setup
57
65
  kintio start
58
66
  kintio status
59
67
  kintio logs --lines 100
60
68
  ```
61
69
 
62
- 启动后应确认 `kintio logs` 包含 `Hono server is listening on port 8888`;投入使用前仍需按
63
- 部署指南完成回调或绑定验证。需要前台进程时使用 `kintio run`;现有源码目录部署可以在完成
64
- 部署指南中的一次性进程管理器迁移后,继续使用原数据库和配置。
70
+ 具体配置见英文[部署指南](https://github.com/Gkxie/kintio/blob/master/docs/setup.md)。
71
+
72
+ 图形界面或非交互调用方可以显式选择临时的原始 PNG,而不是解析终端字符:
73
+
74
+ ```bash
75
+ kintio ilink login --qr-output ~/.kintio/ilink-login.png
76
+ ```
77
+
78
+ 目标文件必须直接位于所选 Kintio 实例目录中且不能预先存在;登录成功、过期、取消或失败后,Kintio 会自动删除该文件,并且
79
+ 不会打印二维码原始内容。二维码五分钟后过期;该命令不会唤醒 Agent。通过本机命令建立的 iLink 身份代表宿主机
80
+ 所有者的明确授权,后续对话直接继承宿主 Agent 配置,不再套用不可信渠道的能力限制。
81
+ 只应让获准控制宿主 Agent 的人扫描该二维码。登录后运行 `kintio ilink start` 即可在不
82
+ 启动 Hono 的情况下处理消息。
83
+
84
+ `kintio ilink delete --account <账号> --yes` 会不可恢复地删除该账号及其在 Kintio
85
+ 中的凭据、会话、消息、媒体、发送记录和登录审计;`--yes` 为强制确认参数。
86
+
87
+ 公网回调部署启动后,应确认 `kintio logs` 包含
88
+ `Hono server is listening on port 8888`。
65
89
  源码构建与贡献者开发环境见英文
66
90
  [贡献指南](https://github.com/Gkxie/kintio/blob/master/CONTRIBUTING.md)。
67
91
 
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 foreground 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. While this runtime is active, additional start commands add
69
+ accounts to the same process.
70
+
71
+ Options:
72
+ --account <id> Provider account ID or Kintio account key
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 exits a foreground
80
+ "kintio ilink start" 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
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.
33
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) {
@@ -508,6 +646,9 @@ export async function runCli(args, overrides = {}) {
508
646
  config: { type: 'string' },
509
647
  lines: { type: 'string' },
510
648
  'no-follow': { type: 'boolean' },
649
+ 'qr-output': { type: 'string' },
650
+ account: { type: 'string' },
651
+ yes: { type: 'boolean' },
511
652
  help: { type: 'boolean', short: 'h' },
512
653
  version: { type: 'boolean', short: 'v' },
513
654
  },
@@ -517,18 +658,118 @@ export async function runCli(args, overrides = {}) {
517
658
  return 0;
518
659
  }
519
660
  const command = parsed.positionals[0];
520
- if (parsed.values.help || !command || command === 'help') {
661
+ const subcommand = parsed.positionals[1];
662
+ if (!command) {
663
+ if (parsed.values['qr-output'] !== undefined) {
664
+ throw new Error('--qr-output is valid only for "kintio ilink login"');
665
+ }
521
666
  runtime.stdout(HELP);
522
667
  return 0;
523
668
  }
524
- if (parsed.positionals.length !== 1) {
525
- throw new Error(`Unexpected argument: ${parsed.positionals[1]}`);
669
+ if (command === 'help') {
670
+ if (parsed.positionals.length !== 1) {
671
+ throw new Error(`Unexpected argument: ${subcommand}`);
672
+ }
673
+ runtime.stdout(HELP);
674
+ return 0;
675
+ }
676
+ if (!COMMANDS.has(command))
677
+ throw new Error(`Unknown command: ${command}`);
678
+ if (command === 'ilink') {
679
+ if (parsed.values.help && parsed.positionals.length === 1) {
680
+ runtime.stdout(ILINK_HELP);
681
+ return 0;
682
+ }
683
+ if (!subcommand || !ILINK_COMMANDS.has(subcommand) ||
684
+ parsed.positionals.length !== 2) {
685
+ throw new Error('Usage: kintio ilink <login|list|start|stop|delete>');
686
+ }
687
+ }
688
+ else if (parsed.positionals.length !== 1) {
689
+ throw new Error(`Unexpected argument: ${subcommand}`);
690
+ }
691
+ if (parsed.values.help) {
692
+ runtime.stdout(command !== 'ilink'
693
+ ? HELP
694
+ : subcommand === 'login' ? ILINK_LOGIN_HELP
695
+ : subcommand === 'list' ? ILINK_LIST_HELP
696
+ : subcommand === 'start' ? ILINK_START_HELP
697
+ : subcommand === 'stop' ? ILINK_STOP_HELP
698
+ : ILINK_DELETE_HELP);
699
+ return 0;
526
700
  }
527
701
  if (command !== 'logs' &&
528
702
  (parsed.values.lines !== undefined || parsed.values['no-follow'])) {
529
703
  throw new Error('--lines and --no-follow are valid only for "kintio logs"');
530
704
  }
705
+ if ((command !== 'ilink' || subcommand !== 'login') &&
706
+ parsed.values['qr-output'] !== undefined) {
707
+ throw new Error('--qr-output is valid only for "kintio ilink login"');
708
+ }
709
+ if (parsed.values['qr-output'] === '') {
710
+ throw new Error('--qr-output requires a non-empty file path');
711
+ }
712
+ if (parsed.values.account !== undefined &&
713
+ (command !== 'ilink' || !['start', 'stop', 'delete'].includes(subcommand || ''))) {
714
+ throw new Error('--account is valid only for "kintio ilink start|stop|delete"');
715
+ }
716
+ if (parsed.values.account === '') {
717
+ throw new Error('--account requires a non-empty account ID or key');
718
+ }
719
+ if (parsed.values.yes && (command !== 'ilink' || subcommand !== 'delete')) {
720
+ throw new Error('--yes is valid only for "kintio ilink delete"');
721
+ }
531
722
  const location = instanceLocation(parsed.values, runtime);
723
+ const qrOutputPath = parsed.values['qr-output'] === undefined
724
+ ? undefined
725
+ : resolveInputPath(parsed.values['qr-output'], runtime.cwd);
726
+ if (qrOutputPath && !samePath(path.dirname(qrOutputPath), location.home)) {
727
+ throw new Error('iLink QR output must be directly inside the instance directory');
728
+ }
729
+ if (command === 'ilink') {
730
+ if (privateFile(location.configFile, 'Kintio config')) {
731
+ assertTrustedDirectory(path.dirname(location.configFile), 'Kintio config directory', false);
732
+ }
733
+ prepareDirectories(location.home);
734
+ return await runWithIlinkSignals(async (signal) => {
735
+ const enrollmentConfig = loadIlinkEnrollmentConfig({
736
+ environment: { ...runtime.env },
737
+ envFile: location.configFile,
738
+ root: location.home,
739
+ });
740
+ if (subcommand === 'login') {
741
+ return await runtime.ilinkLogin({
742
+ config: enrollmentConfig,
743
+ packageRoot: runtime.packageRoot,
744
+ stdout: runtime.stdout,
745
+ stdoutIsTTY: runtime.stdoutIsTTY,
746
+ stdoutColumns: runtime.stdoutColumns,
747
+ ...(qrOutputPath ? { qrOutputPath } : {}),
748
+ signal,
749
+ });
750
+ }
751
+ const commandResult = await runtime.ilinkAccount({
752
+ command: subcommand,
753
+ ...(parsed.values.account ? { selector: parsed.values.account } : {}),
754
+ confirmed: Boolean(parsed.values.yes),
755
+ config: enrollmentConfig,
756
+ packageRoot: runtime.packageRoot,
757
+ signal,
758
+ stdout: runtime.stdout,
759
+ });
760
+ if (subcommand !== 'start' || !commandResult.startForeground)
761
+ return 0;
762
+ return await runtime.ilinkStart({
763
+ config: loadIlinkRuntimeConfig({
764
+ environment: { ...runtime.env },
765
+ envFile: location.configFile,
766
+ root: location.home,
767
+ }),
768
+ signal,
769
+ stdout: runtime.stdout,
770
+ });
771
+ });
772
+ }
532
773
  if (command === 'setup')
533
774
  return setup(location, runtime);
534
775
  if (command === 'start')
@@ -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
  }