@kin-tio/cli 0.6.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 +46 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/README.md +150 -0
- package/README.zh-CN.md +79 -0
- package/THIRD_PARTY_NOTICES +31 -0
- package/assets/ilink-login-card.png +0 -0
- package/bin/kintio.js +3 -0
- package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
- package/dist/cli.js +3 -0
- package/dist/daemon.js +28 -0
- package/dist/index.js +70 -0
- package/dist/mcp-relay.js +11 -0
- package/dist/src/agent/runtime.js +1 -0
- package/dist/src/app.js +34 -0
- package/dist/src/cli.js +578 -0
- package/dist/src/config.js +237 -0
- package/dist/src/domain/message.js +23 -0
- package/dist/src/domain/send-contract.js +205 -0
- package/dist/src/domain/wecom-message.js +281 -0
- package/dist/src/ilink/executor.js +306 -0
- package/dist/src/ilink/inbound-image.js +310 -0
- package/dist/src/ilink/listener.js +306 -0
- package/dist/src/ilink/login-manager.js +198 -0
- package/dist/src/ilink/login-store.js +197 -0
- package/dist/src/ilink/media-gateway.js +83 -0
- package/dist/src/ilink/media.js +267 -0
- package/dist/src/ilink/message.js +247 -0
- package/dist/src/ilink/protocol/client.js +464 -0
- package/dist/src/ilink/protocol/types.js +35 -0
- package/dist/src/ilink/qr.js +109 -0
- package/dist/src/ilink/secret-box.js +143 -0
- package/dist/src/ilink/sqlite-store.js +1194 -0
- package/dist/src/ilink/store-types.js +63 -0
- package/dist/src/lib/image-format.js +23 -0
- package/dist/src/lib/path-identity.js +38 -0
- package/dist/src/lib/private-directory.js +51 -0
- package/dist/src/lib/text.js +19 -0
- package/dist/src/lib/wecom-crypto.js +74 -0
- package/dist/src/lib/xml.js +8 -0
- package/dist/src/mcp/conversation-memory-server.js +179 -0
- package/dist/src/mcp/ilink-server.js +158 -0
- package/dist/src/mcp/ipc-host.js +275 -0
- package/dist/src/mcp/ipc-protocol.js +226 -0
- package/dist/src/mcp/stdio-relay.js +122 -0
- package/dist/src/mcp/wechat-kf-executor.js +295 -0
- package/dist/src/mcp/wechat-kf-server.js +208 -0
- package/dist/src/routes/wecom.js +89 -0
- package/dist/src/runtime/daemon-protocol.js +202 -0
- package/dist/src/runtime/managed-skill.js +49 -0
- package/dist/src/runtime/native-daemon.js +325 -0
- package/dist/src/runtime/single-instance-lock.js +167 -0
- package/dist/src/runtime.js +503 -0
- package/dist/src/services/codex-agent.js +542 -0
- package/dist/src/services/codex-app-server.js +436 -0
- package/dist/src/services/conversation-processor.js +762 -0
- package/dist/src/services/image-stager.js +49 -0
- package/dist/src/services/media-gateway.js +83 -0
- package/dist/src/services/wecom-api.js +311 -0
- package/dist/src/services/wecom-sync.js +316 -0
- package/dist/src/state/persistence.js +124 -0
- package/dist/src/state/sqlite-store.js +3102 -0
- package/dist/src/supervisor.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/version.js +1 -0
- package/package.json +72 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import crossSpawn from 'cross-spawn';
|
|
8
|
+
import { DAEMON_STOP_TIMEOUT_MS, loadConfig, parseStartTimeout, resolveProjectRoot, WORKER_GRACEFUL_TIMEOUT_MS, } from './config.js';
|
|
9
|
+
import { isPathInside, samePath } from './lib/path-identity.js';
|
|
10
|
+
import { assertTrustedDirectory, ensureContainedDirectory, ensurePrivateDirectory, } from './lib/private-directory.js';
|
|
11
|
+
import { daemonRecordPath, readDaemonRecord, requestControl, } from './runtime/daemon-protocol.js';
|
|
12
|
+
import { acquireSingleInstanceLock, processIsAlive, SingleInstanceLockError, } from './runtime/single-instance-lock.js';
|
|
13
|
+
import { installManagedSkill } from './runtime/managed-skill.js';
|
|
14
|
+
import { KINTIO_VERSION } from './version.js';
|
|
15
|
+
const HELP = `Usage: kintio <command> [options]
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
setup Create a private instance directory and configuration
|
|
19
|
+
start Start Kintio in the background
|
|
20
|
+
run Run Kintio in the foreground
|
|
21
|
+
stop Stop the background Kintio process
|
|
22
|
+
restart Restart Kintio with the current installation and config
|
|
23
|
+
status Show the background process status
|
|
24
|
+
logs Follow Kintio logs
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--home <directory> Instance directory (default: ~/.kintio)
|
|
28
|
+
--config <file> Environment file (default: <home>/.env)
|
|
29
|
+
--lines <count> Initial lines for logs (default: 100)
|
|
30
|
+
--no-follow Print logs without following
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
-v, --version Show the Kintio version
|
|
33
|
+
`;
|
|
34
|
+
function defaultExecute(request) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = crossSpawn(request.file, [...request.args], {
|
|
37
|
+
env: request.env,
|
|
38
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
39
|
+
});
|
|
40
|
+
let forceTimer;
|
|
41
|
+
let stopping = false;
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
process.off('SIGINT', stop);
|
|
44
|
+
process.off('SIGTERM', stop);
|
|
45
|
+
process.off('disconnect', stop);
|
|
46
|
+
if (forceTimer)
|
|
47
|
+
clearTimeout(forceTimer);
|
|
48
|
+
};
|
|
49
|
+
const stop = () => {
|
|
50
|
+
if (stopping)
|
|
51
|
+
return;
|
|
52
|
+
stopping = true;
|
|
53
|
+
forceTimer = setTimeout(() => {
|
|
54
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
55
|
+
child.kill('SIGKILL');
|
|
56
|
+
}
|
|
57
|
+
}, WORKER_GRACEFUL_TIMEOUT_MS);
|
|
58
|
+
forceTimer.unref?.();
|
|
59
|
+
try {
|
|
60
|
+
if (child.connected)
|
|
61
|
+
child.send('shutdown');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Closing the IPC channel also enters the Worker's parent-disconnect path.
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
process.once('SIGINT', stop);
|
|
68
|
+
process.once('SIGTERM', stop);
|
|
69
|
+
process.once('disconnect', stop);
|
|
70
|
+
child.once('error', (error) => {
|
|
71
|
+
cleanup();
|
|
72
|
+
reject(error);
|
|
73
|
+
});
|
|
74
|
+
child.once('exit', (code, signal) => {
|
|
75
|
+
cleanup();
|
|
76
|
+
resolve(code ?? (signal ? 1 : 0));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function defaultLaunchDaemon(request) {
|
|
81
|
+
const child = crossSpawn(request.file, [...request.args], {
|
|
82
|
+
cwd: request.cwd,
|
|
83
|
+
env: request.env,
|
|
84
|
+
detached: true,
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
stdio: 'ignore',
|
|
87
|
+
});
|
|
88
|
+
child.once('error', () => undefined);
|
|
89
|
+
if (!child.pid)
|
|
90
|
+
throw new Error('Kintio daemon did not return a process ID');
|
|
91
|
+
const exited = new Promise((resolve) => {
|
|
92
|
+
child.once('close', () => resolve());
|
|
93
|
+
});
|
|
94
|
+
child.unref();
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
pid: child.pid,
|
|
97
|
+
exited,
|
|
98
|
+
kill: (signal) => child.kill(signal),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function runtimeDefaults() {
|
|
102
|
+
return {
|
|
103
|
+
env: process.env,
|
|
104
|
+
cwd: process.cwd(),
|
|
105
|
+
homeDirectory: os.homedir(),
|
|
106
|
+
packageRoot: resolveProjectRoot(import.meta.url),
|
|
107
|
+
execute: defaultExecute,
|
|
108
|
+
launchDaemon: defaultLaunchDaemon,
|
|
109
|
+
stdout: (text) => process.stdout.write(text),
|
|
110
|
+
stderr: (text) => process.stderr.write(text),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function resolveInputPath(value, cwd) {
|
|
114
|
+
return path.resolve(cwd, value);
|
|
115
|
+
}
|
|
116
|
+
function instanceLocation(values, runtime) {
|
|
117
|
+
const hasExplicitHome = values.home !== undefined;
|
|
118
|
+
const hasExplicitConfig = values.config !== undefined;
|
|
119
|
+
const configuredFile = hasExplicitHome && !hasExplicitConfig
|
|
120
|
+
? undefined
|
|
121
|
+
: values.config || runtime.env.KINTIO_CONFIG_FILE;
|
|
122
|
+
const configuredHome = hasExplicitConfig && !hasExplicitHome
|
|
123
|
+
? undefined
|
|
124
|
+
: values.home || runtime.env.KINTIO_HOME;
|
|
125
|
+
const configFile = configuredFile
|
|
126
|
+
? resolveInputPath(configuredFile, runtime.cwd)
|
|
127
|
+
: '';
|
|
128
|
+
const home = configuredHome
|
|
129
|
+
? resolveInputPath(configuredHome, runtime.cwd)
|
|
130
|
+
: configFile
|
|
131
|
+
? path.dirname(configFile)
|
|
132
|
+
: path.join(runtime.homeDirectory, '.kintio');
|
|
133
|
+
const location = {
|
|
134
|
+
home: path.resolve(home),
|
|
135
|
+
configFile: configFile || path.join(path.resolve(home), '.env'),
|
|
136
|
+
};
|
|
137
|
+
if (process.platform === 'win32' &&
|
|
138
|
+
(!isPathInside(runtime.homeDirectory, location.home) ||
|
|
139
|
+
!isPathInside(runtime.homeDirectory, location.configFile))) {
|
|
140
|
+
throw new Error('Windows instances and config files must stay inside the current user profile');
|
|
141
|
+
}
|
|
142
|
+
return Object.freeze(location);
|
|
143
|
+
}
|
|
144
|
+
function regularFile(filePath, label) {
|
|
145
|
+
try {
|
|
146
|
+
const stat = fs.lstatSync(filePath);
|
|
147
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
148
|
+
throw new Error(`${label} is not a regular file: ${filePath}`);
|
|
149
|
+
}
|
|
150
|
+
return stat;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function privateFile(filePath, label) {
|
|
160
|
+
const stat = regularFile(filePath, label);
|
|
161
|
+
if (!stat || process.platform === 'win32')
|
|
162
|
+
return stat;
|
|
163
|
+
const uid = process.getuid?.();
|
|
164
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
165
|
+
throw new Error(`${label} is not owned by the current user: ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
168
|
+
throw new Error(`${label} must not be accessible by group or other users: ${filePath}`);
|
|
169
|
+
}
|
|
170
|
+
return stat;
|
|
171
|
+
}
|
|
172
|
+
function writeNewFile(filePath, content, containmentRoot) {
|
|
173
|
+
if (containmentRoot) {
|
|
174
|
+
ensureContainedDirectory(containmentRoot, path.dirname(filePath));
|
|
175
|
+
assertTrustedDirectory(path.dirname(filePath), 'Target directory', false);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
ensurePrivateDirectory(path.dirname(filePath));
|
|
179
|
+
assertTrustedDirectory(path.dirname(filePath), 'Target directory', false);
|
|
180
|
+
}
|
|
181
|
+
if (regularFile(filePath, 'Target file'))
|
|
182
|
+
return false;
|
|
183
|
+
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
184
|
+
fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
|
185
|
+
try {
|
|
186
|
+
fs.linkSync(temporary, filePath);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
191
|
+
if (!regularFile(filePath, 'Target file')) {
|
|
192
|
+
throw new Error(`Target file appeared with an invalid type: ${filePath}`);
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
fs.rmSync(temporary, { force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function prepareDirectories(home) {
|
|
203
|
+
assertTrustedDirectory(ensurePrivateDirectory(home), 'Kintio instance directory', false);
|
|
204
|
+
assertTrustedDirectory(ensureContainedDirectory(home, path.join(home, 'data')), 'Kintio data directory', true);
|
|
205
|
+
}
|
|
206
|
+
function loadInstanceConfig(location, runtime, environment = runtime.env) {
|
|
207
|
+
return loadConfig({
|
|
208
|
+
environment: { ...environment },
|
|
209
|
+
envFile: location.configFile,
|
|
210
|
+
root: location.home,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function refreshManagedSkill(workingDirectory, runtime) {
|
|
214
|
+
return installManagedSkill({
|
|
215
|
+
packageRoot: runtime.packageRoot,
|
|
216
|
+
workingDirectory,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function setup(location, runtime) {
|
|
220
|
+
prepareDirectories(location.home);
|
|
221
|
+
const templateFile = path.join(runtime.packageRoot, '.env.example');
|
|
222
|
+
const template = fs.readFileSync(templateFile, 'utf8');
|
|
223
|
+
const configInsideHome = isPathInside(location.home, location.configFile);
|
|
224
|
+
const configCreated = writeNewFile(location.configFile, template, configInsideHome ? location.home : undefined);
|
|
225
|
+
const configStat = privateFile(location.configFile, 'Kintio config');
|
|
226
|
+
if (!configStat)
|
|
227
|
+
throw new Error(`Kintio config was not created: ${location.configFile}`);
|
|
228
|
+
const skill = refreshManagedSkill(loadInstanceConfig(location, runtime).codex.workingDirectory, runtime);
|
|
229
|
+
const defaultHome = path.join(runtime.homeDirectory, '.kintio');
|
|
230
|
+
const defaultConfig = path.join(defaultHome, '.env');
|
|
231
|
+
const nextStep = location.home === defaultHome && location.configFile === defaultConfig
|
|
232
|
+
? 'run "kintio start".'
|
|
233
|
+
: 'run "kintio start" with the same --home and --config options.';
|
|
234
|
+
runtime.stdout(`Kintio setup complete.\n` +
|
|
235
|
+
`Home: ${location.home}\n` +
|
|
236
|
+
`Config: ${location.configFile} (${configCreated ? 'created' : 'kept'})\n` +
|
|
237
|
+
`Agent skill: ${skill.file} (${skill.state})\n` +
|
|
238
|
+
`Edit the config, run "codex login status", then ${nextStep}\n`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
function processEnvironment(location, runtime) {
|
|
242
|
+
if (!privateFile(location.configFile, 'Kintio config')) {
|
|
243
|
+
throw new Error(`Kintio config is missing; run "kintio setup": ${location.configFile}`);
|
|
244
|
+
}
|
|
245
|
+
assertTrustedDirectory(path.dirname(location.configFile), 'Kintio config directory', false);
|
|
246
|
+
prepareDirectories(location.home);
|
|
247
|
+
const environment = {
|
|
248
|
+
...runtime.env,
|
|
249
|
+
KINTIO_HOME: location.home,
|
|
250
|
+
KINTIO_CONFIG_FILE: location.configFile,
|
|
251
|
+
NODE_ENV: 'production',
|
|
252
|
+
};
|
|
253
|
+
const config = loadInstanceConfig(location, runtime, environment);
|
|
254
|
+
refreshManagedSkill(config.codex.workingDirectory, runtime);
|
|
255
|
+
return environment;
|
|
256
|
+
}
|
|
257
|
+
function removeDaemonMetadata(location) {
|
|
258
|
+
fs.rmSync(daemonRecordPath(location.home), { force: true });
|
|
259
|
+
}
|
|
260
|
+
async function probeDaemon(location) {
|
|
261
|
+
const record = readDaemonRecord(location.home);
|
|
262
|
+
if (!record) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return await requestControl(location.home, 'ping');
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (processIsAlive(record.daemonPid)) {
|
|
270
|
+
throw new Error(`Kintio daemon is running but unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
|
+
}
|
|
272
|
+
removeDaemonMetadata(location);
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function assertDaemonInstance(location, packageRoot) {
|
|
277
|
+
const daemon = readDaemonRecord(location.home);
|
|
278
|
+
if (!daemon)
|
|
279
|
+
throw new Error('Kintio daemon record is missing');
|
|
280
|
+
if (!samePath(daemon.configFile, location.configFile) ||
|
|
281
|
+
!samePath(daemon.packageRoot, packageRoot)) {
|
|
282
|
+
throw new Error('Kintio is running with another config or installation; use "kintio restart" to switch deliberately');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function withLifecycleLock(location, task) {
|
|
286
|
+
const dataDirectory = ensureContainedDirectory(location.home, path.join(location.home, 'data'));
|
|
287
|
+
let lock;
|
|
288
|
+
try {
|
|
289
|
+
lock = acquireSingleInstanceLock({
|
|
290
|
+
filePath: path.join(dataDirectory, 'lifecycle.lock'),
|
|
291
|
+
hasActiveDatabaseOwner: () => false,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error instanceof SingleInstanceLockError) {
|
|
296
|
+
throw new Error('Another Kintio lifecycle command is already running');
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return await task();
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
lock.release();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function waitForDaemonExit(daemon, timeoutMs) {
|
|
308
|
+
return await Promise.race([
|
|
309
|
+
daemon.exited.then(() => true),
|
|
310
|
+
delay(timeoutMs).then(() => false),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
function removeLaunchMetadata(location, daemonPid) {
|
|
314
|
+
if (readDaemonRecord(location.home)?.daemonPid === daemonPid) {
|
|
315
|
+
fs.rmSync(daemonRecordPath(location.home), { force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function rollbackLaunch(location, daemon) {
|
|
319
|
+
const record = readDaemonRecord(location.home);
|
|
320
|
+
if (record?.daemonPid === daemon.pid) {
|
|
321
|
+
await requestControl(location.home, 'stop').catch(() => undefined);
|
|
322
|
+
if (await waitForDaemonExit(daemon, 5_000)) {
|
|
323
|
+
removeLaunchMetadata(location, daemon.pid);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (await waitForDaemonExit(daemon, 1)) {
|
|
328
|
+
removeLaunchMetadata(location, daemon.pid);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
daemon.kill('SIGTERM');
|
|
332
|
+
if (!(await waitForDaemonExit(daemon, 1_000)))
|
|
333
|
+
daemon.kill('SIGKILL');
|
|
334
|
+
if (!(await waitForDaemonExit(daemon, 5_000))) {
|
|
335
|
+
throw new Error(`Kintio startup rollback could not terminate daemon PID ${daemon.pid}`);
|
|
336
|
+
}
|
|
337
|
+
removeLaunchMetadata(location, daemon.pid);
|
|
338
|
+
}
|
|
339
|
+
async function start(location, runtime, restart) {
|
|
340
|
+
const environment = processEnvironment(location, runtime);
|
|
341
|
+
const timeout = parseStartTimeout(environment.KINTIO_START_TIMEOUT_MS);
|
|
342
|
+
return withLifecycleLock(location, async () => {
|
|
343
|
+
const existing = await probeDaemon(location);
|
|
344
|
+
if (existing && !restart) {
|
|
345
|
+
assertDaemonInstance(location, runtime.packageRoot);
|
|
346
|
+
if (existing.phase !== 'running') {
|
|
347
|
+
await waitUntilRunning(location, Date.now() + timeout);
|
|
348
|
+
}
|
|
349
|
+
runtime.stdout(`Kintio is already running (PID ${existing.workerPid || existing.daemonPid}).\n`);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (existing) {
|
|
353
|
+
await stopDaemon(location, DAEMON_STOP_TIMEOUT_MS);
|
|
354
|
+
}
|
|
355
|
+
const deadline = Date.now() + timeout;
|
|
356
|
+
const daemon = runtime.launchDaemon({
|
|
357
|
+
file: process.execPath,
|
|
358
|
+
args: [path.join(runtime.packageRoot, 'dist/daemon.js')],
|
|
359
|
+
cwd: location.home,
|
|
360
|
+
env: environment,
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
await waitUntilRunning(location, deadline);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
await rollbackLaunch(location, daemon);
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
return 0;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
async function waitUntilRunning(location, deadline) {
|
|
373
|
+
let lastError = 'daemon did not publish control state';
|
|
374
|
+
while (Date.now() < deadline) {
|
|
375
|
+
let response;
|
|
376
|
+
try {
|
|
377
|
+
response = await requestControl(location.home, 'ping', Math.min(500, Math.max(1, deadline - Date.now())));
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
381
|
+
}
|
|
382
|
+
if (response?.phase === 'running' && response.workerPid)
|
|
383
|
+
return;
|
|
384
|
+
if (response?.phase === 'failed') {
|
|
385
|
+
throw new Error(response.message || 'Kintio worker failed to start');
|
|
386
|
+
}
|
|
387
|
+
if (response)
|
|
388
|
+
lastError = response.message || `daemon phase is ${response.phase}`;
|
|
389
|
+
const waitMs = Math.min(100, deadline - Date.now());
|
|
390
|
+
if (waitMs > 0)
|
|
391
|
+
await delay(waitMs);
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`Kintio failed to become ready: ${lastError}; inspect "kintio logs --no-follow"`);
|
|
394
|
+
}
|
|
395
|
+
async function stopDaemon(location, timeoutMs, onNotRunning) {
|
|
396
|
+
const record = readDaemonRecord(location.home);
|
|
397
|
+
if (!record) {
|
|
398
|
+
onNotRunning?.();
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
await requestControl(location.home, 'stop');
|
|
402
|
+
const deadline = Date.now() + timeoutMs;
|
|
403
|
+
const daemonLock = path.join(location.home, 'data/daemon.lock');
|
|
404
|
+
while (Date.now() < deadline &&
|
|
405
|
+
(readDaemonRecord(location.home) || fs.existsSync(daemonLock))) {
|
|
406
|
+
const waitMs = Math.min(50, deadline - Date.now());
|
|
407
|
+
if (waitMs > 0)
|
|
408
|
+
await delay(waitMs);
|
|
409
|
+
}
|
|
410
|
+
if (readDaemonRecord(location.home) || fs.existsSync(daemonLock)) {
|
|
411
|
+
throw new Error('Kintio daemon did not stop within the shutdown budget');
|
|
412
|
+
}
|
|
413
|
+
removeDaemonMetadata(location);
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
async function stop(runtime, location) {
|
|
417
|
+
return withLifecycleLock(location, async () => {
|
|
418
|
+
return await stopDaemon(location, DAEMON_STOP_TIMEOUT_MS, () => runtime.stdout('Kintio is not running.\n'));
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function positiveLineCount(value) {
|
|
422
|
+
const count = Number(value ?? 100);
|
|
423
|
+
if (!Number.isInteger(count) || count < 1 || count > 10_000) {
|
|
424
|
+
throw new Error('--lines must be an integer between 1 and 10000');
|
|
425
|
+
}
|
|
426
|
+
return count;
|
|
427
|
+
}
|
|
428
|
+
function logFilePath(location) {
|
|
429
|
+
return path.join(location.home, 'data/logs/kintio.log');
|
|
430
|
+
}
|
|
431
|
+
function readLogTail(filePath, lines) {
|
|
432
|
+
let descriptor;
|
|
433
|
+
try {
|
|
434
|
+
descriptor = fs.openSync(filePath, 'r');
|
|
435
|
+
const stat = fs.fstatSync(descriptor);
|
|
436
|
+
const source = fs.readFileSync(descriptor, 'utf8');
|
|
437
|
+
const values = source.split(/(?<=\n)/u);
|
|
438
|
+
return {
|
|
439
|
+
text: values.slice(Math.max(0, values.length - lines)).join(''),
|
|
440
|
+
size: Buffer.byteLength(source),
|
|
441
|
+
device: stat.dev,
|
|
442
|
+
inode: stat.ino,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
447
|
+
throw new Error('Kintio has no background logs');
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
if (descriptor !== undefined)
|
|
453
|
+
fs.closeSync(descriptor);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function followLog(filePath, lines, output) {
|
|
457
|
+
const initial = readLogTail(filePath, lines);
|
|
458
|
+
if (initial.text)
|
|
459
|
+
output(initial.text);
|
|
460
|
+
let position = initial.size;
|
|
461
|
+
let device = initial.device;
|
|
462
|
+
let inode = initial.inode;
|
|
463
|
+
while (true) {
|
|
464
|
+
await delay(250);
|
|
465
|
+
let descriptor;
|
|
466
|
+
try {
|
|
467
|
+
descriptor = fs.openSync(filePath, 'r');
|
|
468
|
+
const stat = fs.fstatSync(descriptor);
|
|
469
|
+
if (stat.dev !== device || stat.ino !== inode) {
|
|
470
|
+
device = stat.dev;
|
|
471
|
+
inode = stat.ino;
|
|
472
|
+
position = 0;
|
|
473
|
+
}
|
|
474
|
+
if (stat.size < position)
|
|
475
|
+
position = 0;
|
|
476
|
+
if (stat.size === position)
|
|
477
|
+
continue;
|
|
478
|
+
const buffer = Buffer.alloc(stat.size - position);
|
|
479
|
+
const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, position);
|
|
480
|
+
if (bytesRead > 0)
|
|
481
|
+
output(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
482
|
+
position += bytesRead;
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
486
|
+
position = 0;
|
|
487
|
+
device = undefined;
|
|
488
|
+
inode = undefined;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
if (descriptor !== undefined)
|
|
495
|
+
fs.closeSync(descriptor);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
export async function runCli(args, overrides = {}) {
|
|
500
|
+
const runtime = { ...runtimeDefaults(), ...overrides };
|
|
501
|
+
try {
|
|
502
|
+
const parsed = parseArgs({
|
|
503
|
+
args: [...args],
|
|
504
|
+
allowPositionals: true,
|
|
505
|
+
strict: true,
|
|
506
|
+
options: {
|
|
507
|
+
home: { type: 'string' },
|
|
508
|
+
config: { type: 'string' },
|
|
509
|
+
lines: { type: 'string' },
|
|
510
|
+
'no-follow': { type: 'boolean' },
|
|
511
|
+
help: { type: 'boolean', short: 'h' },
|
|
512
|
+
version: { type: 'boolean', short: 'v' },
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
if (parsed.values.version) {
|
|
516
|
+
runtime.stdout(`${KINTIO_VERSION}\n`);
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const command = parsed.positionals[0];
|
|
520
|
+
if (parsed.values.help || !command || command === 'help') {
|
|
521
|
+
runtime.stdout(HELP);
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
if (parsed.positionals.length !== 1) {
|
|
525
|
+
throw new Error(`Unexpected argument: ${parsed.positionals[1]}`);
|
|
526
|
+
}
|
|
527
|
+
if (command !== 'logs' &&
|
|
528
|
+
(parsed.values.lines !== undefined || parsed.values['no-follow'])) {
|
|
529
|
+
throw new Error('--lines and --no-follow are valid only for "kintio logs"');
|
|
530
|
+
}
|
|
531
|
+
const location = instanceLocation(parsed.values, runtime);
|
|
532
|
+
if (command === 'setup')
|
|
533
|
+
return setup(location, runtime);
|
|
534
|
+
if (command === 'start')
|
|
535
|
+
return await start(location, runtime, false);
|
|
536
|
+
if (command === 'restart')
|
|
537
|
+
return await start(location, runtime, true);
|
|
538
|
+
if (command === 'run') {
|
|
539
|
+
const environment = processEnvironment(location, runtime);
|
|
540
|
+
return await runtime.execute({
|
|
541
|
+
file: process.execPath,
|
|
542
|
+
args: [path.join(runtime.packageRoot, 'dist/index.js')],
|
|
543
|
+
env: { ...environment, KINTIO_MANAGED_WORKER: '1' },
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (command === 'stop')
|
|
547
|
+
return await stop(runtime, location);
|
|
548
|
+
if (command === 'status') {
|
|
549
|
+
const existing = await probeDaemon(location);
|
|
550
|
+
if (!existing) {
|
|
551
|
+
runtime.stdout('Kintio is not running.\n');
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
assertDaemonInstance(location, runtime.packageRoot);
|
|
555
|
+
runtime.stdout(`Kintio is ${existing.phase} ` +
|
|
556
|
+
`(daemon PID ${existing.daemonPid}` +
|
|
557
|
+
`${existing.workerPid ? `, worker PID ${existing.workerPid}` : ''}).` +
|
|
558
|
+
`${existing.message ? ` ${existing.message}` : ''}\n`);
|
|
559
|
+
return existing.phase === 'failed' ? 1 : 0;
|
|
560
|
+
}
|
|
561
|
+
if (command === 'logs') {
|
|
562
|
+
const filePath = logFilePath(location);
|
|
563
|
+
const lines = positiveLineCount(parsed.values.lines);
|
|
564
|
+
if (parsed.values['no-follow']) {
|
|
565
|
+
const tail = readLogTail(filePath, lines);
|
|
566
|
+
if (tail.text)
|
|
567
|
+
runtime.stdout(tail.text);
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
return await followLog(filePath, lines, runtime.stdout);
|
|
571
|
+
}
|
|
572
|
+
throw new Error(`Unknown command: ${command}`);
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
runtime.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
576
|
+
return 1;
|
|
577
|
+
}
|
|
578
|
+
}
|