import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { runCommand } from "./command"; import { runProviderTriage } from "./provider-triage"; interface ProviderAttachOptions { providerId: string; masterServer: string; envFile: string; composeFile: string; logDir: string; hostSshKeyDir: string; token: string | null; force: boolean; up: boolean; } interface ProviderAttachContainerValidation { ok: boolean; containerName: string; restartPolicy: string; restartPolicyOk: boolean; pidMode: string; pidModeOk: boolean; state: string; command: string[]; exitCode: number | null; stderr: string; } function optionValue(args: string[], name: string): string | null { const index = args.indexOf(name); if (index === -1) return null; const value = args[index + 1]; if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`); return value; } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } function safeProviderSlug(providerId: string): string { return providerId.replace(/[^a-zA-Z0-9_.-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "provider"; } function defaultMasterServer(config: UniDeskConfig): string { return `http://${config.network.publicHost}/`; } function defaultHostSshKeyDir(): string { const home = process.env.HOME || "/root"; return join(home, ".unidesk", "host-ssh"); } function parseAttachOptions(config: UniDeskConfig, args: string[]): ProviderAttachOptions { const valueOptions = new Set(["--env-file", "--compose-file", "--log-dir", "--master-server", "--master", "--host-ssh-key-dir", "--provider-token", "--token"]); let providerId: string | undefined; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; if (valueOptions.has(arg)) { index += 1; continue; } if (!arg.startsWith("--")) { providerId = arg; break; } } if (providerId === undefined) { throw new Error("provider attach requires provider id, for example: bun scripts/cli.ts provider attach D518"); } const envFile = optionValue(args, "--env-file") ?? rootPath(".state", `provider-${providerId}.env`); const composeFile = optionValue(args, "--compose-file") ?? rootPath(`provider-${providerId}.yml`); const logDir = optionValue(args, "--log-dir") ?? rootPath("logs", `provider-${providerId}`); return { providerId, masterServer: optionValue(args, "--master-server") ?? optionValue(args, "--master") ?? defaultMasterServer(config), envFile, composeFile, logDir, hostSshKeyDir: optionValue(args, "--host-ssh-key-dir") ?? defaultHostSshKeyDir(), token: optionValue(args, "--provider-token") ?? optionValue(args, "--token"), force: hasFlag(args, "--force"), up: hasFlag(args, "--up"), }; } function envContent(options: ProviderAttachOptions): string { const lines = [ "# Generated by: bun scripts/cli.ts provider attach", "# Required attach inputs are intentionally limited to master server and provider id.", `UNIDESK_MASTER_SERVER=${options.masterServer}`, `PROVIDER_ID=${options.providerId}`, ]; if (options.token !== null) lines.push(`PROVIDER_TOKEN=${options.token}`); return `${lines.join("\n")}\n`; } function composeContent(options: ProviderAttachOptions): string { const slug = safeProviderSlug(options.providerId); const relativeEnv = pathForCompose(options.envFile); const relativeLogDir = pathForCompose(options.logDir); return [ "services:", " provider-gateway:", ` image: unidesk_provider-gateway:${slug}`, " build:", " context: .", " dockerfile: src/components/provider-gateway/Dockerfile", ` container_name: unidesk-provider-gateway-${slug}`, " restart: always", ' pid: "host"', " env_file:", ` - ${relativeEnv}`, " ports:", ' - "127.0.0.1:${PROVIDER_EGRESS_PROXY_HOST_PORT:-18789}:${PROVIDER_EGRESS_PROXY_PORT:-18789}"', " volumes:", " - /var/run/docker.sock:/var/run/docker.sock", " - .:/workspace:ro", ` - ${relativeLogDir}:/var/log/unidesk`, ` - ${options.hostSshKeyDir}:/run/host-ssh:ro`, " extra_hosts:", ' - "host.docker.internal:host-gateway"', "", ].join("\n"); } function pathForCompose(path: string): string { if (path.startsWith(repoRoot)) { const relative = path.slice(repoRoot.length).replace(/^\/+/, ""); return relative.length === 0 ? "." : `./${relative}`; } return path; } function writeGeneratedFile(path: string, content: string, force: boolean): "created" | "updated" | "unchanged" | "skipped" { if (existsSync(path)) { const existing = readFileSync(path, "utf8"); if (existing === content) return "unchanged"; if (!force) return "skipped"; writeFileSync(path, content, "utf8"); return "updated"; } mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, "utf8"); return "created"; } function inspectAttachedContainer(options: ProviderAttachOptions): ProviderAttachContainerValidation { const containerName = `unidesk-provider-gateway-${safeProviderSlug(options.providerId)}`; const command = [ "docker", "inspect", "--format", "{{.HostConfig.RestartPolicy.Name}}\t{{.HostConfig.PidMode}}\t{{.State.Status}}", containerName, ]; const result = runCommand(command, repoRoot); const [restartPolicy = "", pidMode = "", state = ""] = result.stdout.trim().split("\t"); const restartPolicyOk = restartPolicy === "always"; const pidModeOk = pidMode === "host"; return { ok: result.exitCode === 0 && restartPolicyOk && pidModeOk && state === "running", containerName, restartPolicy, restartPolicyOk, pidMode, pidModeOk, state, command, exitCode: result.exitCode, stderr: result.stderr.trim(), }; } export async function runProviderCommand(config: UniDeskConfig, args: string[]): Promise { const [sub] = args; if (sub !== "attach") { if (sub === "triage") { const providerId = args[1]; if (providerId === undefined || providerId.length === 0) { throw new Error("provider triage requires provider id, for example: bun scripts/cli.ts provider triage D601"); } return runProviderTriage(config, providerId, args.slice(2)); } throw new Error("provider requires subcommand: attach|triage"); } const options = parseAttachOptions(config, args.slice(1)); mkdirSync(options.logDir, { recursive: true }); mkdirSync(options.hostSshKeyDir, { recursive: true, mode: 0o700 }); const envStatus = writeGeneratedFile(options.envFile, envContent(options), options.force); const composeStatus = writeGeneratedFile(options.composeFile, composeContent(options), options.force); const composeCommand = [ "docker", "compose", "--env-file", options.envFile, "-f", options.composeFile, "-p", `unidesk-${safeProviderSlug(options.providerId)}`, "up", "-d", "--build", "--remove-orphans", ]; const result = options.up ? runCommand(composeCommand, repoRoot) : null; const containerValidation = options.up ? inspectAttachedContainer(options) : null; return { providerId: options.providerId, masterServer: options.masterServer, envFile: options.envFile, envFileStatus: envStatus, composeFile: options.composeFile, composeFileStatus: composeStatus, logDir: options.logDir, hostSshKeyDir: options.hostSshKeyDir, hostSshKeyPath: join(options.hostSshKeyDir, "id_ed25519"), startCommand: composeCommand, started: options.up, result, containerValidation, notes: [ "The generated env file keeps only UNIDESK_MASTER_SERVER and PROVIDER_ID unless --provider-token is supplied.", "Place a maintenance SSH private key at hostSshKeyPath if this provider should expose host.ssh.", "When --up is used, containerValidation must show restartPolicy=always and pidMode=host before the node is accepted as durable across Docker daemon restarts.", envStatus === "skipped" || composeStatus === "skipped" ? "Existing generated files differed and were not overwritten; rerun with --force to replace them." : "Generated files are ready.", ], }; }