@ardennguyen/zalo-agent-cli 2.5.5 → 2.5.6
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 +9 -0
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/commands/fleet.js +439 -0
- package/src/commands/listen.js +87 -24
- package/src/commands/mcp-webhook.js +197 -0
- package/src/commands/mcp.js +162 -37
- package/src/commands/msg.js +13 -1
- package/src/core/accounts.js +182 -40
- package/src/core/credentials.js +5 -3
- package/src/core/daemon-channel.js +145 -19
- package/src/core/daemon-health.js +403 -0
- package/src/core/fleet-templates.js +177 -0
- package/src/core/fleet.js +255 -0
- package/src/core/log-sink.js +311 -0
- package/src/core/oa-client.js +10 -6
- package/src/core/startup-gate.js +206 -0
- package/src/core/webhook-outbox.js +575 -0
- package/src/index.js +13 -3
- package/src/mcp/mcp-config.js +42 -2
- package/src/mcp/mcp-http-transport.js +37 -18
- package/src/mcp/mcp-tools.js +82 -36
- package/src/mcp/message-buffer.js +94 -4
- package/src/mcp/wait-result.js +52 -0
- package/src/utils/atomic-file.js +209 -0
- package/src/utils/markdown.js +339 -0
package/src/core/accounts.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-account registry at ~/.zalo-agent-cli/accounts.json
|
|
3
3
|
* Maps each account to its own proxy (1:1) and tracks active account.
|
|
4
|
+
*
|
|
5
|
+
* A fleet of daemons and the `account` / `login` commands share this one file, so it is written the
|
|
6
|
+
* way a shared file must be (src/utils/atomic-file.js):
|
|
7
|
+
* - every change is read-modify-write under a lock file (`accounts.json.lock`), so two writers no
|
|
8
|
+
* longer lose each other's change;
|
|
9
|
+
* - every write is a temp file renamed over the target, so a reader never sees a half-written
|
|
10
|
+
* registry and a crash leaves the previous one;
|
|
11
|
+
* - the previous good registry is kept as `accounts.json.bak`;
|
|
12
|
+
* - a file that exists but cannot be read is an ERROR, never "no accounts": the next `login` used
|
|
13
|
+
* to replace it with a registry of one. Reads fall back to the `.bak`; with none, nothing is
|
|
14
|
+
* touched and the message says what to do.
|
|
4
15
|
*/
|
|
5
16
|
|
|
6
|
-
import { readFileSync,
|
|
17
|
+
import { readFileSync, existsSync, mkdirSync, rmSync, renameSync } from "fs";
|
|
7
18
|
import { join } from "path";
|
|
8
19
|
import { CONFIG_DIR, getAccountDir, deleteCredentials } from "./credentials.js";
|
|
9
20
|
import { checkLock } from "./lock.js";
|
|
21
|
+
import { sleepSync, sweepStaleTemps, withFileLock, writeFileAtomic } from "../utils/atomic-file.js";
|
|
10
22
|
|
|
11
23
|
function accountsFile() {
|
|
12
24
|
return join(CONFIG_DIR, "accounts.json");
|
|
@@ -16,21 +28,130 @@ function ensureDir() {
|
|
|
16
28
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
/** The registry exists but cannot be used as the account list. Nothing has been changed. */
|
|
32
|
+
export class RegistryError extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "RegistryError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Errors a read can raise on Windows for the instant a writer's rename replaces the file. */
|
|
40
|
+
const TRANSIENT_READ = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
|
|
41
|
+
|
|
42
|
+
/** @param {string} text @returns {object[]} */
|
|
43
|
+
function parseRegistry(text) {
|
|
44
|
+
const data = JSON.parse(text);
|
|
45
|
+
if (!Array.isArray(data)) throw new Error("it is not a JSON array of accounts");
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {string} file */
|
|
50
|
+
function readFileRetrying(file) {
|
|
51
|
+
for (let attempt = 0; ; attempt++) {
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(file, "utf-8");
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (!TRANSIENT_READ.has(e.code) || attempt >= 5) throw e;
|
|
56
|
+
sleepSync(10);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let _warnedBackup = false;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the registry.
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ accounts: object[], source: "missing"|"file"|"backup" }}
|
|
67
|
+
* @throws {RegistryError} when the file exists but is not a usable account list and there is no good backup
|
|
68
|
+
*/
|
|
69
|
+
function readRegistry() {
|
|
20
70
|
const file = accountsFile();
|
|
21
|
-
if (!existsSync(file)) return [];
|
|
71
|
+
if (!existsSync(file)) return { accounts: [], source: "missing" };
|
|
72
|
+
|
|
73
|
+
let problem;
|
|
74
|
+
// A writer from before atomic writes can leave a half-written file for an instant: look again.
|
|
75
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
76
|
+
try {
|
|
77
|
+
return { accounts: parseRegistry(readFileRetrying(file)), source: "file" };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
problem = e;
|
|
80
|
+
if (attempt < 2) sleepSync(20);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const backup = `${file}.bak`;
|
|
22
85
|
try {
|
|
23
|
-
|
|
86
|
+
const accounts = parseRegistry(readFileSync(backup, "utf-8"));
|
|
87
|
+
if (!_warnedBackup) {
|
|
88
|
+
_warnedBackup = true;
|
|
89
|
+
console.error(
|
|
90
|
+
`[accounts] ${file} is unreadable (${problem.message}); using its last good copy, ${backup}. ` +
|
|
91
|
+
"The next change to the registry replaces the damaged file and keeps it as accounts.json.corrupt-*.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return { accounts, source: "backup" };
|
|
24
95
|
} catch {
|
|
25
|
-
|
|
96
|
+
throw new RegistryError(
|
|
97
|
+
`${file} exists but cannot be read as the account list (${problem.message}). It was left untouched. ` +
|
|
98
|
+
"Fix it, or move it aside to start with no accounts.",
|
|
99
|
+
);
|
|
26
100
|
}
|
|
27
101
|
}
|
|
28
102
|
|
|
103
|
+
function load() {
|
|
104
|
+
return readRegistry().accounts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Write the registry atomically, keeping the previous good one as accounts.json.bak. */
|
|
29
108
|
function save(accounts) {
|
|
30
109
|
ensureDir();
|
|
31
110
|
const file = accountsFile();
|
|
32
|
-
|
|
33
|
-
|
|
111
|
+
try {
|
|
112
|
+
const current = readFileSync(file, "utf-8");
|
|
113
|
+
parseRegistry(current);
|
|
114
|
+
writeFileAtomic(`${file}.bak`, current, { durable: false });
|
|
115
|
+
} catch {
|
|
116
|
+
/* no current registry worth keeping */
|
|
117
|
+
}
|
|
118
|
+
writeFileAtomic(file, JSON.stringify(accounts, null, 2));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Change the registry: lock, read the CURRENT contents, apply `fn`, write if it says it changed
|
|
123
|
+
* something. `fn` gets the account array and returns `{ changed, value }`.
|
|
124
|
+
*
|
|
125
|
+
* @template T
|
|
126
|
+
* @param {(accounts: object[]) => { changed: boolean, value: T }} fn
|
|
127
|
+
* @param {{ timeoutMs?: number }} [lockOptions]
|
|
128
|
+
* @returns {T}
|
|
129
|
+
*/
|
|
130
|
+
function mutate(fn, lockOptions) {
|
|
131
|
+
ensureDir();
|
|
132
|
+
return withFileLock(
|
|
133
|
+
`${accountsFile()}.lock`,
|
|
134
|
+
() => {
|
|
135
|
+
const { accounts, source } = readRegistry();
|
|
136
|
+
const result = fn(accounts);
|
|
137
|
+
if (result.changed) {
|
|
138
|
+
if (source === "backup") {
|
|
139
|
+
// Keep the damaged file for whoever wants to know what happened to it.
|
|
140
|
+
try {
|
|
141
|
+
renameSync(accountsFile(), `${accountsFile()}.corrupt-${Date.now()}`);
|
|
142
|
+
} catch {
|
|
143
|
+
/* it is replaced either way */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
save(accounts);
|
|
147
|
+
// A writer killed between creating its temp file and renaming it leaves that file behind.
|
|
148
|
+
sweepStaleTemps(accountsFile());
|
|
149
|
+
sweepStaleTemps(`${accountsFile()}.bak`);
|
|
150
|
+
}
|
|
151
|
+
return result.value;
|
|
152
|
+
},
|
|
153
|
+
lockOptions,
|
|
154
|
+
);
|
|
34
155
|
}
|
|
35
156
|
|
|
36
157
|
let _pinnedAccountId = null;
|
|
@@ -68,18 +189,18 @@ export function getActive() {
|
|
|
68
189
|
|
|
69
190
|
/** Set an account as active (deactivates others). Returns false if not found. */
|
|
70
191
|
export function setActive(ownId) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
192
|
+
return mutate((accounts) => {
|
|
193
|
+
let found = false;
|
|
194
|
+
for (const a of accounts) {
|
|
195
|
+
if (a.ownId === ownId) {
|
|
196
|
+
a.active = true;
|
|
197
|
+
found = true;
|
|
198
|
+
} else {
|
|
199
|
+
a.active = false;
|
|
200
|
+
}
|
|
79
201
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return found;
|
|
202
|
+
return { changed: found, value: found };
|
|
203
|
+
});
|
|
83
204
|
}
|
|
84
205
|
|
|
85
206
|
/**
|
|
@@ -98,16 +219,17 @@ export function setActive(ownId) {
|
|
|
98
219
|
* in line with it.)
|
|
99
220
|
*/
|
|
100
221
|
export function addAccount(ownId, name = "", proxy = null) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
222
|
+
mutate((accounts) => {
|
|
223
|
+
const existing = accounts.find((a) => a.ownId === ownId);
|
|
224
|
+
for (const a of accounts) a.active = a.ownId === ownId;
|
|
225
|
+
if (existing) {
|
|
226
|
+
existing.name = name || existing.name || "";
|
|
227
|
+
existing.proxy = proxy;
|
|
228
|
+
} else {
|
|
229
|
+
accounts.push({ ownId, name, proxy, active: true });
|
|
230
|
+
}
|
|
231
|
+
return { changed: true, value: undefined };
|
|
232
|
+
});
|
|
111
233
|
}
|
|
112
234
|
|
|
113
235
|
/**
|
|
@@ -161,12 +283,16 @@ export function removeAccount(ownId) {
|
|
|
161
283
|
return { removed: false, skippedLocked };
|
|
162
284
|
}
|
|
163
285
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
filtered
|
|
168
|
-
|
|
169
|
-
|
|
286
|
+
mutate((current) => {
|
|
287
|
+
const filtered = current.filter((a) => a.ownId !== ownId);
|
|
288
|
+
// If removed was active, activate first remaining
|
|
289
|
+
if (filtered.length && !filtered.some((a) => a.active)) {
|
|
290
|
+
filtered[0].active = true;
|
|
291
|
+
}
|
|
292
|
+
// The array is edited in place: `mutate` writes the one it handed us.
|
|
293
|
+
current.splice(0, current.length, ...filtered);
|
|
294
|
+
return { changed: true, value: undefined };
|
|
295
|
+
});
|
|
170
296
|
deleteCredentials(ownId);
|
|
171
297
|
return { removed: true, wiped };
|
|
172
298
|
}
|
|
@@ -183,12 +309,28 @@ export function updateAccountName(ownId, name) {
|
|
|
183
309
|
const id = ownId ? String(ownId).trim() : "";
|
|
184
310
|
const trimmed = typeof name === "string" ? name.trim() : "";
|
|
185
311
|
if (!id || !trimmed) return false;
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
312
|
+
// The common case, a daemon re-reading a name it already has, takes no lock and writes nothing.
|
|
313
|
+
const unchanged = (accounts) => {
|
|
314
|
+
const existing = accounts.find((a) => a.ownId === id);
|
|
315
|
+
return !existing || existing.name === trimmed;
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
318
|
+
if (unchanged(load())) return false;
|
|
319
|
+
return mutate(
|
|
320
|
+
(accounts) => {
|
|
321
|
+
if (unchanged(accounts)) return { changed: false, value: false };
|
|
322
|
+
accounts.find((a) => a.ownId === id).name = trimmed;
|
|
323
|
+
return { changed: true, value: true };
|
|
324
|
+
},
|
|
325
|
+
// Daemons call this from event handlers: a busy registry costs a missed cosmetic refresh,
|
|
326
|
+
// not seconds of a blocked event loop.
|
|
327
|
+
{ timeoutMs: 3000 },
|
|
328
|
+
);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// A cached display name is not worth a daemon: say so and carry on.
|
|
331
|
+
console.error(`[accounts] could not update the cached name of ${id}: ${e.message}`);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
192
334
|
}
|
|
193
335
|
|
|
194
336
|
/** Get account by ownId. */
|
package/src/core/credentials.js
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* All credential files use 0600 permissions (owner read/write only).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync,
|
|
13
|
+
import { readFileSync, unlinkSync, existsSync, mkdirSync } from "fs";
|
|
14
14
|
import { join, resolve } from "path";
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { parseEnv } from "node:util";
|
|
17
|
+
import { writeFileAtomic } from "../utils/atomic-file.js";
|
|
17
18
|
|
|
18
19
|
const defaultConfigDir = join(homedir(), ".zalo-agent-cli");
|
|
19
20
|
|
|
@@ -176,8 +177,9 @@ function credPath(ownId) {
|
|
|
176
177
|
export function saveCredentials(ownId, creds) {
|
|
177
178
|
ensureDirs();
|
|
178
179
|
const target = credPath(ownId);
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
// Atomic and created at 0600: a login is never left half written, and the file is never readable
|
|
181
|
+
// by others, even for the moment between a write and a chmod.
|
|
182
|
+
writeFileAtomic(target, JSON.stringify(creds, null, 2));
|
|
181
183
|
return target;
|
|
182
184
|
}
|
|
183
185
|
|
|
@@ -80,6 +80,7 @@ import http from "node:http";
|
|
|
80
80
|
import fs from "node:fs";
|
|
81
81
|
import path from "node:path";
|
|
82
82
|
import { spawn, spawnSync } from "node:child_process";
|
|
83
|
+
import { createLogSink, createRotatingFile, resolveLogSettings } from "./log-sink.js";
|
|
83
84
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
84
85
|
import { maskSecretArgs } from "../utils/secrets.js";
|
|
85
86
|
|
|
@@ -296,6 +297,8 @@ function readJsonBody(req, reply) {
|
|
|
296
297
|
* long-poll callback invoked by `POST /wait` (`zalo-agent mcp wait`).
|
|
297
298
|
* @param {(params: object) => Promise<object>} [args.onMarkRead] -
|
|
298
299
|
* read-cursor callback invoked by `POST /mark-read` (`zalo-agent mcp mark-read`).
|
|
300
|
+
* @param {() => object} [args.onStatus] - health snapshot returned by `POST /status`
|
|
301
|
+
* (`listen --status`, `mcp start --status`; see ./daemon-health.js).
|
|
299
302
|
* @returns {Promise<{port: number, stop: () => void}>}
|
|
300
303
|
*/
|
|
301
304
|
export function startDaemonChannel({
|
|
@@ -307,6 +310,7 @@ export function startDaemonChannel({
|
|
|
307
310
|
onStop = null,
|
|
308
311
|
onWait = null,
|
|
309
312
|
onMarkRead = null,
|
|
313
|
+
onStatus = null,
|
|
310
314
|
}) {
|
|
311
315
|
const token = randomBytes(24).toString("hex");
|
|
312
316
|
|
|
@@ -457,6 +461,17 @@ export function startDaemonChannel({
|
|
|
457
461
|
}
|
|
458
462
|
};
|
|
459
463
|
|
|
464
|
+
/** The daemon's health snapshot (`listen --status`, `mcp start --status`). */
|
|
465
|
+
const serveStatus = (body, reply) => {
|
|
466
|
+
if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
|
|
467
|
+
if (typeof onStatus !== "function") return reply(503, { error: "this daemon does not report health" });
|
|
468
|
+
try {
|
|
469
|
+
reply(200, { ok: true, health: onStatus() });
|
|
470
|
+
} catch (e) {
|
|
471
|
+
reply(200, { ok: false, error: e?.message || String(e) });
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
460
475
|
const server = http.createServer(async (req, res) => {
|
|
461
476
|
const reply = (code, body) => {
|
|
462
477
|
res.writeHead(code, { "content-type": "application/json" });
|
|
@@ -470,6 +485,7 @@ export function startDaemonChannel({
|
|
|
470
485
|
req.url !== "/stop" &&
|
|
471
486
|
req.url !== "/wait" &&
|
|
472
487
|
req.url !== "/mark-read" &&
|
|
488
|
+
req.url !== "/status" &&
|
|
473
489
|
!stage
|
|
474
490
|
) {
|
|
475
491
|
return reply(404, { error: "not found" });
|
|
@@ -480,6 +496,7 @@ export function startDaemonChannel({
|
|
|
480
496
|
if (req.url === "/stop") return serveStop(body, reply);
|
|
481
497
|
if (req.url === "/wait") return serveWait(body, res, reply);
|
|
482
498
|
if (req.url === "/mark-read") return serveMarkRead(body, reply);
|
|
499
|
+
if (req.url === "/status") return serveStatus(body, reply);
|
|
483
500
|
return stage ? serveStage(stage, body, res, reply) : serveAttachments(body, reply);
|
|
484
501
|
});
|
|
485
502
|
|
|
@@ -841,7 +858,10 @@ export function stopViaDaemon(accountDir, { timeoutMs = 3000 } = {}) {
|
|
|
841
858
|
* @returns {Promise<{ok: boolean, status?: "message"|"timeout"|number, message?: object|null, cursor?: number, error?: string}|null>}
|
|
842
859
|
* null when no daemon channel is published
|
|
843
860
|
*/
|
|
844
|
-
export function waitViaDaemon(
|
|
861
|
+
export function waitViaDaemon(
|
|
862
|
+
accountDir,
|
|
863
|
+
{ threadId = null, since = undefined, timeoutSec = 55, consumer, limit = 1, settleMs = 0 } = {},
|
|
864
|
+
) {
|
|
845
865
|
const chan = getDaemonChannel(accountDir);
|
|
846
866
|
if (!chan) return Promise.resolve(null);
|
|
847
867
|
|
|
@@ -851,6 +871,8 @@ export function waitViaDaemon(accountDir, { threadId = null, since = undefined,
|
|
|
851
871
|
...(threadId ? { threadId: String(threadId) } : {}),
|
|
852
872
|
...(typeof since === "number" && Number.isFinite(since) ? { since } : {}),
|
|
853
873
|
timeoutSec: clampedSec,
|
|
874
|
+
...(limit > 1 ? { limit } : {}),
|
|
875
|
+
...(settleMs > 0 ? { settleMs } : {}),
|
|
854
876
|
...(consumer ? { consumer: String(consumer) } : {}),
|
|
855
877
|
});
|
|
856
878
|
|
|
@@ -881,6 +903,9 @@ export function waitViaDaemon(accountDir, { threadId = null, since = undefined,
|
|
|
881
903
|
ok: true,
|
|
882
904
|
status: body.status,
|
|
883
905
|
message: body.message ?? null,
|
|
906
|
+
messages: Array.isArray(body.messages) ? body.messages : body.message ? [body.message] : [],
|
|
907
|
+
count: body.count ?? (body.message ? 1 : 0),
|
|
908
|
+
hasMore: Boolean(body.hasMore),
|
|
884
909
|
cursor: body.cursor,
|
|
885
910
|
});
|
|
886
911
|
} else {
|
|
@@ -1065,33 +1090,82 @@ function quoteWinArg(arg) {
|
|
|
1065
1090
|
*/
|
|
1066
1091
|
export function attachDaemonLogIfChild() {
|
|
1067
1092
|
if (process.env.ZALO_DAEMON_CHILD !== "1" || !process.env.ZALO_DAEMON_LOG) return;
|
|
1068
|
-
|
|
1093
|
+
const logPath = process.env.ZALO_DAEMON_LOG;
|
|
1094
|
+
// The account is the name of the directory the log lives in (<data dir>/<ownId>/daemon.log).
|
|
1095
|
+
const ownId = path.basename(path.dirname(logPath));
|
|
1096
|
+
const settings = resolveLogSettings({ env: process.env, ownId });
|
|
1097
|
+
|
|
1098
|
+
let file;
|
|
1069
1099
|
try {
|
|
1070
|
-
|
|
1100
|
+
file = createRotatingFile({ file: logPath, maxBytes: settings.maxBytes, keep: settings.keep });
|
|
1101
|
+
file.write(""); // fail now, here, if the log cannot be opened
|
|
1071
1102
|
} catch {
|
|
1072
1103
|
return;
|
|
1073
1104
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
: Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8");
|
|
1079
|
-
fs.writeSync(logFd, buf);
|
|
1080
|
-
} catch {
|
|
1081
|
-
/* ignore logging errors */
|
|
1082
|
-
}
|
|
1105
|
+
// Levels, timestamps (or JSON) and rotation in front of the file; see ./log-sink.js.
|
|
1106
|
+
const sink = createLogSink({ write: file.write, level: settings.level, format: settings.format, accountId: ownId });
|
|
1107
|
+
daemonSink = sink;
|
|
1108
|
+
const done = (encoding, cb) => {
|
|
1083
1109
|
if (typeof encoding === "function") encoding();
|
|
1084
1110
|
else if (typeof cb === "function") cb();
|
|
1085
1111
|
return true;
|
|
1086
1112
|
};
|
|
1087
|
-
process.stdout.write =
|
|
1088
|
-
|
|
1113
|
+
process.stdout.write = (chunk, encoding, cb) => {
|
|
1114
|
+
sink.write(chunk);
|
|
1115
|
+
return done(encoding, cb);
|
|
1116
|
+
};
|
|
1117
|
+
process.stderr.write = (chunk, encoding, cb) => {
|
|
1118
|
+
sink.write(chunk);
|
|
1119
|
+
return done(encoding, cb);
|
|
1120
|
+
};
|
|
1121
|
+
for (const w of settings.warnings) sink.write(`[logging] ${w}\n`);
|
|
1089
1122
|
process.on("uncaughtException", (err) => {
|
|
1090
|
-
|
|
1123
|
+
sink.write(`${err?.stack || err}\n`);
|
|
1124
|
+
sink.flush();
|
|
1091
1125
|
process.exit(1);
|
|
1092
1126
|
});
|
|
1127
|
+
process.on("exit", () => sink.flush());
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** The daemon child's log sink, once attachDaemonLogIfChild has built it. */
|
|
1131
|
+
let daemonSink = null;
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Apply `--log-level` and the per-account settings once the account is known, and, for a run that is not a
|
|
1135
|
+
* background daemon, filter stderr when a level or format was asked for.
|
|
1136
|
+
*
|
|
1137
|
+
* @param {object} args
|
|
1138
|
+
* @param {string} args.ownId
|
|
1139
|
+
* @param {string|null} [args.levelFlag] - Already validated
|
|
1140
|
+
* @param {boolean} [args.filterStderr=true] - Filter stderr of a foreground run; false for the process that only launches a daemon
|
|
1141
|
+
* @returns {{ level: string, format: string, warnings: string[] }}
|
|
1142
|
+
*/
|
|
1143
|
+
export function configureLogging({ ownId, levelFlag = null, filterStderr = true }) {
|
|
1144
|
+
const settings = resolveLogSettings({ env: process.env, ownId, levelFlag });
|
|
1145
|
+
if (daemonSink) {
|
|
1146
|
+
daemonSink.setLevel(settings.level);
|
|
1147
|
+
daemonSink.setFormat(settings.format);
|
|
1148
|
+
} else if (filterStderr && settings.explicit && !foregroundFilter) {
|
|
1149
|
+
// A foreground run writes to the terminal: filter stderr in place, keep stdout (the output) alone.
|
|
1150
|
+
const original = process.stderr.write.bind(process.stderr);
|
|
1151
|
+
foregroundFilter = createLogSink({
|
|
1152
|
+
write: (text) => original(text),
|
|
1153
|
+
level: settings.level,
|
|
1154
|
+
format: settings.format,
|
|
1155
|
+
accountId: ownId,
|
|
1156
|
+
});
|
|
1157
|
+
process.stderr.write = (chunk, encoding, cb) => {
|
|
1158
|
+
foregroundFilter.write(chunk);
|
|
1159
|
+
if (typeof encoding === "function") encoding();
|
|
1160
|
+
else if (typeof cb === "function") cb();
|
|
1161
|
+
return true;
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
return { level: settings.level, format: settings.format, warnings: settings.warnings };
|
|
1093
1165
|
}
|
|
1094
1166
|
|
|
1167
|
+
let foregroundFilter = null;
|
|
1168
|
+
|
|
1095
1169
|
/**
|
|
1096
1170
|
* On Windows, spawn via `Win32_Process.Create` (WMI) so the background daemon
|
|
1097
1171
|
* is parented to `WmiPrvSE.exe` outside any terminal or agent Job Object
|
|
@@ -1104,7 +1178,7 @@ export function attachDaemonLogIfChild() {
|
|
|
1104
1178
|
* @param {string} logPath
|
|
1105
1179
|
* @returns {number|null}
|
|
1106
1180
|
*/
|
|
1107
|
-
function trySpawnWin32JobBreakaway(execPath, args, logPath) {
|
|
1181
|
+
function trySpawnWin32JobBreakaway(execPath, args, logPath, extraEnv = {}) {
|
|
1108
1182
|
if (process.platform !== "win32") return null;
|
|
1109
1183
|
try {
|
|
1110
1184
|
const cmdLine = [quoteWinArg(execPath), ...args.map(quoteWinArg)].join(" ");
|
|
@@ -1123,6 +1197,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
|
|
|
1123
1197
|
timeout: 8000,
|
|
1124
1198
|
env: {
|
|
1125
1199
|
...process.env,
|
|
1200
|
+
...extraEnv,
|
|
1126
1201
|
ZALO_DAEMON_CHILD: "1",
|
|
1127
1202
|
ZALO_DAEMON_LOG: logPath,
|
|
1128
1203
|
ZALO_DAEMON_CMD: cmdLine,
|
|
@@ -1147,6 +1222,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
|
|
|
1147
1222
|
* @param {string} opts.accountDir
|
|
1148
1223
|
* @param {string[]} [opts.args] - CLI arguments (defaults to `process.argv.slice(1)`)
|
|
1149
1224
|
* @param {number} [opts.timeoutMs=15000] - how long to wait for the channel to come up
|
|
1225
|
+
* @param {Record<string, string>} [opts.env] - extra environment for this daemon only (a fleet gives each account its own token)
|
|
1150
1226
|
* @returns {Promise<{
|
|
1151
1227
|
* ok: boolean,
|
|
1152
1228
|
* alreadyRunning?: boolean,
|
|
@@ -1156,7 +1232,7 @@ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
|
|
|
1156
1232
|
* error?: string,
|
|
1157
1233
|
* }>}
|
|
1158
1234
|
*/
|
|
1159
|
-
export async function spawnDaemonProcess({ accountDir, args = process.argv.slice(1), timeoutMs = 15_000 }) {
|
|
1235
|
+
export async function spawnDaemonProcess({ accountDir, args = process.argv.slice(1), timeoutMs = 15_000, env = {} }) {
|
|
1160
1236
|
const status = getDaemonStatus(accountDir);
|
|
1161
1237
|
if (status.running) {
|
|
1162
1238
|
return {
|
|
@@ -1183,7 +1259,7 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
|
|
|
1183
1259
|
/* ignore */
|
|
1184
1260
|
}
|
|
1185
1261
|
|
|
1186
|
-
let pid = trySpawnWin32JobBreakaway(process.execPath, filteredArgs, logPath);
|
|
1262
|
+
let pid = trySpawnWin32JobBreakaway(process.execPath, filteredArgs, logPath, env);
|
|
1187
1263
|
if (pid !== null) {
|
|
1188
1264
|
try {
|
|
1189
1265
|
fs.closeSync(logFd);
|
|
@@ -1195,7 +1271,7 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
|
|
|
1195
1271
|
detached: true,
|
|
1196
1272
|
windowsHide: true,
|
|
1197
1273
|
stdio: ["ignore", logFd, logFd],
|
|
1198
|
-
env: { ...process.env, ZALO_DAEMON_CHILD: "1" },
|
|
1274
|
+
env: { ...process.env, ...env, ZALO_DAEMON_CHILD: "1", ZALO_DAEMON_LOG: logPath },
|
|
1199
1275
|
});
|
|
1200
1276
|
try {
|
|
1201
1277
|
fs.closeSync(logFd);
|
|
@@ -1242,3 +1318,53 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
|
|
|
1242
1318
|
error: "Timed out waiting for background daemon to finish starting.",
|
|
1243
1319
|
};
|
|
1244
1320
|
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Ask the running daemon for its health snapshot (`listen --status`, `mcp start --status`).
|
|
1324
|
+
*
|
|
1325
|
+
* @param {string} accountDir
|
|
1326
|
+
* @returns {Promise<{ok: boolean, health?: object, error?: string}|null>} null when no daemon channel is published;
|
|
1327
|
+
* `ok: false` for a daemon too old to answer (it has the channel but not the route)
|
|
1328
|
+
*/
|
|
1329
|
+
export function statusViaDaemon(accountDir) {
|
|
1330
|
+
const chan = getDaemonChannel(accountDir);
|
|
1331
|
+
if (!chan) return Promise.resolve(null);
|
|
1332
|
+
const payload = JSON.stringify({ token: chan.token });
|
|
1333
|
+
return new Promise((resolve) => {
|
|
1334
|
+
const r = http.request(
|
|
1335
|
+
{
|
|
1336
|
+
host: "127.0.0.1",
|
|
1337
|
+
port: chan.port,
|
|
1338
|
+
path: "/status",
|
|
1339
|
+
method: "POST",
|
|
1340
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
|
|
1341
|
+
timeout: 5000,
|
|
1342
|
+
agent: false,
|
|
1343
|
+
},
|
|
1344
|
+
(res) => {
|
|
1345
|
+
let raw = "";
|
|
1346
|
+
res.setEncoding("utf8");
|
|
1347
|
+
res.on("data", (c) => (raw += c));
|
|
1348
|
+
res.on("end", () => {
|
|
1349
|
+
let body = {};
|
|
1350
|
+
try {
|
|
1351
|
+
body = JSON.parse(raw);
|
|
1352
|
+
} catch {
|
|
1353
|
+
/* non-JSON response */
|
|
1354
|
+
}
|
|
1355
|
+
resolve(
|
|
1356
|
+
res.statusCode === 200 && body.ok
|
|
1357
|
+
? { ok: true, health: body.health }
|
|
1358
|
+
: { ok: false, error: body.error || `HTTP ${res.statusCode}` },
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
},
|
|
1362
|
+
);
|
|
1363
|
+
r.on("timeout", () => {
|
|
1364
|
+
r.destroy();
|
|
1365
|
+
resolve({ ok: false, error: "timeout waiting for the daemon" });
|
|
1366
|
+
});
|
|
1367
|
+
r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
|
|
1368
|
+
r.end(payload);
|
|
1369
|
+
});
|
|
1370
|
+
}
|