@markus-global/cli 0.8.4-rc.2 → 0.8.4-rc.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/dist/tray.mjs DELETED
@@ -1,415 +0,0 @@
1
- import { createRequire } from 'module';
2
- const require = createRequire(import.meta.url);
3
-
4
- // src/tray.ts
5
- import { spawn, exec, execSync } from "node:child_process";
6
- import { readFileSync, existsSync, mkdirSync, appendFileSync, openSync, writeFileSync, unlinkSync } from "node:fs";
7
- import { get as httpGet } from "node:http";
8
- import { createConnection } from "node:net";
9
- import { resolve, dirname, join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { platform, homedir } from "node:os";
12
- import SysTrayModule from "systray2";
13
- var SysTray = SysTrayModule.default || SysTrayModule;
14
- var __dirname = dirname(fileURLToPath(import.meta.url));
15
- var WEB_UI_PORT = 8056;
16
- var WEB_UI_URL = `http://localhost:${WEB_UI_PORT}`;
17
- var BIN_DIR = __dirname;
18
- var APP_DIR = resolve(BIN_DIR, "..");
19
- var LOG_DIR = resolve(homedir(), ".markus", "logs");
20
- var LOG_FILE = join(LOG_DIR, "tray-stderr.log");
21
- var MARKUS_DIR = resolve(homedir(), ".markus");
22
- var LOCK_FILE = join(MARKUS_DIR, "tray.lock");
23
- var STRINGS = {
24
- en: {
25
- openUI: "Open Console",
26
- quit: "Quit Markus",
27
- tooltip: "Markus",
28
- portConflictTitle: "Markus",
29
- portConflictMsg: (port, occupant) => `Port ${port} is already in use by "${occupant}".\\nMarkus cannot start.\\n\\nFree the port or change it in ~/.markus/markus.json`
30
- },
31
- zh: {
32
- openUI: "\u6253\u5F00\u63A7\u5236\u53F0",
33
- quit: "\u9000\u51FA Markus",
34
- tooltip: "Markus",
35
- portConflictTitle: "Markus",
36
- portConflictMsg: (port, occupant) => `\u7AEF\u53E3 ${port} \u5DF2\u88AB "${occupant}" \u5360\u7528\u3002\\nMarkus \u65E0\u6CD5\u542F\u52A8\u3002\\n\\n\u8BF7\u91CA\u653E\u7AEF\u53E3\u6216\u5728 ~/.markus/markus.json \u4E2D\u4FEE\u6539\u7AEF\u53E3`
37
- }
38
- };
39
- function detectLocale() {
40
- const lang = (process.env["LANG"] ?? process.env["LC_ALL"] ?? process.env["LANGUAGE"] ?? "").toLowerCase();
41
- if (lang.startsWith("zh")) return "zh";
42
- if (platform() === "darwin") {
43
- try {
44
- const appleLang = execSync("defaults read -g AppleLanguages 2>/dev/null", { encoding: "utf-8" });
45
- if (/zh/.test(appleLang)) return "zh";
46
- } catch {
47
- }
48
- }
49
- if (platform() === "win32") {
50
- try {
51
- const winLang = execSync('powershell -NoProfile -Command "(Get-Culture).Name"', { encoding: "utf-8" }).trim();
52
- if (winLang.startsWith("zh")) return "zh";
53
- } catch {
54
- }
55
- }
56
- return "en";
57
- }
58
- var t = STRINGS[detectLocale()];
59
- function trayLog(msg) {
60
- const ts = (/* @__PURE__ */ new Date()).toISOString();
61
- const line = `${ts} ${msg}
62
- `;
63
- try {
64
- mkdirSync(LOG_DIR, { recursive: true });
65
- appendFileSync(LOG_FILE, line);
66
- } catch {
67
- }
68
- }
69
- function loadIconBase64() {
70
- for (const p of [resolve(APP_DIR, "logo.png"), resolve(APP_DIR, "markus.ico")]) {
71
- if (existsSync(p)) return readFileSync(p).toString("base64");
72
- }
73
- return "";
74
- }
75
- function openBrowser(url) {
76
- const sys = platform();
77
- const cmd = sys === "darwin" ? `open "${url}"` : sys === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
78
- exec(cmd, (err) => {
79
- if (err) trayLog(`openBrowser failed: ${err.message}`);
80
- });
81
- }
82
- function isProcessAlive(pid) {
83
- try {
84
- process.kill(pid, 0);
85
- return true;
86
- } catch {
87
- return false;
88
- }
89
- }
90
- function acquireLock() {
91
- try {
92
- mkdirSync(MARKUS_DIR, { recursive: true });
93
- if (existsSync(LOCK_FILE)) {
94
- const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
95
- if (pid && !isNaN(pid) && isProcessAlive(pid)) {
96
- return false;
97
- }
98
- }
99
- writeFileSync(LOCK_FILE, String(process.pid));
100
- return true;
101
- } catch {
102
- return true;
103
- }
104
- }
105
- function releaseLock() {
106
- try {
107
- if (existsSync(LOCK_FILE)) {
108
- const pid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
109
- if (pid === process.pid) unlinkSync(LOCK_FILE);
110
- }
111
- } catch {
112
- }
113
- }
114
- function getPortOccupant(port) {
115
- try {
116
- if (platform() === "win32") {
117
- const out = execSync(
118
- `netstat -ano | findstr ":${port}" | findstr "LISTENING"`,
119
- { encoding: "utf-8", timeout: 5e3 }
120
- ).trim();
121
- const firstLine = out.split("\n")[0]?.trim();
122
- if (!firstLine) return "unknown";
123
- const pid = firstLine.split(/\s+/).pop();
124
- if (!pid || pid === "0") return "unknown";
125
- const taskInfo = execSync(
126
- `tasklist /FI "PID eq ${pid}" /FO CSV /NH`,
127
- { encoding: "utf-8", timeout: 5e3 }
128
- ).trim();
129
- return taskInfo.split(",")[0]?.replace(/"/g, "") || "unknown";
130
- } else {
131
- return execSync(
132
- `lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null | head -1 | xargs ps -p -o comm= 2>/dev/null`,
133
- { encoding: "utf-8", timeout: 5e3 }
134
- ).trim() || "unknown";
135
- }
136
- } catch {
137
- return "unknown";
138
- }
139
- }
140
- function getPidsByPort(port) {
141
- try {
142
- if (platform() === "win32") {
143
- const out = execSync(
144
- `netstat -ano | findstr ":${port}" | findstr "LISTENING"`,
145
- { encoding: "utf-8", timeout: 5e3 }
146
- ).trim();
147
- if (!out) return [];
148
- const pids = /* @__PURE__ */ new Set();
149
- for (const line of out.split("\n")) {
150
- const pid = parseInt(line.trim().split(/\s+/).pop() ?? "", 10);
151
- if (pid && !isNaN(pid) && pid !== 0) pids.add(pid);
152
- }
153
- return [...pids];
154
- } else {
155
- const out = execSync(
156
- `lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null`,
157
- { encoding: "utf-8", timeout: 5e3 }
158
- ).trim();
159
- if (!out) return [];
160
- return out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => n && !isNaN(n));
161
- }
162
- } catch {
163
- return [];
164
- }
165
- }
166
- function killPid(pid) {
167
- try {
168
- if (platform() === "win32") {
169
- execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore", timeout: 5e3 });
170
- } else {
171
- process.kill(pid, "SIGTERM");
172
- }
173
- } catch {
174
- }
175
- }
176
- function forceKillPid(pid) {
177
- try {
178
- if (platform() === "win32") {
179
- execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore", timeout: 5e3 });
180
- } else {
181
- process.kill(pid, "SIGKILL");
182
- }
183
- } catch {
184
- }
185
- }
186
- function showPortConflictDialog(port, occupant) {
187
- const msg = t.portConflictMsg(port, occupant);
188
- if (platform() === "darwin") {
189
- exec(`osascript -e 'display dialog "${msg}" with title "${t.portConflictTitle}" buttons {"OK"} default button "OK" with icon stop'`, () => {
190
- });
191
- } else if (platform() === "win32") {
192
- const escaped = msg.replace(/'/g, "''");
193
- exec(
194
- `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.MessageBox]::Show('${escaped}','${t.portConflictTitle}','OK','Error')"`,
195
- () => {
196
- }
197
- );
198
- }
199
- }
200
- function isPortListening(port) {
201
- return new Promise((res) => {
202
- const socket = createConnection({ port, host: "127.0.0.1" }, () => {
203
- socket.destroy();
204
- res(true);
205
- });
206
- socket.on("error", () => {
207
- socket.destroy();
208
- res(false);
209
- });
210
- socket.setTimeout(2e3, () => {
211
- socket.destroy();
212
- res(false);
213
- });
214
- });
215
- }
216
- function checkHealthOnce(url) {
217
- return new Promise((res) => {
218
- const req = httpGet(url, (r) => {
219
- r.resume();
220
- res(r.statusCode !== void 0 && r.statusCode >= 200 && r.statusCode < 400);
221
- });
222
- req.on("error", () => res(false));
223
- req.setTimeout(2e3, () => {
224
- req.destroy();
225
- res(false);
226
- });
227
- });
228
- }
229
- function waitForHealth(url, intervalMs = 500, maxMs = 3e4) {
230
- return new Promise((ok) => {
231
- const deadline = Date.now() + maxMs;
232
- const check = () => {
233
- const req = httpGet(url, (res) => {
234
- res.resume();
235
- if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) {
236
- ok(true);
237
- return;
238
- }
239
- if (Date.now() >= deadline) {
240
- ok(false);
241
- return;
242
- }
243
- setTimeout(check, intervalMs);
244
- });
245
- req.on("error", () => {
246
- if (Date.now() >= deadline) ok(false);
247
- else setTimeout(check, intervalMs);
248
- });
249
- req.setTimeout(2e3, () => req.destroy());
250
- };
251
- check();
252
- });
253
- }
254
- function resolveMarkusCommand() {
255
- const symlink = "/usr/local/bin/markus";
256
- if (existsSync(symlink)) return { cmd: symlink, args: ["start"] };
257
- const wrapper = resolve(APP_DIR, "markus");
258
- if (existsSync(wrapper)) return { cmd: wrapper, args: ["start"] };
259
- const markusBin = resolve(BIN_DIR, "Markus");
260
- const nodeBin = platform() === "darwin" && existsSync(markusBin) ? markusBin : resolve(BIN_DIR, platform() === "win32" ? "node.exe" : "node");
261
- const markusMjs = resolve(BIN_DIR, "markus.mjs");
262
- return { cmd: nodeBin, args: [markusMjs, "start"] };
263
- }
264
- var serverProcess = null;
265
- async function ensureServerRunning() {
266
- if (await isPortListening(WEB_UI_PORT)) {
267
- const isMarkus = await checkHealthOnce(`${WEB_UI_URL}/api/health`);
268
- if (isMarkus) {
269
- trayLog(`Server already running on port ${WEB_UI_PORT}`);
270
- openBrowser(WEB_UI_URL);
271
- return;
272
- }
273
- const occupant = getPortOccupant(WEB_UI_PORT);
274
- trayLog(`Port ${WEB_UI_PORT} occupied by "${occupant}"`);
275
- showPortConflictDialog(WEB_UI_PORT, occupant);
276
- return;
277
- }
278
- mkdirSync(LOG_DIR, { recursive: true });
279
- const { cmd, args } = resolveMarkusCommand();
280
- trayLog(`Starting server: ${cmd} ${args.join(" ")}`);
281
- const outFd = openSync(join(LOG_DIR, "stdout.log"), "a");
282
- const errFd = openSync(join(LOG_DIR, "stderr.log"), "a");
283
- serverProcess = spawn(cmd, args, {
284
- stdio: ["ignore", outFd, errFd],
285
- detached: false,
286
- env: { ...process.env, NO_BROWSER: "1" },
287
- windowsHide: true
288
- });
289
- serverProcess.on("exit", (code) => {
290
- if (code && code !== 0) trayLog(`Server exited with code ${code}`);
291
- serverProcess = null;
292
- });
293
- serverProcess.on("error", (err) => {
294
- trayLog(`Failed to spawn server: ${err.message}`);
295
- serverProcess = null;
296
- });
297
- const healthy = await waitForHealth(`${WEB_UI_URL}/api/health`);
298
- if (healthy) {
299
- trayLog("Server healthy \u2014 opening browser");
300
- openBrowser(WEB_UI_URL);
301
- } else {
302
- trayLog("Server did not become healthy within 30s");
303
- }
304
- }
305
- function killServer() {
306
- trayLog("Stopping server...");
307
- if (platform() === "darwin") {
308
- try {
309
- const uid = execSync("id -u", { encoding: "utf-8" }).trim();
310
- execSync(`launchctl bootout gui/${uid}/global.markus 2>/dev/null`, { encoding: "utf-8" });
311
- trayLog("LaunchAgent unloaded");
312
- } catch {
313
- }
314
- }
315
- if (serverProcess) {
316
- if (platform() === "win32") {
317
- try {
318
- execSync(`taskkill /PID ${serverProcess.pid} /T /F`, { stdio: "ignore", timeout: 5e3 });
319
- } catch {
320
- }
321
- } else {
322
- serverProcess.kill("SIGTERM");
323
- }
324
- serverProcess = null;
325
- }
326
- const pids = getPidsByPort(WEB_UI_PORT).filter((p) => p !== process.pid);
327
- for (const pid of pids) {
328
- trayLog(`Killing server process ${pid}`);
329
- killPid(pid);
330
- }
331
- setTimeout(() => {
332
- const remaining = getPidsByPort(WEB_UI_PORT).filter((p) => p !== process.pid);
333
- for (const pid of remaining) {
334
- trayLog(`Force killing server process ${pid}`);
335
- forceKillPid(pid);
336
- }
337
- }, 3e3);
338
- }
339
- var tray = null;
340
- async function main() {
341
- trayLog(`Tray starting (locale=${detectLocale()}, pid=${process.pid})`);
342
- if (!acquireLock()) {
343
- const serverUp = await checkHealthOnce(`${WEB_UI_URL}/api/health`);
344
- if (serverUp) {
345
- trayLog("Another tray instance is already running \u2014 opening browser and exiting");
346
- openBrowser(WEB_UI_URL);
347
- setTimeout(() => process.exit(0), 1e3);
348
- return;
349
- }
350
- trayLog("Stale lock file detected (server not healthy) \u2014 taking over");
351
- try {
352
- unlinkSync(LOCK_FILE);
353
- } catch {
354
- }
355
- writeFileSync(LOCK_FILE, String(process.pid));
356
- }
357
- process.on("exit", releaseLock);
358
- process.on("SIGINT", () => {
359
- releaseLock();
360
- process.exit(0);
361
- });
362
- process.on("SIGTERM", () => {
363
- releaseLock();
364
- process.exit(0);
365
- });
366
- let trayOk = false;
367
- try {
368
- tray = new SysTray({
369
- menu: {
370
- icon: loadIconBase64(),
371
- title: "",
372
- tooltip: t.tooltip,
373
- items: [
374
- { title: t.openUI, tooltip: t.openUI, enabled: true },
375
- { title: "<SEPARATOR>", tooltip: "", enabled: true },
376
- { title: t.quit, tooltip: t.quit, enabled: true }
377
- ]
378
- },
379
- copyDir: false
380
- });
381
- await tray.ready();
382
- trayOk = true;
383
- tray.onClick(async (action) => {
384
- const title = action.item.title;
385
- if (title === t.openUI) {
386
- openBrowser(WEB_UI_URL);
387
- } else if (title === t.quit) {
388
- trayLog("Quit requested");
389
- killServer();
390
- setTimeout(async () => {
391
- releaseLock();
392
- if (tray) await tray.kill(false);
393
- process.exit(0);
394
- }, 4e3);
395
- }
396
- });
397
- tray.onError((err) => {
398
- trayLog(`Tray error: ${err.message}`);
399
- });
400
- } catch (trayErr) {
401
- trayLog(`Tray icon unavailable (${trayErr}) \u2014 running in headless mode`);
402
- tray = null;
403
- }
404
- await ensureServerRunning();
405
- if (!trayOk) {
406
- trayLog("Running without tray icon \u2014 server is up, keeping process alive");
407
- await new Promise(() => {
408
- });
409
- }
410
- }
411
- main().catch((err) => {
412
- trayLog(`Tray failed to start: ${err}`);
413
- releaseLock();
414
- process.exit(1);
415
- });