yaport 0.1.0 → 0.1.2

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.
@@ -1,12 +1,14 @@
1
+ import { homedir, tmpdir } from "node:os";
1
2
  import express from "express";
2
- import { spawn } from "node:child_process";
3
+ import { execFile, spawn } from "node:child_process";
3
4
  import { statSync } from "node:fs";
4
5
  import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
- import { homedir, tmpdir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { ZodError, z } from "zod";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { isIP } from "node:net";
11
+ import { promisify } from "node:util";
10
12
  //#region src/shared/sshTimeouts.ts
11
13
  var CONNECT_TIMEOUT_SECONDS_OPTIONS = [
12
14
  8,
@@ -35,7 +37,7 @@ var connectionEndpointSchema = z.object({
35
37
  var connectTimeoutSchema = timeoutChoiceSchema(CONNECT_TIMEOUT_SECONDS_OPTIONS, "连接超时");
36
38
  var commandTimeoutSchema = timeoutChoiceSchema(COMMAND_TIMEOUT_SECONDS_OPTIONS, "命令超时");
37
39
  var machineInputSchema = z.object({
38
- name: z.string().trim().min(1).max(80),
40
+ name: z.preprocess((value) => typeof value === "string" && value.trim() === "" ? void 0 : value, z.string().trim().max(80).optional()),
39
41
  host: connectionEndpointSchema.shape.host,
40
42
  password: connectionEndpointSchema.shape.password,
41
43
  port: connectionEndpointSchema.shape.port,
@@ -43,10 +45,20 @@ var machineInputSchema = z.object({
43
45
  connectTimeoutSeconds: connectTimeoutSchema.optional(),
44
46
  commandTimeoutSeconds: commandTimeoutSchema.optional(),
45
47
  jumpHosts: z.array(connectionEndpointSchema).max(2).optional()
48
+ }).superRefine((input, context) => {
49
+ validateEndpointUser(input, context, ["user"]);
50
+ input.jumpHosts?.forEach((jumpHost, index) => {
51
+ validateEndpointUser(jumpHost, context, [
52
+ "jumpHosts",
53
+ index,
54
+ "user"
55
+ ]);
56
+ });
46
57
  }).transform((input) => {
47
58
  const connectTimeoutSeconds = input.connectTimeoutSeconds ?? 15;
48
59
  return {
49
60
  ...input,
61
+ name: input.name ?? defaultMachineName(input),
50
62
  connectTimeoutSeconds,
51
63
  commandTimeoutSeconds: input.commandTimeoutSeconds ?? defaultCommandTimeoutSeconds(connectTimeoutSeconds)
52
64
  };
@@ -61,6 +73,18 @@ var emptyMachineFile = { machines: [] };
61
73
  function timeoutChoiceSchema(options, label) {
62
74
  return z.coerce.number().int().refine((value) => options.includes(value), { message: `${label}必须是 ${options.join(" / ")} 秒之一。` });
63
75
  }
76
+ function validateEndpointUser(endpoint, context, path) {
77
+ if (!endpoint.user && isIP(endpoint.host)) context.addIssue({
78
+ code: "custom",
79
+ path,
80
+ message: "IP 地址连接必须填写用户;OpenSSH alias 可以留空。"
81
+ });
82
+ }
83
+ function defaultMachineName(endpoint) {
84
+ const userPrefix = endpoint.user ? `${endpoint.user}@` : "";
85
+ const portSuffix = endpoint.port ? `:${endpoint.port}` : "";
86
+ return `${userPrefix}${endpoint.host}${portSuffix}`;
87
+ }
64
88
  var JsonMachineStore = class {
65
89
  filePath;
66
90
  constructor(filePath) {
@@ -82,6 +106,29 @@ var JsonMachineStore = class {
82
106
  await this.write({ machines: [...file.machines, machine] });
83
107
  return machine;
84
108
  }
109
+ async delete(id) {
110
+ const file = await this.read();
111
+ const machines = file.machines.filter((machine) => machine.id !== id);
112
+ if (machines.length === file.machines.length) return false;
113
+ await this.write({ machines });
114
+ return true;
115
+ }
116
+ async update(id, input) {
117
+ const file = await this.read();
118
+ const machineIndex = file.machines.findIndex((machine) => machine.id === id);
119
+ if (machineIndex === -1) return;
120
+ const existingMachine = file.machines[machineIndex];
121
+ const machine = {
122
+ ...existingMachine,
123
+ ...preserveEndpointSecrets(input, existingMachine),
124
+ id: existingMachine.id,
125
+ createdAt: existingMachine.createdAt
126
+ };
127
+ const machines = [...file.machines];
128
+ machines[machineIndex] = machine;
129
+ await this.write({ machines });
130
+ return machine;
131
+ }
85
132
  async read() {
86
133
  try {
87
134
  const raw = await readFile(this.filePath, "utf8");
@@ -104,22 +151,126 @@ var JsonMachineStore = class {
104
151
  await chmod(this.filePath, 384);
105
152
  }
106
153
  };
154
+ function preserveEndpointSecrets(input, existingMachine) {
155
+ const nextMachine = { ...input };
156
+ if (!nextMachine.password && existingMachine.password && sameEndpoint(nextMachine, existingMachine)) nextMachine.password = existingMachine.password;
157
+ if (nextMachine.jumpHosts) nextMachine.jumpHosts = nextMachine.jumpHosts.map((jumpHost, index) => {
158
+ const existingJumpHost = existingMachine.jumpHosts?.[index];
159
+ if (!jumpHost.password && existingJumpHost?.password && sameEndpoint(jumpHost, existingJumpHost)) return {
160
+ ...jumpHost,
161
+ password: existingJumpHost.password
162
+ };
163
+ return jumpHost;
164
+ });
165
+ return nextMachine;
166
+ }
167
+ function sameEndpoint(first, second) {
168
+ return first.host === second.host && first.port === second.port && first.user === second.user;
169
+ }
170
+ //#endregion
171
+ //#region src/server/caddyExternalRoutes.ts
172
+ function parseCaddyExternalRoutes(caddyfile) {
173
+ return extractSiteAddresses(stripComments$1(caddyfile)).flatMap((address) => ({
174
+ port: address.port,
175
+ serverName: address.serverName,
176
+ source: "caddy",
177
+ url: caddyRouteUrl(address)
178
+ }));
179
+ }
180
+ function extractSiteAddresses(caddyfile) {
181
+ const addresses = [];
182
+ for (const header of extractTopLevelBlockHeaders(caddyfile)) for (const rawAddress of header.split(",")) {
183
+ const address = parseCaddyAddress(rawAddress.trim());
184
+ if (address) addresses.push(address);
185
+ }
186
+ return addresses;
187
+ }
188
+ function extractTopLevelBlockHeaders(caddyfile) {
189
+ const headers = [];
190
+ let depth = 0;
191
+ let lineStart = 0;
192
+ for (let index = 0; index < caddyfile.length; index += 1) {
193
+ const character = caddyfile[index];
194
+ if (character === "{") {
195
+ if (depth === 0) {
196
+ const header = caddyfile.slice(lineStart, index).trim();
197
+ if (header) headers.push(header);
198
+ }
199
+ depth += 1;
200
+ continue;
201
+ }
202
+ if (character === "}") {
203
+ depth = Math.max(0, depth - 1);
204
+ continue;
205
+ }
206
+ if (character === "\n" && depth === 0) lineStart = index + 1;
207
+ }
208
+ return headers;
209
+ }
210
+ function parseCaddyAddress(rawAddress) {
211
+ const candidate = rawAddress.trim();
212
+ if (candidate.startsWith("http://") || candidate.startsWith("https://")) return parseUrlAddress(candidate);
213
+ const address = candidate.replace(/\/.*$/, "").trim();
214
+ if (!address || address === "_" || address.includes("$") || address.startsWith("*.") || address.startsWith(":")) return;
215
+ const portMatch = address.match(/^(.+):(\d+)$/);
216
+ const serverName = portMatch ? portMatch[1] : address;
217
+ const port = portMatch ? Number(portMatch[2]) : 443;
218
+ if (!isDisplayableServerName$1(serverName) || !isValidPort(port)) return;
219
+ return {
220
+ port,
221
+ scheme: port === 443 ? "https" : "http",
222
+ serverName
223
+ };
224
+ }
225
+ function parseUrlAddress(address) {
226
+ try {
227
+ const url = new URL(address);
228
+ const scheme = url.protocol === "https:" ? "https" : "http";
229
+ const serverName = url.hostname;
230
+ const port = url.port ? Number(url.port) : scheme === "https" ? 443 : 80;
231
+ if (!isDisplayableServerName$1(serverName) || !isValidPort(port)) return;
232
+ return {
233
+ port,
234
+ scheme,
235
+ serverName
236
+ };
237
+ } catch {
238
+ return;
239
+ }
240
+ }
241
+ function stripComments$1(config) {
242
+ return config.split("\n").map((line) => line.replace(/(^|[^\\])#.*/, "$1")).join("\n");
243
+ }
244
+ function isDisplayableServerName$1(serverName) {
245
+ return Boolean(serverName) && !serverName.includes("*") && /^[A-Za-z0-9.-]+$/.test(serverName);
246
+ }
247
+ function isValidPort(port) {
248
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
249
+ }
250
+ function caddyRouteUrl(address) {
251
+ if (address.scheme === "https" && address.port === 443) return `https://${address.serverName}`;
252
+ return `${address.scheme}://${address.serverName}:${address.port}`;
253
+ }
107
254
  //#endregion
108
255
  //#region src/server/nginxExternalRoutes.ts
109
- function parseNginxExternalRoutes(config) {
256
+ function parseNginxExternalRoutes(config, source = "nginx") {
110
257
  return extractServerBlocks(stripComments(config)).flatMap((block) => {
111
258
  const validNames = block.serverNames.filter(isDisplayableServerName);
112
259
  return block.listens.flatMap((listen) => validNames.map((serverName) => ({
113
260
  port: listen.port,
114
261
  serverName,
115
- source: "nginx",
262
+ source,
116
263
  url: routeUrl(serverName, listen)
117
264
  })));
118
265
  });
119
266
  }
120
267
  function attachExternalRoutes(ports, routes) {
121
268
  const routesByPort = /* @__PURE__ */ new Map();
122
- for (const route of routes) routesByPort.set(route.port, [...routesByPort.get(route.port) ?? [], route]);
269
+ for (const route of routes) {
270
+ const currentRoutes = routesByPort.get(route.port) ?? [];
271
+ if (currentRoutes.some((currentRoute) => sameExternalRoute(currentRoute, route))) continue;
272
+ routesByPort.set(route.port, [...currentRoutes, route]);
273
+ }
123
274
  return ports.map((port) => {
124
275
  const externalRoutes = routesByPort.get(port.port);
125
276
  if (!externalRoutes?.length) return port;
@@ -129,6 +280,9 @@ function attachExternalRoutes(ports, routes) {
129
280
  };
130
281
  });
131
282
  }
283
+ function sameExternalRoute(left, right) {
284
+ return left.port === right.port && left.serverName === right.serverName && left.source === right.source && left.url === right.url;
285
+ }
132
286
  function stripComments(config) {
133
287
  return config.split("\n").map((line) => line.replace(/(^|[^\\])#.*/, "$1")).join("\n");
134
288
  }
@@ -246,6 +400,33 @@ function parseProcess(value) {
246
400
  };
247
401
  }
248
402
  //#endregion
403
+ //#region src/server/sshConnection.ts
404
+ var SSH_CONTROL_SOCKET_DIRECTORY = "/tmp/yaport-ssh";
405
+ async function ensureSshControlSocketDirectory() {
406
+ await mkdir(SSH_CONTROL_SOCKET_DIRECTORY, {
407
+ recursive: true,
408
+ mode: 448
409
+ });
410
+ }
411
+ function buildSshControlPath(machine) {
412
+ return join(SSH_CONTROL_SOCKET_DIRECTORY, machineConnectionHash(machine).slice(0, 40));
413
+ }
414
+ function machineConnectionHash(machine) {
415
+ return createHash("sha256").update(JSON.stringify(machineConnectionIdentity(machine))).digest("hex");
416
+ }
417
+ function machineConnectionIdentity(machine) {
418
+ return {
419
+ host: machine.host,
420
+ port: machine.port,
421
+ user: machine.user,
422
+ jumpHosts: machine.jumpHosts?.map((jumpHost) => ({
423
+ host: jumpHost.host,
424
+ port: jumpHost.port,
425
+ user: jumpHost.user
426
+ }))
427
+ };
428
+ }
429
+ //#endregion
249
430
  //#region src/server/sshPortInventory.ts
250
431
  var SshCommandError = class extends Error {
251
432
  stderr;
@@ -255,9 +436,8 @@ var SshCommandError = class extends Error {
255
436
  this.name = "SshCommandError";
256
437
  }
257
438
  };
258
- var CONTROL_SOCKET_DIRECTORY = "/tmp/yaport-ssh";
259
- var NGINX_EXTERNAL_ROUTE_CACHE_MS = 300 * 1e3;
260
- var nginxExternalRouteCache = /* @__PURE__ */ new Map();
439
+ var PROXY_EXTERNAL_ROUTE_CACHE_MS = 300 * 1e3;
440
+ var proxyExternalRouteCache = /* @__PURE__ */ new Map();
261
441
  async function collectPortInventory(machine, runner = runSsh, options = {}) {
262
442
  const sshOptions = buildSshOptions(machine);
263
443
  const sshArgsOptions = await buildSshArgsOptions(machine, options);
@@ -267,7 +447,7 @@ async function collectPortInventory(machine, runner = runSsh, options = {}) {
267
447
  "-lntup"
268
448
  ], sshArgsOptions), sshOptions);
269
449
  const ports = parseSsListeningPorts(stdout);
270
- const [portsWithCommandsResult, externalRoutesResult] = await Promise.allSettled([attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions), readCachedNginxExternalRoutes(machine, runner, sshOptions, sshArgsOptions)]);
450
+ const [portsWithCommandsResult, externalRoutesResult] = await Promise.allSettled([attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions), readCachedProxyExternalRoutes(machine, ports, runner, sshOptions, sshArgsOptions)]);
271
451
  return attachExternalRoutes(portsWithCommandsResult.status === "fulfilled" ? portsWithCommandsResult.value : ports, externalRoutesResult.status === "fulfilled" ? externalRoutesResult.value : []);
272
452
  }
273
453
  async function attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions) {
@@ -294,24 +474,33 @@ async function attachProcessCommands(machine, ports, runner, sshOptions, sshArgs
294
474
  return ports;
295
475
  }
296
476
  }
297
- async function readCachedNginxExternalRoutes(machine, runner, sshOptions, sshArgsOptions) {
477
+ async function readCachedProxyExternalRoutes(machine, ports, runner, sshOptions, sshArgsOptions) {
478
+ const proxySources = proxySourcesForPorts(ports);
479
+ if (proxySources.length === 0) return [];
298
480
  const cacheKey = machineConnectionCacheKey(machine);
299
- const cached = nginxExternalRouteCache.get(cacheKey);
481
+ const cached = proxyExternalRouteCache.get(cacheKey);
300
482
  if (cached && cached.expiresAt > Date.now()) return cached.routes;
301
- try {
302
- const routes = parseNginxExternalRoutes((await runner(buildSshArgs(machine, ["nginx", "-T"], sshArgsOptions), sshOptions)).stdout);
303
- nginxExternalRouteCache.set(cacheKey, {
304
- expiresAt: Date.now() + NGINX_EXTERNAL_ROUTE_CACHE_MS,
305
- routes
306
- });
307
- return routes;
308
- } catch {
309
- nginxExternalRouteCache.set(cacheKey, {
310
- expiresAt: Date.now() + NGINX_EXTERNAL_ROUTE_CACHE_MS,
311
- routes: []
312
- });
313
- return [];
483
+ const routes = (await Promise.allSettled(proxySources.map((source) => readProxyExternalRoutes(machine, source, runner, sshOptions, sshArgsOptions)))).flatMap((result) => result.status === "fulfilled" ? result.value : []);
484
+ proxyExternalRouteCache.set(cacheKey, {
485
+ expiresAt: Date.now() + PROXY_EXTERNAL_ROUTE_CACHE_MS,
486
+ routes
487
+ });
488
+ return routes;
489
+ }
490
+ function proxySourcesForPorts(ports) {
491
+ const sources = /* @__PURE__ */ new Set();
492
+ for (const port of ports) {
493
+ const processName = port.processName?.toLowerCase() ?? "";
494
+ if (processName.includes("nginx")) sources.add("nginx");
495
+ if (processName.includes("openresty")) sources.add("openresty");
496
+ if (processName.includes("tengine")) sources.add("tengine");
497
+ if (processName.includes("caddy")) sources.add("caddy");
314
498
  }
499
+ return [...sources];
500
+ }
501
+ async function readProxyExternalRoutes(machine, source, runner, sshOptions, sshArgsOptions) {
502
+ if (source === "caddy") return parseCaddyExternalRoutes((await runner(buildSshArgs(machine, ["cat", "/etc/caddy/Caddyfile"], sshArgsOptions), sshOptions)).stdout);
503
+ return parseNginxExternalRoutes((await runner(buildSshArgs(machine, [source, "-T"], sshArgsOptions), sshOptions)).stdout, source);
315
504
  }
316
505
  function buildSshOptions(machine) {
317
506
  const entries = buildAskPassEntries(machine);
@@ -332,11 +521,8 @@ function parsePsCommands(output) {
332
521
  }
333
522
  async function buildSshArgsOptions(machine, options) {
334
523
  if (!options.reuseConnection) return {};
335
- await mkdir(CONTROL_SOCKET_DIRECTORY, {
336
- recursive: true,
337
- mode: 448
338
- });
339
- return { controlPath: join(CONTROL_SOCKET_DIRECTORY, machineConnectionHash(machine).slice(0, 40)) };
524
+ await ensureSshControlSocketDirectory();
525
+ return { controlPath: buildSshControlPath(machine) };
340
526
  }
341
527
  function buildSshArgs(machine, command, options = {}) {
342
528
  const hasPasswords = buildAskPassEntries(machine).length > 0;
@@ -344,32 +530,17 @@ function buildSshArgs(machine, command, options = {}) {
344
530
  if (hasPasswords) args.push("-o", "PasswordAuthentication=yes", "-o", "KbdInteractiveAuthentication=yes", "-o", "NumberOfPasswordPrompts=1");
345
531
  args.push("-o", `ConnectTimeout=${machine.connectTimeoutSeconds ?? 15}`);
346
532
  if (options.controlPath) args.push("-o", "ControlMaster=auto", "-o", "ControlPersist=5m", "-o", `ControlPath=${options.controlPath}`);
347
- if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost).join(","));
533
+ if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost$1).join(","));
348
534
  if (machine.port) args.push("-p", String(machine.port));
349
535
  return [
350
536
  ...args,
351
- formatSshTarget(machine),
537
+ formatSshTarget$1(machine),
352
538
  ...command
353
539
  ];
354
540
  }
355
541
  function machineConnectionCacheKey(machine) {
356
542
  return `${machine.id}:${machineConnectionHash(machine)}`;
357
543
  }
358
- function machineConnectionHash(machine) {
359
- return createHash("sha256").update(JSON.stringify(machineConnectionIdentity(machine))).digest("hex");
360
- }
361
- function machineConnectionIdentity(machine) {
362
- return {
363
- host: machine.host,
364
- port: machine.port,
365
- user: machine.user,
366
- jumpHosts: machine.jumpHosts?.map((jumpHost) => ({
367
- host: jumpHost.host,
368
- port: jumpHost.port,
369
- user: jumpHost.user
370
- }))
371
- };
372
- }
373
544
  function buildAskPassEntries(machine) {
374
545
  return [...machine.jumpHosts ?? [], machine].flatMap((endpoint) => endpoint.password ? [{
375
546
  password: endpoint.password,
@@ -379,11 +550,11 @@ function buildAskPassEntries(machine) {
379
550
  function buildPromptKeys(endpoint) {
380
551
  return [...endpoint.user ? [`${endpoint.user}@${endpoint.host}`] : [], endpoint.host];
381
552
  }
382
- function formatJumpHost(jumpHost) {
383
- const target = formatSshTarget(jumpHost);
553
+ function formatJumpHost$1(jumpHost) {
554
+ const target = formatSshTarget$1(jumpHost);
384
555
  return jumpHost.port ? `${target}:${jumpHost.port}` : target;
385
556
  }
386
- function formatSshTarget(target) {
557
+ function formatSshTarget$1(target) {
387
558
  return target.user ? `${target.user}@${target.host}` : target.host;
388
559
  }
389
560
  async function runSsh(args, options) {
@@ -486,6 +657,61 @@ process.exit(1);
486
657
  `;
487
658
  }
488
659
  //#endregion
660
+ //#region src/server/sshTerminal.ts
661
+ var UnsupportedTerminalError = class extends Error {
662
+ constructor(platform) {
663
+ super(`当前系统暂不支持自动打开 Terminal:${platform}`);
664
+ this.name = "UnsupportedTerminalError";
665
+ }
666
+ };
667
+ function buildInteractiveSshArgs(machine, options = {}) {
668
+ const args = [];
669
+ if (options.controlPath) args.push("-o", "ControlMaster=auto", "-o", "ControlPersist=5m", "-o", `ControlPath=${options.controlPath}`);
670
+ if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost).join(","));
671
+ if (machine.port) args.push("-p", String(machine.port));
672
+ args.push(formatSshTarget(machine));
673
+ return args;
674
+ }
675
+ async function openSshTerminal(machine, options = {}) {
676
+ const platform = options.platform ?? process.platform;
677
+ if (platform !== "darwin") throw new UnsupportedTerminalError(platform);
678
+ const spawnCommand = options.spawnCommand ?? spawn;
679
+ await ensureSshControlSocketDirectory();
680
+ await runCommand(spawnCommand, "osascript", [
681
+ "-e",
682
+ "tell application \"Terminal\" to activate",
683
+ "-e",
684
+ `tell application "Terminal" to do script ${appleScriptString(["ssh", ...buildInteractiveSshArgs(machine, { controlPath: buildSshControlPath(machine) })].map(shellQuote).join(" "))}`
685
+ ]);
686
+ }
687
+ function runCommand(spawnCommand, command, args) {
688
+ return new Promise((resolve, reject) => {
689
+ const child = spawnCommand(command, args, { stdio: "ignore" });
690
+ child.on("error", reject);
691
+ child.on("close", (code) => {
692
+ if (code === 0) {
693
+ resolve();
694
+ return;
695
+ }
696
+ reject(/* @__PURE__ */ new Error(`打开 Terminal 失败,退出码 ${code ?? "unknown"}。`));
697
+ });
698
+ });
699
+ }
700
+ function formatJumpHost(jumpHost) {
701
+ const target = formatSshTarget(jumpHost);
702
+ return jumpHost.port ? `${target}:${jumpHost.port}` : target;
703
+ }
704
+ function formatSshTarget(target) {
705
+ return target.user ? `${target.user}@${target.host}` : target.host;
706
+ }
707
+ function shellQuote(value) {
708
+ if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value;
709
+ return `'${value.replace(/'/g, "'\\''")}'`;
710
+ }
711
+ function appleScriptString(value) {
712
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
713
+ }
714
+ //#endregion
489
715
  //#region src/server/app.ts
490
716
  function createApp(dependencies) {
491
717
  const app = express();
@@ -502,6 +728,36 @@ function createApp(dependencies) {
502
728
  const machine = await dependencies.machineStore.add(input);
503
729
  response.status(201).json({ machine: stripMachineSecrets$1(machine) });
504
730
  }));
731
+ app.put("/api/machines/:id", asyncHandler(async (request, response) => {
732
+ const input = machineInputSchema.parse(request.body);
733
+ const machine = await dependencies.machineStore.update(String(request.params.id), input);
734
+ if (!machine) {
735
+ response.status(404).json({ error: "Remote machine not found." });
736
+ return;
737
+ }
738
+ response.json({ machine: stripMachineSecrets$1(machine) });
739
+ }));
740
+ app.delete("/api/machines/:id", asyncHandler(async (request, response) => {
741
+ if (!await dependencies.machineStore.delete(String(request.params.id))) {
742
+ response.status(404).json({ error: "Remote machine not found." });
743
+ return;
744
+ }
745
+ response.status(204).send();
746
+ }));
747
+ app.post("/api/machines/:id/terminal", asyncHandler(async (request, response) => {
748
+ const machineId = String(request.params.id);
749
+ const machine = await dependencies.machineStore.get(machineId);
750
+ if (!machine) {
751
+ response.status(404).json({ error: "Remote machine not found." });
752
+ return;
753
+ }
754
+ if (!dependencies.openSshTerminal) {
755
+ response.status(501).json({ error: "Terminal opening is not configured." });
756
+ return;
757
+ }
758
+ await dependencies.openSshTerminal(machine);
759
+ response.json({ ok: true });
760
+ }));
505
761
  app.get("/api/machines/:id/ports", asyncHandler(async (request, response) => {
506
762
  const machineId = String(request.params.id);
507
763
  const machine = await dependencies.machineStore.get(machineId);
@@ -535,6 +791,13 @@ function createApp(dependencies) {
535
791
  });
536
792
  return;
537
793
  }
794
+ if (error instanceof UnsupportedTerminalError) {
795
+ response.status(501).json({
796
+ error: "Unsupported terminal.",
797
+ message: error.message
798
+ });
799
+ return;
800
+ }
538
801
  response.status(500).json({ error: "Unexpected server error." });
539
802
  });
540
803
  return app;
@@ -631,7 +894,8 @@ function openBrowser(url) {
631
894
  async function createYaportHttpApp(mode, dataFile) {
632
895
  const app = createApp({
633
896
  machineStore: new JsonMachineStore(dataFile),
634
- collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options)
897
+ collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options),
898
+ openSshTerminal
635
899
  });
636
900
  if (mode === "production") {
637
901
  const clientDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
@@ -670,16 +934,235 @@ function formatUrlHost(host) {
670
934
  return host;
671
935
  }
672
936
  //#endregion
937
+ //#region src/server/serviceManager.ts
938
+ var execFileAsync = promisify(execFile);
939
+ var SERVICE_LABEL = "com.boenfu.yaport";
940
+ var SYSTEMD_SERVICE_NAME = "yaport.service";
941
+ var UnsupportedServicePlatformError = class extends Error {
942
+ constructor(platform) {
943
+ super(`System service is only supported on macOS and Linux. Current platform: ${platform}.`);
944
+ }
945
+ };
946
+ async function installYaportService(options) {
947
+ const definition = buildYaportServiceDefinition(options);
948
+ const runCommand = options.runCommand ?? defaultRunCommand;
949
+ await mkdir(dirname(definition.filePath), { recursive: true });
950
+ await mkdir(join(options.home, ".yaport"), { recursive: true });
951
+ await writeFile(definition.filePath, definition.contents, "utf8");
952
+ for (const command of definition.installCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
953
+ return definition;
954
+ }
955
+ async function uninstallYaportService(options) {
956
+ const definition = buildYaportServiceDefinition(options);
957
+ const runCommand = options.runCommand ?? defaultRunCommand;
958
+ for (const command of definition.uninstallCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
959
+ await rm(definition.filePath, { force: true });
960
+ return definition;
961
+ }
962
+ async function printYaportServiceStatus(options) {
963
+ const runCommand = options.runCommand ?? defaultRunCommand;
964
+ if (options.platform === "darwin") {
965
+ printCommandResult(await runCommand("launchctl", ["print", launchdServiceTarget(options.uid)], { allowFailure: true }));
966
+ return;
967
+ }
968
+ if (options.platform === "linux") {
969
+ printCommandResult(await runCommand("systemctl", [
970
+ "--user",
971
+ "status",
972
+ SYSTEMD_SERVICE_NAME
973
+ ], { allowFailure: true }));
974
+ return;
975
+ }
976
+ throw new UnsupportedServicePlatformError(String(options.platform));
977
+ }
978
+ function buildYaportServiceDefinition(options) {
979
+ if (options.platform === "darwin") return buildLaunchAgentDefinition(options);
980
+ if (options.platform === "linux") return buildSystemdDefinition(options);
981
+ throw new UnsupportedServicePlatformError(String(options.platform));
982
+ }
983
+ function buildLaunchAgentDefinition(options) {
984
+ const filePath = join(options.home, "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
985
+ const programArguments = serviceProgramArguments(options);
986
+ const logDirectory = join(options.home, ".yaport");
987
+ const target = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
988
+ return {
989
+ filePath,
990
+ contents: `<?xml version="1.0" encoding="UTF-8"?>
991
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
992
+ <plist version="1.0">
993
+ <dict>
994
+ <key>Label</key>
995
+ <string>${SERVICE_LABEL}</string>
996
+ <key>ProgramArguments</key>
997
+ <array>
998
+ ${programArguments.map((argument) => ` <string>${escapeXml(argument)}</string>`).join("\n")}
999
+ </array>
1000
+ <key>WorkingDirectory</key>
1001
+ <string>${escapeXml(options.home)}</string>
1002
+ <key>RunAtLoad</key>
1003
+ <true/>
1004
+ <key>KeepAlive</key>
1005
+ <true/>
1006
+ <key>StandardOutPath</key>
1007
+ <string>${escapeXml(join(logDirectory, "service.log"))}</string>
1008
+ <key>StandardErrorPath</key>
1009
+ <string>${escapeXml(join(logDirectory, "service.error.log"))}</string>
1010
+ </dict>
1011
+ </plist>
1012
+ `,
1013
+ installCommands: [
1014
+ {
1015
+ command: "launchctl",
1016
+ args: [
1017
+ "bootout",
1018
+ target,
1019
+ filePath
1020
+ ],
1021
+ allowFailure: true
1022
+ },
1023
+ {
1024
+ command: "launchctl",
1025
+ args: [
1026
+ "bootstrap",
1027
+ target,
1028
+ filePath
1029
+ ]
1030
+ },
1031
+ {
1032
+ command: "launchctl",
1033
+ args: ["enable", `${target}/${SERVICE_LABEL}`]
1034
+ },
1035
+ {
1036
+ command: "launchctl",
1037
+ args: [
1038
+ "kickstart",
1039
+ "-k",
1040
+ `${target}/${SERVICE_LABEL}`
1041
+ ]
1042
+ }
1043
+ ],
1044
+ uninstallCommands: [{
1045
+ command: "launchctl",
1046
+ args: [
1047
+ "bootout",
1048
+ target,
1049
+ filePath
1050
+ ],
1051
+ allowFailure: true
1052
+ }, {
1053
+ command: "launchctl",
1054
+ args: ["disable", `${target}/${SERVICE_LABEL}`],
1055
+ allowFailure: true
1056
+ }]
1057
+ };
1058
+ }
1059
+ function buildSystemdDefinition(options) {
1060
+ const filePath = join(options.home, ".config/systemd/user", SYSTEMD_SERVICE_NAME);
1061
+ const execStart = serviceProgramArguments(options).map(systemdQuote).join(" ");
1062
+ return {
1063
+ filePath,
1064
+ contents: `[Unit]
1065
+ Description=Yaport local web UI
1066
+ After=network-online.target
1067
+
1068
+ [Service]
1069
+ Type=simple
1070
+ WorkingDirectory=${systemdQuote(options.home)}
1071
+ ExecStart=${execStart}
1072
+ Restart=on-failure
1073
+ Environment=NODE_ENV=production
1074
+
1075
+ [Install]
1076
+ WantedBy=default.target
1077
+ `,
1078
+ installCommands: [{
1079
+ command: "systemctl",
1080
+ args: ["--user", "daemon-reload"]
1081
+ }, {
1082
+ command: "systemctl",
1083
+ args: [
1084
+ "--user",
1085
+ "enable",
1086
+ "--now",
1087
+ SYSTEMD_SERVICE_NAME
1088
+ ]
1089
+ }],
1090
+ uninstallCommands: [{
1091
+ command: "systemctl",
1092
+ args: [
1093
+ "--user",
1094
+ "disable",
1095
+ "--now",
1096
+ SYSTEMD_SERVICE_NAME
1097
+ ],
1098
+ allowFailure: true
1099
+ }, {
1100
+ command: "systemctl",
1101
+ args: ["--user", "daemon-reload"]
1102
+ }]
1103
+ };
1104
+ }
1105
+ function serviceProgramArguments(options) {
1106
+ return [
1107
+ options.nodePath,
1108
+ options.cliPath,
1109
+ "serve",
1110
+ "--host",
1111
+ options.host,
1112
+ "--port",
1113
+ String(options.port),
1114
+ "--data-file",
1115
+ options.dataFile,
1116
+ "--no-open"
1117
+ ];
1118
+ }
1119
+ function launchdServiceTarget(uid) {
1120
+ return `gui/${uid ?? process.getuid?.() ?? 0}/${SERVICE_LABEL}`;
1121
+ }
1122
+ async function defaultRunCommand(command, args, options = {}) {
1123
+ try {
1124
+ return await execFileAsync(command, args, { encoding: "utf8" });
1125
+ } catch (error) {
1126
+ if (options.allowFailure) return {
1127
+ stderr: errorWithOutput(error).stderr,
1128
+ stdout: errorWithOutput(error).stdout
1129
+ };
1130
+ throw error;
1131
+ }
1132
+ }
1133
+ function printCommandResult(result) {
1134
+ if (result?.stdout) console.log(result.stdout.trimEnd());
1135
+ if (result?.stderr) console.error(result.stderr.trimEnd());
1136
+ }
1137
+ function errorWithOutput(error) {
1138
+ if (error && typeof error === "object") {
1139
+ const output = error;
1140
+ return {
1141
+ stderr: typeof output.stderr === "string" ? output.stderr : "",
1142
+ stdout: typeof output.stdout === "string" ? output.stdout : ""
1143
+ };
1144
+ }
1145
+ return {};
1146
+ }
1147
+ function escapeXml(value) {
1148
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1149
+ }
1150
+ function systemdQuote(value) {
1151
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
1152
+ }
1153
+ //#endregion
673
1154
  //#region src/server/cli.ts
674
1155
  var usage = `Usage: yaport [serve options]
675
1156
  Usage: yaport serve [options]
676
- Usage: yaport add-machine --name <name> --host <host> [connection options]
1157
+ Usage: yaport add-machine --host <host> [connection options]
1158
+ Usage: yaport service <install|uninstall|status> [options]
677
1159
 
678
1160
  Start the 丫口 local web UI.
679
1161
 
680
1162
  Commands:
681
1163
  serve Start the local web UI. This is the default command.
682
1164
  add-machine Add a remote machine to the local 丫口 data file.
1165
+ service Install, uninstall, or inspect the user-level autostart service.
683
1166
 
684
1167
  Serve options:
685
1168
  --host <host> Host to bind. Default: ${DEFAULT_HOST}
@@ -689,11 +1172,12 @@ Serve options:
689
1172
  -h, --help Print this help message
690
1173
 
691
1174
  add-machine required options:
692
- --name <name> Display name in the UI.
693
1175
  --host <host> SSH host, IP, or OpenSSH alias. For "ssh erya", use --host erya.
694
1176
 
695
1177
  add-machine connection options:
1178
+ --name <name> Display name in the UI. Defaults to user@host:port when omitted.
696
1179
  --user <user> SSH user. Omit for OpenSSH aliases that already define User.
1180
+ Required when --host is an IP address.
697
1181
  --port <port> SSH port. Omit for OpenSSH aliases that already define Port.
698
1182
  --password <value> SSH password for the target machine. Prefer --password-env.
699
1183
  --password-env <env> Read the target machine SSH password from an environment variable.
@@ -718,20 +1202,33 @@ add-machine output options:
718
1202
  --json Print deterministic JSON. Passwords are never printed.
719
1203
  --data-file <path> Remote machine config file. Default lookup: ./.yaport, then ~/.yaport.
720
1204
 
1205
+ service options:
1206
+ install Create and start a user-level service. macOS uses LaunchAgent; Linux uses systemd --user.
1207
+ uninstall Stop and remove the user-level service.
1208
+ status Print service status using launchctl or systemctl --user.
1209
+ --host <host> Host used by the installed service. Default: ${DEFAULT_HOST}
1210
+ --port <port> Port used by the installed service. Default: ${DEFAULT_PORT}
1211
+ --data-file <path> Machine config file used by the installed service. Default lookup: ./.yaport, then ~/.yaport.
1212
+ --json Print deterministic JSON for install/uninstall.
1213
+
721
1214
  Examples:
722
- yaport add-machine --name Erya --host erya --json
1215
+ yaport add-machine --host erya --json
723
1216
  TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host 10.0.0.5 --user root --password-env TARGET_PASSWORD --json
724
1217
  JUMP_PASSWORD='[redacted-jump-password]' TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host prod.internal --user app --password-env TARGET_PASSWORD --connect-timeout 30 --command-timeout 90 --jump1-host jump.internal --jump1-user ops --jump1-password-env JUMP_PASSWORD --json
1218
+ yaport service install --port 5173 --json
1219
+ yaport service status
1220
+ yaport service uninstall
725
1221
 
726
1222
  Agent notes:
727
1223
  - Prefer --json for machine-readable output.
728
1224
  - Prefer --password-env and --jumpN-password-env to avoid putting passwords in process arguments.
729
- - Omit --user and --port when --host is an OpenSSH alias such as "erya".
1225
+ - Omit --user and --port when --host is an OpenSSH alias such as "erya"; IP hosts must include --user.
730
1226
  - The command is non-interactive. Missing required flags fail fast with a non-zero exit code.
731
1227
  `;
732
1228
  function parseCliOptions(argv, env = process.env) {
733
1229
  if (argv[0] === "add-machine") return parseAddMachineOptions(argv.slice(1), env);
734
1230
  if (argv[0] === "serve") return parseServeOptions(argv.slice(1), env);
1231
+ if (argv[0] === "service") return parseServiceOptions(argv.slice(1), env);
735
1232
  return parseServeOptions(argv, env);
736
1233
  }
737
1234
  function parseServeOptions(argv, env) {
@@ -854,7 +1351,7 @@ function parseAddMachineOptions(argv, env) {
854
1351
  }
855
1352
  if (options.help) return options;
856
1353
  const input = machineInputSchema.parse({
857
- name: requiredValue(machine.name, "--name"),
1354
+ ...machine.name !== void 0 ? { name: machine.name } : {},
858
1355
  host: requiredValue(machine.host, "--host"),
859
1356
  ...machine.connectTimeoutSeconds ? { connectTimeoutSeconds: machine.connectTimeoutSeconds } : {},
860
1357
  ...machine.commandTimeoutSeconds ? { commandTimeoutSeconds: machine.commandTimeoutSeconds } : {},
@@ -866,6 +1363,50 @@ function parseAddMachineOptions(argv, env) {
866
1363
  input
867
1364
  };
868
1365
  }
1366
+ function parseServiceOptions(argv, env) {
1367
+ const action = argv[0];
1368
+ const options = {
1369
+ command: "service",
1370
+ action: action === "uninstall" || action === "status" ? action : "install",
1371
+ dataFile: env.YAPORT_DATA_FILE ?? defaultDataFile(),
1372
+ help: false,
1373
+ host: env.HOST ?? "127.0.0.1",
1374
+ json: false,
1375
+ port: Number(env.PORT ?? 5173)
1376
+ };
1377
+ const optionStartIndex = action === "install" || action === "uninstall" || action === "status" ? 1 : 0;
1378
+ if (!action || action === "-h" || action === "--help") options.help = true;
1379
+ else if (action !== "install" && action !== "uninstall" && action !== "status") throw new Error("service action must be install, uninstall, or status.");
1380
+ for (let index = optionStartIndex; index < argv.length; index += 1) {
1381
+ const arg = argv[index];
1382
+ if (arg === "-h" || arg === "--help") {
1383
+ options.help = true;
1384
+ continue;
1385
+ }
1386
+ if (arg === "--json") {
1387
+ options.json = true;
1388
+ continue;
1389
+ }
1390
+ if (arg === "--host") {
1391
+ options.host = readValue(argv, index, arg);
1392
+ index += 1;
1393
+ continue;
1394
+ }
1395
+ if (arg === "--port") {
1396
+ options.port = Number(readValue(argv, index, arg));
1397
+ index += 1;
1398
+ continue;
1399
+ }
1400
+ if (arg === "--data-file") {
1401
+ options.dataFile = readValue(argv, index, arg);
1402
+ index += 1;
1403
+ continue;
1404
+ }
1405
+ throw new Error(`Unknown option: ${arg}`);
1406
+ }
1407
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
1408
+ return options;
1409
+ }
869
1410
  function readValue(argv, optionIndex, optionName) {
870
1411
  const value = argv[optionIndex + 1];
871
1412
  if (!value || value.startsWith("-")) throw new Error(`${optionName} requires a value.`);
@@ -929,6 +1470,10 @@ async function main() {
929
1470
  await addMachine(options);
930
1471
  return;
931
1472
  }
1473
+ if (options.command === "service") {
1474
+ await runServiceCommand(options);
1475
+ return;
1476
+ }
932
1477
  await startYaportServer({
933
1478
  host: options.host,
934
1479
  port: options.port,
@@ -937,6 +1482,40 @@ async function main() {
937
1482
  open: options.open
938
1483
  });
939
1484
  }
1485
+ async function runServiceCommand(options) {
1486
+ const serviceOptions = {
1487
+ cliPath: process.argv[1],
1488
+ dataFile: options.dataFile,
1489
+ home: homedir(),
1490
+ host: options.host,
1491
+ nodePath: process.execPath,
1492
+ platform: process.platform,
1493
+ port: options.port,
1494
+ uid: process.getuid?.()
1495
+ };
1496
+ if (options.action === "install") {
1497
+ printServiceResult(options, "installed", (await installYaportService(serviceOptions)).filePath);
1498
+ return;
1499
+ }
1500
+ if (options.action === "uninstall") {
1501
+ printServiceResult(options, "uninstalled", (await uninstallYaportService(serviceOptions)).filePath);
1502
+ return;
1503
+ }
1504
+ await printYaportServiceStatus(serviceOptions);
1505
+ }
1506
+ function printServiceResult(options, status, filePath) {
1507
+ if (options.json) {
1508
+ console.log(JSON.stringify({
1509
+ action: options.action,
1510
+ filePath,
1511
+ service: "yaport",
1512
+ status
1513
+ }, null, 2));
1514
+ return;
1515
+ }
1516
+ console.log(`Yaport service ${status}.`);
1517
+ console.log(`Service file: ${filePath}`);
1518
+ }
940
1519
  async function addMachine(options) {
941
1520
  if (!options.input) throw new Error("Missing add-machine input.");
942
1521
  const publicMachine = stripMachineSecrets(await new JsonMachineStore(options.dataFile).add(options.input));