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
43 changes: 30 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ Choose a provider before first use.
The local Lima provider is the default:

```bash
./rootcell --init-env macos-lima

# Store the default Bedrock provider key in Keychain.
security add-generic-password -a "$USER" -s aws-bedrock-api-key -w "<your-key>"

Expand All @@ -141,19 +143,13 @@ notes.

### AWS EC2

Create or edit the instance `.env` before the first run:
Initialize the instance `.env` before the first run:

```bash
mkdir -p instances/aws-dev
cp .env.defaults instances/aws-dev/.env
cat >> instances/aws-dev/.env <<'EOF'
ROOTCELL_VM_PROVIDER=aws-ec2
ROOTCELL_AWS_PROFILE=your-profile
ROOTCELL_AWS_REGION=us-east-1
ROOTCELL_AWS_CONTROL_CIDR=auto
EOF

./rootcell --instance aws-dev
./rootcell -i aws-dev --init-env aws-ec2
./rootcell -i aws-dev edit env

./rootcell -i aws-dev
```

See [AWS EC2 provider](src/rootcell/providers/aws-ec2/README.md) for Terraform
Expand Down Expand Up @@ -187,6 +183,7 @@ state root.
./rootcell # open a bash shell inside the agent VM
./rootcell pi # run pi directly
./rootcell -- nix flake update # run any command inside the agent VM
./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
./rootcell edit ssh # edit the SSH allowlist in $EDITOR
Expand All @@ -199,8 +196,11 @@ state root.
./rootcell spy # tail formatted Bedrock Runtime traffic
./rootcell spy --raw # include sanitized raw JSON bodies too
./rootcell spy --tui # browse Bedrock Runtime traffic interactively
./rootcell -i aws-dev --init-env aws-ec2 # initialize a provider-specific instance .env
./rootcell -i local --init-env macos-lima # initialize an explicit local Lima .env

./rootcell --instance dev # open the dev instance shell
./rootcell --instance dev edit env # edit the dev instance environment
./rootcell --instance dev edit dns # edit the dev instance DNS allowlist
./rootcell --instance dev allow # reload only the dev instance allowlists
```
Expand Down Expand Up @@ -379,8 +379,25 @@ same instance settings.

### Environment

`./rootcell` seeds `<instance-dir>/.env` from `.env.defaults` on first run. Edit
that file for instance-local settings such as:
Use `./rootcell -i <name> --init-env <provider-type>` to create the selected
instance directory, seed allowlists and secret mappings, and write a
provider-specific `<instance-dir>/.env`:

```bash
./rootcell -i local --init-env macos-lima
./rootcell -i aws-dev --init-env aws-ec2
```

The supported provider types are `macos-lima` and `aws-ec2`. `macos-lima`
writes `ROOTCELL_VM_PROVIDER=lima`; `aws-ec2` writes `ROOTCELL_VM_PROVIDER=aws-ec2`
plus `ROOTCELL_AWS_PROFILE`, `ROOTCELL_AWS_REGION`, and
`ROOTCELL_AWS_CONTROL_CIDR`. The AWS profile and region default from your
current host environment when available, otherwise to `default` and `us-east-1`.

Normal `./rootcell` entry also seeds `<instance-dir>/.env` from `.env.defaults`
on first run if it does not already exist. Edit that file for instance-local
settings such as these, or run `./rootcell -i <name> edit env` to open it in
`$EDITOR`:

```sh
ROOTCELL_VM_PROVIDER=lima
Expand Down
39 changes: 31 additions & 8 deletions src/rootcell/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { isRootcellSubcommand, ROOTCELL_SUBCOMMANDS, type RootcellSubcommand } f
import { listRootcellInstanceNames, validateInstanceName } from "./instance.ts";
import { parseSchema } from "./schema.ts";
import {
ParsedRootcellInitEnvArgsSchema,
ParsedRootcellHandledArgsSchema,
ParsedRootcellRunArgsSchema,
ROOTCELL_INIT_ENV_PROVIDER_TYPES,
RootcellInitEnvProviderTypeSchema,
SpyOptionsSchema,
type ParsedRootcellArgs,
type SpyOptions,
Expand All @@ -15,6 +18,7 @@ const DEFAULT_SPY_OPTIONS: SpyOptions = { raw: false, dedupe: true, tui: false }

interface GlobalArgs {
readonly instance?: string | readonly string[];
readonly initEnv?: string | readonly string[];
}

interface GuestArgs extends GlobalArgs {
Expand All @@ -29,7 +33,7 @@ interface SpyArgs extends GlobalArgs {
}

interface EditArgs extends GlobalArgs {
readonly allowlist?: string;
readonly target?: string;
}

type ParserArgv<T> = Argv<T>;
Expand Down Expand Up @@ -127,6 +131,11 @@ function createParser(args: readonly string[]): Argv<GuestArgs & SpyArgs> {
default: "default",
normalize: false,
})
.option("init-env", {
choices: ROOTCELL_INIT_ENV_PROVIDER_TYPES,
describe: "initialize the selected instance environment for a provider",
type: "string",
})
// yargs' completion request flag is normally implicit. With
// unknown-options-as-args enabled for command pass-through, it must be
// declared so completion requests still reach yargs.
Expand All @@ -141,12 +150,12 @@ function createParser(args: readonly string[]): Argv<GuestArgs & SpyArgs> {
.command(...rootcellSubcommand("stop"))
.command(...rootcellSubcommand("remove"))
.command(
"edit <allowlist>",
"edit <target>",
subcommandDescription("edit"),
(argv: ParserArgv<EditArgs>) => argv
.positional("allowlist", {
choices: ["http", "https", "dns", "ssh"],
describe: "allowlist to edit",
.positional("target", {
choices: ["env", "http", "https", "dns", "ssh"],
describe: "instance file to edit",
type: "string",
})
.demandCommand(0, 0)
Expand Down Expand Up @@ -187,9 +196,11 @@ function createParser(args: readonly string[]): Argv<GuestArgs & SpyArgs> {
.example("$0", "open an interactive shell inside the agent VM")
.example("$0 pi", "run pi inside the agent VM")
.example("$0 -- nix flake update", "run any command inside the agent VM")
.example("$0 edit env", "edit the default instance environment in $EDITOR")
.example("$0 edit http", "edit the HTTPS allowlist for the default 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 --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")
.example("$0 remove --instance dev", "delete the dev instance VM state")
Expand All @@ -212,8 +223,20 @@ export function parseRootcellArgs(args: readonly string[]): ParsedRootcellArgs {
}

const subcommand = parsedSubcommand(argv);
const initEnv = lastString(argv.initEnv);
if (initEnv !== undefined) {
if (subcommand !== undefined || stringArray(argv.command).length > 0 || stringArray(argv["--"]).length > 0) {
throw new Error("--init-env cannot be combined with a rootcell command");
}
return parseSchema(ParsedRootcellInitEnvArgsSchema, {
kind: "init-env",
instanceName: instanceName(argv),
providerType: parseSchema(RootcellInitEnvProviderTypeSchema, initEnv, "invalid --init-env provider"),
}, "invalid parsed rootcell args");
}

if (subcommand !== undefined) {
const rest = subcommand === "edit" ? [argString((argv as ArgumentsCamelCase<EditArgs>).allowlist)] : [];
const rest = subcommand === "edit" ? [argString((argv as ArgumentsCamelCase<EditArgs>).target)] : [];
return parseSchema(ParsedRootcellRunArgsSchema, {
kind: "run",
instanceName: instanceName(argv),
Expand Down Expand Up @@ -259,11 +282,11 @@ function firstRootcellToken(args: readonly string[]): string | undefined {
if (arg === undefined || arg === "--") {
return undefined;
}
if (arg === "--instance" || arg === "-i" || arg === "--get-yargs-completions") {
if (arg === "--instance" || arg === "-i" || arg === "--init-env" || arg === "--get-yargs-completions") {
index += 1;
continue;
}
if (arg.startsWith("--instance=") || (arg.startsWith("-i") && arg.length > 2)) {
if (arg.startsWith("--instance=") || arg.startsWith("--init-env=") || (arg.startsWith("-i") && arg.length > 2)) {
continue;
}
if (arg.startsWith("-")) {
Expand Down
92 changes: 92 additions & 0 deletions src/rootcell/init-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { instancePaths, seedRootcellInstanceFiles } from "./instance.ts";
import {
ROOTCELL_AWS_CONTROL_CIDR_ENV,
ROOTCELL_AWS_PROFILE_ENV,
ROOTCELL_AWS_REGION_ENV,
ROOTCELL_VM_PROVIDER_ENV,
} from "./providers/aws-ec2-config.ts";
import type { RootcellInitEnvProviderType } from "./types.ts";

interface EnvAssignment {
readonly key: string;
readonly value: string;
readonly overwrite?: boolean;
}

export interface InitEnvResult {
readonly envPath: string;
readonly providerType: RootcellInitEnvProviderType;
}

export function initRootcellInstanceEnv(
repoDir: string,
instanceName: string,
providerType: RootcellInitEnvProviderType,
log: (message: string) => void,
env: NodeJS.ProcessEnv = process.env,
): InitEnvResult {
seedRootcellInstanceFiles(repoDir, instanceName, log, env);
const paths = instancePaths(repoDir, instanceName, env);
const existing = existsSync(paths.envPath) ? readFileSync(paths.envPath, "utf8") : "";
const content = upsertEnvAssignments(
existing,
providerEnvAssignments(providerType, env),
`# Provider initialized by rootcell --init-env ${providerType}.`,
);
if (content !== existing) {
writeFileSync(paths.envPath, content, { encoding: "utf8", mode: 0o600 });
}
log(`initialized ${instanceName} environment for ${providerType} at ${paths.envPath}`);
return { envPath: paths.envPath, providerType };
}

function providerEnvAssignments(providerType: RootcellInitEnvProviderType, env: NodeJS.ProcessEnv): readonly EnvAssignment[] {
if (providerType === "aws-ec2") {
return [
{ key: ROOTCELL_VM_PROVIDER_ENV, value: "aws-ec2", overwrite: true },
{ key: ROOTCELL_AWS_PROFILE_ENV, value: env[ROOTCELL_AWS_PROFILE_ENV] ?? env.AWS_PROFILE ?? "default" },
{ key: ROOTCELL_AWS_REGION_ENV, value: env[ROOTCELL_AWS_REGION_ENV] ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1" },
{ key: ROOTCELL_AWS_CONTROL_CIDR_ENV, value: env[ROOTCELL_AWS_CONTROL_CIDR_ENV] ?? "auto" },
];
}
return [
{ key: ROOTCELL_VM_PROVIDER_ENV, value: "lima", overwrite: true },
];
}

function upsertEnvAssignments(
text: string,
assignments: readonly EnvAssignment[],
comment: string,
): string {
const pending = new Map(assignments.map((assignment) => [assignment.key, assignment]));
const lines = text.length === 0 ? [] : text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
const nextLines = lines.map((line) => {
if (line.startsWith("#")) {
return line;
}
const equalsAt = line.indexOf("=");
const key = equalsAt === -1 ? line : line.slice(0, equalsAt);
const assignment = pending.get(key);
if (assignment === undefined) {
return line;
}
pending.delete(key);
return assignment.overwrite === true ? `${key}=${assignment.value}` : line;
});

if (pending.size > 0) {
if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") {
nextLines.push("");
}
nextLines.push(comment);
for (const assignment of assignments) {
if (pending.has(assignment.key)) {
nextLines.push(`${assignment.key}=${assignment.value}`);
}
}
}

return `${nextLines.join("\n")}\n`;
}
2 changes: 1 addition & 1 deletion src/rootcell/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const ROOTCELL_SUBCOMMANDS: readonly SubcommandMetadata[] = [
{ name: "list", description: "list rootcell VMs and their current state" },
{ name: "stop", description: "stop the selected rootcell instance VMs" },
{ name: "remove", description: "stop the selected instance and delete VM state" },
{ name: "edit", description: "open an allowlist in $EDITOR" },
{ name: "edit", description: "open an instance config file in $EDITOR" },
{ 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" },
Expand Down
9 changes: 8 additions & 1 deletion src/rootcell/providers/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ AWS infrastructure with a generated Terraform module.

## Required Instance Environment

Set these in the instance `.env` before first use:
Initialize the instance `.env` before first use:

```sh
./rootcell -i aws-dev --init-env aws-ec2
./rootcell -i aws-dev edit env
```

The command writes these provider settings:

```sh
ROOTCELL_VM_PROVIDER=aws-ec2
Expand Down
9 changes: 7 additions & 2 deletions src/rootcell/providers/macos-lima-user-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ HTTPS, SSH, and the host control path through the firewall.

## Required Instance Environment

The Lima provider is the default. This is optional, but useful when you want the
instance `.env` to be explicit:
The Lima provider is the default. To make an instance `.env` explicit, run:

```sh
./rootcell -i local --init-env macos-lima
```

That command writes:

```sh
ROOTCELL_VM_PROVIDER=lima
Expand Down
Loading