diff --git a/CLAUDE.md b/CLAUDE.md index 1100a34..f29d8c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Single command today (`deploy`), but the layout assumes more will be added. - `src/cli.ts` — commander entry. Defines global flags (`--cwd`, `--dry-run`, `--json`, `--yes`, `--verbose`) on the root program and registers subcommands. Each subcommand action calls `createContext(program.opts())` and passes the context as the first argument to its handler. New commands should follow this pattern: register in `cli.ts`, implement in `src/commands/.ts`, take `(context, options)`. - `src/lib/context.ts` — `WPopContext` carries the resolved cwd and the global flags. Every side-effecting helper takes a context; nothing reads `process.cwd()` or the flags directly. - `src/lib/run.ts` — `run(context, cmd, args, opts)` is the single chokepoint for spawning processes via `execa`. **In dry-run mode it logs and returns without executing.** Any new shell-out must go through `run` (or use `execa` directly only when capturing stdout, and in that case branch on `context.dryRun` like `sshOutput` in `deploy.ts`). -- `src/lib/env.ts` — Zod schema for deploy-time env vars (`REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PATH`, `REMOTE_PORT`, `SSH_PRIVATE_KEY`, `WPOP_CACHE_DIR`, `WP_VERSION`, `WP_LOCALE`). Schema is parsed lazily inside the command, not at import. +- `src/lib/env.ts` — Zod schema and resolution for deploy-time env vars (`REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PATH`, `REMOTE_PORT`, `SSH_PRIVATE_KEY`, `WPOP_CACHE_DIR`, `WP_VERSION`, `WP_LOCALE`). Schema is parsed lazily inside the command, not at import. If any `REMOTE_*` value is missing, it can read Gitea Actions variables from the inferred repo/org using `WEBSIMPLE_GITEA_API_TOKEN`, `WPOP_GITEA_TOKEN`, or `GITEA_TOKEN`; repo inference comes from `git remote get-url origin` and can be overridden with `WPOP_GITEA_REPO` or `WPOP_GITEA_OWNER` + `WPOP_GITEA_REPO_NAME`. - `src/lib/ssh.ts` — builds `PreparedSsh` from env: optionally writes `SSH_PRIVATE_KEY` to a 0600 tempfile, runs `ssh-keyscan` to populate `~/.ssh/known_hosts`, then verifies access with `ssh -o BatchMode=yes`. `sshArgs`/`sshTarget`/`rsyncSshShell` are used to construct ssh and rsync invocations consistently. - `src/commands/deploy.ts` — orchestrates the deploy. Order matters: 1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`). diff --git a/README.md b/README.md index eb721ad..1307537 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ # WPop WordPress operations CLI for Websimple projects. + +## Deploy environment + +`wpop deploy` needs: + +- `REMOTE_HOST` +- `REMOTE_PORT` +- `REMOTE_USER` +- `REMOTE_PATH` + +If any `REMOTE_*` value is missing, `wpop` tries to read Websimple Gitea Actions variables from the +current repository and its organization. Provide a read-only Gitea token with one of: + +- `WEBSIMPLE_GITEA_API_TOKEN` +- `WPOP_GITEA_TOKEN` +- `GITEA_TOKEN` + +By default, the repository is inferred from `git remote get-url origin`. You can override this with: + +- `WPOP_GITEA_REPO=wp-sites/example` +- `WPOP_GITEA_OWNER=wp-sites` and `WPOP_GITEA_REPO_NAME=example` +- `WPOP_GITEA_BASE_URL=https://gitea.websimple.com` diff --git a/package.json b/package.json index 9dee0fa..18eca7e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint:fix": "oxlint . --fix", "lint": "oxlint .", "prepare": "husky", - "release": "pnpm check && changelogen --release --push --noAuthors && pnpm publish", + "release": "pnpm check && pnpm build && changelogen --release --push --noAuthors && pnpm publish", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index c2241d4..d1ca946 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -37,6 +37,13 @@ const CORE_RSYNC_EXCLUDES = [ ".well-known/", ] as const; +const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__"; +const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__"; +const COMPOSER_AUTOLOAD_BEGIN = "__WPOP_COMPOSER_AUTOLOAD_BEGIN__"; +const COMPOSER_AUTOLOAD_END = "__WPOP_COMPOSER_AUTOLOAD_END__"; +const SSH_RUN_BEGIN = "__WPOP_SSH_RUN_BEGIN__"; +const SSH_RUN_END = "__WPOP_SSH_RUN_END__"; + type PackageManager = { lockfile: string; install: readonly string[]; @@ -80,8 +87,14 @@ type DeployReport = { }; export async function deploy(context: WPopContext, options: DeployOptions): Promise { - const env = readDeployEnv(); - const components = parseComponents(options.include); + const env = await readDeployEnv(process.env, { cwd: context.cwd }); + const promptedForComponents = shouldPromptForComponents(context, options.include); + const components = await resolveComponents(context, options.include); + if (!components) { + consola.warn("Aborted."); + return; + } + const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR); const report = buildReport(context, env, components); @@ -96,7 +109,7 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom if (context.dryRun) { consola.info("Dry-run: no remote changes will be made"); } - if (!(await confirm(context, components, env))) { + if (!promptedForComponents && !(await confirm(context, components, env))) { consola.warn("Aborted."); return; } @@ -118,7 +131,7 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom await syncVendor(context, env, ssh); report.steps.push("sync:vendor"); if (existsSync(join(context.cwd, "vendor"))) { - await checkRemoteComposerAutoload(context, env, ssh); + await ensureRemoteComposerAutoload(context, env, ssh); } } @@ -164,6 +177,41 @@ function sshTargetSummary(env: DeployEnv): string { return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`; } +function shouldPromptForComponents(context: WPopContext, include?: string): boolean { + return !include && !context.yes && !context.dryRun && !context.json; +} + +async function resolveComponents( + context: WPopContext, + include?: string, +): Promise | undefined> { + if (!shouldPromptForComponents(context, include)) { + return parseComponents(include); + } + + const response = await prompts({ + type: "multiselect", + name: "components", + message: "Select deploy components", + choices: ALL_COMPONENTS.map((component) => ({ + title: component, + value: component, + selected: DEFAULT_COMPONENTS.includes(component as (typeof DEFAULT_COMPONENTS)[number]), + })), + instructions: false, + }); + + if (!Array.isArray(response.components)) { + return undefined; + } + + if (response.components.length === 0) { + throw new Error("Select at least one deploy component"); + } + + return new Set(response.components as DeployComponent[]); +} + async function confirm( context: WPopContext, components: Set, @@ -341,17 +389,20 @@ async function listRemoteTopLevelDirs( // Paths flow through env vars and are quoted with JSON.stringify, which is // close enough to POSIX double-quote escaping for the values we handle here. const script = `set -e +printf '%s\\n' ${JSON.stringify(REMOTE_LIST_BEGIN)} cd ${JSON.stringify(env.REMOTE_PATH)} if [ -d ${JSON.stringify(path)} ]; then find ${JSON.stringify(path)} -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort fi +printf '%s\\n' ${JSON.stringify(REMOTE_LIST_END)} `; const { stdout } = await sshOutput(context, ssh, script); - return stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + if (context.dryRun) { + return []; + } + + return parseMarkedOutput(stdout, REMOTE_LIST_BEGIN, REMOTE_LIST_END, "remote directory listing"); } async function syncCore( @@ -410,19 +461,141 @@ async function syncVendor(context: WPopContext, env: DeployEnv, ssh: PreparedSsh } } -async function checkRemoteComposerAutoload( +async function ensureRemoteComposerAutoload( context: WPopContext, env: DeployEnv, ssh: PreparedSsh, ): Promise { consola.info("Checking remote Composer autoload inclusion"); + const script = `printf '%s\\n' ${JSON.stringify(COMPOSER_AUTOLOAD_BEGIN)} +if ! cd ${JSON.stringify(env.REMOTE_PATH)}; then + echo missing_remote_path +elif [ ! -f wp-config.php ]; then + echo missing_wp_config +elif grep -q 'vendor/autoload.php' wp-config.php; then + echo ok +else + echo missing_autoload +fi +printf '%s\\n' ${JSON.stringify(COMPOSER_AUTOLOAD_END)} +`; + + const { stdout } = await sshOutput(context, ssh, script); + if (context.dryRun) { + return; + } + + const [status] = parseMarkedOutput( + stdout, + COMPOSER_AUTOLOAD_BEGIN, + COMPOSER_AUTOLOAD_END, + "remote Composer autoload check", + ); + + if (status === "ok") { + return; + } + + if (status === "missing_wp_config") { + throw new Error(`Remote wp-config.php not found in ${env.REMOTE_PATH}`); + } + + if (status === "missing_autoload") { + await repairRemoteComposerAutoload(context, env, ssh); + return; + } + + if (status === "missing_remote_path") { + throw new Error(`Remote path does not exist or is not accessible: ${env.REMOTE_PATH}`); + } + + throw new Error(`Unexpected remote Composer autoload check result: ${status ?? "empty output"}`); +} + +async function repairRemoteComposerAutoload( + context: WPopContext, + env: DeployEnv, + ssh: PreparedSsh, +): Promise { + if (context.dryRun) { + consola.info("[dry-run] Would add Composer autoload include to remote wp-config.php"); + return; + } + + if (context.json && !context.yes) { + throw new Error( + [ + "Remote wp-config.php does not include vendor/autoload.php.", + "Re-run with --yes to allow wpop to add the Composer autoload include automatically.", + ].join("\n"), + ); + } + + if (!context.yes && !(await confirmRemoteComposerAutoloadRepair(env))) { + throw new Error( + [ + "Remote wp-config.php does not include vendor/autoload.php.", + "Deploy cannot continue safely without the Composer autoload include.", + ].join("\n"), + ); + } + + consola.info("Adding Composer autoload include to remote wp-config.php"); const script = `set -e cd ${JSON.stringify(env.REMOTE_PATH)} -test -f wp-config.php -grep -q 'vendor/autoload.php' wp-config.php +php <<'PHP' + { + const response = await prompts({ + type: "confirm", + name: "confirmed", + message: [ + "Remote wp-config.php does not include vendor/autoload.php.", + `Add the Composer autoload include on ${env.REMOTE_HOST}:${env.REMOTE_PATH}/wp-config.php?`, + ].join(" "), + initial: false, + }); + + return Boolean(response.confirmed); } async function syncContentComponent( @@ -500,11 +673,55 @@ fi } async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise { - await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args]); + const result = await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args], { + capture: true, + reject: false, + }); + + if (context.dryRun) { + return; + } + + const output = filterRemoteBanner([result.stdout, result.stderr].filter(Boolean).join("\n")); + if (context.verbose && output) { + process.stdout.write(`${output}\n`); + } + + if (result.exitCode !== 0) { + if (!context.verbose && output) { + process.stderr.write(`${output}\n`); + } + throw new Error(`rsync failed with exit code ${result.exitCode}`); + } } async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): Promise { - await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script }); + const result = await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { + stdin: wrapRemoteScriptOutput(script), + capture: true, + reject: false, + }); + + if (context.dryRun) { + return; + } + + const output = parseMarkedOutput( + result.stdout, + SSH_RUN_BEGIN, + SSH_RUN_END, + "remote command output", + ); + if (context.verbose && output.length > 0) { + process.stdout.write(`${output.join("\n")}\n`); + } + + if (result.exitCode !== 0) { + if (!context.verbose && output.length > 0) { + process.stderr.write(`${output.join("\n")}\n`); + } + throw new Error(`Remote SSH command failed with exit code ${result.exitCode}`); + } } async function sshOutput( @@ -517,3 +734,59 @@ async function sshOutput( capture: true, }); } + +function parseMarkedOutput( + stdout: string, + beginMarker: string, + endMarker: string, + label: string, +): string[] { + const output = stdout.split("\n").map((line) => line.trim()); + const begin = output.indexOf(beginMarker); + const end = output.indexOf(endMarker); + + if (begin === -1 || end === -1 || end < begin) { + throw new Error(`Could not parse ${label} from SSH output`); + } + + return output.slice(begin + 1, end).filter(Boolean); +} + +function wrapRemoteScriptOutput(script: string): string { + return `printf '%s\\n' ${JSON.stringify(SSH_RUN_BEGIN)} +( +${script} +) 2>&1 +status=$? +printf '%s\\n' ${JSON.stringify(SSH_RUN_END)} +exit "$status" +`; +} + +function filterRemoteBanner(output: string): string { + const lines = output.split("\n"); + const filtered: string[] = []; + let skippingBanner = false; + + for (const line of lines) { + if (line.includes("This server is managed by Ansible and Cloud-init.")) { + skippingBanner = true; + continue; + } + + if (skippingBanner) { + if (line.trim().startsWith("Last deployment:")) { + skippingBanner = false; + } + continue; + } + + filtered.push(line); + } + + return filtered + .map((line) => line.trimEnd()) + .filter((line, index, all) => line.trim() || (index > 0 && index < all.length - 1)) + .join("\n") + .trim(); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 18f926d..9cca0fc 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { consola } from "consola"; +import { execa } from "execa"; const deployEnvSchema = z.object({ REMOTE_HOST: z.string().min(1), @@ -11,8 +13,266 @@ const deployEnvSchema = z.object({ WP_LOCALE: z.string().default("fr_CA"), }); +const remoteEnvKeys = ["REMOTE_HOST", "REMOTE_PORT", "REMOTE_USER", "REMOTE_PATH"] as const; +const requiredRemoteEnvKeys = ["REMOTE_HOST", "REMOTE_USER", "REMOTE_PATH"] as const; +const giteaVariableKeys = [ + "REMOTE_HOST", + "REMOTE_PORT", + "REMOTE_USER", + "REMOTE_PATH", + "WP_SITE_URL", +] as const; + export type DeployEnv = z.infer; -export function readDeployEnv(env: NodeJS.ProcessEnv = process.env): DeployEnv { - return deployEnvSchema.parse(env); +type GiteaVariableKey = (typeof giteaVariableKeys)[number]; + +type GiteaRepo = { + owner: string; + repo: string; +}; + +type GiteaVariable = { + name?: string; + data?: string; + value?: string; +}; + +export async function readDeployEnv( + env: NodeJS.ProcessEnv = process.env, + options: { cwd?: string } = {}, +): Promise { + const normalizedEnv = normalizeEnv(env); + const missingRemoteKeys = remoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key])); + + if (missingRemoteKeys.length > 0) { + const giteaEnv = await readGiteaDeployEnv(normalizedEnv, options.cwd); + for (const key of giteaVariableKeys) { + if (!hasValue(normalizedEnv[key]) && hasValue(giteaEnv[key])) { + normalizedEnv[key] = giteaEnv[key]; + } + } + } + + const missingRequiredKeys = requiredRemoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key])); + if (missingRequiredKeys.length > 0) { + throw new Error( + [ + `Missing deploy environment variable(s): ${missingRequiredKeys.join(", ")}`, + "Set them directly or provide WEBSIMPLE_GITEA_API_TOKEN so wpop can read Gitea Actions variables.", + ].join("\n"), + ); + } + + return deployEnvSchema.parse(normalizedEnv); +} + +function normalizeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const normalized = { ...env }; + for (const key of [...remoteEnvKeys, "WP_SITE_URL"] as const) { + if (typeof normalized[key] === "string" && normalized[key].trim() === "") { + delete normalized[key]; + } + } + return normalized; +} + +async function readGiteaDeployEnv( + env: NodeJS.ProcessEnv, + cwd = process.cwd(), +): Promise>> { + const token = getGiteaToken(env); + if (!token) { + return {}; + } + + const repo = await resolveGiteaRepo(env, cwd); + if (!repo) { + consola.warn("Could not infer Gitea repository from git origin; skipping Gitea env lookup"); + return {}; + } + + consola.info( + `Reading deploy environment from Gitea Actions variables (${repo.owner}/${repo.repo})`, + ); + + const baseUrl = getGiteaBaseUrl(env); + const [orgVariables, repoVariables] = await Promise.all([ + fetchGiteaVariables( + baseUrl, + token, + `/api/v1/orgs/${encodeURIComponent(repo.owner)}/actions/variables`, + true, + ), + fetchGiteaVariables( + baseUrl, + token, + `/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/actions/variables`, + false, + ), + ]); + + return { + ...variablesToEnv(orgVariables), + ...variablesToEnv(repoVariables), + }; +} + +function getGiteaToken(env: NodeJS.ProcessEnv): string | undefined { + return firstValue(env.WPOP_GITEA_TOKEN, env.WEBSIMPLE_GITEA_API_TOKEN, env.GITEA_TOKEN); +} + +function getGiteaBaseUrl(env: NodeJS.ProcessEnv): string { + return firstValue(env.WPOP_GITEA_BASE_URL, env.GITEA_BASE_URL) ?? "https://gitea.websimple.com"; +} + +async function resolveGiteaRepo( + env: NodeJS.ProcessEnv, + cwd: string, +): Promise { + const explicit = parseRepoSpec(env.WPOP_GITEA_REPO); + if (explicit) { + return explicit; + } + + const explicitRepo = firstValue(env.WPOP_GITEA_REPO_NAME, env.GITEA_REPOSITORY); + if (explicitRepo) { + const owner = firstValue(env.WPOP_GITEA_OWNER, env.GITEA_REPOSITORY_OWNER) ?? "wp-sites"; + return parseRepoSpec(explicitRepo) ?? { owner, repo: trimGitSuffix(explicitRepo) }; + } + + const originUrl = await readGitOriginUrl(cwd); + if (!originUrl) { + return undefined; + } + + return parseGiteaRemoteUrl(originUrl); +} + +async function readGitOriginUrl(cwd: string): Promise { + const result = await execa("git", ["remote", "get-url", "origin"], { + cwd, + reject: false, + }); + + if (result.exitCode !== 0) { + return undefined; + } + + return result.stdout.trim() || undefined; +} + +function parseRepoSpec(spec: string | undefined): GiteaRepo | undefined { + const value = firstValue(spec); + if (!value) { + return undefined; + } + + const [owner, repo] = value.split("/", 2); + if (!owner || !repo) { + return undefined; + } + + return { owner, repo: trimGitSuffix(repo) }; +} + +function parseGiteaRemoteUrl(remoteUrl: string): GiteaRepo | undefined { + try { + const url = new URL(remoteUrl); + if (url.hostname === "gitea.websimple.com") { + return parseRepoSpec(url.pathname.replace(/^\/+/, "")); + } + } catch { + // Not a URL-form remote; try scp-style below. + } + + const urlMatch = remoteUrl.match( + /gitea\.websimple\.com[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/, + ); + if (urlMatch?.groups) { + return { + owner: urlMatch.groups.owner, + repo: trimGitSuffix(urlMatch.groups.repo), + }; + } + + return undefined; +} + +async function fetchGiteaVariables( + baseUrl: string, + token: string, + path: string, + optional: boolean, +): Promise { + const variables: GiteaVariable[] = []; + + for (let page = 1; page <= 100; page += 1) { + const url = new URL(path, baseUrl); + url.searchParams.set("page", String(page)); + url.searchParams.set("limit", "100"); + + const response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + }, + }); + + if (response.status === 404 && optional) { + return variables; + } + + if (!response.ok) { + throw new Error(`Gitea API request failed (${response.status}) while reading ${path}`); + } + + const pageVariables = (await response.json()) as GiteaVariable[]; + variables.push(...pageVariables); + + if (pageVariables.length < 100) { + return variables; + } + } + + throw new Error("Gitea Actions variables pagination exceeded safety limit"); +} + +function variablesToEnv(variables: GiteaVariable[]): Partial> { + const env: Partial> = {}; + for (const variable of variables) { + if (!isGiteaVariableKey(variable.name)) { + continue; + } + + const value = firstValue(variable.data, variable.value); + if (value) { + env[variable.name] = value; + } + } + + return env; +} + +function isGiteaVariableKey(value: string | undefined): value is GiteaVariableKey { + return giteaVariableKeys.includes(value as GiteaVariableKey); +} + +function firstValue(...values: (string | undefined)[]): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + + return undefined; +} + +function hasValue(value: string | undefined): boolean { + return Boolean(firstValue(value)); +} + +function trimGitSuffix(value: string): string { + return value.endsWith(".git") ? value.slice(0, -4) : value; } diff --git a/src/lib/run.ts b/src/lib/run.ts index db84864..7f60ac4 100644 --- a/src/lib/run.ts +++ b/src/lib/run.ts @@ -1,16 +1,17 @@ import { consola } from "consola"; -import { execa } from "execa"; +import { execa, type ExecaError } from "execa"; import type { WPopContext } from "./context"; export type RunOptions = { cwd?: string; env?: NodeJS.ProcessEnv; + reject?: boolean; stdin?: string; }; export type CaptureOptions = RunOptions & { capture: true }; -export type CaptureResult = { stdout: string }; +export type CaptureResult = { exitCode: number; stderr: string; stdout: string }; export async function run( context: WPopContext, @@ -35,24 +36,50 @@ export async function run( if (context.dryRun) { consola.info(`[dry-run] ${printable}`); - return capture ? { stdout: "" } : undefined; + return capture ? { exitCode: 0, stderr: "", stdout: "" } : undefined; } consola.debug(printable); if (capture) { - const { stdout } = await execa(command, args, { + const result = await execa(command, args, { + cwd: options.cwd ?? context.cwd, + env: options.env, + input: options.stdin, + reject: options.reject, + }); + return { exitCode: result.exitCode ?? 0, stderr: result.stderr, stdout: result.stdout }; + } + + if (context.verbose) { + await execa(command, args, { + cwd: options.cwd ?? context.cwd, + env: options.env, + input: options.stdin, + stdio: options.stdin ? ["pipe", "inherit", "inherit"] : "inherit", + }); + return; + } + + try { + await execa(command, args, { cwd: options.cwd ?? context.cwd, env: options.env, input: options.stdin, }); - return { stdout }; + } catch (error) { + printProcessFailureOutput(error); + throw error; + } +} + +function printProcessFailureOutput(error: unknown): void { + const processError = error as Partial; + const output = [processError.stdout, processError.stderr] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .join("\n"); + + if (output) { + process.stderr.write(`${output}\n`); } - - await execa(command, args, { - cwd: options.cwd ?? context.cwd, - env: options.env, - input: options.stdin, - stdio: options.stdin ? ["pipe", "inherit", "inherit"] : "inherit", - }); } diff --git a/src/lib/ssh.ts b/src/lib/ssh.ts index dd4354d..a71bdf9 100644 --- a/src/lib/ssh.ts +++ b/src/lib/ssh.ts @@ -61,6 +61,7 @@ export function sshTarget(config: PreparedSsh): string { export function sshArgs(config: PreparedSsh): string[] { const args = [ + "-T", "-p", String(config.port), "-o",