feat: add remote main server cli passthrough
This commit is contained in:
+9
-1
@@ -6,8 +6,10 @@ import { emitError, emitJson } from "./src/output";
|
||||
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
|
||||
import { runChecks } from "./src/check";
|
||||
import { runSsh } from "./src/ssh";
|
||||
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||
const args = remoteOptions.args;
|
||||
const commandName = args.join(" ") || "help";
|
||||
|
||||
function help(): unknown {
|
||||
@@ -16,6 +18,7 @@ function help(): unknown {
|
||||
output: "json",
|
||||
commands: [
|
||||
{ command: "help", description: "List supported commands." },
|
||||
{ command: "--main-server-ip <ip> [--main-server-user root] [--main-server-key path] <command>", description: "Pass the command through SSH and execute this CLI on the main server." },
|
||||
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
|
||||
{ command: "check", description: "Run config, TypeScript, file presence, and docker-compose config checks." },
|
||||
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, and provider gateway." },
|
||||
@@ -84,6 +87,11 @@ function latestJobId(): string {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (remoteOptions.host !== null) {
|
||||
process.exitCode = await runRemoteCli(remoteOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const [top, sub, third, fourth] = args;
|
||||
if (top === undefined || top === "help" || top === "--help" || top === "-h") {
|
||||
emitJson(commandName, help());
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
host: string | null;
|
||||
user: string;
|
||||
port: number;
|
||||
projectRoot: string;
|
||||
identityFile: string | null;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]);
|
||||
const userOptions = new Set(["--main-server-user", "--server-user"]);
|
||||
const portOptions = new Set(["--main-server-port", "--server-port"]);
|
||||
const rootOptions = new Set(["--main-server-root", "--server-root"]);
|
||||
const keyOptions = new Set(["--main-server-key", "--server-key"]);
|
||||
|
||||
function positivePort(raw: string, option: string): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 65535) throw new Error(`${option} must be a TCP port from 1 to 65535`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredValue(argv: string[], index: number, option: string): string {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`${option} requires a non-empty value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions {
|
||||
const rest: string[] = [];
|
||||
const options: RemoteCliOptions = {
|
||||
host: null,
|
||||
user: "root",
|
||||
port: 22,
|
||||
projectRoot: "/root/unidesk",
|
||||
identityFile: null,
|
||||
args: rest,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index] ?? "";
|
||||
if (arg === "--") {
|
||||
rest.push(...argv.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (hostOptions.has(arg)) {
|
||||
options.host = requiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (userOptions.has(arg)) {
|
||||
options.user = requiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (portOptions.has(arg)) {
|
||||
options.port = positivePort(requiredValue(argv, index, arg), arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (rootOptions.has(arg)) {
|
||||
options.projectRoot = requiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (keyOptions.has(arg)) {
|
||||
options.identityFile = requiredValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
rest.push(arg);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export async function runRemoteCli(options: RemoteCliOptions): Promise<number> {
|
||||
if (options.host === null) throw new Error("runRemoteCli requires --main-server-ip or --server");
|
||||
const remoteArgs = options.args.length === 0 ? ["help"] : options.args;
|
||||
const remoteCommand = [
|
||||
"cd",
|
||||
shellQuote(options.projectRoot),
|
||||
"&&",
|
||||
"exec",
|
||||
"bun",
|
||||
"scripts/cli.ts",
|
||||
...remoteArgs.map(shellQuote),
|
||||
].join(" ");
|
||||
const sshArgs = [
|
||||
"-p",
|
||||
String(options.port),
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"ServerAliveInterval=20",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
];
|
||||
if (options.identityFile !== null) sshArgs.push("-i", options.identityFile);
|
||||
sshArgs.push(`${options.user}@${options.host}`, remoteCommand);
|
||||
|
||||
const child = spawn("ssh", sshArgs, {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
return await new Promise<number>((resolve) => {
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`unidesk remote cli failed to start ssh: ${error.message}\n`);
|
||||
resolve(127);
|
||||
});
|
||||
child.on("close", (code) => resolve(code ?? 255));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user