@pablozaiden/devbox 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/dist/devbox.js +153 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ It does not modify the original `devcontainer.json`. Instead, it generates a der
6
6
 
7
7
  ## What it does
8
8
 
9
- - Discovers `.devcontainer/devcontainer.json` or `.devcontainer.json` in the current directory.
9
+ - Discovers `.devcontainer/devcontainer.json` or `.devcontainer.json` in the current directory, and can target `.devcontainer/<subpath>/devcontainer.json` with a flag.
10
10
  - Reuses or creates the devcontainer with Docker + Dev Container CLI.
11
11
  - Names the managed container as `devbox-<project>-<port>`.
12
12
  - Publishes the same TCP port on host and container.
@@ -54,15 +54,23 @@ devbox up 5001
54
54
  # Continue even if SSH agent sharing is unavailable
55
55
  devbox up 5001 --allow-missing-ssh
56
56
 
57
+ # Use a specific devcontainer under .devcontainer/services/api
58
+ devbox up 5001 --devcontainer-subpath services/api
59
+
57
60
  # Rebuild/recreate the managed devcontainer
58
61
  devbox rebuild 5001
59
62
 
63
+ # Open an interactive shell in the running managed devcontainer for this workspace
64
+ devbox shell
65
+
60
66
  # Stop and remove the managed container while preserving the workspace-mounted SSH credentials
61
67
  devbox down
62
68
  ```
63
69
 
64
70
  If you omit the port for `up` or `rebuild`, `devbox` will reuse the last port stored for the current workspace.
65
71
 
72
+ `devbox shell` requires an already running managed container for the current workspace. If none is running, use `devbox up` first.
73
+
66
74
  ## Development
67
75
 
68
76
  ```bash
@@ -88,6 +96,8 @@ cd examples/smoke-workspace
88
96
  ## Notes
89
97
 
90
98
  - The generated config is written next to the original devcontainer config, using the alternate accepted devcontainer filename so relative Dockerfile paths keep working.
99
+ - `--devcontainer-subpath services/api` tells `devbox` to use `.devcontainer/services/api/devcontainer.json`.
100
+ - `devbox shell` opens an interactive shell inside the running managed container for the current workspace.
91
101
  - `down` removes managed containers but does not delete the workspace `.sshcred` or `.devbox-ssh-host-keys/`, so the SSH password and SSH host identity survive rebuilds.
92
102
  - Re-running `devbox` after a host restart recreates the desired state: container up, port published, SSH runner started again.
93
103
  - When Docker Desktop host services are available, `devbox` can share the SSH agent without relying on a host-shell `SSH_AUTH_SOCK`.
package/dist/devbox.js CHANGED
@@ -839,16 +839,19 @@ function helpText() {
839
839
  return `${CLI_NAME} - manage a devcontainer plus ssh-server-runner
840
840
 
841
841
  Usage:
842
- ${CLI_NAME} [port] [--allow-missing-ssh]
843
- ${CLI_NAME} up [port] [--allow-missing-ssh]
844
- ${CLI_NAME} rebuild [port] [--allow-missing-ssh]
845
- ${CLI_NAME} down
842
+ ${CLI_NAME} [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>]
843
+ ${CLI_NAME} up [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>]
844
+ ${CLI_NAME} rebuild [port] [--allow-missing-ssh] [--devcontainer-subpath <subpath>]
845
+ ${CLI_NAME} shell
846
+ ${CLI_NAME} down [--devcontainer-subpath <subpath>]
846
847
  ${CLI_NAME} --help
847
848
 
848
849
  Notes:
849
850
  - The same port is published on host and container.
850
851
  - If no port is provided for up/rebuild, the last stored port for this workspace is reused.
851
852
  - Pass --allow-missing-ssh to continue without SSH agent sharing when no usable SSH agent socket is available.
853
+ - Pass --devcontainer-subpath to use .devcontainer/<subpath>/devcontainer.json.
854
+ - ${CLI_NAME} shell opens an interactive shell in the running managed container for this workspace.
852
855
  - Only image/Dockerfile-based devcontainers are supported in v1.`;
853
856
  }
854
857
  function parseArgs(argv) {
@@ -858,7 +861,7 @@ function parseArgs(argv) {
858
861
  }
859
862
  let command = "up";
860
863
  const first = args[0];
861
- if (first === "up" || first === "down" || first === "rebuild" || first === "help") {
864
+ if (first === "up" || first === "down" || first === "rebuild" || first === "shell" || first === "help") {
862
865
  command = first;
863
866
  args.shift();
864
867
  } else if (first === "--help" || first === "-h") {
@@ -866,6 +869,7 @@ function parseArgs(argv) {
866
869
  }
867
870
  let port;
868
871
  let allowMissingSsh = false;
872
+ let devcontainerSubpath;
869
873
  const positionals = [];
870
874
  for (let index = 0;index < args.length; index += 1) {
871
875
  const arg = args[index];
@@ -876,6 +880,19 @@ function parseArgs(argv) {
876
880
  allowMissingSsh = true;
877
881
  continue;
878
882
  }
883
+ if (arg === "--devcontainer-subpath") {
884
+ const value = args[index + 1];
885
+ if (!value) {
886
+ throw new UserError("Expected a value after --devcontainer-subpath.");
887
+ }
888
+ devcontainerSubpath = parseDevcontainerSubpath(value);
889
+ index += 1;
890
+ continue;
891
+ }
892
+ if (arg.startsWith("--devcontainer-subpath=")) {
893
+ devcontainerSubpath = parseDevcontainerSubpath(arg.slice("--devcontainer-subpath=".length));
894
+ continue;
895
+ }
879
896
  if (arg === "--port" || arg === "-p") {
880
897
  const value = args[index + 1];
881
898
  if (!value) {
@@ -901,11 +918,23 @@ function parseArgs(argv) {
901
918
  if (command === "down") {
902
919
  throw new UserError("The down command does not accept a port.");
903
920
  }
921
+ if (command === "shell") {
922
+ throw new UserError("The shell command does not accept a port.");
923
+ }
904
924
  port = parsePort(positionals[0]);
905
925
  }
906
926
  if (command === "down" && port !== undefined) {
907
927
  throw new UserError("The down command does not accept a port.");
908
928
  }
929
+ if (command === "shell" && port !== undefined) {
930
+ throw new UserError("The shell command does not accept a port.");
931
+ }
932
+ if (command === "shell" && devcontainerSubpath !== undefined) {
933
+ throw new UserError("The shell command does not accept --devcontainer-subpath.");
934
+ }
935
+ if (devcontainerSubpath) {
936
+ return { command, port, allowMissingSsh, devcontainerSubpath };
937
+ }
909
938
  return { command, port, allowMissingSsh };
910
939
  }
911
940
  function parsePort(raw) {
@@ -977,7 +1006,7 @@ async function deleteWorkspaceState(workspacePath) {
977
1006
  await rm(getWorkspaceStateDir(workspacePath), { recursive: true, force: true });
978
1007
  }
979
1008
  function resolvePort(command, explicitPort, state) {
980
- if (command === "down" || command === "help") {
1009
+ if (command === "down" || command === "shell" || command === "help") {
981
1010
  throw new UserError(`resolvePort cannot be used for ${command}.`);
982
1011
  }
983
1012
  if (explicitPort !== undefined) {
@@ -988,11 +1017,8 @@ function resolvePort(command, explicitPort, state) {
988
1017
  }
989
1018
  throw new UserError(`No port was provided and no previous port is stored for this workspace. Run \`${CLI_NAME} <port>\` first.`);
990
1019
  }
991
- async function discoverDevcontainerConfig(workspacePath) {
992
- const candidates = [
993
- path.join(workspacePath, ".devcontainer", "devcontainer.json"),
994
- path.join(workspacePath, ".devcontainer.json")
995
- ];
1020
+ async function discoverDevcontainerConfig(workspacePath, devcontainerSubpath) {
1021
+ const candidates = getDevcontainerCandidates(workspacePath, devcontainerSubpath);
996
1022
  for (const candidate of candidates) {
997
1023
  if (!existsSync(candidate)) {
998
1024
  continue;
@@ -1014,7 +1040,8 @@ async function discoverDevcontainerConfig(workspacePath) {
1014
1040
  validateSupportedDevcontainerConfig(config);
1015
1041
  return { path: candidate, config };
1016
1042
  }
1017
- throw new UserError(`No devcontainer definition was found in ${workspacePath}. Expected .devcontainer/devcontainer.json or .devcontainer.json.`);
1043
+ const expectedLocations = devcontainerSubpath ? `.devcontainer/${formatDevcontainerSubpath(devcontainerSubpath)}/devcontainer.json` : ".devcontainer/devcontainer.json or .devcontainer.json";
1044
+ throw new UserError(`No devcontainer definition was found in ${workspacePath}. Expected ${expectedLocations}.`);
1018
1045
  }
1019
1046
  function validateSupportedDevcontainerConfig(config) {
1020
1047
  if (config.dockerComposeFile !== undefined) {
@@ -1097,6 +1124,32 @@ async function getKnownHostsPath() {
1097
1124
  function quoteShell(value) {
1098
1125
  return `'${value.replaceAll("'", `'"'"'`)}'`;
1099
1126
  }
1127
+ function parseDevcontainerSubpath(raw) {
1128
+ const trimmed = raw.trim();
1129
+ if (trimmed.length === 0) {
1130
+ throw new UserError("Devcontainer subpath cannot be empty.");
1131
+ }
1132
+ if (path.isAbsolute(trimmed) || trimmed.startsWith("\\")) {
1133
+ throw new UserError(`Devcontainer subpath must stay inside .devcontainer. Received: ${raw}`);
1134
+ }
1135
+ const segments = trimmed.split(/[\\/]+/u).filter((segment) => segment.length > 0);
1136
+ if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
1137
+ throw new UserError(`Devcontainer subpath must stay inside .devcontainer. Received: ${raw}`);
1138
+ }
1139
+ return path.join(...segments);
1140
+ }
1141
+ function getDevcontainerCandidates(workspacePath, devcontainerSubpath) {
1142
+ if (devcontainerSubpath) {
1143
+ return [path.join(workspacePath, ".devcontainer", devcontainerSubpath, "devcontainer.json")];
1144
+ }
1145
+ return [
1146
+ path.join(workspacePath, ".devcontainer", "devcontainer.json"),
1147
+ path.join(workspacePath, ".devcontainer.json")
1148
+ ];
1149
+ }
1150
+ function formatDevcontainerSubpath(subpath) {
1151
+ return subpath.split(path.sep).join("/");
1152
+ }
1100
1153
  function dedupe(values) {
1101
1154
  return [...new Set(values)];
1102
1155
  }
@@ -1247,6 +1300,15 @@ function requiresSshAuthSockPermissionFix(sshAuthSockSource) {
1247
1300
  function buildEnsureSshAuthSockAccessibleScript() {
1248
1301
  return `if [ -S ${quoteShell(SSH_AUTH_SOCK_TARGET)} ]; then chmod 666 ${quoteShell(SSH_AUTH_SOCK_TARGET)}; fi`;
1249
1302
  }
1303
+ function buildInteractiveShellScript() {
1304
+ return [
1305
+ "if command -v bash >/dev/null 2>&1; then",
1306
+ " exec bash -l",
1307
+ "fi",
1308
+ "exec sh"
1309
+ ].join(`
1310
+ `);
1311
+ }
1250
1312
  function getRunnerHostKeysDir(remoteWorkspaceFolder) {
1251
1313
  const trimmed = remoteWorkspaceFolder.endsWith("/") ? remoteWorkspaceFolder.slice(0, -1) : remoteWorkspaceFolder;
1252
1314
  return `${trimmed}/${RUNNER_HOST_KEYS_DIRNAME}`;
@@ -1453,6 +1515,39 @@ async function persistRunnerHostKeys(containerId, remoteWorkspaceFolder) {
1453
1515
  user: "root"
1454
1516
  });
1455
1517
  }
1518
+ function resolveShellContainerId(input) {
1519
+ const running = input.containers.filter((container) => container.State?.Running);
1520
+ if (input.preferredContainerId) {
1521
+ const preferred = running.find((container) => container.Id === input.preferredContainerId);
1522
+ if (preferred) {
1523
+ return preferred.Id;
1524
+ }
1525
+ }
1526
+ if (running.length === 1) {
1527
+ return running[0].Id;
1528
+ }
1529
+ if (running.length === 0) {
1530
+ throw new UserError("No running managed container was found for this workspace. Run `devbox up` first.");
1531
+ }
1532
+ throw new UserError("More than one managed container is running for this workspace. Run `devbox down` first.");
1533
+ }
1534
+ function buildDevcontainerShellCommand(containerId, terminalSize) {
1535
+ const args = ["devcontainer", "exec", "--container-id", containerId];
1536
+ if (terminalSize?.columns && terminalSize.columns > 0) {
1537
+ args.push("--terminal-columns", String(terminalSize.columns));
1538
+ }
1539
+ if (terminalSize?.rows && terminalSize.rows > 0) {
1540
+ args.push("--terminal-rows", String(terminalSize.rows));
1541
+ }
1542
+ args.push("sh", "-lc", buildInteractiveShellScript());
1543
+ return args;
1544
+ }
1545
+ async function openInteractiveShell(containerId) {
1546
+ return executeInteractive(buildDevcontainerShellCommand(containerId, {
1547
+ columns: process.stdout.isTTY ? process.stdout.columns : undefined,
1548
+ rows: process.stdout.isTTY ? process.stdout.rows : undefined
1549
+ }));
1550
+ }
1456
1551
  async function hasDockerDesktopHostService() {
1457
1552
  const result = await execute(["docker", "info", "--format", "{{.OperatingSystem}}"], {
1458
1553
  stdoutMode: "capture",
@@ -1542,6 +1637,25 @@ async function execute(command, options) {
1542
1637
  }
1543
1638
  return result;
1544
1639
  }
1640
+ async function executeInteractive(command, options) {
1641
+ const env = { ...process.env, ...options?.env ?? {} };
1642
+ for (const [key, value] of Object.entries(env)) {
1643
+ if (value === undefined) {
1644
+ delete env[key];
1645
+ }
1646
+ }
1647
+ const subprocess = spawn(command[0], command.slice(1), {
1648
+ cwd: options?.cwd,
1649
+ env,
1650
+ stdio: "inherit"
1651
+ });
1652
+ return new Promise((resolve, reject) => {
1653
+ subprocess.once("error", reject);
1654
+ subprocess.once("close", (exitCode) => {
1655
+ resolve(exitCode ?? 0);
1656
+ });
1657
+ });
1658
+ }
1545
1659
  async function consumeStream(stream, mode, useStderr) {
1546
1660
  if (!stream) {
1547
1661
  return "";
@@ -1659,17 +1773,21 @@ async function main() {
1659
1773
  }
1660
1774
  const workspacePath = await realpath(process.cwd());
1661
1775
  const state = await loadWorkspaceState(workspacePath);
1776
+ if (parsed.command === "shell") {
1777
+ await handleShell(workspacePath, state);
1778
+ return;
1779
+ }
1662
1780
  if (parsed.command === "down") {
1663
- await handleDown(workspacePath, state);
1781
+ await handleDown(workspacePath, state, parsed.devcontainerSubpath);
1664
1782
  return;
1665
1783
  }
1666
- await handleUpLike(parsed.command, workspacePath, state, parsed.port, parsed.allowMissingSsh);
1784
+ await handleUpLike(parsed.command, workspacePath, state, parsed.port, parsed.allowMissingSsh, parsed.devcontainerSubpath);
1667
1785
  }
1668
- async function handleUpLike(command, workspacePath, state, explicitPort, allowMissingSsh) {
1786
+ async function handleUpLike(command, workspacePath, state, explicitPort, allowMissingSsh, devcontainerSubpath) {
1669
1787
  const environment = await ensureHostEnvironment({ allowMissingSsh });
1670
1788
  const port = resolvePort(command, explicitPort, state);
1671
1789
  const knownHostsPath = await getKnownHostsPath();
1672
- const discovered = await discoverDevcontainerConfig(workspacePath);
1790
+ const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath);
1673
1791
  const generatedConfigPath = getGeneratedConfigPath(discovered.path);
1674
1792
  const legacyGeneratedConfigPath = getLegacyGeneratedConfigPath(discovered.path);
1675
1793
  const workspaceHash = hashWorkspacePath(workspacePath);
@@ -1743,7 +1861,24 @@ Ready. ${upResult.containerId.slice(0, 12)} is available on port ${port}.`);
1743
1861
  console.log("Host known_hosts was not found, so only SSH agent sharing was configured.");
1744
1862
  }
1745
1863
  }
1746
- async function handleDown(workspacePath, state) {
1864
+ async function handleShell(workspacePath, state) {
1865
+ if (!isExecutableAvailable("docker")) {
1866
+ throw new UserError("Docker is required but was not found in PATH.");
1867
+ }
1868
+ if (!isExecutableAvailable("devcontainer")) {
1869
+ throw new UserError("Dev Container CLI is required but was not found in PATH.");
1870
+ }
1871
+ const labels = labelsForWorkspaceHash(hashWorkspacePath(workspacePath));
1872
+ const containerIds = await listManagedContainers(labels);
1873
+ const containers = await inspectContainers(containerIds);
1874
+ const containerId = resolveShellContainerId({
1875
+ containers,
1876
+ preferredContainerId: state?.lastContainerId
1877
+ });
1878
+ console.log(`Opening shell inside ${containerId.slice(0, 12)}...`);
1879
+ process.exitCode = await openInteractiveShell(containerId);
1880
+ }
1881
+ async function handleDown(workspacePath, state, devcontainerSubpath) {
1747
1882
  if (!isExecutableAvailable("docker")) {
1748
1883
  throw new UserError("Docker is required but was not found in PATH.");
1749
1884
  }
@@ -1755,7 +1890,7 @@ async function handleDown(workspacePath, state) {
1755
1890
  generatedConfigPaths.add(state.generatedConfigPath);
1756
1891
  }
1757
1892
  try {
1758
- const discovered = await discoverDevcontainerConfig(workspacePath);
1893
+ const discovered = await discoverDevcontainerConfig(workspacePath, devcontainerSubpath);
1759
1894
  generatedConfigPaths.add(getGeneratedConfigPath(discovered.path));
1760
1895
  generatedConfigPaths.add(getLegacyGeneratedConfigPath(discovered.path));
1761
1896
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/devbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "CLI to run and expose a devcontainer with SSH agent sharing and a forwarded ssh-server-runner port.",
6
6
  "repository": {