Skip to content

Commit c4245ff

Browse files
aledbfclaude
andcommitted
feat(cli): add devcontainer open to launch VS Code in the dev container
Go-only command that builds the VS Code dev-container folder URI for a workspace and launches the editor with it, so this CLI can be the provisioner while you still edit in VS Code — the extension bundles its own CLI and can't be pointed at an external binary, so this URI hand-off is the supported bridge. The URI is vscode-remote://dev-container+<hex><containerWorkspaceFolder>, where <hex> is the hex-encoded JSON {hostPath, configFile:{scheme:"file", path}} — the exact shape VS Code's resolver expects (verified against the vscli launcher and by decoding a real --dry-run authority). Launch is `code --folder-uri <uri>`. Flags: --workspace-folder, --config, --editor (default code; e.g. code-insiders, cursor), --dry-run (print URI + command without launching). Registered in the flag inventory so TestFlagInventoryParity stays green; documented in go-only-features.md. Format locked by TestDevcontainerFolderURI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4e354a4 commit c4245ff

5 files changed

Lines changed: 262 additions & 0 deletions

File tree

‎docs/go-only-features.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ divergences, each covered by tests.
77

88
- [`devcontainer check`](#devcontainer-check) — host preflight
99
- [`devcontainer setup`](#devcontainer-setup) — apply safe host fixes
10+
- [`devcontainer open`](#devcontainer-open) — launch VS Code attached to the dev container
1011
- [`up --cache-image`](#up---cache-image) — boot from a prebuilt image
1112
- [`read-configuration --cache-key`](#read-configuration---cache-key) — deterministic cache key
1213
- [`build --secrets-file`](#build---secrets-file) — BuildKit build secrets
@@ -67,6 +68,35 @@ devcontainer setup --json
6768
6869
Flags: `--json`, `--dry-run`, `--docker-path`.
6970

71+
## `devcontainer open`
72+
73+
Open a workspace folder in VS Code **inside its dev container**, from the shell. It builds
74+
the `vscode-remote://dev-container+…` folder URI for the workspace and launches the editor
75+
with it — VS Code's Dev Containers extension then provisions (or reconnects to) the
76+
container.
77+
78+
```sh
79+
devcontainer up --workspace-folder . # provision (optional; VS Code will too)
80+
devcontainer open --workspace-folder . # attach the editor
81+
# or just:
82+
devcontainer open .
83+
```
84+
85+
Because `up` and `open` stamp/consume the same `devcontainer.local_folder` /
86+
`devcontainer.config_file` labels, running `up` first means VS Code **reconnects** to the
87+
container you already provisioned instead of building a new one; running `open` alone lets
88+
VS Code provision on connect. This is the bridge for using this CLI as the provisioner while
89+
still editing in VS Code (the extension bundles its own CLI and cannot be pointed at an
90+
external binary, so this URI hand-off is the supported path).
91+
92+
The remote authority is `dev-container+<hex>`, where `<hex>` is the hex-encoded JSON
93+
`{"hostPath": <local folder>, "configFile": {"scheme": "file", "path": <devcontainer.json>}}`,
94+
followed by the container's `workspaceFolder`. Use `--dry-run` to print the URI and launch
95+
command without opening anything (handy for scripting or debugging).
96+
97+
Flags: `--workspace-folder`, `--config`, `--editor` (default `code`; e.g. `code-insiders`,
98+
`cursor`), `--dry-run`.
99+
70100
## `up --cache-image`
71101

72102
Boot the container from an already-built image (features baked in), **skipping the image

‎docs/parity/cli-flags-inventory.yaml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,25 @@ commands:
10451045
docker-path:
10461046
type: string
10471047
description: "Docker CLI path."
1048+
# Go-only: launch VS Code attached to a workspace's dev container.
1049+
open:
1050+
description: "Open a workspace in VS Code, attached to its dev container"
1051+
handler: openHandler
1052+
flags:
1053+
workspace-folder:
1054+
type: string
1055+
description: "Workspace folder path (defaults to [path] or the current directory)."
1056+
config:
1057+
type: string
1058+
description: "devcontainer.json path."
1059+
editor:
1060+
type: string
1061+
default: "code"
1062+
description: "Editor launcher binary (e.g. code, code-insiders, cursor)."
1063+
dry-run:
1064+
type: boolean
1065+
default: false
1066+
description: "Print the folder URI and launch command without opening the editor."
10481067

10491068
# =============================================================================
10501069
# JSON OUTPUT ENVELOPES SUMMARY

‎internal/cli/open.go‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
12+
"github.com/devcontainers/cli/internal/config"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type openOpts struct {
17+
workspaceFolder string
18+
configPath string
19+
editor string
20+
dryRun bool
21+
}
22+
23+
// fileURIJSON is VS Code's serialized `file:` URI for the dev container config,
24+
// as embedded in the dev-container remote authority. On Linux there is no
25+
// authority component (it only appears for WSL/UNC paths on Windows).
26+
type fileURIJSON struct {
27+
Scheme string `json:"scheme"`
28+
Authority string `json:"authority,omitempty"`
29+
Path string `json:"path"`
30+
}
31+
32+
// devcontainerURIJSON is the JSON payload hex-encoded into the
33+
// `dev-container+<hex>` remote authority. Field names/shape match what the VS
34+
// Code Dev Containers resolver expects (verified against the vscli launcher).
35+
type devcontainerURIJSON struct {
36+
HostPath string `json:"hostPath"`
37+
ConfigFile fileURIJSON `json:"configFile"`
38+
}
39+
40+
func newOpenCmd() *cobra.Command {
41+
var opts openOpts
42+
43+
cmd := &cobra.Command{
44+
Use: "open [path]",
45+
Short: "Open a workspace in VS Code, attached to its dev container",
46+
Long: `Open a workspace folder in VS Code inside its dev container.
47+
48+
This builds the vscode-remote:// dev-container URI for the workspace and launches
49+
the editor with it. VS Code's Dev Containers extension then provisions (or
50+
reconnects to) the container — so 'open' pairs with 'up': run 'devcontainer up'
51+
first to provision, then 'devcontainer open' to attach the editor.
52+
53+
Go-only command; not part of the upstream @devcontainers/cli.`,
54+
Args: cobra.MaximumNArgs(1),
55+
RunE: func(cmd *cobra.Command, args []string) error {
56+
if len(args) > 0 && opts.workspaceFolder == "" {
57+
opts.workspaceFolder = args[0]
58+
}
59+
return runOpen(cmd.Context(), outputFor(cmd), opts)
60+
},
61+
}
62+
63+
f := cmd.Flags()
64+
f.StringVar(&opts.workspaceFolder, "workspace-folder", "", "Workspace folder path (defaults to [path] or the current directory).")
65+
f.StringVar(&opts.configPath, "config", "", "devcontainer.json path.")
66+
f.StringVar(&opts.editor, "editor", "code", "Editor launcher binary (e.g. code, code-insiders, cursor).")
67+
f.BoolVar(&opts.dryRun, "dry-run", false, "Print the folder URI and launch command without opening the editor.")
68+
69+
return cmd
70+
}
71+
72+
func runOpen(ctx context.Context, out Output, opts openOpts) error {
73+
workspaceFolder := opts.workspaceFolder
74+
if workspaceFolder == "" {
75+
workspaceFolder, _ = os.Getwd()
76+
}
77+
workspaceFolder = resolvePath(workspaceFolder)
78+
79+
configPath := ""
80+
if opts.configPath != "" {
81+
configPath = resolvePath(opts.configPath)
82+
}
83+
84+
// Load the config to discover the resolved devcontainer.json path and the
85+
// workspace folder *inside* the container (the URI's trailing path).
86+
loadResult, err := config.LoadDevContainerConfig(workspaceFolder, configPath, "")
87+
if err != nil {
88+
return err
89+
}
90+
91+
localConfigPath := loadResult.Config.ConfigFilePath
92+
containerFolder := ""
93+
if loadResult.WorkspaceConfig != nil {
94+
containerFolder = loadResult.WorkspaceConfig.WorkspaceFolder
95+
}
96+
if containerFolder == "" {
97+
return fmt.Errorf("could not determine the container workspace folder for %s", workspaceFolder)
98+
}
99+
100+
uri, err := devcontainerFolderURI(workspaceFolder, localConfigPath, containerFolder)
101+
if err != nil {
102+
return err
103+
}
104+
105+
editor := opts.editor
106+
if editor == "" {
107+
editor = "code"
108+
}
109+
110+
if opts.dryRun {
111+
fmt.Fprintln(out.Stdout(), uri)
112+
fmt.Fprintf(out.Stderr(), "%s --folder-uri %s\n", editor, uri)
113+
return nil
114+
}
115+
116+
fmt.Fprintf(out.Stderr(), "Opening %s in VS Code (dev container)...\n", workspaceFolder)
117+
launch := exec.CommandContext(ctx, editor, "--folder-uri", uri)
118+
launch.Stdout = out.Stdout()
119+
launch.Stderr = out.Stderr()
120+
if err := launch.Run(); err != nil {
121+
return fmt.Errorf("launch %q: %w (is the editor's launcher on PATH? override with --editor)", editor, err)
122+
}
123+
return nil
124+
}
125+
126+
// devcontainerFolderURI builds the vscode-remote:// folder URI that opens
127+
// containerFolder inside the dev container for the given local workspace and
128+
// config. The remote authority is `dev-container+<hex>` where <hex> is the
129+
// hex-encoded JSON {hostPath, configFile:{scheme,path}}.
130+
func devcontainerFolderURI(localWorkspaceFolder, localConfigPath, containerFolder string) (string, error) {
131+
payload := devcontainerURIJSON{
132+
HostPath: localWorkspaceFolder,
133+
ConfigFile: fileURIJSON{
134+
Scheme: "file",
135+
// Linux fs paths already use forward slashes; use the absolute path as-is.
136+
Path: filepath.ToSlash(localConfigPath),
137+
},
138+
}
139+
data, err := json.Marshal(payload)
140+
if err != nil {
141+
return "", err
142+
}
143+
authority := "dev-container+" + hex.EncodeToString(data)
144+
return "vscode-remote://" + authority + containerFolder, nil
145+
}

‎internal/cli/open_test.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cli
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestDevcontainerFolderURI locks the vscode-remote dev-container URI format:
11+
// vscode-remote://dev-container+<hex><containerFolder>, where <hex> is the
12+
// hex-encoded JSON {hostPath, configFile:{scheme,path}}. The shape is verified
13+
// against the working vscli launcher; a drift here silently breaks `open`.
14+
func TestDevcontainerFolderURI(t *testing.T) {
15+
const (
16+
localWS = "/home/me/project"
17+
localConfig = "/home/me/project/.devcontainer/devcontainer.json"
18+
container = "/workspaces/project"
19+
)
20+
21+
uri, err := devcontainerFolderURI(localWS, localConfig, container)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
const prefix = "vscode-remote://dev-container+"
27+
if !strings.HasPrefix(uri, prefix) {
28+
t.Fatalf("uri = %q, want prefix %q", uri, prefix)
29+
}
30+
rest := strings.TrimPrefix(uri, prefix)
31+
32+
// The URI ends with the container workspace folder, verbatim.
33+
if !strings.HasSuffix(rest, container) {
34+
t.Fatalf("uri does not end with container folder %q: %q", container, uri)
35+
}
36+
hexStr := strings.TrimSuffix(rest, container)
37+
38+
raw, err := hex.DecodeString(hexStr)
39+
if err != nil {
40+
t.Fatalf("authority is not valid hex: %v", err)
41+
}
42+
43+
var got devcontainerURIJSON
44+
if err := json.Unmarshal(raw, &got); err != nil {
45+
t.Fatalf("decoded authority is not the expected JSON: %v (%s)", err, raw)
46+
}
47+
if got.HostPath != localWS {
48+
t.Errorf("hostPath = %q, want %q", got.HostPath, localWS)
49+
}
50+
if got.ConfigFile.Scheme != "file" {
51+
t.Errorf("configFile.scheme = %q, want file", got.ConfigFile.Scheme)
52+
}
53+
if got.ConfigFile.Path != localConfig {
54+
t.Errorf("configFile.path = %q, want %q", got.ConfigFile.Path, localConfig)
55+
}
56+
// On Linux there is no authority component.
57+
if got.ConfigFile.Authority != "" {
58+
t.Errorf("configFile.authority = %q, want empty on Linux", got.ConfigFile.Authority)
59+
}
60+
61+
// The exact hex is stable for a fixed input (guards accidental field reorder
62+
// or key renames that would change what VS Code receives).
63+
wantJSON := `{"hostPath":"/home/me/project","configFile":{"scheme":"file","path":"/home/me/project/.devcontainer/devcontainer.json"}}`
64+
if string(raw) != wantJSON {
65+
t.Errorf("payload JSON =\n %s\nwant\n %s", raw, wantJSON)
66+
}
67+
}

‎internal/cli/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func NewRootCommand() *cobra.Command {
4848
newTemplatesCmd(),
4949
newCheckCmd(),
5050
newSetupCmd(),
51+
newOpenCmd(),
5152
)
5253

5354
return root

0 commit comments

Comments
 (0)