Skip to content

Commit 5c9b884

Browse files
fix(devbox): scp command improved to allow for src or dest to be devboxes (#109)
## Description <!-- Provide a brief description of your changes --> **Note:** PR titles should follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(devbox): add support for custom env vars` or `fix(snapshot): resolve pagination issue`) as they are used for automatic release notes generation. ## Type of Change <!-- Mark the relevant option with an 'x' --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test updates ## Related Issues <!-- Link to related issues using #issue-number --> Closes # ## Changes Made <!-- Describe the changes in detail --> ## Testing <!-- Describe how you tested your changes --> - [ ] I have tested locally - [ ] I have added/updated tests - [ ] All existing tests pass ## Checklist - [ ] My code follows the code style of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Screenshots (if applicable) <!-- Add screenshots to help explain your changes --> ## Additional Notes <!-- Any additional information that reviewers should know --> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent ad33a02 commit 5c9b884

11 files changed

Lines changed: 1156 additions & 99 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: E2E Smoke Tests
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
environment:
10+
description: "Target environment"
11+
required: true
12+
type: choice
13+
options:
14+
- dev
15+
- prod
16+
default: dev
17+
18+
jobs:
19+
e2e:
20+
runs-on: ubuntu-slim
21+
steps:
22+
- name: Checkout code
23+
uses: actions/checkout@v6
24+
25+
- name: Setup pnpm
26+
uses: pnpm/action-setup@v4
27+
28+
- name: Setup Node.js
29+
uses: actions/setup-node@v6
30+
with:
31+
node-version: "20"
32+
cache: "pnpm"
33+
34+
- name: Install dependencies
35+
run: pnpm install --frozen-lockfile
36+
37+
- name: Run e2e smoke tests
38+
env:
39+
RUNLOOP_API_KEY: ${{ inputs.environment == 'prod' && secrets.RUNLOOP_SMOKETEST_PROD_API_KEY || secrets.RUNLOOP_SMOKETEST_DEV_API_KEY }}
40+
RUNLOOP_ENV: ${{ inputs.environment }}
41+
run: pnpm run test:e2e

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ rli devbox suspend <id> # Suspend a devbox
9292
rli devbox resume <id> # Resume a suspended devbox
9393
rli devbox shutdown <id> # Shutdown a devbox
9494
rli devbox ssh <id> # SSH into a devbox
95-
rli devbox scp <id> <src> <dst> # Copy files to/from a devbox using scp
96-
rli devbox rsync <id> <src> <dst> # Sync files to/from a devbox using rsync
95+
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
96+
rli devbox rsync <src> <dst> # Sync files to/from a devbox using rsy...
9797
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...
9898
rli devbox read <id> # Read a file from a devbox using the API
9999
rli devbox write <id> # Write a file to a devbox using the API

jest.e2e.config.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { pathsToModuleNameMapper } from "ts-jest";
2+
import { readFileSync } from "fs";
3+
4+
// Read and parse tsconfig.json
5+
const tsconfig = JSON.parse(readFileSync("./tsconfig.json", "utf8"));
6+
const compilerOptions = tsconfig.compilerOptions;
7+
8+
export default {
9+
preset: "ts-jest/presets/default-esm",
10+
testEnvironment: "node",
11+
12+
roots: ["<rootDir>/tests"],
13+
testMatch: ["**/__tests__/e2e/**/*.test.ts"],
14+
15+
setupFilesAfterEnv: ["<rootDir>/tests/setup-e2e.ts"],
16+
17+
moduleNameMapper: {
18+
"^(\\.{1,2}/.*)\\.js$": "$1",
19+
...pathsToModuleNameMapper(compilerOptions.paths, {
20+
prefix: "<rootDir>/",
21+
useESM: true,
22+
}),
23+
},
24+
25+
transform: {
26+
"^.+\\.tsx?$": [
27+
"ts-jest",
28+
{
29+
useESM: true,
30+
tsconfig: {
31+
...compilerOptions,
32+
rootDir: ".",
33+
module: "ESNext",
34+
moduleResolution: "Node",
35+
allowSyntheticDefaultImports: true,
36+
esModuleInterop: true,
37+
},
38+
},
39+
],
40+
},
41+
42+
extensionsToTreatAsEsm: [".ts"],
43+
44+
// e2e tests may take a while (devbox creation, transfers, etc.)
45+
testTimeout: 180_000,
46+
47+
moduleFileExtensions: ["ts", "js", "json"],
48+
clearMocks: true,
49+
restoreMocks: true,
50+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
2525
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
2626
"test:components": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.components.config.js --coverage --forceExit",
27+
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.e2e.config.js --forceExit",
2728
"docs:commands": "pnpm run build && node scripts/generate-command-docs.js",
2829
"prepare": "husky"
2930
},

src/commands/devbox/rsync.ts

Lines changed: 89 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,78 @@
11
/**
22
* Rsync files to/from devbox command
3+
*
4+
* Supports standard rsync-like syntax where the devbox ID (dbx_*) is used as a hostname:
5+
* rli devbox rsync dbx_abc123:/remote/path ./local/path # download
6+
* rli devbox rsync ./local/path dbx_abc123:/remote/path # upload
7+
* rli devbox rsync root@dbx_abc123:/remote/path ./local/path # explicit user
8+
*
9+
* If no user is specified for a remote path, the devbox's configured user is used.
10+
* Paths without a dbx_ hostname are treated as local paths.
311
*/
412

5-
import { exec } from "child_process";
13+
import { execFile } from "child_process";
614
import { promisify } from "util";
7-
import { getClient } from "../../utils/client.js";
815
import { output, outputError } from "../../utils/output.js";
9-
import { getSSHKey, getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
16+
import { getProxyCommand, checkSSHTools } from "../../utils/ssh.js";
17+
import { parseSCPPath, resolveRemote, type ParsedSCPPath } from "./scp.js";
1018

11-
const execAsync = promisify(exec);
19+
const execFileAsync = promisify(execFile);
1220

13-
interface RsyncOptions {
14-
src: string;
15-
dst: string;
21+
export interface RsyncOptions {
1622
rsyncOptions?: string;
1723
output?: string;
1824
}
1925

20-
export async function rsyncFiles(devboxId: string, options: RsyncOptions) {
26+
/**
27+
* Build the rsync command for a single-remote transfer (local <-> devbox).
28+
*/
29+
export function buildRsyncCommand(opts: {
30+
sshInfo: { keyfilePath: string; url: string };
31+
proxyCommand: string;
32+
parsedSrc: ParsedSCPPath;
33+
parsedDst: ParsedSCPPath;
34+
defaultUser: string;
35+
rsyncOptions?: string;
36+
}): string[] {
37+
// Rsync re-splits the -e value on whitespace internally, so the
38+
// ProxyCommand (which contains spaces) must be single-quoted.
39+
const sshTransport = `ssh -i ${opts.sshInfo.keyfilePath} -o 'ProxyCommand=${opts.proxyCommand}' -o StrictHostKeyChecking=no`;
40+
41+
const rsyncCommand = [
42+
"rsync",
43+
"-vrz", // v: verbose, r: recursive, z: compress
44+
"-e",
45+
sshTransport,
46+
];
47+
48+
if (opts.rsyncOptions) {
49+
rsyncCommand.push(...opts.rsyncOptions.split(" "));
50+
}
51+
52+
// Build src argument
53+
if (opts.parsedSrc.isRemote) {
54+
const user = opts.parsedSrc.user || opts.defaultUser;
55+
rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedSrc.path}`);
56+
} else {
57+
rsyncCommand.push(opts.parsedSrc.path);
58+
}
59+
60+
// Build dst argument
61+
if (opts.parsedDst.isRemote) {
62+
const user = opts.parsedDst.user || opts.defaultUser;
63+
rsyncCommand.push(`${user}@${opts.sshInfo.url}:${opts.parsedDst.path}`);
64+
} else {
65+
rsyncCommand.push(opts.parsedDst.path);
66+
}
67+
68+
return rsyncCommand;
69+
}
70+
71+
export async function rsyncFiles(
72+
src: string,
73+
dst: string,
74+
options: RsyncOptions,
75+
) {
2176
try {
2277
// Check if SSH tools are available
2378
const sshToolsAvailable = await checkSSHTools();
@@ -27,55 +82,45 @@ export async function rsyncFiles(devboxId: string, options: RsyncOptions) {
2782
);
2883
}
2984

30-
const client = getClient();
85+
const parsedSrc = parseSCPPath(src);
86+
const parsedDst = parseSCPPath(dst);
3187

32-
// Get devbox details to determine user
33-
const devbox = await client.devboxes.retrieve(devboxId);
34-
const user = devbox.launch_parameters?.user_parameters?.username || "user";
88+
if (!parsedSrc.isRemote && !parsedDst.isRemote) {
89+
outputError(
90+
"At least one of src or dst must be a remote devbox path (e.g. dbx_<id>:/path)",
91+
);
92+
}
3593

36-
// Get SSH key
37-
const sshInfo = await getSSHKey(devboxId);
38-
if (!sshInfo) {
39-
outputError("Failed to create SSH key");
94+
if (parsedSrc.isRemote && parsedDst.isRemote) {
95+
outputError(
96+
"Devbox-to-devbox rsync is not supported. Only one side can be a remote devbox path.",
97+
);
4098
}
4199

100+
const devboxId = parsedSrc.isRemote ? parsedSrc.host! : parsedDst.host!;
101+
const remote = await resolveRemote(devboxId);
42102
const proxyCommand = getProxyCommand();
43-
const sshOptions = `-i ${sshInfo!.keyfilePath} -o ProxyCommand='${proxyCommand}' -o StrictHostKeyChecking=no`;
44103

45-
const rsyncCommand = [
46-
"rsync",
47-
"-vrz", // v: verbose, r: recursive, z: compress
48-
"-e",
49-
`"ssh ${sshOptions}"`,
50-
];
51-
52-
if (options.rsyncOptions) {
53-
rsyncCommand.push(...options.rsyncOptions.split(" "));
54-
}
55-
56-
// Handle remote paths (starting with :)
57-
if (options.src.startsWith(":")) {
58-
rsyncCommand.push(`${user}@${sshInfo!.url}:${options.src.slice(1)}`);
59-
rsyncCommand.push(options.dst);
60-
} else {
61-
rsyncCommand.push(options.src);
62-
if (options.dst.startsWith(":")) {
63-
rsyncCommand.push(`${user}@${sshInfo!.url}:${options.dst.slice(1)}`);
64-
} else {
65-
rsyncCommand.push(options.dst);
66-
}
67-
}
104+
const rsyncCommand = buildRsyncCommand({
105+
sshInfo: remote.sshInfo,
106+
proxyCommand,
107+
parsedSrc,
108+
parsedDst,
109+
defaultUser: remote.defaultUser,
110+
rsyncOptions: options.rsyncOptions,
111+
});
68112

69-
await execAsync(rsyncCommand.join(" "));
113+
const [cmd, ...args] = rsyncCommand;
114+
await execFileAsync(cmd, args);
70115

71116
// Default: just output the destination for easy scripting
72117
if (!options.output || options.output === "text") {
73-
console.log(options.dst);
118+
console.log(dst);
74119
} else {
75120
output(
76121
{
77-
source: options.src,
78-
destination: options.dst,
122+
source: src,
123+
destination: dst,
79124
},
80125
{ format: options.output, defaultFormat: "json" },
81126
);

0 commit comments

Comments
 (0)