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.
- package/README.md +60 -6
- package/dist/client/assets/index-DQ_KWWqB.css +1 -0
- package/dist/client/assets/index-g0iLkETl.js +9 -0
- package/dist/client/index.html +2 -2
- package/dist/server/cli.js +482 -14
- package/package.json +1 -1
- package/dist/client/assets/index-Dss-tOs5.js +0 -9
- package/dist/client/assets/index-RIEF8eK4.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -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.
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 --
|
|
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 --
|
|
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:
|
|
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));
|