Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/every-trains-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sandbox": patch
---

Fix copying files to local path when not already present
91 changes: 48 additions & 43 deletions packages/sandbox/src/commands/cp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { sandboxClient } from "../client";
import * as cmd from "cmd-ts";
import { sandboxId } from "../args/sandbox-id";
import * as Fs from "cmd-ts/batteries/fs";
import fs from "node:fs/promises";
import path from "node:path";
import { scope } from "../args/scope";
Expand All @@ -11,26 +10,27 @@ import chalk from "chalk";

export const args = {} as const;

const localOrRemote = cmd.extendType(cmd.string, {
async from(input) {
const parts = input.split(":");
if (parts.length === 2) {
const [id, path] = parts;
if (!id || !path) {
throw new Error(
[
`Invalid copy path format: "${input}".`,
`${chalk.bold("hint:")} Expected format: SANDBOX_ID:PATH (e.g., sbx_abc123:/home/user/file.txt).`,
"╰▶ Local paths should not contain colons.",
].join("\n"),
);
}
return { type: "remote", id: await sandboxId.from(id), path } as const;
export const parseLocalOrRemotePath = async (input: string) => {
const parts = input.split(":");
if (parts.length === 2) {
const [id, path] = parts;
if (!id || !path) {
throw new Error(
[
`Invalid copy path format: "${input}".`,
`${chalk.bold("hint:")} Expected format: SANDBOX_ID:PATH (e.g., sbx_abc123:/home/user/file.txt).`,
"╰▶ Local paths should not contain colons.",
].join("\n"),
);
}
return { type: "remote", sandboxId: await sandboxId.from(id), path } as const;
}

const file = await Fs.File.from(input);
return { type: "local", file } as const;
},
return { type: "local", path: input } as const;
};

const localOrRemote = cmd.extendType(cmd.string, {
from: parseLocalOrRemotePath,
});

export const cp = cmd.command({
Expand All @@ -51,53 +51,58 @@ export const cp = cmd.command({
scope,
},
async handler({ scope, source, dest }) {
const spinner = ora({ text: "reading file..." }).start();
const sourceFile =
source.type === "local"
? await fs.readFile(source.file)
: await (async (src) => {
const sandbox = await sandboxClient.get({
sandboxId: src.id,
teamId: scope.team,
token: scope.token,
projectId: scope.project,
});
const file = await sandbox.readFile({ path: src.path });
if (!file) {
return null;
}
return consume.buffer(file);
})(source);
const spinner = ora({ text: `Reading source file (${source.path})...` }).start();
let sourceFile: Buffer<ArrayBufferLike> | null = null;

if (source.type === "local") {
sourceFile = await fs.readFile(source.path).catch((err) => {
if (err.code === "ENOENT") {
return null;
}
throw err;
})
} else {
const sandbox = await sandboxClient.get({
sandboxId: source.sandboxId,
teamId: scope.team,
token: scope.token,
projectId: scope.project,
});
const file = await sandbox.readFile({ path: source.path });
if (file) {
sourceFile = await consume.buffer(file);
}
}

if (!sourceFile) {
if (source.type === "remote") {
const dir = path.dirname(source.path);
spinner.fail(
[
`File not found: ${source.path} in sandbox ${source.id}.`,
`${chalk.bold("hint:")} Verify the file path exists using \`sandbox exec ${source.id} ls ${dir}\`.`,
`File not found: ${source.path} in sandbox ${source.sandboxId}.`,
`${chalk.bold("hint:")} Verify the file path exists using \`sandbox exec ${source.sandboxId} ls ${dir}\`.`,
].join("\n"),
);
} else {
spinner.fail("file not found");
spinner.fail(`Source file (${source.path}) not found.`);
}
return;
}

spinner.text = "writing file...";
spinner.text = `Writing to destination file (${dest.path})...`;

if (dest.type === "local") {
await fs.writeFile(dest.file, sourceFile);
await fs.writeFile(dest.path, sourceFile);
} else {
const sandbox = await sandboxClient.get({
sandboxId: dest.id,
sandboxId: dest.sandboxId,
teamId: scope.team,
projectId: scope.project,
token: scope.token,
});
await sandbox.writeFiles([{ path: dest.path, content: sourceFile }]);
}

spinner.succeed("copied successfully!");
spinner.succeed(`Copied ${source.path} to ${dest.path} successfully!`);
},
});
23 changes: 23 additions & 0 deletions packages/sandbox/test/commands/cp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { parseLocalOrRemotePath } from "../../src/commands/cp";

describe("copy path parsing", () => {
test("accepts non-existent local paths", async () => {
await expect(parseLocalOrRemotePath("./does-not-exist.txt")).resolves.toEqual(
{
type: "local",
path: "./does-not-exist.txt",
},
);
});

test("parses remote paths", async () => {
await expect(
parseLocalOrRemotePath("sbx_Z1bhKlvVP1ecxCg2ewRUSU0hg1ik:/etc/os-release"),
).resolves.toEqual({
type: "remote",
sandboxId: "sbx_Z1bhKlvVP1ecxCg2ewRUSU0hg1ik",
path: "/etc/os-release",
});
});
});