tmex-cli 0.1.4
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/README.md +17 -0
- package/bin/tmex.js +8 -0
- package/dist/cli-node.js +1512 -0
- package/dist/runtime/cpufeatures-6knf6x1a.node +0 -0
- package/dist/runtime/server.js +52833 -0
- package/dist/runtime/sshcrypto-6px2c22x.node +0 -0
- package/package.json +30 -0
- package/resources/fe-dist/assets/index-D7lUHQCR.css +32 -0
- package/resources/fe-dist/assets/index-DNlxXBdI.js +333 -0
- package/resources/fe-dist/assets/index-DNlxXBdI.js.map +1 -0
- package/resources/fe-dist/index.html +14 -0
- package/resources/gateway-drizzle/0000_busy_starjammers.sql +76 -0
- package/resources/gateway-drizzle/0001_lowly_the_twelve.sql +2 -0
- package/resources/gateway-drizzle/meta/0000_snapshot.json +499 -0
- package/resources/gateway-drizzle/meta/0001_snapshot.json +515 -0
- package/resources/gateway-drizzle/meta/_journal.json +20 -0
package/dist/cli-node.js
ADDED
|
@@ -0,0 +1,1512 @@
|
|
|
1
|
+
// src/commands/doctor.ts
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { resolve as resolve4 } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/constants.ts
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
var MIN_BUN_VERSION = "1.3.0";
|
|
9
|
+
var DEFAULT_SERVICE_NAME = "tmex";
|
|
10
|
+
function defaultInstallDir(platform) {
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
return resolve(homedir(), "Library", "Application Support", "tmex");
|
|
13
|
+
}
|
|
14
|
+
return resolve(homedir(), ".local", "share", "tmex");
|
|
15
|
+
}
|
|
16
|
+
function defaultDatabasePath(installDir) {
|
|
17
|
+
return resolve(installDir, "data", "tmex.db");
|
|
18
|
+
}
|
|
19
|
+
function defaultHost() {
|
|
20
|
+
return "127.0.0.1";
|
|
21
|
+
}
|
|
22
|
+
function defaultPort() {
|
|
23
|
+
return 9883;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/i18n/index.ts
|
|
27
|
+
var MESSAGES = {
|
|
28
|
+
en: {
|
|
29
|
+
"cli.help": `tmex CLI
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
tmex init [--no-interactive --install-dir <path> --host <host> --port <port> --db-path <path> --autostart <true|false>]
|
|
33
|
+
tmex doctor [--install-dir <path>] [--json]
|
|
34
|
+
tmex upgrade [--version <version>] [--install-dir <path>]
|
|
35
|
+
tmex uninstall [--install-dir <path>] [--yes] [--purge]
|
|
36
|
+
|
|
37
|
+
Global flags:
|
|
38
|
+
--lang <en|zh-CN>`,
|
|
39
|
+
"cli.error.unknownCommand": "Unknown command: {{command}}",
|
|
40
|
+
"common.cancelled": "Cancelled by user.",
|
|
41
|
+
"common.done": "Done.",
|
|
42
|
+
"errors.args.missingFlag": "Missing required flag: --{{flag}}",
|
|
43
|
+
"errors.args.invalidFlag": "Invalid flag value: --{{flag}}={{value}}",
|
|
44
|
+
"errors.validate.invalidPort": "Invalid port: {{value}}",
|
|
45
|
+
"errors.validate.emptyField": "{{field}} cannot be empty.",
|
|
46
|
+
"errors.version.invalid": "Invalid version: {{input}}",
|
|
47
|
+
"errors.layout.packageRootNotFound": "Unable to locate tmex package root. Please ensure dist artifacts are complete.",
|
|
48
|
+
"errors.layout.runtimeMissing": "Runtime artifact not found: {{path}}",
|
|
49
|
+
"errors.layout.feMissing": "Frontend static assets not found: {{path}}",
|
|
50
|
+
"errors.layout.drizzleMissing": "Gateway migration assets not found: {{path}}",
|
|
51
|
+
"bun.notFound": "Bun not found. Please install Bun and ensure it is available in PATH.",
|
|
52
|
+
"bun.versionExecFailed": "Failed to execute bun --version. Please verify Bun installation.",
|
|
53
|
+
"bun.versionTooLow": "Bun version too low: current {{version}}, required >= {{minVersion}}",
|
|
54
|
+
"bun.checkFailed": "Bun check failed.",
|
|
55
|
+
"service.install.unsupportedPlatform": "Automatic service installation is not supported on this platform: {{platform}}",
|
|
56
|
+
"service.systemd.daemonReloadFailed": "systemctl daemon-reload failed: {{detail}}",
|
|
57
|
+
"service.systemd.enableFailed": "systemctl enable failed: {{detail}}",
|
|
58
|
+
"service.systemd.restartFailed": "systemctl restart failed: {{detail}}",
|
|
59
|
+
"service.systemd.startFailed": "systemctl start failed: {{detail}}",
|
|
60
|
+
"service.systemd.startRuntimeFailed": "systemctl start failed: {{detail}}",
|
|
61
|
+
"service.launchd.bootstrapFailed": "launchctl bootstrap failed: {{detail}}",
|
|
62
|
+
"service.status.none": "Service manager is not integrated for platform: {{platform}}",
|
|
63
|
+
"service.status.plistMissing": "launchd plist not found",
|
|
64
|
+
"service.hint.systemd": "systemctl --user status {{serviceName}}",
|
|
65
|
+
"service.hint.launchd": "launchctl print gui/$(id -u)/com.tmex.{{serviceName}}",
|
|
66
|
+
"service.hint.none": "No service manager command on this platform.",
|
|
67
|
+
"init.prompt.installDir": "Install directory (install-dir)",
|
|
68
|
+
"init.prompt.host": "Bind host",
|
|
69
|
+
"init.prompt.port": "Bind port",
|
|
70
|
+
"init.prompt.dbPath": "Database path (db-path)",
|
|
71
|
+
"init.prompt.autostart": "Enable autostart",
|
|
72
|
+
"init.prompt.serviceName": "Service name (service-name)",
|
|
73
|
+
"init.prompt.dirExistsConfirm": "Directory {{installDir}} already exists. Continue (will not delete existing config/db)?",
|
|
74
|
+
"init.error.installDirNotEmpty": "Install directory is not empty: {{installDir}}. Use --force to overwrite.",
|
|
75
|
+
"init.warning.noServiceManager": "Service manager is not supported on platform {{platform}}. Files are deployed but autostart is not configured.",
|
|
76
|
+
"init.done": "Initialization completed.",
|
|
77
|
+
"init.summary.installDir": "Install dir",
|
|
78
|
+
"init.summary.serviceName": "Service name",
|
|
79
|
+
"init.summary.bun": "Bun",
|
|
80
|
+
"init.summary.autostart": "Autostart",
|
|
81
|
+
"init.summary.autostart.on": "on",
|
|
82
|
+
"init.summary.autostart.off": "off",
|
|
83
|
+
"init.summary.serviceHint": "Service status command",
|
|
84
|
+
"doctor.platform.supported": "Platform: {{platform}}",
|
|
85
|
+
"doctor.platform.unsupported": "Platform {{platform}} is not officially supported (only macOS and common Linux distros are guaranteed).",
|
|
86
|
+
"doctor.bun.ok": "Bun installed: {{version}}",
|
|
87
|
+
"doctor.bun.fail": "Bun check failed: {{reason}}",
|
|
88
|
+
"doctor.tmux.ok": "tmux installed",
|
|
89
|
+
"doctor.tmux.fail": "tmux not found (tmex requires tmux).",
|
|
90
|
+
"doctor.ssh.ok": "ssh installed",
|
|
91
|
+
"doctor.ssh.missing": "ssh not found; SSH devices will not work.",
|
|
92
|
+
"doctor.installDir.exists": "Install directory exists: {{installDir}}",
|
|
93
|
+
"doctor.installDir.missing": "Install directory not found: {{installDir}}",
|
|
94
|
+
"doctor.env.exists": "Config file found: {{envPath}}",
|
|
95
|
+
"doctor.env.missing": "Config file not found: {{envPath}}",
|
|
96
|
+
"doctor.env.keyMissing": "Missing config key: {{key}}",
|
|
97
|
+
"doctor.db.missing": "Database file not found (may be normal before first start): {{path}}",
|
|
98
|
+
"doctor.db.exists": "Database file exists: {{path}}",
|
|
99
|
+
"doctor.port.invalid": "Invalid port in config: {{value}}",
|
|
100
|
+
"doctor.service.notInstalled": "Service not installed: {{serviceName}}",
|
|
101
|
+
"doctor.service.notRunning": "Service not running: {{serviceName}}",
|
|
102
|
+
"doctor.service.running": "Service running: {{serviceName}}",
|
|
103
|
+
"doctor.service.noManager": "{{detail}}",
|
|
104
|
+
"doctor.health.pass": "Health check OK: {{url}}",
|
|
105
|
+
"doctor.health.fail": "Health check failed or unreachable: {{url}}",
|
|
106
|
+
"upgrade.delegateFailed": "Upgrade delegation failed with exit code {{code}}",
|
|
107
|
+
"upgrade.missingMeta": "Install metadata not found: {{path}}. Please run init first.",
|
|
108
|
+
"upgrade.healthFailed": "Health check failed: HTTP {{status}}",
|
|
109
|
+
"upgrade.done": "Upgrade completed.",
|
|
110
|
+
"upgrade.failedRollingBack": "Upgrade failed; rolling back.",
|
|
111
|
+
"upgrade.summary.targetVersion": "Target version",
|
|
112
|
+
"upgrade.summary.installDir": "Install dir",
|
|
113
|
+
"uninstall.prompt.removeService": "Uninstall system service",
|
|
114
|
+
"uninstall.prompt.removeProgram": "Remove program files (runtime/resources/run.sh/meta)",
|
|
115
|
+
"uninstall.prompt.removeEnv": "Remove app.env",
|
|
116
|
+
"uninstall.prompt.removeDatabase": "Remove database file",
|
|
117
|
+
"uninstall.done": "Uninstall completed.",
|
|
118
|
+
"uninstall.summary.installDir": "Install dir",
|
|
119
|
+
"uninstall.summary.serviceName": "Service name",
|
|
120
|
+
"runtime.restartRequested": "Restart requested; exiting for service manager restart.",
|
|
121
|
+
"runtime.started": "Service started on {{url}}",
|
|
122
|
+
"runtime.frontendMissing": "Frontend assets not found.",
|
|
123
|
+
"runtime.methodNotAllowed": "Method Not Allowed",
|
|
124
|
+
"runtime.forbidden": "Forbidden"
|
|
125
|
+
},
|
|
126
|
+
"zh-CN": {
|
|
127
|
+
"cli.help": `tmex CLI
|
|
128
|
+
|
|
129
|
+
用法:
|
|
130
|
+
tmex init [--no-interactive --install-dir <path> --host <host> --port <port> --db-path <path> --autostart <true|false>]
|
|
131
|
+
tmex doctor [--install-dir <path>] [--json]
|
|
132
|
+
tmex upgrade [--version <version>] [--install-dir <path>]
|
|
133
|
+
tmex uninstall [--install-dir <path>] [--yes] [--purge]
|
|
134
|
+
|
|
135
|
+
全局参数:
|
|
136
|
+
--lang <en|zh-CN>`,
|
|
137
|
+
"cli.error.unknownCommand": "未知命令:{{command}}",
|
|
138
|
+
"common.cancelled": "已取消。",
|
|
139
|
+
"common.done": "完成。",
|
|
140
|
+
"errors.args.missingFlag": "缺少必要参数:--{{flag}}",
|
|
141
|
+
"errors.args.invalidFlag": "参数值非法:--{{flag}}={{value}}",
|
|
142
|
+
"errors.validate.invalidPort": "非法端口:{{value}}",
|
|
143
|
+
"errors.validate.emptyField": "{{field}} 不能为空。",
|
|
144
|
+
"errors.version.invalid": "非法版本号:{{input}}",
|
|
145
|
+
"errors.layout.packageRootNotFound": "无法定位 tmex 包根目录,请确认 dist 产物完整。",
|
|
146
|
+
"errors.layout.runtimeMissing": "未找到 runtime 产物:{{path}}",
|
|
147
|
+
"errors.layout.feMissing": "未找到前端静态资源:{{path}}",
|
|
148
|
+
"errors.layout.drizzleMissing": "未找到网关迁移资源:{{path}}",
|
|
149
|
+
"bun.notFound": "未检测到 Bun,请先安装 Bun 并确保在 PATH 中可用。",
|
|
150
|
+
"bun.versionExecFailed": "无法执行 bun --version,请检查 Bun 安装是否完整。",
|
|
151
|
+
"bun.versionTooLow": "Bun 版本过低:当前 {{version}},要求 >= {{minVersion}}",
|
|
152
|
+
"bun.checkFailed": "Bun 检查失败。",
|
|
153
|
+
"service.install.unsupportedPlatform": "当前平台不支持自动安装服务:{{platform}}",
|
|
154
|
+
"service.systemd.daemonReloadFailed": "systemctl daemon-reload 失败:{{detail}}",
|
|
155
|
+
"service.systemd.enableFailed": "systemctl enable 失败:{{detail}}",
|
|
156
|
+
"service.systemd.restartFailed": "systemctl restart 失败:{{detail}}",
|
|
157
|
+
"service.systemd.startFailed": "systemctl start 失败:{{detail}}",
|
|
158
|
+
"service.systemd.startRuntimeFailed": "systemctl 启动失败:{{detail}}",
|
|
159
|
+
"service.launchd.bootstrapFailed": "launchctl bootstrap 失败:{{detail}}",
|
|
160
|
+
"service.status.none": "当前平台未集成服务管理:{{platform}}",
|
|
161
|
+
"service.status.plistMissing": "plist 不存在",
|
|
162
|
+
"service.hint.systemd": "systemctl --user status {{serviceName}}",
|
|
163
|
+
"service.hint.launchd": "launchctl print gui/$(id -u)/com.tmex.{{serviceName}}",
|
|
164
|
+
"service.hint.none": "当前平台无服务管理命令",
|
|
165
|
+
"init.prompt.installDir": "安装目录(install-dir)",
|
|
166
|
+
"init.prompt.host": "监听 host",
|
|
167
|
+
"init.prompt.port": "监听端口",
|
|
168
|
+
"init.prompt.dbPath": "数据库路径(db-path)",
|
|
169
|
+
"init.prompt.autostart": "是否启用开机启动",
|
|
170
|
+
"init.prompt.serviceName": "服务名称(service-name)",
|
|
171
|
+
"init.prompt.dirExistsConfirm": "目录 {{installDir}} 已存在,是否继续(不会删除现有配置与数据库)?",
|
|
172
|
+
"init.error.installDirNotEmpty": "安装目录已存在且非空:{{installDir}}。如需覆盖请加 --force",
|
|
173
|
+
"init.warning.noServiceManager": "当前平台 {{platform}} 未实现自动服务安装,已完成文件部署。",
|
|
174
|
+
"init.done": "初始化完成。",
|
|
175
|
+
"init.summary.installDir": "安装目录",
|
|
176
|
+
"init.summary.serviceName": "服务名称",
|
|
177
|
+
"init.summary.bun": "Bun",
|
|
178
|
+
"init.summary.autostart": "自启动",
|
|
179
|
+
"init.summary.autostart.on": "开启",
|
|
180
|
+
"init.summary.autostart.off": "关闭",
|
|
181
|
+
"init.summary.serviceHint": "服务状态命令",
|
|
182
|
+
"doctor.platform.supported": "平台:{{platform}}",
|
|
183
|
+
"doctor.platform.unsupported": "当前平台 {{platform}} 非官方支持范围(仅保证 macOS 与常见 Linux 发行版)。",
|
|
184
|
+
"doctor.bun.ok": "Bun 已安装:{{version}}",
|
|
185
|
+
"doctor.bun.fail": "Bun 检查失败:{{reason}}",
|
|
186
|
+
"doctor.tmux.ok": "tmux 已安装",
|
|
187
|
+
"doctor.tmux.fail": "未检测到 tmux(tmex 需要 tmux 才能工作)。",
|
|
188
|
+
"doctor.ssh.ok": "ssh 已安装",
|
|
189
|
+
"doctor.ssh.missing": "未检测到 ssh,远程设备将不可用。",
|
|
190
|
+
"doctor.installDir.exists": "安装目录存在:{{installDir}}",
|
|
191
|
+
"doctor.installDir.missing": "未发现安装目录:{{installDir}}",
|
|
192
|
+
"doctor.env.exists": "发现配置文件:{{envPath}}",
|
|
193
|
+
"doctor.env.missing": "未发现配置文件:{{envPath}}",
|
|
194
|
+
"doctor.env.keyMissing": "配置缺失:{{key}}",
|
|
195
|
+
"doctor.db.missing": "数据库文件不存在(首次启动前可能正常):{{path}}",
|
|
196
|
+
"doctor.db.exists": "数据库文件存在:{{path}}",
|
|
197
|
+
"doctor.port.invalid": "配置端口非法:{{value}}",
|
|
198
|
+
"doctor.service.notInstalled": "服务未安装:{{serviceName}}",
|
|
199
|
+
"doctor.service.notRunning": "服务未运行:{{serviceName}}",
|
|
200
|
+
"doctor.service.running": "服务运行中:{{serviceName}}",
|
|
201
|
+
"doctor.service.noManager": "{{detail}}",
|
|
202
|
+
"doctor.health.pass": "健康检查通过:{{url}}",
|
|
203
|
+
"doctor.health.fail": "健康检查失败或不可达:{{url}}",
|
|
204
|
+
"upgrade.delegateFailed": "委托升级失败,退出码 {{code}}",
|
|
205
|
+
"upgrade.missingMeta": "未找到安装元数据:{{path}},请先执行 init",
|
|
206
|
+
"upgrade.healthFailed": "健康检查失败:HTTP {{status}}",
|
|
207
|
+
"upgrade.done": "升级完成。",
|
|
208
|
+
"upgrade.failedRollingBack": "升级失败,开始回滚。",
|
|
209
|
+
"upgrade.summary.targetVersion": "目标版本",
|
|
210
|
+
"upgrade.summary.installDir": "安装目录",
|
|
211
|
+
"uninstall.prompt.removeService": "是否卸载系统服务",
|
|
212
|
+
"uninstall.prompt.removeProgram": "是否删除程序文件(runtime/resources/run.sh/meta)",
|
|
213
|
+
"uninstall.prompt.removeEnv": "是否删除 app.env",
|
|
214
|
+
"uninstall.prompt.removeDatabase": "是否删除数据库文件",
|
|
215
|
+
"uninstall.done": "卸载完成。",
|
|
216
|
+
"uninstall.summary.installDir": "安装目录",
|
|
217
|
+
"uninstall.summary.serviceName": "服务名称",
|
|
218
|
+
"runtime.restartRequested": "收到重启请求,退出并等待服务管理器拉起。",
|
|
219
|
+
"runtime.started": "服务已启动:{{url}}",
|
|
220
|
+
"runtime.frontendMissing": "未找到前端静态资源。",
|
|
221
|
+
"runtime.methodNotAllowed": "方法不允许",
|
|
222
|
+
"runtime.forbidden": "禁止访问"
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
var currentLang = "en";
|
|
226
|
+
function normalizeLang(input) {
|
|
227
|
+
if (!input)
|
|
228
|
+
return "en";
|
|
229
|
+
const raw = input.trim();
|
|
230
|
+
if (!raw)
|
|
231
|
+
return "en";
|
|
232
|
+
const lower = raw.toLowerCase();
|
|
233
|
+
if (lower === "en" || lower === "en-us" || lower === "en_us")
|
|
234
|
+
return "en";
|
|
235
|
+
if (lower === "zh" || lower === "zh-cn" || lower === "zh_cn" || lower === "cn")
|
|
236
|
+
return "zh-CN";
|
|
237
|
+
return "en";
|
|
238
|
+
}
|
|
239
|
+
function setLang(lang) {
|
|
240
|
+
currentLang = lang;
|
|
241
|
+
}
|
|
242
|
+
function interpolate(template, vars) {
|
|
243
|
+
if (!vars)
|
|
244
|
+
return template;
|
|
245
|
+
return template.replaceAll(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
246
|
+
const value = vars[key];
|
|
247
|
+
return value === undefined ? "" : String(value);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function t(key, vars) {
|
|
251
|
+
const table = MESSAGES[currentLang] ?? MESSAGES.en;
|
|
252
|
+
const fallback = MESSAGES.en[key];
|
|
253
|
+
const template = table[key] ?? fallback ?? key;
|
|
254
|
+
return interpolate(template, vars);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/lib/bun.ts
|
|
258
|
+
import { existsSync } from "node:fs";
|
|
259
|
+
import { homedir as homedir2 } from "node:os";
|
|
260
|
+
import { join } from "node:path";
|
|
261
|
+
|
|
262
|
+
// src/lib/process.ts
|
|
263
|
+
import { spawn } from "node:child_process";
|
|
264
|
+
async function runCommand(command, args, options = {}) {
|
|
265
|
+
const stdio = options.stdio ?? "pipe";
|
|
266
|
+
return await new Promise((resolve2, reject) => {
|
|
267
|
+
const child = spawn(command, args, {
|
|
268
|
+
cwd: options.cwd,
|
|
269
|
+
env: options.env,
|
|
270
|
+
stdio
|
|
271
|
+
});
|
|
272
|
+
let stdout = "";
|
|
273
|
+
let stderr = "";
|
|
274
|
+
if (stdio === "pipe") {
|
|
275
|
+
child.stdout?.on("data", (chunk) => {
|
|
276
|
+
stdout += chunk.toString();
|
|
277
|
+
});
|
|
278
|
+
child.stderr?.on("data", (chunk) => {
|
|
279
|
+
stderr += chunk.toString();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
child.on("error", reject);
|
|
283
|
+
child.on("close", (code) => {
|
|
284
|
+
resolve2({
|
|
285
|
+
code: code ?? 1,
|
|
286
|
+
stdout,
|
|
287
|
+
stderr
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/lib/semver.ts
|
|
294
|
+
function parseSemver(input) {
|
|
295
|
+
const match = input.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
296
|
+
if (!match) {
|
|
297
|
+
throw new Error(t("errors.version.invalid", { input }));
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
major: Number(match[1]),
|
|
301
|
+
minor: Number(match[2]),
|
|
302
|
+
patch: Number(match[3])
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function compareSemver(left, right) {
|
|
306
|
+
const a = parseSemver(left);
|
|
307
|
+
const b = parseSemver(right);
|
|
308
|
+
if (a.major !== b.major)
|
|
309
|
+
return a.major > b.major ? 1 : -1;
|
|
310
|
+
if (a.minor !== b.minor)
|
|
311
|
+
return a.minor > b.minor ? 1 : -1;
|
|
312
|
+
if (a.patch !== b.patch)
|
|
313
|
+
return a.patch > b.patch ? 1 : -1;
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/lib/bun.ts
|
|
318
|
+
async function locateBunFromShell() {
|
|
319
|
+
const result = await runCommand("zsh", ["-lic", "command -v bun"], { stdio: "pipe" }).catch(() => null);
|
|
320
|
+
if (!result || result.code !== 0) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const bin = result.stdout.trim();
|
|
324
|
+
if (!bin) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
return bin;
|
|
328
|
+
}
|
|
329
|
+
async function findBunBinary() {
|
|
330
|
+
const zshBin = await locateBunFromShell();
|
|
331
|
+
if (zshBin) {
|
|
332
|
+
return zshBin;
|
|
333
|
+
}
|
|
334
|
+
const fallback = join(homedir2(), ".bun", "bin", "bun");
|
|
335
|
+
if (existsSync(fallback)) {
|
|
336
|
+
return fallback;
|
|
337
|
+
}
|
|
338
|
+
const direct = await runCommand("bun", ["--version"], { stdio: "pipe" }).catch(() => null);
|
|
339
|
+
if (direct?.code === 0) {
|
|
340
|
+
return "bun";
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
async function checkBunVersion(minVersion = MIN_BUN_VERSION) {
|
|
345
|
+
const bunPath = await findBunBinary();
|
|
346
|
+
if (!bunPath) {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
reason: t("bun.notFound")
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const versionResult = await runCommand(bunPath, ["--version"], { stdio: "pipe" }).catch(() => null);
|
|
353
|
+
if (!versionResult || versionResult.code !== 0) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
reason: t("bun.versionExecFailed"),
|
|
357
|
+
path: bunPath
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const version = versionResult.stdout.trim();
|
|
361
|
+
if (compareSemver(version, minVersion) < 0) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
path: bunPath,
|
|
365
|
+
version,
|
|
366
|
+
reason: t("bun.versionTooLow", { version, minVersion })
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
path: bunPath,
|
|
372
|
+
version
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/lib/env-file.ts
|
|
377
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
378
|
+
function parseEnvContent(content) {
|
|
379
|
+
const result = {};
|
|
380
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
381
|
+
const line = rawLine.trim();
|
|
382
|
+
if (!line || line.startsWith("#"))
|
|
383
|
+
continue;
|
|
384
|
+
const eqIndex = line.indexOf("=");
|
|
385
|
+
if (eqIndex <= 0)
|
|
386
|
+
continue;
|
|
387
|
+
const key = line.slice(0, eqIndex).trim();
|
|
388
|
+
const value = line.slice(eqIndex + 1).trim();
|
|
389
|
+
result[key] = value;
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
function stringifyEnv(values) {
|
|
394
|
+
const lines = Object.keys(values).sort((left, right) => left.localeCompare(right)).map((key) => `${key}=${values[key]}`);
|
|
395
|
+
return `${lines.join(`
|
|
396
|
+
`)}
|
|
397
|
+
`;
|
|
398
|
+
}
|
|
399
|
+
async function readEnvFile(filePath) {
|
|
400
|
+
const content = await readFile(filePath, "utf8");
|
|
401
|
+
return parseEnvContent(content);
|
|
402
|
+
}
|
|
403
|
+
async function writeEnvFile(filePath, values) {
|
|
404
|
+
await writeFile(filePath, stringifyEnv(values), { encoding: "utf8", mode: 384 });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/lib/fs-utils.ts
|
|
408
|
+
import { constants } from "node:fs";
|
|
409
|
+
import { access, cp, mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
410
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
411
|
+
async function pathExists(path) {
|
|
412
|
+
try {
|
|
413
|
+
await access(path, constants.F_OK);
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function ensureDir(path) {
|
|
420
|
+
await mkdir(path, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
async function copyDirectory(from, to) {
|
|
423
|
+
await cp(from, to, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
async function writeText(path, content, mode) {
|
|
426
|
+
await ensureDir(dirname(path));
|
|
427
|
+
await writeFile2(path, content, {
|
|
428
|
+
encoding: "utf8",
|
|
429
|
+
mode
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
async function readText(path) {
|
|
433
|
+
return await readFile2(path, "utf8");
|
|
434
|
+
}
|
|
435
|
+
function resolvePath(...parts) {
|
|
436
|
+
return resolve2(...parts);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/lib/install-layout.ts
|
|
440
|
+
import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
|
|
441
|
+
import { fileURLToPath } from "node:url";
|
|
442
|
+
function createInstallLayout(installDir) {
|
|
443
|
+
return {
|
|
444
|
+
installDir,
|
|
445
|
+
runtimeDir: join2(installDir, "runtime"),
|
|
446
|
+
runtimeServerPath: join2(installDir, "runtime", "server.js"),
|
|
447
|
+
resourcesDir: join2(installDir, "resources"),
|
|
448
|
+
feDir: join2(installDir, "resources", "fe-dist"),
|
|
449
|
+
drizzleDir: join2(installDir, "resources", "gateway-drizzle"),
|
|
450
|
+
envPath: join2(installDir, "app.env"),
|
|
451
|
+
runScriptPath: join2(installDir, "run.sh"),
|
|
452
|
+
metaPath: join2(installDir, "install-meta.json")
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async function locatePackageRoot(startDir) {
|
|
456
|
+
let current = startDir;
|
|
457
|
+
while (true) {
|
|
458
|
+
const packageJsonPath = join2(current, "package.json");
|
|
459
|
+
if (await pathExists(packageJsonPath)) {
|
|
460
|
+
const parsed = await readText(packageJsonPath).then((content) => JSON.parse(content)).catch(() => null);
|
|
461
|
+
if (parsed) {
|
|
462
|
+
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
463
|
+
const bin = typeof parsed.bin === "object" && parsed.bin !== null ? parsed.bin : null;
|
|
464
|
+
const hasTmexBin = bin !== null && typeof bin.tmex === "string";
|
|
465
|
+
const hasTmexCliBin = bin !== null && typeof bin["tmex-cli"] === "string";
|
|
466
|
+
if ((name === "tmex-cli" || name === "tmex") && (hasTmexBin || hasTmexCliBin)) {
|
|
467
|
+
return current;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const parent = dirname2(current);
|
|
472
|
+
if (parent === current) {
|
|
473
|
+
throw new Error(t("errors.layout.packageRootNotFound"));
|
|
474
|
+
}
|
|
475
|
+
current = parent;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function resolvePackageLayout(fromModuleUrl) {
|
|
479
|
+
const currentDir = dirname2(fileURLToPath(fromModuleUrl));
|
|
480
|
+
const packageRoot = await locatePackageRoot(currentDir);
|
|
481
|
+
const layout = {
|
|
482
|
+
packageRoot,
|
|
483
|
+
cliDistPath: join2(packageRoot, "dist", "cli-node.js"),
|
|
484
|
+
runtimeDirPath: join2(packageRoot, "dist", "runtime"),
|
|
485
|
+
resourceFePath: join2(packageRoot, "resources", "fe-dist"),
|
|
486
|
+
resourceDrizzlePath: join2(packageRoot, "resources", "gateway-drizzle")
|
|
487
|
+
};
|
|
488
|
+
if (!await pathExists(join2(layout.runtimeDirPath, "server.js"))) {
|
|
489
|
+
throw new Error(t("errors.layout.runtimeMissing", { path: join2(layout.runtimeDirPath, "server.js") }));
|
|
490
|
+
}
|
|
491
|
+
if (!await pathExists(layout.resourceFePath)) {
|
|
492
|
+
throw new Error(t("errors.layout.feMissing", { path: layout.resourceFePath }));
|
|
493
|
+
}
|
|
494
|
+
if (!await pathExists(layout.resourceDrizzlePath)) {
|
|
495
|
+
throw new Error(t("errors.layout.drizzleMissing", { path: layout.resourceDrizzlePath }));
|
|
496
|
+
}
|
|
497
|
+
return layout;
|
|
498
|
+
}
|
|
499
|
+
function resolveInstallDir(input) {
|
|
500
|
+
return resolve3(input);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/lib/json-file.ts
|
|
504
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
505
|
+
import { dirname as dirname3 } from "node:path";
|
|
506
|
+
async function readJsonFile(path) {
|
|
507
|
+
const content = await readFile3(path, "utf8");
|
|
508
|
+
return JSON.parse(content);
|
|
509
|
+
}
|
|
510
|
+
async function writeJsonFile(path, value, mode) {
|
|
511
|
+
await ensureDir(dirname3(path));
|
|
512
|
+
await writeFile3(path, `${JSON.stringify(value, null, 2)}
|
|
513
|
+
`, { encoding: "utf8", mode });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/lib/platform.ts
|
|
517
|
+
function detectServiceManager(platform = process.platform) {
|
|
518
|
+
if (platform === "linux")
|
|
519
|
+
return "systemd-user";
|
|
520
|
+
if (platform === "darwin")
|
|
521
|
+
return "launchd";
|
|
522
|
+
return "none";
|
|
523
|
+
}
|
|
524
|
+
function isSupportedPlatform(platform = process.platform) {
|
|
525
|
+
return platform === "linux" || platform === "darwin";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/lib/service.ts
|
|
529
|
+
import { rm } from "node:fs/promises";
|
|
530
|
+
import { homedir as homedir3 } from "node:os";
|
|
531
|
+
import { join as join3 } from "node:path";
|
|
532
|
+
function systemdUnitPath(serviceName) {
|
|
533
|
+
return join3(homedir3(), ".config", "systemd", "user", `${serviceName}.service`);
|
|
534
|
+
}
|
|
535
|
+
function launchdLabel(serviceName) {
|
|
536
|
+
return `com.tmex.${serviceName}`;
|
|
537
|
+
}
|
|
538
|
+
function launchdLaunchAgentsPlistPath(serviceName) {
|
|
539
|
+
return join3(homedir3(), "Library", "LaunchAgents", `${launchdLabel(serviceName)}.plist`);
|
|
540
|
+
}
|
|
541
|
+
function launchdLocalPlistPath(serviceName, installDir) {
|
|
542
|
+
return join3(installDir, `${launchdLabel(serviceName)}.plist`);
|
|
543
|
+
}
|
|
544
|
+
function buildSystemdServiceContent({
|
|
545
|
+
serviceName,
|
|
546
|
+
runScriptPath,
|
|
547
|
+
installDir
|
|
548
|
+
}) {
|
|
549
|
+
const escapedInstallDir = installDir.replaceAll("\\", "\\\\");
|
|
550
|
+
const escapedRunScriptPath = runScriptPath.replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
|
|
551
|
+
return `[Unit]
|
|
552
|
+
Description=tmex (${serviceName})
|
|
553
|
+
After=network.target
|
|
554
|
+
|
|
555
|
+
[Service]
|
|
556
|
+
Type=simple
|
|
557
|
+
WorkingDirectory=${escapedInstallDir}
|
|
558
|
+
SyslogIdentifier=tmex
|
|
559
|
+
StandardOutput=journal
|
|
560
|
+
StandardError=journal
|
|
561
|
+
ExecStart=/usr/bin/env bash "${escapedRunScriptPath}"
|
|
562
|
+
Restart=always
|
|
563
|
+
RestartSec=3
|
|
564
|
+
TimeoutStopSec=20
|
|
565
|
+
|
|
566
|
+
[Install]
|
|
567
|
+
WantedBy=default.target
|
|
568
|
+
`;
|
|
569
|
+
}
|
|
570
|
+
function escapeXml(value) {
|
|
571
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
572
|
+
}
|
|
573
|
+
function buildLaunchdPlist({
|
|
574
|
+
serviceName,
|
|
575
|
+
runScriptPath,
|
|
576
|
+
installDir
|
|
577
|
+
}) {
|
|
578
|
+
const label = launchdLabel(serviceName);
|
|
579
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
580
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
581
|
+
<plist version="1.0">
|
|
582
|
+
<dict>
|
|
583
|
+
<key>Label</key>
|
|
584
|
+
<string>${escapeXml(label)}</string>
|
|
585
|
+
<key>ProgramArguments</key>
|
|
586
|
+
<array>
|
|
587
|
+
<string>/bin/bash</string>
|
|
588
|
+
<string>${escapeXml(runScriptPath)}</string>
|
|
589
|
+
</array>
|
|
590
|
+
<key>WorkingDirectory</key>
|
|
591
|
+
<string>${escapeXml(installDir)}</string>
|
|
592
|
+
<key>RunAtLoad</key>
|
|
593
|
+
<true/>
|
|
594
|
+
<key>KeepAlive</key>
|
|
595
|
+
<true/>
|
|
596
|
+
<key>StandardOutPath</key>
|
|
597
|
+
<string>${escapeXml(join3(installDir, "tmex.log"))}</string>
|
|
598
|
+
<key>StandardErrorPath</key>
|
|
599
|
+
<string>${escapeXml(join3(installDir, "tmex.err.log"))}</string>
|
|
600
|
+
</dict>
|
|
601
|
+
</plist>
|
|
602
|
+
`;
|
|
603
|
+
}
|
|
604
|
+
async function installSystemdService(options) {
|
|
605
|
+
const unitPath = systemdUnitPath(options.serviceName);
|
|
606
|
+
await ensureDir(join3(homedir3(), ".config", "systemd", "user"));
|
|
607
|
+
await writeText(unitPath, buildSystemdServiceContent(options));
|
|
608
|
+
const daemonReload = await runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
609
|
+
if (daemonReload.code !== 0) {
|
|
610
|
+
throw new Error(t("service.systemd.daemonReloadFailed", {
|
|
611
|
+
detail: daemonReload.stderr || daemonReload.stdout
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
if (options.autostart) {
|
|
615
|
+
const enable = await runCommand("systemctl", ["--user", "enable", options.serviceName]);
|
|
616
|
+
if (enable.code !== 0) {
|
|
617
|
+
throw new Error(t("service.systemd.enableFailed", {
|
|
618
|
+
detail: enable.stderr || enable.stdout
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const restart = await runCommand("systemctl", ["--user", "restart", options.serviceName]);
|
|
623
|
+
if (restart.code !== 0) {
|
|
624
|
+
throw new Error(t("service.systemd.restartFailed", {
|
|
625
|
+
detail: restart.stderr || restart.stdout
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function installLaunchdService(options) {
|
|
630
|
+
const launchAgentsPath = launchdLaunchAgentsPlistPath(options.serviceName);
|
|
631
|
+
const localPath = launchdLocalPlistPath(options.serviceName, options.installDir);
|
|
632
|
+
const targetPath = options.autostart ? launchAgentsPath : localPath;
|
|
633
|
+
if (options.autostart) {
|
|
634
|
+
await ensureDir(join3(homedir3(), "Library", "LaunchAgents"));
|
|
635
|
+
}
|
|
636
|
+
await writeText(targetPath, buildLaunchdPlist(options));
|
|
637
|
+
await runCommand("launchctl", [
|
|
638
|
+
"bootout",
|
|
639
|
+
`gui/${process.getuid?.() ?? 0}`,
|
|
640
|
+
launchAgentsPath
|
|
641
|
+
]).catch(() => null);
|
|
642
|
+
await runCommand("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}`, localPath]).catch(() => null);
|
|
643
|
+
const uid = String(process.getuid?.() ?? 0);
|
|
644
|
+
const bootstrap = await runCommand("launchctl", ["bootstrap", `gui/${uid}`, targetPath]);
|
|
645
|
+
if (bootstrap.code !== 0) {
|
|
646
|
+
throw new Error(t("service.launchd.bootstrapFailed", {
|
|
647
|
+
detail: bootstrap.stderr || bootstrap.stdout
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function installService(options) {
|
|
652
|
+
const manager = detectServiceManager();
|
|
653
|
+
if (manager === "systemd-user") {
|
|
654
|
+
await installSystemdService(options);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (manager === "launchd") {
|
|
658
|
+
await installLaunchdService(options);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
throw new Error(t("service.install.unsupportedPlatform", { platform: process.platform }));
|
|
662
|
+
}
|
|
663
|
+
async function stopSystemd(serviceName) {
|
|
664
|
+
await runCommand("systemctl", ["--user", "stop", serviceName]).catch(() => null);
|
|
665
|
+
}
|
|
666
|
+
async function stopService(serviceName, installDir) {
|
|
667
|
+
const manager = detectServiceManager();
|
|
668
|
+
if (manager === "systemd-user") {
|
|
669
|
+
await stopSystemd(serviceName);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (manager === "launchd") {
|
|
673
|
+
const uid = String(process.getuid?.() ?? 0);
|
|
674
|
+
const launchAgentsPath = launchdLaunchAgentsPlistPath(serviceName);
|
|
675
|
+
await runCommand("launchctl", ["bootout", `gui/${uid}`, launchAgentsPath]).catch(() => null);
|
|
676
|
+
if (installDir) {
|
|
677
|
+
const localPath = launchdLocalPlistPath(serviceName, installDir);
|
|
678
|
+
await runCommand("launchctl", ["bootout", `gui/${uid}`, localPath]).catch(() => null);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function uninstallSystemdService(serviceName) {
|
|
683
|
+
await runCommand("systemctl", ["--user", "disable", "--now", serviceName]).catch(() => null);
|
|
684
|
+
const unitPath = systemdUnitPath(serviceName);
|
|
685
|
+
if (await pathExists(unitPath)) {
|
|
686
|
+
await rm(unitPath, { force: true });
|
|
687
|
+
}
|
|
688
|
+
await runCommand("systemctl", ["--user", "daemon-reload"]).catch(() => null);
|
|
689
|
+
}
|
|
690
|
+
async function uninstallService(options) {
|
|
691
|
+
const manager = detectServiceManager();
|
|
692
|
+
if (manager === "systemd-user") {
|
|
693
|
+
await uninstallSystemdService(options.serviceName);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (manager === "launchd") {
|
|
697
|
+
const uid = String(process.getuid?.() ?? 0);
|
|
698
|
+
const launchAgentsPath = launchdLaunchAgentsPlistPath(options.serviceName);
|
|
699
|
+
await runCommand("launchctl", ["bootout", `gui/${uid}`, launchAgentsPath]).catch(() => null);
|
|
700
|
+
await rm(launchAgentsPath, { force: true }).catch(() => null);
|
|
701
|
+
if (options.installDir) {
|
|
702
|
+
const localPath = launchdLocalPlistPath(options.serviceName, options.installDir);
|
|
703
|
+
await runCommand("launchctl", ["bootout", `gui/${uid}`, localPath]).catch(() => null);
|
|
704
|
+
await rm(localPath, { force: true }).catch(() => null);
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function querySystemdStatus(serviceName) {
|
|
710
|
+
const unitPath = systemdUnitPath(serviceName);
|
|
711
|
+
const installed = await pathExists(unitPath);
|
|
712
|
+
const active = await runCommand("systemctl", ["--user", "is-active", serviceName]).catch(() => null);
|
|
713
|
+
const enabled = await runCommand("systemctl", ["--user", "is-enabled", serviceName]).catch(() => null);
|
|
714
|
+
return {
|
|
715
|
+
manager: "systemd-user",
|
|
716
|
+
installed,
|
|
717
|
+
running: active?.code === 0,
|
|
718
|
+
autostartEnabled: enabled?.code === 0,
|
|
719
|
+
detail: active?.stdout.trim() || enabled?.stdout.trim() || undefined
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
async function queryLaunchdStatus(serviceName, installDir) {
|
|
723
|
+
const launchAgentsPath = launchdLaunchAgentsPlistPath(serviceName);
|
|
724
|
+
const localPath = installDir ? launchdLocalPlistPath(serviceName, installDir) : null;
|
|
725
|
+
const hasLaunchAgents = await pathExists(launchAgentsPath);
|
|
726
|
+
const hasLocal = localPath ? await pathExists(localPath) : false;
|
|
727
|
+
const installed = hasLaunchAgents || hasLocal;
|
|
728
|
+
const uid = String(process.getuid?.() ?? 0);
|
|
729
|
+
const label = launchdLabel(serviceName);
|
|
730
|
+
const printed = await runCommand("launchctl", ["print", `gui/${uid}/${label}`]).catch(() => null);
|
|
731
|
+
return {
|
|
732
|
+
manager: "launchd",
|
|
733
|
+
installed,
|
|
734
|
+
running: printed?.code === 0,
|
|
735
|
+
autostartEnabled: hasLaunchAgents,
|
|
736
|
+
detail: installed ? printed?.code === 0 ? "loaded" : (printed?.stderr || printed?.stdout || "").trim() : t("service.status.plistMissing")
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
async function getServiceStatus(serviceName, installDir) {
|
|
740
|
+
const manager = detectServiceManager();
|
|
741
|
+
if (manager === "systemd-user") {
|
|
742
|
+
return await querySystemdStatus(serviceName);
|
|
743
|
+
}
|
|
744
|
+
if (manager === "launchd") {
|
|
745
|
+
return await queryLaunchdStatus(serviceName, installDir);
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
manager: "none",
|
|
749
|
+
installed: false,
|
|
750
|
+
running: false,
|
|
751
|
+
autostartEnabled: false,
|
|
752
|
+
detail: t("service.status.none", { platform: process.platform })
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function serviceHint(serviceName) {
|
|
756
|
+
const manager = detectServiceManager();
|
|
757
|
+
if (manager === "systemd-user") {
|
|
758
|
+
return t("service.hint.systemd", { serviceName });
|
|
759
|
+
}
|
|
760
|
+
if (manager === "launchd") {
|
|
761
|
+
return t("service.hint.launchd", { serviceName });
|
|
762
|
+
}
|
|
763
|
+
return t("service.hint.none");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/lib/validate.ts
|
|
767
|
+
function asString(value) {
|
|
768
|
+
if (typeof value === "string")
|
|
769
|
+
return value;
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
function asBoolean(value) {
|
|
773
|
+
if (typeof value === "boolean")
|
|
774
|
+
return value;
|
|
775
|
+
if (typeof value !== "string")
|
|
776
|
+
return;
|
|
777
|
+
const normalized = value.trim().toLowerCase();
|
|
778
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "y") {
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "n") {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
function parsePort(value) {
|
|
787
|
+
const port = Number(value);
|
|
788
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
789
|
+
throw new Error(t("errors.validate.invalidPort", { value }));
|
|
790
|
+
}
|
|
791
|
+
return port;
|
|
792
|
+
}
|
|
793
|
+
function assertNonEmpty(value, fieldName) {
|
|
794
|
+
const trimmed = value.trim();
|
|
795
|
+
if (!trimmed) {
|
|
796
|
+
throw new Error(t("errors.validate.emptyField", { field: fieldName }));
|
|
797
|
+
}
|
|
798
|
+
return trimmed;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/commands/doctor.ts
|
|
802
|
+
async function checkCommandExists(bin, args, id, okMessage, failMessage) {
|
|
803
|
+
const result = await runCommand(bin, args, { stdio: "pipe" }).catch(() => null);
|
|
804
|
+
if (result?.code === 0) {
|
|
805
|
+
return { id, level: "pass", message: okMessage, detail: result.stdout.trim() };
|
|
806
|
+
}
|
|
807
|
+
return { id, level: "fail", message: failMessage, detail: result?.stderr || result?.stdout };
|
|
808
|
+
}
|
|
809
|
+
function printChecks(checks, json) {
|
|
810
|
+
if (json) {
|
|
811
|
+
console.log(JSON.stringify({ checks }, null, 2));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
for (const check of checks) {
|
|
815
|
+
const prefix = check.level === "pass" ? "PASS" : check.level === "warn" ? "WARN" : "FAIL";
|
|
816
|
+
console.log(`[${prefix}] ${check.message}`);
|
|
817
|
+
if (check.detail) {
|
|
818
|
+
console.log(` ${check.detail.trim()}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async function runDoctor(parsed) {
|
|
823
|
+
const json = asBoolean(parsed.flags.json) ?? false;
|
|
824
|
+
const installDirFlag = asString(parsed.flags["install-dir"]);
|
|
825
|
+
const installDir = resolveInstallDir(installDirFlag || defaultInstallDir(process.platform));
|
|
826
|
+
const installLayout = createInstallLayout(installDir);
|
|
827
|
+
const checks = [];
|
|
828
|
+
if (!isSupportedPlatform()) {
|
|
829
|
+
checks.push({
|
|
830
|
+
id: "platform",
|
|
831
|
+
level: "warn",
|
|
832
|
+
message: t("doctor.platform.unsupported", { platform: process.platform })
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
checks.push({
|
|
836
|
+
id: "platform",
|
|
837
|
+
level: "pass",
|
|
838
|
+
message: t("doctor.platform.supported", { platform: process.platform })
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const bun = await checkBunVersion();
|
|
842
|
+
if (bun.ok) {
|
|
843
|
+
checks.push({
|
|
844
|
+
id: "bun",
|
|
845
|
+
level: "pass",
|
|
846
|
+
message: t("doctor.bun.ok", { version: bun.version }),
|
|
847
|
+
detail: bun.path
|
|
848
|
+
});
|
|
849
|
+
} else {
|
|
850
|
+
checks.push({
|
|
851
|
+
id: "bun",
|
|
852
|
+
level: "fail",
|
|
853
|
+
message: t("doctor.bun.fail", { reason: bun.reason || t("bun.checkFailed") }),
|
|
854
|
+
detail: bun.path
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
checks.push(await checkCommandExists("tmux", ["-V"], "tmux", t("doctor.tmux.ok"), t("doctor.tmux.fail")));
|
|
858
|
+
const ssh = await runCommand("ssh", ["-V"], { stdio: "pipe" }).catch(() => null);
|
|
859
|
+
if (ssh?.code === 0) {
|
|
860
|
+
checks.push({
|
|
861
|
+
id: "ssh",
|
|
862
|
+
level: "pass",
|
|
863
|
+
message: t("doctor.ssh.ok"),
|
|
864
|
+
detail: (ssh.stderr || ssh.stdout).trim()
|
|
865
|
+
});
|
|
866
|
+
} else {
|
|
867
|
+
checks.push({ id: "ssh", level: "warn", message: t("doctor.ssh.missing") });
|
|
868
|
+
}
|
|
869
|
+
let healthHost = "127.0.0.1";
|
|
870
|
+
let healthPort = "9883";
|
|
871
|
+
if (await pathExists(installDir)) {
|
|
872
|
+
checks.push({
|
|
873
|
+
id: "install-dir",
|
|
874
|
+
level: "pass",
|
|
875
|
+
message: t("doctor.installDir.exists", { installDir })
|
|
876
|
+
});
|
|
877
|
+
} else {
|
|
878
|
+
checks.push({
|
|
879
|
+
id: "install-dir",
|
|
880
|
+
level: "warn",
|
|
881
|
+
message: t("doctor.installDir.missing", { installDir })
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (await pathExists(installLayout.envPath)) {
|
|
885
|
+
checks.push({
|
|
886
|
+
id: "env",
|
|
887
|
+
level: "pass",
|
|
888
|
+
message: t("doctor.env.exists", { envPath: installLayout.envPath })
|
|
889
|
+
});
|
|
890
|
+
const env = await readEnvFile(installLayout.envPath);
|
|
891
|
+
const required = ["TMEX_MASTER_KEY", "DATABASE_URL", "GATEWAY_PORT", "TMEX_BIND_HOST"];
|
|
892
|
+
for (const key of required) {
|
|
893
|
+
if (!env[key]) {
|
|
894
|
+
checks.push({
|
|
895
|
+
id: `env.${key}`,
|
|
896
|
+
level: "fail",
|
|
897
|
+
message: t("doctor.env.keyMissing", { key })
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const dbPath = env.DATABASE_URL;
|
|
902
|
+
if (dbPath) {
|
|
903
|
+
const resolved = resolve4(dbPath);
|
|
904
|
+
const exists = await pathExists(resolved);
|
|
905
|
+
if (!exists) {
|
|
906
|
+
checks.push({
|
|
907
|
+
id: "db",
|
|
908
|
+
level: "warn",
|
|
909
|
+
message: t("doctor.db.missing", { path: resolved })
|
|
910
|
+
});
|
|
911
|
+
} else {
|
|
912
|
+
const st = await stat(resolved);
|
|
913
|
+
checks.push({
|
|
914
|
+
id: "db",
|
|
915
|
+
level: "pass",
|
|
916
|
+
message: t("doctor.db.exists", { path: resolved }),
|
|
917
|
+
detail: `${st.size} bytes`
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const port = env.GATEWAY_PORT;
|
|
922
|
+
if (port) {
|
|
923
|
+
healthPort = port;
|
|
924
|
+
const portNum = Number(port);
|
|
925
|
+
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
|
926
|
+
checks.push({
|
|
927
|
+
id: "port",
|
|
928
|
+
level: "fail",
|
|
929
|
+
message: t("doctor.port.invalid", { value: port })
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (env.TMEX_BIND_HOST) {
|
|
934
|
+
healthHost = env.TMEX_BIND_HOST;
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
checks.push({
|
|
938
|
+
id: "env",
|
|
939
|
+
level: "warn",
|
|
940
|
+
message: t("doctor.env.missing", { envPath: installLayout.envPath })
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
let serviceName = asString(parsed.flags["service-name"]) || "tmex";
|
|
944
|
+
if (await pathExists(installLayout.metaPath)) {
|
|
945
|
+
const meta = await readJsonFile(installLayout.metaPath).catch(() => null);
|
|
946
|
+
if (meta?.serviceName) {
|
|
947
|
+
serviceName = meta.serviceName;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const status = await getServiceStatus(serviceName, installDir);
|
|
951
|
+
if (status.manager === "none") {
|
|
952
|
+
checks.push({
|
|
953
|
+
id: "service",
|
|
954
|
+
level: "warn",
|
|
955
|
+
message: t("doctor.service.noManager", { detail: status.detail || "" })
|
|
956
|
+
});
|
|
957
|
+
} else if (!status.installed) {
|
|
958
|
+
checks.push({
|
|
959
|
+
id: "service",
|
|
960
|
+
level: "warn",
|
|
961
|
+
message: t("doctor.service.notInstalled", { serviceName }),
|
|
962
|
+
detail: status.detail
|
|
963
|
+
});
|
|
964
|
+
} else if (!status.running) {
|
|
965
|
+
checks.push({
|
|
966
|
+
id: "service",
|
|
967
|
+
level: "warn",
|
|
968
|
+
message: t("doctor.service.notRunning", { serviceName }),
|
|
969
|
+
detail: status.detail
|
|
970
|
+
});
|
|
971
|
+
} else {
|
|
972
|
+
checks.push({
|
|
973
|
+
id: "service",
|
|
974
|
+
level: "pass",
|
|
975
|
+
message: t("doctor.service.running", { serviceName }),
|
|
976
|
+
detail: status.detail
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
const healthUrl = `http://${healthHost}:${healthPort}/healthz`;
|
|
980
|
+
const normalizedHealthUrl = healthHost === "0.0.0.0" ? `http://127.0.0.1:${healthPort}/healthz` : healthUrl;
|
|
981
|
+
const healthResponse = await fetch(normalizedHealthUrl, {
|
|
982
|
+
signal: AbortSignal.timeout(3000)
|
|
983
|
+
}).catch(() => null);
|
|
984
|
+
if (healthResponse?.ok) {
|
|
985
|
+
checks.push({
|
|
986
|
+
id: "healthz",
|
|
987
|
+
level: "pass",
|
|
988
|
+
message: t("doctor.health.pass", { url: normalizedHealthUrl })
|
|
989
|
+
});
|
|
990
|
+
} else {
|
|
991
|
+
checks.push({
|
|
992
|
+
id: "healthz",
|
|
993
|
+
level: "warn",
|
|
994
|
+
message: t("doctor.health.fail", { url: normalizedHealthUrl })
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
printChecks(checks, json);
|
|
998
|
+
const failed = checks.some((check) => check.level === "fail");
|
|
999
|
+
if (failed) {
|
|
1000
|
+
process.exitCode = 1;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/commands/init.ts
|
|
1005
|
+
import { readdir } from "node:fs/promises";
|
|
1006
|
+
import { dirname as dirname4, resolve as resolve6 } from "node:path";
|
|
1007
|
+
|
|
1008
|
+
// src/lib/install.ts
|
|
1009
|
+
import { randomBytes } from "node:crypto";
|
|
1010
|
+
import { chmod, copyFile, rm as rm2 } from "node:fs/promises";
|
|
1011
|
+
import { resolve as resolve5 } from "node:path";
|
|
1012
|
+
function generateMasterKey() {
|
|
1013
|
+
return randomBytes(32).toString("base64");
|
|
1014
|
+
}
|
|
1015
|
+
function buildAppEnvValues(input) {
|
|
1016
|
+
return {
|
|
1017
|
+
NODE_ENV: "production",
|
|
1018
|
+
TMEX_BIND_HOST: input.host,
|
|
1019
|
+
GATEWAY_PORT: String(input.port),
|
|
1020
|
+
DATABASE_URL: input.databasePath,
|
|
1021
|
+
TMEX_MASTER_KEY: input.masterKey,
|
|
1022
|
+
TMEX_BASE_URL: `http://${input.host}:${input.port}`,
|
|
1023
|
+
TMEX_SITE_NAME: "tmex"
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
async function ensureInstallDir(installDir, force) {
|
|
1027
|
+
if (!await pathExists(installDir)) {
|
|
1028
|
+
await ensureDir(installDir);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (!force) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
await rm2(installDir, { recursive: true, force: true });
|
|
1035
|
+
await ensureDir(installDir);
|
|
1036
|
+
}
|
|
1037
|
+
async function deployRuntimeFiles(packageLayout, installLayout) {
|
|
1038
|
+
await rm2(installLayout.runtimeDir, { recursive: true, force: true });
|
|
1039
|
+
await rm2(installLayout.feDir, { recursive: true, force: true });
|
|
1040
|
+
await rm2(installLayout.drizzleDir, { recursive: true, force: true });
|
|
1041
|
+
await ensureDir(installLayout.runtimeDir);
|
|
1042
|
+
await ensureDir(installLayout.resourcesDir);
|
|
1043
|
+
await copyDirectory(packageLayout.runtimeDirPath, installLayout.runtimeDir);
|
|
1044
|
+
await copyDirectory(packageLayout.resourceFePath, installLayout.feDir);
|
|
1045
|
+
await copyDirectory(packageLayout.resourceDrizzlePath, installLayout.drizzleDir);
|
|
1046
|
+
}
|
|
1047
|
+
async function writeRunScript(installLayout, bunPath) {
|
|
1048
|
+
const lines = [
|
|
1049
|
+
"#!/usr/bin/env bash",
|
|
1050
|
+
"set -euo pipefail",
|
|
1051
|
+
"",
|
|
1052
|
+
'SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)"',
|
|
1053
|
+
'while IFS= read -r line || [[ -n "$line" ]]; do',
|
|
1054
|
+
` line="\${line%$'\\r'}"`,
|
|
1055
|
+
' [[ "$line" =~ ^[[:space:]]*$ ]] && continue',
|
|
1056
|
+
' [[ "$line" =~ ^[[:space:]]*# ]] && continue',
|
|
1057
|
+
' export "$line"',
|
|
1058
|
+
`done < "${installLayout.envPath}"`,
|
|
1059
|
+
"",
|
|
1060
|
+
'if [[ -n "${HOME:-}" ]] && [[ -d "${HOME}/.bun/bin" ]]; then',
|
|
1061
|
+
' export PATH="${HOME}/.bun/bin:${PATH:-}"',
|
|
1062
|
+
"fi",
|
|
1063
|
+
"",
|
|
1064
|
+
`export TMEX_FE_DIST_DIR="${installLayout.feDir}"`,
|
|
1065
|
+
`export TMEX_MIGRATIONS_DIR="${installLayout.drizzleDir}"`,
|
|
1066
|
+
"",
|
|
1067
|
+
`exec "${bunPath}" "${installLayout.runtimeServerPath}"`,
|
|
1068
|
+
""
|
|
1069
|
+
];
|
|
1070
|
+
const script = lines.join(`
|
|
1071
|
+
`);
|
|
1072
|
+
await writeText(installLayout.runScriptPath, script, 493);
|
|
1073
|
+
await chmod(installLayout.runScriptPath, 493);
|
|
1074
|
+
}
|
|
1075
|
+
async function writeInstallMeta(installLayout, meta) {
|
|
1076
|
+
await writeJsonFile(installLayout.metaPath, meta, 384);
|
|
1077
|
+
}
|
|
1078
|
+
async function backupInstallArtifacts(installLayout, backupDir) {
|
|
1079
|
+
await ensureDir(backupDir);
|
|
1080
|
+
if (await pathExists(installLayout.runtimeDir)) {
|
|
1081
|
+
await copyDirectory(installLayout.runtimeDir, resolve5(backupDir, "runtime"));
|
|
1082
|
+
}
|
|
1083
|
+
if (await pathExists(installLayout.resourcesDir)) {
|
|
1084
|
+
await copyDirectory(installLayout.resourcesDir, resolve5(backupDir, "resources"));
|
|
1085
|
+
}
|
|
1086
|
+
if (await pathExists(installLayout.runScriptPath)) {
|
|
1087
|
+
await copyFile(installLayout.runScriptPath, resolve5(backupDir, "run.sh"));
|
|
1088
|
+
}
|
|
1089
|
+
if (await pathExists(installLayout.metaPath)) {
|
|
1090
|
+
await copyFile(installLayout.metaPath, resolve5(backupDir, "install-meta.json"));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function restoreInstallArtifacts(installLayout, backupDir) {
|
|
1094
|
+
const runtimeBackup = resolve5(backupDir, "runtime");
|
|
1095
|
+
const resourcesBackup = resolve5(backupDir, "resources");
|
|
1096
|
+
const runScriptBackup = resolve5(backupDir, "run.sh");
|
|
1097
|
+
const metaBackup = resolve5(backupDir, "install-meta.json");
|
|
1098
|
+
await rm2(installLayout.runtimeDir, { recursive: true, force: true });
|
|
1099
|
+
await rm2(installLayout.resourcesDir, { recursive: true, force: true });
|
|
1100
|
+
if (await pathExists(runtimeBackup)) {
|
|
1101
|
+
await copyDirectory(runtimeBackup, installLayout.runtimeDir);
|
|
1102
|
+
}
|
|
1103
|
+
if (await pathExists(resourcesBackup)) {
|
|
1104
|
+
await copyDirectory(resourcesBackup, installLayout.resourcesDir);
|
|
1105
|
+
}
|
|
1106
|
+
if (await pathExists(runScriptBackup)) {
|
|
1107
|
+
await copyFile(runScriptBackup, installLayout.runScriptPath);
|
|
1108
|
+
}
|
|
1109
|
+
if (await pathExists(metaBackup)) {
|
|
1110
|
+
await copyFile(metaBackup, installLayout.metaPath);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/lib/prompt.ts
|
|
1115
|
+
import { stdin, stdout } from "node:process";
|
|
1116
|
+
import { createInterface } from "node:readline/promises";
|
|
1117
|
+
async function promptText(ctx, message, defaultValue) {
|
|
1118
|
+
if (ctx.nonInteractive) {
|
|
1119
|
+
return defaultValue ?? "";
|
|
1120
|
+
}
|
|
1121
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1122
|
+
try {
|
|
1123
|
+
const suffix = defaultValue !== undefined ? ` (${defaultValue})` : "";
|
|
1124
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
1125
|
+
return answer || defaultValue || "";
|
|
1126
|
+
} finally {
|
|
1127
|
+
rl.close();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function promptConfirm(ctx, message, defaultValue) {
|
|
1131
|
+
if (ctx.nonInteractive) {
|
|
1132
|
+
return defaultValue;
|
|
1133
|
+
}
|
|
1134
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1135
|
+
try {
|
|
1136
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
1137
|
+
const answer = (await rl.question(`${message} [${hint}]: `)).trim().toLowerCase();
|
|
1138
|
+
if (!answer) {
|
|
1139
|
+
return defaultValue;
|
|
1140
|
+
}
|
|
1141
|
+
if (answer === "y" || answer === "yes")
|
|
1142
|
+
return true;
|
|
1143
|
+
if (answer === "n" || answer === "no")
|
|
1144
|
+
return false;
|
|
1145
|
+
return defaultValue;
|
|
1146
|
+
} finally {
|
|
1147
|
+
rl.close();
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/lib/version.ts
|
|
1152
|
+
import { join as join4 } from "node:path";
|
|
1153
|
+
async function readPackageVersion(packageRoot) {
|
|
1154
|
+
const pkg = await readJsonFile(join4(packageRoot, "package.json"));
|
|
1155
|
+
return pkg.version || "0.0.0";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/commands/init.ts
|
|
1159
|
+
function mustGetStringFlag(flags, key) {
|
|
1160
|
+
const value = asString(flags[key]);
|
|
1161
|
+
if (!value) {
|
|
1162
|
+
throw new Error(t("errors.args.missingFlag", { flag: key }));
|
|
1163
|
+
}
|
|
1164
|
+
return value;
|
|
1165
|
+
}
|
|
1166
|
+
function mustGetBooleanFlag(flags, key) {
|
|
1167
|
+
const value = asBoolean(flags[key]);
|
|
1168
|
+
if (value === undefined) {
|
|
1169
|
+
throw new Error(t("errors.args.invalidFlag", { flag: key, value: String(flags[key]) }));
|
|
1170
|
+
}
|
|
1171
|
+
return value;
|
|
1172
|
+
}
|
|
1173
|
+
async function directoryHasContent(path) {
|
|
1174
|
+
if (!await pathExists(path)) {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
const items = await readdir(path);
|
|
1178
|
+
return items.length > 0;
|
|
1179
|
+
}
|
|
1180
|
+
async function buildInitConfig(parsed) {
|
|
1181
|
+
const nonInteractive = parsed.flags["no-interactive"] === true;
|
|
1182
|
+
const force = asBoolean(parsed.flags.force) ?? false;
|
|
1183
|
+
if (nonInteractive) {
|
|
1184
|
+
const installDir2 = resolveInstallDir(assertNonEmpty(mustGetStringFlag(parsed.flags, "install-dir"), "install-dir"));
|
|
1185
|
+
const host2 = assertNonEmpty(mustGetStringFlag(parsed.flags, "host"), "host");
|
|
1186
|
+
const port2 = parsePort(assertNonEmpty(mustGetStringFlag(parsed.flags, "port"), "port"));
|
|
1187
|
+
const databasePath2 = resolve6(assertNonEmpty(mustGetStringFlag(parsed.flags, "db-path"), "db-path"));
|
|
1188
|
+
const autostart2 = mustGetBooleanFlag(parsed.flags, "autostart");
|
|
1189
|
+
const serviceName2 = assertNonEmpty(asString(parsed.flags["service-name"]) || DEFAULT_SERVICE_NAME, "service-name");
|
|
1190
|
+
return {
|
|
1191
|
+
installDir: installDir2,
|
|
1192
|
+
host: host2,
|
|
1193
|
+
port: port2,
|
|
1194
|
+
databasePath: databasePath2,
|
|
1195
|
+
autostart: autostart2,
|
|
1196
|
+
serviceName: serviceName2,
|
|
1197
|
+
force,
|
|
1198
|
+
nonInteractive
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const fallbackInstallDir = defaultInstallDir(process.platform);
|
|
1202
|
+
const installDirPrompt = await promptText({ nonInteractive: false }, t("init.prompt.installDir"), asString(parsed.flags["install-dir"]) || fallbackInstallDir);
|
|
1203
|
+
const installDir = resolveInstallDir(assertNonEmpty(installDirPrompt, "install-dir"));
|
|
1204
|
+
const hostPrompt = await promptText({ nonInteractive: false }, t("init.prompt.host"), asString(parsed.flags.host) || defaultHost());
|
|
1205
|
+
const host = assertNonEmpty(hostPrompt, "host");
|
|
1206
|
+
const portPrompt = await promptText({ nonInteractive: false }, t("init.prompt.port"), asString(parsed.flags.port) || String(defaultPort()));
|
|
1207
|
+
const port = parsePort(assertNonEmpty(portPrompt, "port"));
|
|
1208
|
+
const databasePathPrompt = await promptText({ nonInteractive: false }, t("init.prompt.dbPath"), asString(parsed.flags["db-path"]) || defaultDatabasePath(installDir));
|
|
1209
|
+
const databasePath = resolve6(assertNonEmpty(databasePathPrompt, "db-path"));
|
|
1210
|
+
const autostart = asBoolean(parsed.flags.autostart) ?? await promptConfirm({ nonInteractive: false }, t("init.prompt.autostart"), true);
|
|
1211
|
+
const serviceNamePrompt = await promptText({ nonInteractive: false }, t("init.prompt.serviceName"), asString(parsed.flags["service-name"]) || DEFAULT_SERVICE_NAME);
|
|
1212
|
+
const serviceName = assertNonEmpty(serviceNamePrompt, "service-name");
|
|
1213
|
+
return {
|
|
1214
|
+
installDir,
|
|
1215
|
+
host,
|
|
1216
|
+
port,
|
|
1217
|
+
databasePath,
|
|
1218
|
+
autostart,
|
|
1219
|
+
serviceName,
|
|
1220
|
+
force,
|
|
1221
|
+
nonInteractive
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
async function runInit(parsed) {
|
|
1225
|
+
const config = await buildInitConfig(parsed);
|
|
1226
|
+
const bun = await checkBunVersion();
|
|
1227
|
+
if (!bun.ok || !bun.path) {
|
|
1228
|
+
throw new Error(bun.reason || t("bun.checkFailed"));
|
|
1229
|
+
}
|
|
1230
|
+
if (!config.force && await directoryHasContent(config.installDir)) {
|
|
1231
|
+
if (config.nonInteractive) {
|
|
1232
|
+
throw new Error(t("init.error.installDirNotEmpty", { installDir: config.installDir }));
|
|
1233
|
+
}
|
|
1234
|
+
const confirmed = await promptConfirm({ nonInteractive: false }, t("init.prompt.dirExistsConfirm", { installDir: config.installDir }), false);
|
|
1235
|
+
if (!confirmed) {
|
|
1236
|
+
throw new Error(t("common.cancelled"));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const packageLayout = await resolvePackageLayout(import.meta.url);
|
|
1240
|
+
const installLayout = createInstallLayout(config.installDir);
|
|
1241
|
+
await ensureInstallDir(config.installDir, config.force);
|
|
1242
|
+
await ensureDir(dirname4(config.databasePath));
|
|
1243
|
+
await deployRuntimeFiles(packageLayout, installLayout);
|
|
1244
|
+
const masterKey = generateMasterKey();
|
|
1245
|
+
const envValues = buildAppEnvValues({
|
|
1246
|
+
host: config.host,
|
|
1247
|
+
port: config.port,
|
|
1248
|
+
databasePath: config.databasePath,
|
|
1249
|
+
masterKey
|
|
1250
|
+
});
|
|
1251
|
+
await writeEnvFile(installLayout.envPath, envValues);
|
|
1252
|
+
await writeRunScript(installLayout, bun.path);
|
|
1253
|
+
const manager = detectServiceManager();
|
|
1254
|
+
if (manager === "none") {
|
|
1255
|
+
console.warn(`[tmex] ${t("init.warning.noServiceManager", { platform: process.platform })}`);
|
|
1256
|
+
} else {
|
|
1257
|
+
await installService({
|
|
1258
|
+
serviceName: config.serviceName,
|
|
1259
|
+
installDir: config.installDir,
|
|
1260
|
+
runScriptPath: installLayout.runScriptPath,
|
|
1261
|
+
autostart: config.autostart
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
const cliVersion = await readPackageVersion(packageLayout.packageRoot);
|
|
1265
|
+
const meta = {
|
|
1266
|
+
serviceName: config.serviceName,
|
|
1267
|
+
platform: process.platform,
|
|
1268
|
+
autostart: config.autostart,
|
|
1269
|
+
installDir: config.installDir,
|
|
1270
|
+
updatedAt: new Date().toISOString(),
|
|
1271
|
+
cliVersion
|
|
1272
|
+
};
|
|
1273
|
+
await writeInstallMeta(installLayout, meta);
|
|
1274
|
+
console.log(`[tmex] ${t("init.done")}`);
|
|
1275
|
+
console.log(`- ${t("init.summary.installDir")}: ${config.installDir}`);
|
|
1276
|
+
console.log(`- ${t("init.summary.serviceName")}: ${config.serviceName}`);
|
|
1277
|
+
console.log(`- ${t("init.summary.bun")}: ${bun.version} (${bun.path})`);
|
|
1278
|
+
console.log(`- ${t("init.summary.autostart")}: ${config.autostart ? t("init.summary.autostart.on") : t("init.summary.autostart.off")}`);
|
|
1279
|
+
if (manager !== "none") {
|
|
1280
|
+
console.log(`- ${t("init.summary.serviceHint")}: ${serviceHint(config.serviceName)}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// src/commands/uninstall.ts
|
|
1285
|
+
import { rm as rm3 } from "node:fs/promises";
|
|
1286
|
+
async function removeIfExists(path) {
|
|
1287
|
+
if (await pathExists(path)) {
|
|
1288
|
+
await rm3(path, { recursive: true, force: true });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
async function runUninstall(parsed) {
|
|
1292
|
+
const installDir = resolveInstallDir(asString(parsed.flags["install-dir"]) || defaultInstallDir(process.platform));
|
|
1293
|
+
const installLayout = createInstallLayout(installDir);
|
|
1294
|
+
const yes = asBoolean(parsed.flags.yes) ?? false;
|
|
1295
|
+
const purge = asBoolean(parsed.flags.purge) ?? false;
|
|
1296
|
+
let serviceName = asString(parsed.flags["service-name"]) || "tmex";
|
|
1297
|
+
if (await pathExists(installLayout.metaPath)) {
|
|
1298
|
+
const meta = await readJsonFile(installLayout.metaPath);
|
|
1299
|
+
serviceName = meta.serviceName;
|
|
1300
|
+
}
|
|
1301
|
+
const ask = async (message, defaultValue) => {
|
|
1302
|
+
if (yes)
|
|
1303
|
+
return defaultValue;
|
|
1304
|
+
return await promptConfirm({ nonInteractive: false }, message, defaultValue);
|
|
1305
|
+
};
|
|
1306
|
+
const removeService = await ask(t("uninstall.prompt.removeService"), true);
|
|
1307
|
+
const removeProgram = await ask(t("uninstall.prompt.removeProgram"), true);
|
|
1308
|
+
const removeEnv = await ask(t("uninstall.prompt.removeEnv"), purge);
|
|
1309
|
+
const removeDatabase = await ask(t("uninstall.prompt.removeDatabase"), purge);
|
|
1310
|
+
let databasePath;
|
|
1311
|
+
if (await pathExists(installLayout.envPath)) {
|
|
1312
|
+
const env = await readEnvFile(installLayout.envPath).catch(() => ({}));
|
|
1313
|
+
databasePath = env.DATABASE_URL;
|
|
1314
|
+
}
|
|
1315
|
+
if (removeService) {
|
|
1316
|
+
await uninstallService({ serviceName, installDir });
|
|
1317
|
+
}
|
|
1318
|
+
if (removeProgram) {
|
|
1319
|
+
await removeIfExists(installLayout.runtimeDir);
|
|
1320
|
+
await removeIfExists(installLayout.resourcesDir);
|
|
1321
|
+
await removeIfExists(installLayout.runScriptPath);
|
|
1322
|
+
await removeIfExists(installLayout.metaPath);
|
|
1323
|
+
}
|
|
1324
|
+
if (removeEnv) {
|
|
1325
|
+
await removeIfExists(installLayout.envPath);
|
|
1326
|
+
}
|
|
1327
|
+
if (removeDatabase) {
|
|
1328
|
+
if (databasePath) {
|
|
1329
|
+
await removeIfExists(resolvePath(databasePath));
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (purge) {
|
|
1333
|
+
await removeIfExists(installLayout.installDir);
|
|
1334
|
+
}
|
|
1335
|
+
console.log(`[tmex] ${t("uninstall.done")}`);
|
|
1336
|
+
console.log(`- ${t("uninstall.summary.installDir")}: ${installLayout.installDir}`);
|
|
1337
|
+
console.log(`- ${t("uninstall.summary.serviceName")}: ${serviceName}`);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// src/commands/upgrade.ts
|
|
1341
|
+
import { mkdtemp, rm as rm4 } from "node:fs/promises";
|
|
1342
|
+
import { tmpdir } from "node:os";
|
|
1343
|
+
import { join as join5 } from "node:path";
|
|
1344
|
+
async function delegateUpgrade(parsed, targetVersion) {
|
|
1345
|
+
const args = ["--yes", `tmex-cli@${targetVersion}`, "upgrade", "--apply-current-package"];
|
|
1346
|
+
const passthrough = ["install-dir", "service-name", "yes", "lang"];
|
|
1347
|
+
for (const key of passthrough) {
|
|
1348
|
+
const value = parsed.flags[key];
|
|
1349
|
+
if (value === undefined)
|
|
1350
|
+
continue;
|
|
1351
|
+
if (value === true) {
|
|
1352
|
+
args.push(`--${key}`);
|
|
1353
|
+
} else {
|
|
1354
|
+
args.push(`--${key}`, String(value));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const result = await runCommand("npx", args, { stdio: "inherit" });
|
|
1358
|
+
if (result.code !== 0) {
|
|
1359
|
+
throw new Error(t("upgrade.delegateFailed", { code: result.code }));
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
async function verifyHealth(installLayout) {
|
|
1363
|
+
if (!await pathExists(installLayout.envPath)) {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const env = await readEnvFile(installLayout.envPath).catch(() => ({}));
|
|
1367
|
+
const port = String(env.GATEWAY_PORT || "9883");
|
|
1368
|
+
const hostFromEnv = String(env.TMEX_BIND_HOST || "127.0.0.1");
|
|
1369
|
+
const host = hostFromEnv === "0.0.0.0" ? "127.0.0.1" : hostFromEnv;
|
|
1370
|
+
const url = `http://${host}:${port}/healthz`;
|
|
1371
|
+
const startedAt = Date.now();
|
|
1372
|
+
let lastError = null;
|
|
1373
|
+
while (Date.now() - startedAt < 30000) {
|
|
1374
|
+
try {
|
|
1375
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
|
1376
|
+
if (response.ok) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
lastError = new Error(t("upgrade.healthFailed", { status: response.status }));
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1382
|
+
}
|
|
1383
|
+
await new Promise((resolve7) => setTimeout(resolve7, 1000));
|
|
1384
|
+
}
|
|
1385
|
+
throw lastError || new Error(t("upgrade.healthFailed", { status: "timeout" }));
|
|
1386
|
+
}
|
|
1387
|
+
async function runUpgrade(parsed) {
|
|
1388
|
+
const applyCurrent = asBoolean(parsed.flags["apply-current-package"]) ?? false;
|
|
1389
|
+
const targetVersion = asString(parsed.flags.version) || "latest";
|
|
1390
|
+
if (!applyCurrent) {
|
|
1391
|
+
await delegateUpgrade(parsed, targetVersion);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const installDir = resolveInstallDir(asString(parsed.flags["install-dir"]) || defaultInstallDir(process.platform));
|
|
1395
|
+
const installLayout = createInstallLayout(installDir);
|
|
1396
|
+
if (!await pathExists(installLayout.metaPath)) {
|
|
1397
|
+
throw new Error(t("upgrade.missingMeta", { path: installLayout.metaPath }));
|
|
1398
|
+
}
|
|
1399
|
+
const bun = await checkBunVersion();
|
|
1400
|
+
if (!bun.ok || !bun.path) {
|
|
1401
|
+
throw new Error(bun.reason || t("bun.checkFailed"));
|
|
1402
|
+
}
|
|
1403
|
+
const meta = await readJsonFile(installLayout.metaPath);
|
|
1404
|
+
const packageLayout = await resolvePackageLayout(import.meta.url);
|
|
1405
|
+
const backupDir = await mkdtemp(join5(tmpdir(), "tmex-upgrade-"));
|
|
1406
|
+
try {
|
|
1407
|
+
await stopService(meta.serviceName, installDir);
|
|
1408
|
+
await backupInstallArtifacts(installLayout, backupDir);
|
|
1409
|
+
await deployRuntimeFiles(packageLayout, installLayout);
|
|
1410
|
+
await writeRunScript(installLayout, bun.path);
|
|
1411
|
+
const cliVersion = await readPackageVersion(packageLayout.packageRoot);
|
|
1412
|
+
meta.updatedAt = new Date().toISOString();
|
|
1413
|
+
meta.cliVersion = cliVersion;
|
|
1414
|
+
await writeInstallMeta(installLayout, meta);
|
|
1415
|
+
await installService({
|
|
1416
|
+
serviceName: meta.serviceName,
|
|
1417
|
+
runScriptPath: installLayout.runScriptPath,
|
|
1418
|
+
installDir,
|
|
1419
|
+
autostart: meta.autostart
|
|
1420
|
+
});
|
|
1421
|
+
await verifyHealth(installLayout);
|
|
1422
|
+
console.log(`[tmex] ${t("upgrade.done")}`);
|
|
1423
|
+
console.log(`- ${t("upgrade.summary.targetVersion")}: ${targetVersion}`);
|
|
1424
|
+
console.log(`- ${t("upgrade.summary.installDir")}: ${installDir}`);
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
console.error(`[tmex] ${t("upgrade.failedRollingBack")}`);
|
|
1427
|
+
await restoreInstallArtifacts(installLayout, backupDir);
|
|
1428
|
+
await installService({
|
|
1429
|
+
serviceName: meta.serviceName,
|
|
1430
|
+
runScriptPath: installLayout.runScriptPath,
|
|
1431
|
+
installDir,
|
|
1432
|
+
autostart: meta.autostart
|
|
1433
|
+
}).catch(() => null);
|
|
1434
|
+
throw error;
|
|
1435
|
+
} finally {
|
|
1436
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => null);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// src/lib/args.ts
|
|
1441
|
+
function parseArgs(argv) {
|
|
1442
|
+
let command = null;
|
|
1443
|
+
const flags = {};
|
|
1444
|
+
const positionals = [];
|
|
1445
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
1446
|
+
const token = argv[index];
|
|
1447
|
+
if (!token.startsWith("--")) {
|
|
1448
|
+
if (command === null) {
|
|
1449
|
+
command = token;
|
|
1450
|
+
} else {
|
|
1451
|
+
positionals.push(token);
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const noPrefix = token.slice(2);
|
|
1456
|
+
const equalIndex = noPrefix.indexOf("=");
|
|
1457
|
+
if (equalIndex >= 0) {
|
|
1458
|
+
const key = noPrefix.slice(0, equalIndex);
|
|
1459
|
+
const value = noPrefix.slice(equalIndex + 1);
|
|
1460
|
+
flags[key] = value;
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
const maybeNext = argv[index + 1];
|
|
1464
|
+
if (maybeNext && !maybeNext.startsWith("--")) {
|
|
1465
|
+
flags[noPrefix] = maybeNext;
|
|
1466
|
+
index += 1;
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
flags[noPrefix] = true;
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
command,
|
|
1473
|
+
flags,
|
|
1474
|
+
positionals
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/cli-node.ts
|
|
1479
|
+
function printHelp() {
|
|
1480
|
+
console.log(t("cli.help"));
|
|
1481
|
+
}
|
|
1482
|
+
async function main() {
|
|
1483
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
1484
|
+
const requestedLang = (typeof parsed.flags.lang === "string" ? parsed.flags.lang : undefined) || process.env.TMEX_CLI_LANG;
|
|
1485
|
+
setLang(normalizeLang(requestedLang));
|
|
1486
|
+
switch (parsed.command) {
|
|
1487
|
+
case "init":
|
|
1488
|
+
await runInit(parsed);
|
|
1489
|
+
return;
|
|
1490
|
+
case "doctor":
|
|
1491
|
+
await runDoctor(parsed);
|
|
1492
|
+
return;
|
|
1493
|
+
case "upgrade":
|
|
1494
|
+
await runUpgrade(parsed);
|
|
1495
|
+
return;
|
|
1496
|
+
case "uninstall":
|
|
1497
|
+
await runUninstall(parsed);
|
|
1498
|
+
return;
|
|
1499
|
+
case "--help":
|
|
1500
|
+
case "-h":
|
|
1501
|
+
case "help":
|
|
1502
|
+
case undefined:
|
|
1503
|
+
case null:
|
|
1504
|
+
printHelp();
|
|
1505
|
+
return;
|
|
1506
|
+
default:
|
|
1507
|
+
throw new Error(t("cli.error.unknownCommand", { command: parsed.command }));
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
export {
|
|
1511
|
+
main
|
|
1512
|
+
};
|