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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ state root.
./rootcell select dev # use the dev instance by default
./rootcell -- pi # run pi directly
./rootcell -- nix flake update # run any command inside the agent VM
./rootcell copy ./file :/tmp/ # copy a host file into the agent VM
./rootcell copy -r :/tmp/out ./out # copy files back from the agent VM recursively
./rootcell edit env # edit the instance .env in $EDITOR
./rootcell edit http # edit the HTTPS allowlist in $EDITOR
./rootcell edit dns # edit the DNS allowlist in $EDITOR
Expand Down
46 changes: 44 additions & 2 deletions src/rootcell/args.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import yargs from "yargs/yargs";
import type { Argv, ArgumentsCamelCase } from "yargs";
import { parseRootcellCopySpec } from "./copy.ts";
import { completeExtensionCommand } from "./extensions/commands.ts";
import { isRootcellSubcommand, ROOTCELL_SUBCOMMANDS, type RootcellSubcommand } from "./metadata.ts";
import { DEFAULT_INSTANCE, listRootcellInstanceNames, readSelectedRootcellInstance, validateInstanceName } from "./instance.ts";
Expand All @@ -10,6 +11,7 @@ import {
ParsedRootcellRunArgsSchema,
ParsedRootcellSelectArgsSchema,
ROOTCELL_INIT_ENV_PROVIDER_TYPES,
RootcellCopyOptionsSchema,
RootcellInitEnvProviderTypeSchema,
SpyOptionsSchema,
type ParsedRootcellArgs,
Expand Down Expand Up @@ -40,6 +42,11 @@ interface ExtensionArgs extends GlobalArgs {
readonly extensionArgs?: readonly string[];
}

interface CopyArgs extends GlobalArgs {
readonly copyArgs?: readonly string[];
readonly recursive?: boolean;
}

interface SelectArgs extends GlobalArgs {
readonly selectedInstance?: string;
}
Expand Down Expand Up @@ -212,6 +219,25 @@ function createParser(args: readonly string[]): Argv {
.command(...rootcellSubcommand("provision"))
.command(...rootcellSubcommand("allow"))
.command(...rootcellSubcommand("pubkey"))
.command(
"copy [copyArgs..]",
subcommandDescription("copy"),
(argv: ParserArgv) => argv
.parserConfiguration({ "unknown-options-as-args": false })
.option("recursive", {
alias: "r",
describe: "copy directories recursively",
type: "boolean",
default: false,
})
.positional("copyArgs", {
array: true,
describe: "source path(s) and target path; guest paths use :/path",
type: "string",
})
.demandCommand(0, 0)
.strictOptions(),
)
.command(...rootcellSubcommand("list"))
.command(...rootcellSubcommand("stop"))
.command(...rootcellSubcommand("remove"))
Expand Down Expand Up @@ -265,6 +291,8 @@ function createParser(args: readonly string[]): Argv {
.example("$0 edit http", "edit the HTTPS allowlist for the selected instance")
.example("$0 --instance dev edit dns", "edit the DNS allowlist for the dev instance")
.example("$0 --instance dev allow", "reload allowlists for the dev instance")
.example("$0 copy ./file :/tmp/", "copy a host file into the selected agent VM")
.example("$0 copy -r :/tmp/output ./output", "copy a directory from the selected agent VM")
.example("$0 --instance aws-dev --init-env aws-ec2", "initialize an AWS EC2 instance environment")
.example("$0 list", "list rootcell VMs and their current state")
.example("$0 stop --instance dev", "stop the dev instance VMs")
Expand All @@ -277,7 +305,7 @@ function createParser(args: readonly string[]): Argv {

export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs {
rejectUnknownSpyHelpOptions(args);
const argv = createParser(args).parseSync() as ArgumentsCamelCase<GuestArgs & SpyArgs & ExtensionArgs & SelectArgs>;
const argv = createParser(args).parseSync() as ArgumentsCamelCase<GuestArgs & SpyArgs & ExtensionArgs & CopyArgs & SelectArgs>;
const firstToken = firstRootcellToken(args);
if (
argv.help === true
Expand Down Expand Up @@ -319,7 +347,14 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs {
? [argString((argv as ArgumentsCamelCase<EditArgs>).target)]
: subcommand === "extension"
? stringArray((argv as ArgumentsCamelCase<ExtensionArgs>).extensionArgs)
: subcommand === "copy"
? validatedCopyArgs(argv)
: [];
const copyOptions = subcommand === "copy"
? parseSchema(RootcellCopyOptionsSchema, {
recursive: argv.recursive ?? false,
}, "invalid copy options")
: undefined;
return parseSchema(ParsedRootcellRunArgsSchema, {
kind: "run",
instanceName: instanceName(argv),
Expand All @@ -330,6 +365,7 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs {
open: argv.open ?? true,
}, "invalid spy options")
: DEFAULT_SPY_OPTIONS,
...(copyOptions === undefined ? {} : { copyOptions }),
}, "invalid parsed rootcell args");
}

Expand Down Expand Up @@ -358,7 +394,13 @@ function fail(message: string, error: Error): never {
throw error instanceof Error ? error : new Error(message);
}

function parsedSubcommand(argv: ArgumentsCamelCase<GuestArgs & SpyArgs & ExtensionArgs & SelectArgs>): RootcellSubcommand | undefined {
function validatedCopyArgs(argv: ArgumentsCamelCase<CopyArgs>): readonly string[] {
const rest = stringArray(argv.copyArgs);
parseRootcellCopySpec(rest);
return rest;
}

function parsedSubcommand(argv: ArgumentsCamelCase<GuestArgs & SpyArgs & ExtensionArgs & CopyArgs & SelectArgs>): RootcellSubcommand | undefined {
const command = argv._[0];
return typeof command === "string" && isRootcellSubcommand(command) ? command : undefined;
}
Expand Down
52 changes: 52 additions & 0 deletions src/rootcell/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface RootcellCopySpec {
readonly sources: readonly string[];
readonly target: string;
readonly direction: "host-to-guest" | "guest-to-host";
}

export function parseRootcellCopySpec(operands: readonly string[]): RootcellCopySpec {
if (operands.length < 2) {
throw new Error("copy requires at least one source and a target");
}
for (const operand of operands) {
if (operand === ":") {
throw new Error("copy guest paths must include a path after ':'");
}
}

const sources = operands.slice(0, -1);
const target = operands[operands.length - 1];
if (target === undefined) {
throw new Error("copy requires at least one source and a target");
}

const firstSourceIsGuest = isRootcellGuestCopyPath(sources[0] ?? "");
if (!sources.every((source) => isRootcellGuestCopyPath(source) === firstSourceIsGuest)) {
throw new Error("copy sources must be all host paths or all guest paths");
}

const targetIsGuest = isRootcellGuestCopyPath(target);
if (firstSourceIsGuest === targetIsGuest) {
throw new Error("copy requires exactly one side to use guest path shorthand");
}

return {
sources,
target,
direction: firstSourceIsGuest ? "guest-to-host" : "host-to-guest",
};
}

export function isRootcellGuestCopyPath(value: string): boolean {
return value.startsWith(":");
}

export function rootcellGuestCopyPath(value: string): string {
if (!isRootcellGuestCopyPath(value)) {
return value;
}
if (value === ":") {
throw new Error("copy guest paths must include a path after ':'");
}
return value.slice(1);
}
3 changes: 2 additions & 1 deletion src/rootcell/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface SubcommandMetadata {
readonly name: "provision" | "allow" | "pubkey" | "spy" | "list" | "stop" | "remove" | "edit" | "extension" | "select";
readonly name: "provision" | "allow" | "pubkey" | "copy" | "spy" | "list" | "stop" | "remove" | "edit" | "extension" | "select";
readonly description: string;
}

Expand All @@ -13,6 +13,7 @@ export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [
{ name: "provision", description: "re-copy files and rebuild both VMs" },
{ name: "allow", description: "hot-reload allowlists into the firewall VM" },
{ name: "pubkey", description: "print the agent VM SSH public key" },
{ name: "copy", description: "copy files between the host and the agent VM" },
{ name: "spy", description: "open the browser spy through a local SSH tunnel" },
] as const;

Expand Down
6 changes: 5 additions & 1 deletion src/rootcell/providers/aws-ec2.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ProxyJumpSshTransport, type ProxyJumpSshEndpoints } from "../transports/proxyjump-ssh.ts";
import type { CommandResult, InheritedCommandResult } from "../types.ts";
import type { RootcellConfig } from "../types.ts";
import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts";
import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts";
import type { AwsEc2NetworkAttachment } from "./aws-ec2-network.ts";
import { AwsEc2TerraformProject } from "./aws-ec2-terraform.ts";

Expand Down Expand Up @@ -100,6 +100,10 @@ export class AwsEc2VmProvider implements VmProvider<AwsEc2NetworkAttachment> {
return await this.transport.execInteractive(name, command, options);
}

copy(name: string, sources: readonly string[], target: string, options: CopyOptions = {}): Promise<void> {
return this.transport.copy(name, sources, target, options);
}

copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise<void> {
return this.transport.copyToGuest(name, hostPath, guestPath, options);
}
Expand Down
27 changes: 25 additions & 2 deletions src/rootcell/providers/lima.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import { isRootcellGuestCopyPath, rootcellGuestCopyPath } from "../copy.ts";
import { resolveHostTool } from "../host-tools.ts";
import { runAsyncInherited, runCapture, runInherited } from "../process.ts";
import { NonEmptyStringSchema, parseSchema, PositiveSafeIntegerSchema } from "../schema.ts";
import { forgetKnownHost, ProxyJumpSshTransport, sshConfigValue, type ProxyJumpSshEndpoints } from "../transports/proxyjump-ssh.ts";
import type { RootcellConfig } from "../types.ts";
import type { CommandResult, InheritedCommandResult } from "../types.ts";
import type { LimaUserV2NetworkAttachment } from "./macos-lima-user-v2-network.ts";
import type { CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts";
import type { CopyOptions, CopyToGuestOptions, ExecOptions, LocalPortForwardHandle, LocalPortForwardOptions, VmProvider, VmRole, VmStatus } from "./types.ts";

const LimaProviderSchema = z.custom<"lima">((value) => value === "lima", { message: "provider mismatch" });
const LimaVmRoleSchema = z.custom<VmRole>(
Expand Down Expand Up @@ -224,11 +225,22 @@ export class LimaVmProvider implements VmProvider<LimaUserV2NetworkAttachment> {
return await this.transport.execInteractive(name, command, options);
}

copy(name: string, sources: readonly string[], target: string, options: CopyOptions = {}): Promise<void> {
runInherited(this.ensureLimactl(), [
"--tty=false",
"copy",
...(options.recursive === true ? ["-r"] : []),
...sources.map((source) => this.limaCopyOperand(name, source)),
this.limaCopyOperand(name, target),
]);
return Promise.resolve();
}

copyToGuest(name: string, hostPath: string, guestPath: string, options: CopyToGuestOptions = {}): Promise<void> {
if (this.shouldUseBootstrapSsh(name)) {
return this.copyToGuestBootstrap(name, hostPath, guestPath, options);
}
return this.transport.copyToGuest(name, hostPath, guestPath, options);
return this.copy(name, [hostPath], `:${guestPath}`, options);
}

forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise<LocalPortForwardHandle> {
Expand Down Expand Up @@ -568,6 +580,17 @@ export class LimaVmProvider implements VmProvider<LimaUserV2NetworkAttachment> {
}
throw new Error(`unknown rootcell VM for Lima provider: ${name}`);
}

private limaCopyOperand(name: string, operand: string): string {
if (!isRootcellGuestCopyPath(operand)) {
return operand;
}
return `${this.limaInstanceName(name)}:${rootcellGuestCopyPath(operand)}`;
}

private limaInstanceName(name: string): string {
return this.readVmState(name)?.limaInstance ?? name;
}
}

export function limaYaml(input: {
Expand Down
5 changes: 5 additions & 0 deletions src/rootcell/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export interface CopyToGuestOptions {
readonly recursive?: boolean;
}

export interface CopyOptions {
readonly recursive?: boolean;
}

export interface LocalPortForwardOptions {
readonly localHost: string;
readonly localPort: number;
Expand Down Expand Up @@ -92,6 +96,7 @@ export interface VmProvider<TAttachment extends VmNetworkAttachment = VmNetworkA
exec(name: string, command: readonly string[], options?: ExecOptions): Promise<InheritedCommandResult>;
execCapture(name: string, command: readonly string[], options?: ExecOptions): Promise<CommandResult>;
execInteractive(name: string, command: readonly string[], options?: ExecOptions): Promise<number>;
copy(name: string, sources: readonly string[], target: string, options?: CopyOptions): Promise<void>;
copyToGuest(name: string, hostPath: string, guestPath: string, options?: CopyToGuestOptions): Promise<void>;
forwardLocalPort(name: string, options: LocalPortForwardOptions): Promise<LocalPortForwardHandle>;
}
Expand Down
Loading