Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ rli devbox suspend <id> # Suspend a devbox
rli devbox resume <id> # Resume a suspended devbox
rli devbox shutdown <id> # Shutdown a devbox
rli devbox ssh <id> # SSH into a devbox
rli devbox scp <id> <src> <dst> # Copy files to/from a devbox using scp
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
rli devbox rsync <id> <src> <dst> # Sync files to/from a devbox using rsync
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...
rli devbox read <id> # Read a file from a devbox using the API
Expand Down
285 changes: 243 additions & 42 deletions src/commands/devbox/scp.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,202 @@
/**
* SCP files to/from devbox command
*
* Supports standard SCP-like syntax where the devbox ID (dbx_*) is used as a hostname:
* rli devbox scp dbx_abc123:/remote/path ./local/path # download
* rli devbox scp ./local/path dbx_abc123:/remote/path # upload
* rli devbox scp root@dbx_abc123:/remote/path ./local/path # explicit user
* rli devbox scp dbx_src:/file dbx_dst:/file # devbox-to-devbox
*
* If no user is specified for a remote path, the devbox's configured user is used.
* Paths without a dbx_ hostname are treated as local paths.
*
* Devbox-to-devbox transfers use scp -3 to route data through the local machine,
* with a temporary SSH config so each devbox uses its own key.
Comment thread
dines-rl marked this conversation as resolved.
*/

import { exec } from "child_process";
import { promisify } from "util";
import { writeFile, unlink, mkdir } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { getClient } from "../../utils/client.js";
import { output, outputError } from "../../utils/output.js";
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
import {
getSSHKey,
getProxyCommand,
checkSSHTools,
SSHKeyInfo,
} from "../../utils/ssh.js";

const execAsync = promisify(exec);

interface SCPOptions {
src: string;
dst: string;
export interface SCPOptions {
scpOptions?: string;
output?: string;
}

export async function scpFiles(devboxId: string, options: SCPOptions) {
export interface ParsedSCPPath {
user?: string;
host?: string;
path: string;
isRemote: boolean;
}

/**
* Resolved info for a remote devbox endpoint.
*/
export interface ResolvedRemote {
devboxId: string;
defaultUser: string;
sshInfo: SSHKeyInfo;
}

/**
* Parse an SCP-style path into its components.
*
* Supported formats:
* user@dbx_id:path -> remote with explicit user
* dbx_id:path -> remote with default user
* /local/path -> local (absolute)
* ./relative -> local (relative)
* filename -> local (bare filename, no colon)
*/
export function parseSCPPath(input: string): ParsedSCPPath {
// Match [user@]host:path where host is a devbox ID (dbx_*).
// This avoids false positives on local paths that happen to contain colons.
const match = input.match(/^(?:([^@/:]+)@)?(dbx_[^@/:]+):(.*)$/);
if (match) {
return {
user: match[1] || undefined,
host: match[2],
path: match[3],
isRemote: true,
};
}
return { path: input, isRemote: false };
}

/**
* Resolve a devbox ID to its SSH info and default user.
*/
async function resolveRemote(devboxId: string): Promise<ResolvedRemote> {
const client = getClient();
const devbox = await client.devboxes.retrieve(devboxId);
const defaultUser =
devbox.launch_parameters?.user_parameters?.username || "user";

const sshInfo = await getSSHKey(devboxId);
if (!sshInfo) {
throw new Error(`Failed to create SSH key for ${devboxId}`);
}

return { devboxId, defaultUser, sshInfo };
}

/**
* Build the SCP command for a single-remote transfer (local <-> devbox).
*/
export function buildSCPCommand(opts: {
sshInfo: { keyfilePath: string; url: string };
proxyCommand: string;
parsedSrc: ParsedSCPPath;
parsedDst: ParsedSCPPath;
defaultUser: string;
scpOptions?: string;
}): string[] {
const scpCommand = [
"scp",
"-i",
opts.sshInfo.keyfilePath,
"-o",
`ProxyCommand=${opts.proxyCommand}`,
"-o",
"StrictHostKeyChecking=no",
];

if (opts.scpOptions) {
scpCommand.push(...opts.scpOptions.split(" "));
}

// Build src argument
if (opts.parsedSrc.isRemote) {
const user = opts.parsedSrc.user || opts.defaultUser;
scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`);
} else {
scpCommand.push(opts.parsedSrc.path);
}

// Build dst argument
if (opts.parsedDst.isRemote) {
const user = opts.parsedDst.user || opts.defaultUser;
scpCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`);
} else {
scpCommand.push(opts.parsedDst.path);
}

return scpCommand;
}

/**
* Build the SCP command for a dual-remote transfer (devbox -> devbox).
* Uses scp -3 to route data through the local machine and a temporary
* SSH config file so each devbox resolves to its own key/proxy.
*/
export function buildDualRemoteSCPCommand(opts: {
srcRemote: ResolvedRemote;
dstRemote: ResolvedRemote;
proxyCommand: string;
parsedSrc: ParsedSCPPath;
parsedDst: ParsedSCPPath;
sshConfigPath: string;
scpOptions?: string;
}): string[] {
const scpCommand = [
"scp",
"-3",
"-F",
opts.sshConfigPath,
"-o",
"StrictHostKeyChecking=no",
];

if (opts.scpOptions) {
scpCommand.push(...opts.scpOptions.split(" "));
}

const srcUser = opts.parsedSrc.user || opts.srcRemote.defaultUser;
scpCommand.push(
`${srcUser}@${opts.srcRemote.sshInfo.url}:${opts.parsedSrc.path}`,
);

const dstUser = opts.parsedDst.user || opts.dstRemote.defaultUser;
scpCommand.push(
`${dstUser}@${opts.dstRemote.sshInfo.url}:${opts.parsedDst.path}`,
);

return scpCommand;
}

/**
* Generate a temporary SSH config file for dual-remote transfers.
* Maps each devbox URL to its identity file and proxy command.
*/
export function generateSCPConfig(
Comment thread
dines-rl marked this conversation as resolved.
remotes: ResolvedRemote[],
proxyCommand: string,
): string {
return remotes
.map(
(r) =>
`Host ${r.sshInfo.url}\n` +
` IdentityFile ${r.sshInfo.keyfilePath}\n` +
` ProxyCommand ${proxyCommand}\n` +
` StrictHostKeyChecking no`,
)
.join("\n\n");
}

export async function scpFiles(src: string, dst: string, options: SCPOptions) {
try {
// Check if SSH tools are available
const sshToolsAvailable = await checkSSHTools();
Expand All @@ -27,56 +206,78 @@ export async function scpFiles(devboxId: string, options: SCPOptions) {
);
}

const client = getClient();
const parsedSrc = parseSCPPath(src);
const parsedDst = parseSCPPath(dst);

// Get devbox details to determine user
const devbox = await client.devboxes.retrieve(devboxId);
const user = devbox.launch_parameters?.user_parameters?.username || "user";

// Get SSH key
const sshInfo = await getSSHKey(devboxId);
if (!sshInfo) {
outputError("Failed to create SSH key");
if (!parsedSrc.isRemote && !parsedDst.isRemote) {
outputError(
"At least one of src or dst must be a remote devbox path (e.g. dbx_<id>:/path)",
Comment thread
dines-rl marked this conversation as resolved.
);
}

const proxyCommand = getProxyCommand();
const scpCommand = [
"scp",
"-i",
sshInfo!.keyfilePath,
"-o",
`ProxyCommand=${proxyCommand}`,
"-o",
"StrictHostKeyChecking=no",
];

if (options.scpOptions) {
scpCommand.push(...options.scpOptions.split(" "));
}
const dualRemote = parsedSrc.isRemote && parsedDst.isRemote;
Comment thread
dines-rl marked this conversation as resolved.
Outdated

// Handle remote paths (starting with :)
if (options.src.startsWith(":")) {
scpCommand.push(`${user}@${sshInfo!.url}:${options.src.slice(1)}`);
scpCommand.push(options.dst);
} else {
scpCommand.push(options.src);
if (options.dst.startsWith(":")) {
scpCommand.push(`${user}@${sshInfo!.url}:${options.dst.slice(1)}`);
} else {
scpCommand.push(options.dst);
let scpCommand: string[];

if (dualRemote) {
// Both sides are remote devboxes — resolve both in parallel
const [srcRemote, dstRemote] = await Promise.all([
resolveRemote(parsedSrc.host!),
resolveRemote(parsedDst.host!),
]);

// Write a temporary SSH config so scp can find the right key per host
const configContent = generateSCPConfig(
[srcRemote, dstRemote],
proxyCommand,
);
const configDir = join(tmpdir(), "runloop-scp");
await mkdir(configDir, { recursive: true });
const configPath = join(configDir, `scp-${Date.now()}.conf`);
Comment thread
dines-rl marked this conversation as resolved.
Outdated
await writeFile(configPath, configContent, { mode: 0o600 });

try {
scpCommand = buildDualRemoteSCPCommand({
srcRemote,
dstRemote,
proxyCommand,
parsedSrc,
parsedDst,
sshConfigPath: configPath,
scpOptions: options.scpOptions,
});

await execAsync(scpCommand.join(" "));
Comment thread
dines-rl marked this conversation as resolved.
Outdated
} finally {
// Clean up temp config
await unlink(configPath).catch(() => {});
Comment thread
dines-rl marked this conversation as resolved.
}
}
} else {
// Single remote — one side is local
const devboxId = parsedSrc.isRemote ? parsedSrc.host! : parsedDst.host!;
const remote = await resolveRemote(devboxId);

scpCommand = buildSCPCommand({
sshInfo: remote.sshInfo,
proxyCommand,
parsedSrc,
parsedDst,
defaultUser: remote.defaultUser,
scpOptions: options.scpOptions,
});

await execAsync(scpCommand.join(" "));
await execAsync(scpCommand.join(" "));
Comment thread
dines-rl marked this conversation as resolved.
Outdated
}

// Default: just output the destination for easy scripting
if (!options.output || options.output === "text") {
console.log(options.dst);
console.log(dst);
} else {
output(
{
source: options.src,
destination: options.dst,
source: src,
destination: dst,
},
{ format: options.output, defaultFormat: "json" },
);
Expand Down
22 changes: 17 additions & 5 deletions src/utils/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function createProgram(): Command {
program
.name("rli")
.description("Beautiful CLI for Runloop devbox management")
.version(VERSION);
.version(VERSION)
.showHelpAfterError()
.showSuggestionAfterError();

// Devbox commands
const devbox = program
Expand Down Expand Up @@ -195,16 +197,26 @@ export function createProgram(): Command {
});

devbox
.command("scp <id> <src> <dst>")
.description("Copy files to/from a devbox using scp")
.command("scp <src> <dst>")
.description(
"Copy files to/from a devbox using scp. Use the devbox ID (dbx_*) as a hostname in src or dst.\n\n" +
" Examples:\n" +
" $ rli devbox scp dbx_abc123:/home/user/file.txt ./file.txt # download from devbox\n" +
" $ rli devbox scp ./file.txt dbx_abc123:/home/user/file.txt # upload to devbox\n" +
" $ rli devbox scp root@dbx_abc123:/etc/hosts ./hosts # with explicit user\n" +
" $ rli devbox scp dbx_src:/data/file.txt dbx_dst:/data/file.txt # devbox to devbox\n\n" +
" If no user is specified, the devbox's configured user is used.\n" +
" Paths without a dbx_ hostname are treated as local.\n" +
" Devbox-to-devbox transfers route through your local machine via scp -3.",
)
.option("--scp-options <options>", "Additional scp options (quoted)")
.option(
"-o, --output [format]",
"Output format: text|json|yaml (default: text)",
)
.action(async (id, src, dst, options) => {
.action(async (src, dst, options) => {
const { scpFiles } = await import("../commands/devbox/scp.js");
await scpFiles(id, { src, dst, ...options });
await scpFiles(src, dst, options);
});

devbox
Expand Down
Loading
Loading