yaport 0.1.0 → 0.1.1

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,6 +151,22 @@ 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
+ }
107
170
  //#endregion
108
171
  //#region src/server/nginxExternalRoutes.ts
109
172
  function parseNginxExternalRoutes(config) {
@@ -344,11 +407,11 @@ function buildSshArgs(machine, command, options = {}) {
344
407
  if (hasPasswords) args.push("-o", "PasswordAuthentication=yes", "-o", "KbdInteractiveAuthentication=yes", "-o", "NumberOfPasswordPrompts=1");
345
408
  args.push("-o", `ConnectTimeout=${machine.connectTimeoutSeconds ?? 15}`);
346
409
  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(","));
410
+ if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost$1).join(","));
348
411
  if (machine.port) args.push("-p", String(machine.port));
349
412
  return [
350
413
  ...args,
351
- formatSshTarget(machine),
414
+ formatSshTarget$1(machine),
352
415
  ...command
353
416
  ];
354
417
  }
@@ -379,11 +442,11 @@ function buildAskPassEntries(machine) {
379
442
  function buildPromptKeys(endpoint) {
380
443
  return [...endpoint.user ? [`${endpoint.user}@${endpoint.host}`] : [], endpoint.host];
381
444
  }
382
- function formatJumpHost(jumpHost) {
383
- const target = formatSshTarget(jumpHost);
445
+ function formatJumpHost$1(jumpHost) {
446
+ const target = formatSshTarget$1(jumpHost);
384
447
  return jumpHost.port ? `${target}:${jumpHost.port}` : target;
385
448
  }
386
- function formatSshTarget(target) {
449
+ function formatSshTarget$1(target) {
387
450
  return target.user ? `${target.user}@${target.host}` : target.host;
388
451
  }
389
452
  async function runSsh(args, options) {
@@ -486,6 +549,58 @@ process.exit(1);
486
549
  `;
487
550
  }
488
551
  //#endregion
552
+ //#region src/server/sshTerminal.ts
553
+ var UnsupportedTerminalError = class extends Error {
554
+ constructor(platform) {
555
+ super(`当前系统暂不支持自动打开 Terminal:${platform}`);
556
+ this.name = "UnsupportedTerminalError";
557
+ }
558
+ };
559
+ function buildInteractiveSshArgs(machine) {
560
+ const args = [];
561
+ if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost).join(","));
562
+ if (machine.port) args.push("-p", String(machine.port));
563
+ args.push(formatSshTarget(machine));
564
+ return args;
565
+ }
566
+ async function openSshTerminal(machine, options = {}) {
567
+ const platform = options.platform ?? process.platform;
568
+ if (platform !== "darwin") throw new UnsupportedTerminalError(platform);
569
+ await runCommand(options.spawnCommand ?? spawn, "osascript", [
570
+ "-e",
571
+ "tell application \"Terminal\" to activate",
572
+ "-e",
573
+ `tell application "Terminal" to do script ${appleScriptString(["ssh", ...buildInteractiveSshArgs(machine)].map(shellQuote).join(" "))}`
574
+ ]);
575
+ }
576
+ function runCommand(spawnCommand, command, args) {
577
+ return new Promise((resolve, reject) => {
578
+ const child = spawnCommand(command, args, { stdio: "ignore" });
579
+ child.on("error", reject);
580
+ child.on("close", (code) => {
581
+ if (code === 0) {
582
+ resolve();
583
+ return;
584
+ }
585
+ reject(/* @__PURE__ */ new Error(`打开 Terminal 失败,退出码 ${code ?? "unknown"}。`));
586
+ });
587
+ });
588
+ }
589
+ function formatJumpHost(jumpHost) {
590
+ const target = formatSshTarget(jumpHost);
591
+ return jumpHost.port ? `${target}:${jumpHost.port}` : target;
592
+ }
593
+ function formatSshTarget(target) {
594
+ return target.user ? `${target.user}@${target.host}` : target.host;
595
+ }
596
+ function shellQuote(value) {
597
+ if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value;
598
+ return `'${value.replace(/'/g, "'\\''")}'`;
599
+ }
600
+ function appleScriptString(value) {
601
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
602
+ }
603
+ //#endregion
489
604
  //#region src/server/app.ts
490
605
  function createApp(dependencies) {
491
606
  const app = express();
@@ -502,6 +617,36 @@ function createApp(dependencies) {
502
617
  const machine = await dependencies.machineStore.add(input);
503
618
  response.status(201).json({ machine: stripMachineSecrets$1(machine) });
504
619
  }));
620
+ app.put("/api/machines/:id", asyncHandler(async (request, response) => {
621
+ const input = machineInputSchema.parse(request.body);
622
+ const machine = await dependencies.machineStore.update(String(request.params.id), input);
623
+ if (!machine) {
624
+ response.status(404).json({ error: "Remote machine not found." });
625
+ return;
626
+ }
627
+ response.json({ machine: stripMachineSecrets$1(machine) });
628
+ }));
629
+ app.delete("/api/machines/:id", asyncHandler(async (request, response) => {
630
+ if (!await dependencies.machineStore.delete(String(request.params.id))) {
631
+ response.status(404).json({ error: "Remote machine not found." });
632
+ return;
633
+ }
634
+ response.status(204).send();
635
+ }));
636
+ app.post("/api/machines/:id/terminal", asyncHandler(async (request, response) => {
637
+ const machineId = String(request.params.id);
638
+ const machine = await dependencies.machineStore.get(machineId);
639
+ if (!machine) {
640
+ response.status(404).json({ error: "Remote machine not found." });
641
+ return;
642
+ }
643
+ if (!dependencies.openSshTerminal) {
644
+ response.status(501).json({ error: "Terminal opening is not configured." });
645
+ return;
646
+ }
647
+ await dependencies.openSshTerminal(machine);
648
+ response.json({ ok: true });
649
+ }));
505
650
  app.get("/api/machines/:id/ports", asyncHandler(async (request, response) => {
506
651
  const machineId = String(request.params.id);
507
652
  const machine = await dependencies.machineStore.get(machineId);
@@ -535,6 +680,13 @@ function createApp(dependencies) {
535
680
  });
536
681
  return;
537
682
  }
683
+ if (error instanceof UnsupportedTerminalError) {
684
+ response.status(501).json({
685
+ error: "Unsupported terminal.",
686
+ message: error.message
687
+ });
688
+ return;
689
+ }
538
690
  response.status(500).json({ error: "Unexpected server error." });
539
691
  });
540
692
  return app;
@@ -631,7 +783,8 @@ function openBrowser(url) {
631
783
  async function createYaportHttpApp(mode, dataFile) {
632
784
  const app = createApp({
633
785
  machineStore: new JsonMachineStore(dataFile),
634
- collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options)
786
+ collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options),
787
+ openSshTerminal
635
788
  });
636
789
  if (mode === "production") {
637
790
  const clientDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
@@ -670,16 +823,235 @@ function formatUrlHost(host) {
670
823
  return host;
671
824
  }
672
825
  //#endregion
826
+ //#region src/server/serviceManager.ts
827
+ var execFileAsync = promisify(execFile);
828
+ var SERVICE_LABEL = "com.boenfu.yaport";
829
+ var SYSTEMD_SERVICE_NAME = "yaport.service";
830
+ var UnsupportedServicePlatformError = class extends Error {
831
+ constructor(platform) {
832
+ super(`System service is only supported on macOS and Linux. Current platform: ${platform}.`);
833
+ }
834
+ };
835
+ async function installYaportService(options) {
836
+ const definition = buildYaportServiceDefinition(options);
837
+ const runCommand = options.runCommand ?? defaultRunCommand;
838
+ await mkdir(dirname(definition.filePath), { recursive: true });
839
+ await mkdir(join(options.home, ".yaport"), { recursive: true });
840
+ await writeFile(definition.filePath, definition.contents, "utf8");
841
+ for (const command of definition.installCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
842
+ return definition;
843
+ }
844
+ async function uninstallYaportService(options) {
845
+ const definition = buildYaportServiceDefinition(options);
846
+ const runCommand = options.runCommand ?? defaultRunCommand;
847
+ for (const command of definition.uninstallCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
848
+ await rm(definition.filePath, { force: true });
849
+ return definition;
850
+ }
851
+ async function printYaportServiceStatus(options) {
852
+ const runCommand = options.runCommand ?? defaultRunCommand;
853
+ if (options.platform === "darwin") {
854
+ printCommandResult(await runCommand("launchctl", ["print", launchdServiceTarget(options.uid)], { allowFailure: true }));
855
+ return;
856
+ }
857
+ if (options.platform === "linux") {
858
+ printCommandResult(await runCommand("systemctl", [
859
+ "--user",
860
+ "status",
861
+ SYSTEMD_SERVICE_NAME
862
+ ], { allowFailure: true }));
863
+ return;
864
+ }
865
+ throw new UnsupportedServicePlatformError(String(options.platform));
866
+ }
867
+ function buildYaportServiceDefinition(options) {
868
+ if (options.platform === "darwin") return buildLaunchAgentDefinition(options);
869
+ if (options.platform === "linux") return buildSystemdDefinition(options);
870
+ throw new UnsupportedServicePlatformError(String(options.platform));
871
+ }
872
+ function buildLaunchAgentDefinition(options) {
873
+ const filePath = join(options.home, "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
874
+ const programArguments = serviceProgramArguments(options);
875
+ const logDirectory = join(options.home, ".yaport");
876
+ const target = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
877
+ return {
878
+ filePath,
879
+ contents: `<?xml version="1.0" encoding="UTF-8"?>
880
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
881
+ <plist version="1.0">
882
+ <dict>
883
+ <key>Label</key>
884
+ <string>${SERVICE_LABEL}</string>
885
+ <key>ProgramArguments</key>
886
+ <array>
887
+ ${programArguments.map((argument) => ` <string>${escapeXml(argument)}</string>`).join("\n")}
888
+ </array>
889
+ <key>WorkingDirectory</key>
890
+ <string>${escapeXml(options.home)}</string>
891
+ <key>RunAtLoad</key>
892
+ <true/>
893
+ <key>KeepAlive</key>
894
+ <true/>
895
+ <key>StandardOutPath</key>
896
+ <string>${escapeXml(join(logDirectory, "service.log"))}</string>
897
+ <key>StandardErrorPath</key>
898
+ <string>${escapeXml(join(logDirectory, "service.error.log"))}</string>
899
+ </dict>
900
+ </plist>
901
+ `,
902
+ installCommands: [
903
+ {
904
+ command: "launchctl",
905
+ args: [
906
+ "bootout",
907
+ target,
908
+ filePath
909
+ ],
910
+ allowFailure: true
911
+ },
912
+ {
913
+ command: "launchctl",
914
+ args: [
915
+ "bootstrap",
916
+ target,
917
+ filePath
918
+ ]
919
+ },
920
+ {
921
+ command: "launchctl",
922
+ args: ["enable", `${target}/${SERVICE_LABEL}`]
923
+ },
924
+ {
925
+ command: "launchctl",
926
+ args: [
927
+ "kickstart",
928
+ "-k",
929
+ `${target}/${SERVICE_LABEL}`
930
+ ]
931
+ }
932
+ ],
933
+ uninstallCommands: [{
934
+ command: "launchctl",
935
+ args: [
936
+ "bootout",
937
+ target,
938
+ filePath
939
+ ],
940
+ allowFailure: true
941
+ }, {
942
+ command: "launchctl",
943
+ args: ["disable", `${target}/${SERVICE_LABEL}`],
944
+ allowFailure: true
945
+ }]
946
+ };
947
+ }
948
+ function buildSystemdDefinition(options) {
949
+ const filePath = join(options.home, ".config/systemd/user", SYSTEMD_SERVICE_NAME);
950
+ const execStart = serviceProgramArguments(options).map(systemdQuote).join(" ");
951
+ return {
952
+ filePath,
953
+ contents: `[Unit]
954
+ Description=Yaport local web UI
955
+ After=network-online.target
956
+
957
+ [Service]
958
+ Type=simple
959
+ WorkingDirectory=${systemdQuote(options.home)}
960
+ ExecStart=${execStart}
961
+ Restart=on-failure
962
+ Environment=NODE_ENV=production
963
+
964
+ [Install]
965
+ WantedBy=default.target
966
+ `,
967
+ installCommands: [{
968
+ command: "systemctl",
969
+ args: ["--user", "daemon-reload"]
970
+ }, {
971
+ command: "systemctl",
972
+ args: [
973
+ "--user",
974
+ "enable",
975
+ "--now",
976
+ SYSTEMD_SERVICE_NAME
977
+ ]
978
+ }],
979
+ uninstallCommands: [{
980
+ command: "systemctl",
981
+ args: [
982
+ "--user",
983
+ "disable",
984
+ "--now",
985
+ SYSTEMD_SERVICE_NAME
986
+ ],
987
+ allowFailure: true
988
+ }, {
989
+ command: "systemctl",
990
+ args: ["--user", "daemon-reload"]
991
+ }]
992
+ };
993
+ }
994
+ function serviceProgramArguments(options) {
995
+ return [
996
+ options.nodePath,
997
+ options.cliPath,
998
+ "serve",
999
+ "--host",
1000
+ options.host,
1001
+ "--port",
1002
+ String(options.port),
1003
+ "--data-file",
1004
+ options.dataFile,
1005
+ "--no-open"
1006
+ ];
1007
+ }
1008
+ function launchdServiceTarget(uid) {
1009
+ return `gui/${uid ?? process.getuid?.() ?? 0}/${SERVICE_LABEL}`;
1010
+ }
1011
+ async function defaultRunCommand(command, args, options = {}) {
1012
+ try {
1013
+ return await execFileAsync(command, args, { encoding: "utf8" });
1014
+ } catch (error) {
1015
+ if (options.allowFailure) return {
1016
+ stderr: errorWithOutput(error).stderr,
1017
+ stdout: errorWithOutput(error).stdout
1018
+ };
1019
+ throw error;
1020
+ }
1021
+ }
1022
+ function printCommandResult(result) {
1023
+ if (result?.stdout) console.log(result.stdout.trimEnd());
1024
+ if (result?.stderr) console.error(result.stderr.trimEnd());
1025
+ }
1026
+ function errorWithOutput(error) {
1027
+ if (error && typeof error === "object") {
1028
+ const output = error;
1029
+ return {
1030
+ stderr: typeof output.stderr === "string" ? output.stderr : "",
1031
+ stdout: typeof output.stdout === "string" ? output.stdout : ""
1032
+ };
1033
+ }
1034
+ return {};
1035
+ }
1036
+ function escapeXml(value) {
1037
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1038
+ }
1039
+ function systemdQuote(value) {
1040
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
1041
+ }
1042
+ //#endregion
673
1043
  //#region src/server/cli.ts
674
1044
  var usage = `Usage: yaport [serve options]
675
1045
  Usage: yaport serve [options]
676
- Usage: yaport add-machine --name <name> --host <host> [connection options]
1046
+ Usage: yaport add-machine --host <host> [connection options]
1047
+ Usage: yaport service <install|uninstall|status> [options]
677
1048
 
678
1049
  Start the 丫口 local web UI.
679
1050
 
680
1051
  Commands:
681
1052
  serve Start the local web UI. This is the default command.
682
1053
  add-machine Add a remote machine to the local 丫口 data file.
1054
+ service Install, uninstall, or inspect the user-level autostart service.
683
1055
 
684
1056
  Serve options:
685
1057
  --host <host> Host to bind. Default: ${DEFAULT_HOST}
@@ -689,11 +1061,12 @@ Serve options:
689
1061
  -h, --help Print this help message
690
1062
 
691
1063
  add-machine required options:
692
- --name <name> Display name in the UI.
693
1064
  --host <host> SSH host, IP, or OpenSSH alias. For "ssh erya", use --host erya.
694
1065
 
695
1066
  add-machine connection options:
1067
+ --name <name> Display name in the UI. Defaults to user@host:port when omitted.
696
1068
  --user <user> SSH user. Omit for OpenSSH aliases that already define User.
1069
+ Required when --host is an IP address.
697
1070
  --port <port> SSH port. Omit for OpenSSH aliases that already define Port.
698
1071
  --password <value> SSH password for the target machine. Prefer --password-env.
699
1072
  --password-env <env> Read the target machine SSH password from an environment variable.
@@ -718,20 +1091,33 @@ add-machine output options:
718
1091
  --json Print deterministic JSON. Passwords are never printed.
719
1092
  --data-file <path> Remote machine config file. Default lookup: ./.yaport, then ~/.yaport.
720
1093
 
1094
+ service options:
1095
+ install Create and start a user-level service. macOS uses LaunchAgent; Linux uses systemd --user.
1096
+ uninstall Stop and remove the user-level service.
1097
+ status Print service status using launchctl or systemctl --user.
1098
+ --host <host> Host used by the installed service. Default: ${DEFAULT_HOST}
1099
+ --port <port> Port used by the installed service. Default: ${DEFAULT_PORT}
1100
+ --data-file <path> Machine config file used by the installed service. Default lookup: ./.yaport, then ~/.yaport.
1101
+ --json Print deterministic JSON for install/uninstall.
1102
+
721
1103
  Examples:
722
- yaport add-machine --name Erya --host erya --json
1104
+ yaport add-machine --host erya --json
723
1105
  TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host 10.0.0.5 --user root --password-env TARGET_PASSWORD --json
724
1106
  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
1107
+ yaport service install --port 5173 --json
1108
+ yaport service status
1109
+ yaport service uninstall
725
1110
 
726
1111
  Agent notes:
727
1112
  - Prefer --json for machine-readable output.
728
1113
  - 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".
1114
+ - Omit --user and --port when --host is an OpenSSH alias such as "erya"; IP hosts must include --user.
730
1115
  - The command is non-interactive. Missing required flags fail fast with a non-zero exit code.
731
1116
  `;
732
1117
  function parseCliOptions(argv, env = process.env) {
733
1118
  if (argv[0] === "add-machine") return parseAddMachineOptions(argv.slice(1), env);
734
1119
  if (argv[0] === "serve") return parseServeOptions(argv.slice(1), env);
1120
+ if (argv[0] === "service") return parseServiceOptions(argv.slice(1), env);
735
1121
  return parseServeOptions(argv, env);
736
1122
  }
737
1123
  function parseServeOptions(argv, env) {
@@ -854,7 +1240,7 @@ function parseAddMachineOptions(argv, env) {
854
1240
  }
855
1241
  if (options.help) return options;
856
1242
  const input = machineInputSchema.parse({
857
- name: requiredValue(machine.name, "--name"),
1243
+ ...machine.name !== void 0 ? { name: machine.name } : {},
858
1244
  host: requiredValue(machine.host, "--host"),
859
1245
  ...machine.connectTimeoutSeconds ? { connectTimeoutSeconds: machine.connectTimeoutSeconds } : {},
860
1246
  ...machine.commandTimeoutSeconds ? { commandTimeoutSeconds: machine.commandTimeoutSeconds } : {},
@@ -866,6 +1252,50 @@ function parseAddMachineOptions(argv, env) {
866
1252
  input
867
1253
  };
868
1254
  }
1255
+ function parseServiceOptions(argv, env) {
1256
+ const action = argv[0];
1257
+ const options = {
1258
+ command: "service",
1259
+ action: action === "uninstall" || action === "status" ? action : "install",
1260
+ dataFile: env.YAPORT_DATA_FILE ?? defaultDataFile(),
1261
+ help: false,
1262
+ host: env.HOST ?? "127.0.0.1",
1263
+ json: false,
1264
+ port: Number(env.PORT ?? 5173)
1265
+ };
1266
+ const optionStartIndex = action === "install" || action === "uninstall" || action === "status" ? 1 : 0;
1267
+ if (!action || action === "-h" || action === "--help") options.help = true;
1268
+ else if (action !== "install" && action !== "uninstall" && action !== "status") throw new Error("service action must be install, uninstall, or status.");
1269
+ for (let index = optionStartIndex; index < argv.length; index += 1) {
1270
+ const arg = argv[index];
1271
+ if (arg === "-h" || arg === "--help") {
1272
+ options.help = true;
1273
+ continue;
1274
+ }
1275
+ if (arg === "--json") {
1276
+ options.json = true;
1277
+ continue;
1278
+ }
1279
+ if (arg === "--host") {
1280
+ options.host = readValue(argv, index, arg);
1281
+ index += 1;
1282
+ continue;
1283
+ }
1284
+ if (arg === "--port") {
1285
+ options.port = Number(readValue(argv, index, arg));
1286
+ index += 1;
1287
+ continue;
1288
+ }
1289
+ if (arg === "--data-file") {
1290
+ options.dataFile = readValue(argv, index, arg);
1291
+ index += 1;
1292
+ continue;
1293
+ }
1294
+ throw new Error(`Unknown option: ${arg}`);
1295
+ }
1296
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
1297
+ return options;
1298
+ }
869
1299
  function readValue(argv, optionIndex, optionName) {
870
1300
  const value = argv[optionIndex + 1];
871
1301
  if (!value || value.startsWith("-")) throw new Error(`${optionName} requires a value.`);
@@ -929,6 +1359,10 @@ async function main() {
929
1359
  await addMachine(options);
930
1360
  return;
931
1361
  }
1362
+ if (options.command === "service") {
1363
+ await runServiceCommand(options);
1364
+ return;
1365
+ }
932
1366
  await startYaportServer({
933
1367
  host: options.host,
934
1368
  port: options.port,
@@ -937,6 +1371,40 @@ async function main() {
937
1371
  open: options.open
938
1372
  });
939
1373
  }
1374
+ async function runServiceCommand(options) {
1375
+ const serviceOptions = {
1376
+ cliPath: process.argv[1],
1377
+ dataFile: options.dataFile,
1378
+ home: homedir(),
1379
+ host: options.host,
1380
+ nodePath: process.execPath,
1381
+ platform: process.platform,
1382
+ port: options.port,
1383
+ uid: process.getuid?.()
1384
+ };
1385
+ if (options.action === "install") {
1386
+ printServiceResult(options, "installed", (await installYaportService(serviceOptions)).filePath);
1387
+ return;
1388
+ }
1389
+ if (options.action === "uninstall") {
1390
+ printServiceResult(options, "uninstalled", (await uninstallYaportService(serviceOptions)).filePath);
1391
+ return;
1392
+ }
1393
+ await printYaportServiceStatus(serviceOptions);
1394
+ }
1395
+ function printServiceResult(options, status, filePath) {
1396
+ if (options.json) {
1397
+ console.log(JSON.stringify({
1398
+ action: options.action,
1399
+ filePath,
1400
+ service: "yaport",
1401
+ status
1402
+ }, null, 2));
1403
+ return;
1404
+ }
1405
+ console.log(`Yaport service ${status}.`);
1406
+ console.log(`Service file: ${filePath}`);
1407
+ }
940
1408
  async function addMachine(options) {
941
1409
  if (!options.input) throw new Error("Missing add-machine input.");
942
1410
  const publicMachine = stripMachineSecrets(await new JsonMachineStore(options.dataFile).add(options.input));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaport",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.5.0",
6
6
  "bin": {