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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der
- Shares a usable SSH agent socket with the container and copies a validated, non-empty `known_hosts` snapshot into the container.
- Exposes the SSH service on the chosen host port and, when a host public key is available, installs it for key-based SSH login inside the devcontainer.
- Seeds the container user's global Git `user.name` and `user.email` from the host when available.
- Injects `GH_TOKEN` from the GitHub CLI when available, optionally using a persisted per-workspace `gh` account.
- Runs devbox's bundled SSH server setup script inside the devcontainer.
- Stores devbox-owned state, SSH credentials, SSH metadata, and SSH host keys under the workspace-local `.devbox/` directory so they survive `down` / `rebuild`.

Expand Down Expand Up @@ -65,6 +66,12 @@ devbox up <port> --allow-missing-ssh
# Override the public key installed for SSH login inside the container
devbox up <port> --ssh-public-key ~/.ssh/work-devbox.pub

# Use a specific GitHub CLI account for GH_TOKEN injection and persist it for this workspace
devbox up --gh-user work-account

# Use an account on a non-default GitHub host
devbox up --gh-user work-account --gh-host github.example.com

# Use a specific devcontainer under .devcontainer/services/api
devbox up <port> --devcontainer-subpath services/api

Expand Down Expand Up @@ -100,6 +107,8 @@ When you run `devbox rebuild`, omitting the port reuses the last stored port for

`devbox rebuild` reuses the previously selected source for the workspace. If the workspace was started from `--template`, rebuild uses that saved template again. `rebuild --template ...` is intentionally not supported.

GitHub CLI authentication for `GH_TOKEN` injection can be pinned per workspace with `--gh-user <login>` and optional `--gh-host <host>`. Devbox stores only the selected account metadata in `.devbox/state.json` as `githubAuth`; it does not store the token. Later `up`, `rebuild`, and `arise` runs reuse that account by calling `gh auth token --hostname <host> --user <login>`. The selection precedence is: explicit flags, `DEVBOX_GH_USER` / `DEVBOX_GH_HOST`, saved `.devbox/state.json`, then the currently active `gh` account.

`devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first.

`devbox status` always prints JSON so it can be used directly from scripts and automation.
Expand Down Expand Up @@ -134,14 +143,18 @@ Example:
"sshUser": "root",
"sshPort": 5001,
"remoteUser": "vscode",
"githubAuth": {
"host": "github.com",
"user": "work-account"
},
"hasStateFile": true,
"hasCredentialFile": true,
"hasSshMetadataFile": true,
"warnings": []
}
```

The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, and the stored/generated config paths. Devbox-owned state paths point inside the current workspace's `.devbox/` directory.
The full payload also includes useful diagnostic fields such as `workspaceHash`, `labels`, `publishedPorts`, `statePath`, `credentialPath`, `sshMetadataPath`, `updatedAt`, `githubAuth`, and the stored/generated config paths. Devbox-owned state paths point inside the current workspace's `.devbox/` directory.

When available, the status payload also includes `publicKeyConfigured` and `publicKeySource` so automation can tell whether devbox installed a host SSH public key for key-based login.

Expand Down Expand Up @@ -194,8 +207,10 @@ The complex example uses several devcontainer features, so the first `up` or `re
- When `devbox` uses a repo devcontainer, the generated config is written next to the original devcontainer config, using the alternate accepted devcontainer filename so relative Dockerfile paths keep working.
- When `devbox` uses `--template`, it writes the generated config to `.devbox/.devcontainer.json` instead of creating a source devcontainer definition inside the repo.
- `.devbox/` contains all devbox-owned local state (`state.json`, `user-data/`, template generated configs, and `ssh/`) and should stay ignored by version control.
- `.devbox/state.json` may include `githubAuth: { "host": "...", "user": "..." }` so tools can detect or preserve the GitHub CLI account devbox will use for future `GH_TOKEN` injection.
- `--devcontainer-subpath services/api` tells `devbox` to use `.devcontainer/services/api/devcontainer.json`.
- `--template <name>` explicitly chooses a built-in template, even if the repo already has a devcontainer definition.
- `--gh-user <login>` and `--gh-host <host>` select the GitHub CLI account used for `GH_TOKEN` injection without changing the globally active `gh` account.
- `devbox shell` opens an interactive shell inside the running managed container for the current workspace.
- `devbox status` reports live container state when available and falls back to saved workspace state in `.devbox/state.json` plus the persisted `.devbox/ssh/credentials` password file and `.devbox/ssh/metadata.json` metadata when the container is stopped or Docker is unavailable.
- `devbox arise` only attempts workspaces it can recover from stopped managed containers and that still have at least one persisted devbox leftover, such as saved state, `.devbox/ssh/credentials`, `.devbox/ssh/metadata.json`, or `.devbox/ssh/host-keys/`.
Expand Down
45 changes: 43 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
getManagedPortFromContainerName,
getManagedLabels,
prepareKnownHostsMount,
parseGithubHost,
parseGithubUser,
getWorkspaceSshMetadataFile,
getWorkspaceStateDir,
getWorkspaceUserDataDir,
Expand All @@ -24,6 +26,7 @@ import {
resolveUpPortPreference,
saveWorkspaceState,
type DockerInspect,
type GithubAuthPreference,
UserError,
writeManagedConfig,
} from "./core";
Expand Down Expand Up @@ -110,6 +113,8 @@ async function main(): Promise<void> {
parsed.devcontainerSubpath,
parsed.sshPublicKeyPath,
parsed.templateName,
parsed.githubUser,
parsed.githubHost,
);
}

Expand All @@ -122,8 +127,16 @@ async function handleUpLike(
devcontainerSubpath: string | undefined,
sshPublicKeyPath?: string,
templateName?: string,
explicitGithubUser?: string,
explicitGithubHost?: string,
): Promise<void> {
const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath });
const githubAuth = resolveGithubAuthPreference({
explicitUser: explicitGithubUser,
explicitHost: explicitGithubHost,
state,
env: process.env,
});
const environment = await ensureHostEnvironment({ allowMissingSsh, workspacePath, githubAuth });
const resolvedSshPublicKey = await resolveSshPublicKey({ overridePath: sshPublicKeyPath });
const workspaceHash = hashWorkspacePath(workspacePath);
const labels = getManagedLabels(workspaceHash);
Expand Down Expand Up @@ -192,7 +205,11 @@ async function handleUpLike(
console.log(`Using host SSH agent socket from ${environment.sshAuthSock}.`);
}
if (environment.githubToken) {
console.log("Using host GitHub authentication from gh.");
if (environment.githubAuth) {
console.log(`Using host GitHub authentication from gh account ${environment.githubAuth.user}@${environment.githubAuth.host}.`);
} else {
console.log("Using host GitHub authentication from gh.");
}
}

if (resolvedConfig.configSource === "repo" && resolvedConfig.sourceConfigPath) {
Expand Down Expand Up @@ -308,6 +325,7 @@ async function handleUpLike(
userDataDir,
labels,
template: resolvedConfig.template,
githubAuth: environment.githubAuth,
containerId: upResult.containerId,
}),
);
Expand Down Expand Up @@ -435,6 +453,29 @@ async function handleArise(): Promise<void> {
}
}

function resolveGithubAuthPreference(input: {
explicitUser?: string;
explicitHost?: string;
state: Awaited<ReturnType<typeof loadWorkspaceState>>;
env: Record<string, string | undefined>;
}): GithubAuthPreference | null {
const envUser = input.env.DEVBOX_GH_USER ? parseGithubUser(input.env.DEVBOX_GH_USER) : undefined;
const envHost = input.env.DEVBOX_GH_HOST ? parseGithubHost(input.env.DEVBOX_GH_HOST) : undefined;
const user = input.explicitUser ?? envUser ?? input.state?.githubAuth?.user;

if (!user) {
if (input.explicitHost || envHost) {
throw new UserError("A GitHub user is required when selecting a GitHub host. Pass --gh-user or set DEVBOX_GH_USER.");
}
return null;
}

return {
user: parseGithubUser(user),
host: parseGithubHost(input.explicitHost ?? envHost ?? input.state?.githubAuth?.host ?? "github.com"),
};
}

function getPublishedHostPorts(container: DockerInspect): number[] {
const ports = container.NetworkSettings?.Ports ?? {};
const values = new Set<number>();
Expand Down
130 changes: 117 additions & 13 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface ParsedArgs {
devcontainerSubpath?: string;
sshPublicKeyPath?: string;
templateName?: string;
githubUser?: string;
githubHost?: string;
}

export type DevcontainerConfig = Record<string, unknown>;
Expand Down Expand Up @@ -66,10 +68,16 @@ export interface WorkspaceState {
labels: Record<string, string>;
userDataDir: string;
template: WorkspaceTemplateState | null;
githubAuth: GithubAuthPreference | null;
lastContainerId?: string;
updatedAt: string;
}

export interface GithubAuthPreference {
host: string;
user: string;
}

export interface WorkspaceTemplateState {
name: string;
description: string;
Expand Down Expand Up @@ -128,7 +136,7 @@ export class UserError extends Error {
}

export function helpText(): string {
return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath <subpath>]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer.\n rebuild Recreate the managed devcontainer.\n shell Open an interactive shell in the running managed container.\n status Print JSON describing the managed devbox for this workspace.\n templates Print JSON describing the built-in templates.\n arise Restart stopped managed workspaces discovered from existing containers.\n down Stop and remove the managed container for this workspace.\n help Show this help.\n\nOptions:\n -p, --port <port> Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.\n --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template <name> Use a built-in template instead of a repo devcontainer.\n -h, --help Show this help.`;
return `${CLI_NAME} v${pkg.version} - manage a devcontainer plus a bundled SSH server\n\nUsage:\n ${CLI_NAME}\n ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--template <name>] [--gh-user <login>] [--gh-host <host>]\n ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>] [--ssh-public-key <path>] [--gh-user <login>] [--gh-host <host>]\n ${CLI_NAME} shell\n ${CLI_NAME} status\n ${CLI_NAME} templates\n ${CLI_NAME} arise\n ${CLI_NAME} down [--devcontainer-subpath <subpath>]\n ${CLI_NAME} help\n ${CLI_NAME} --help\n\nCommands:\n up Start or reuse the managed devcontainer.\n rebuild Recreate the managed devcontainer.\n shell Open an interactive shell in the running managed container.\n status Print JSON describing the managed devbox for this workspace.\n templates Print JSON describing the built-in templates.\n arise Restart stopped managed workspaces discovered from existing containers.\n down Stop and remove the managed container for this workspace.\n help Show this help.\n\nOptions:\n -p, --port <port> Publish the same port on host and container.\n --allow-missing-ssh Continue without SSH agent sharing when unavailable.\n --devcontainer-subpath <subpath> Use .devcontainer/<subpath>/devcontainer.json.\n --ssh-public-key <path> Use a specific SSH public key file instead of ~/.ssh/id_rsa.pub.\n --template <name> Use a built-in template instead of a repo devcontainer.\n --gh-user <login> Use and persist a specific GitHub CLI account for GH_TOKEN injection.\n --gh-host <host> GitHub host for --gh-user. Defaults to github.com.\n -h, --help Show this help.`;
}

export function parseArgs(argv: string[]): ParsedArgs {
Expand Down Expand Up @@ -165,6 +173,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
let devcontainerSubpath: string | undefined;
let sshPublicKeyPath: string | undefined;
let templateName: string | undefined;
let githubUser: string | undefined;
let githubHost: string | undefined;
const positionals: string[] = [];

for (let index = 0; index < args.length; index += 1) {
Expand Down Expand Up @@ -214,6 +224,36 @@ export function parseArgs(argv: string[]): ParsedArgs {
continue;
}

if (arg === "--gh-user") {
const value = args[index + 1];
if (!value) {
throw new UserError("Expected a value after --gh-user.");
}
githubUser = parseGithubUser(value);
index += 1;
continue;
}

if (arg.startsWith("--gh-user=")) {
githubUser = parseGithubUser(arg.slice("--gh-user=".length));
continue;
}

if (arg === "--gh-host") {
const value = args[index + 1];
if (!value) {
throw new UserError("Expected a value after --gh-host.");
}
githubHost = parseGithubHost(value);
index += 1;
continue;
}

if (arg.startsWith("--gh-host=")) {
githubHost = parseGithubHost(arg.slice("--gh-host=".length));
continue;
}

if (arg.startsWith("--template=")) {
templateName = parseTemplateName(arg.slice("--template=".length));
continue;
Expand Down Expand Up @@ -322,6 +362,14 @@ export function parseArgs(argv: string[]): ParsedArgs {
throw new UserError("The templates command does not accept --ssh-public-key.");
}

if (command !== "up" && command !== "rebuild" && githubUser !== undefined) {
throw new UserError(`The ${command} command does not accept --gh-user.`);
}

if (command !== "up" && command !== "rebuild" && githubHost !== undefined) {
throw new UserError(`The ${command} command does not accept --gh-host.`);
}

if (command === "status" && allowMissingSsh) {
throw new UserError("The status command does not accept --allow-missing-ssh.");
}
Expand Down Expand Up @@ -362,18 +410,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
throw new UserError("The templates command does not accept --template.");
}

if (devcontainerSubpath || sshPublicKeyPath || templateName) {
return {
command,
port,
allowMissingSsh,
...(devcontainerSubpath ? { devcontainerSubpath } : {}),
...(sshPublicKeyPath ? { sshPublicKeyPath } : {}),
...(templateName ? { templateName } : {}),
};
}

return { command, port, allowMissingSsh };
return {
command,
port,
allowMissingSsh,
...(devcontainerSubpath ? { devcontainerSubpath } : {}),
...(sshPublicKeyPath ? { sshPublicKeyPath } : {}),
...(templateName ? { templateName } : {}),
...(githubUser ? { githubUser } : {}),
...(githubHost ? { githubHost } : {}),
};
}

export function parsePort(raw: string): number {
Expand All @@ -389,6 +435,42 @@ export function parsePort(raw: string): number {
return port;
}

export function parseGithubUser(raw: string): string {
const value = raw.trim();
if (!value || /\s/.test(value)) {
throw new UserError(`Invalid GitHub user: ${raw}`);
}

return value;
}

export function parseGithubHost(raw: string): string {
const value = raw.trim();
if (!value || /\s/.test(value) || value.includes("/") || value.includes(":")) {
throw new UserError(`Invalid GitHub host: ${raw}`);
}

return value;
}

function isValidGithubUser(raw: string): boolean {
try {
parseGithubUser(raw);
return true;
} catch {
return false;
}
}

function isValidGithubHost(raw: string): boolean {
try {
parseGithubHost(raw);
return true;
} catch {
return false;
}
}

export function hashWorkspacePath(workspacePath: string): string {
return createHash("sha256").update(workspacePath).digest("hex").slice(0, 16);
}
Expand Down Expand Up @@ -668,6 +750,7 @@ export function createWorkspaceState(input: {
userDataDir: string;
labels: Record<string, string>;
template: WorkspaceTemplateState | null;
githubAuth: GithubAuthPreference | null;
containerId?: string;
}): WorkspaceState {
return {
Expand All @@ -681,6 +764,7 @@ export function createWorkspaceState(input: {
labels: input.labels,
userDataDir: input.userDataDir,
template: input.template ? cloneTemplateState(input.template) : null,
githubAuth: input.githubAuth ? { ...input.githubAuth } : null,
lastContainerId: input.containerId,
updatedAt: new Date().toISOString(),
};
Expand Down Expand Up @@ -1063,6 +1147,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null {

const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString();
const lastContainerId = typeof record.lastContainerId === "string" ? record.lastContainerId : undefined;
const githubAuth = normalizeGithubAuthPreference(record.githubAuth);

if (record.version === 1) {
if (typeof record.sourceConfigPath !== "string") {
Expand All @@ -1080,6 +1165,7 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null {
labels: record.labels as Record<string, string>,
userDataDir: record.userDataDir,
template: null,
githubAuth: null,
lastContainerId,
updatedAt,
};
Expand Down Expand Up @@ -1109,11 +1195,29 @@ function migrateWorkspaceState(value: unknown): WorkspaceState | null {
labels: record.labels as Record<string, string>,
userDataDir: record.userDataDir,
template: (record.template as WorkspaceTemplateState | null | undefined) ?? null,
githubAuth,
lastContainerId,
updatedAt,
};
}

function normalizeGithubAuthPreference(value: unknown): GithubAuthPreference | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}

const record = value as Record<string, unknown>;
if (typeof record.host !== "string" || typeof record.user !== "string") {
return null;
}

if (!isValidGithubHost(record.host) || !isValidGithubUser(record.user)) {
return null;
}

return { host: record.host.trim(), user: record.user.trim() };
}

function cloneTemplateState(template: WorkspaceTemplateState): WorkspaceTemplateState {
return {
...template,
Expand Down
Loading
Loading