Skip to content

Commit b4abb31

Browse files
committed
feat(cli): add coder open vscode
Fixes #7667
1 parent fd43985 commit b4abb31

File tree

6 files changed

+225
-2
lines changed

6 files changed

+225
-2
lines changed

cli/open.go

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/skratchdot/open-golang/open"
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/v2/cli/clibase"
13+
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/codersdk"
15+
)
16+
17+
func (r *RootCmd) open() *clibase.Cmd {
18+
cmd := &clibase.Cmd{
19+
Use: "open",
20+
Short: "Open a workspace",
21+
Handler: func(inv *clibase.Invocation) error {
22+
return inv.Command.HelpHandler(inv)
23+
},
24+
Children: []*clibase.Cmd{
25+
r.openVSCode(),
26+
},
27+
}
28+
return cmd
29+
}
30+
31+
func (r *RootCmd) openVSCode() *clibase.Cmd {
32+
var testNoOpen bool
33+
34+
client := new(codersdk.Client)
35+
cmd := &clibase.Cmd{
36+
Annotations: workspaceCommand,
37+
Use: "vscode <workspace> [<directory in workspace>]",
38+
Short: "Open a workspace in Visual Studio Code",
39+
Middleware: clibase.Chain(
40+
clibase.RequireRangeArgs(1, -1),
41+
r.InitClient(client),
42+
),
43+
Handler: func(inv *clibase.Invocation) error {
44+
ctx, cancel := context.WithCancel(inv.Context())
45+
defer cancel()
46+
47+
// Prepare an API key. This is for automagical configuration of
48+
// VS Code, however, we could try to probe VS Code settings to see
49+
// if the current configuration is valid. Future improvement idea.
50+
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
51+
if err != nil {
52+
return xerrors.Errorf("create API key: %w", err)
53+
}
54+
55+
// We need a started workspace to figure out e.g. expanded directory.
56+
// Pehraps the vscode-coder extension could handle this by accepting
57+
// default_directory=true, then probing the agent. Then we wouldn't
58+
// need to wait for the agent to start.
59+
workspaceName := inv.Args[0]
60+
autostart := true
61+
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceName)
62+
if err != nil {
63+
return xerrors.Errorf("get workspace and agent: %w", err)
64+
}
65+
66+
//
67+
wait := false
68+
for _, script := range workspaceAgent.Scripts {
69+
if script.StartBlocksLogin {
70+
wait = true
71+
break
72+
}
73+
}
74+
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
75+
Fetch: client.WorkspaceAgent,
76+
FetchLogs: client.WorkspaceAgentLogsAfter,
77+
Wait: wait,
78+
})
79+
if err != nil {
80+
if xerrors.Is(err, context.Canceled) {
81+
return cliui.Canceled
82+
}
83+
return xerrors.Errorf("agent: %w", err)
84+
}
85+
86+
// If the ExpandedDirectory was initially missing, it could mean
87+
// that the agent hadn't reported it in yet. Retry once.
88+
if workspaceAgent.ExpandedDirectory == "" {
89+
autostart = false // Don't retry autostart.
90+
workspace, workspaceAgent, err = getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceName)
91+
if err != nil {
92+
return xerrors.Errorf("get workspace and agent retry: %w", err)
93+
}
94+
}
95+
96+
var folder string
97+
switch {
98+
case len(inv.Args) > 1:
99+
folder = inv.Args[1]
100+
// Perhaps we could SSH in to expand the directory?
101+
if strings.HasPrefix(folder, "~") {
102+
return xerrors.Errorf("folder path %q not supported, use an absolute path instead", folder)
103+
}
104+
case workspaceAgent.ExpandedDirectory != "":
105+
folder = workspaceAgent.ExpandedDirectory
106+
}
107+
108+
qp := url.Values{}
109+
110+
qp.Add("url", client.URL.String())
111+
qp.Add("token", apiKey.Key)
112+
qp.Add("owner", workspace.OwnerName)
113+
qp.Add("workspace", workspace.Name)
114+
qp.Add("agent", workspaceAgent.Name)
115+
if folder != "" {
116+
qp.Add("folder", folder)
117+
}
118+
119+
uri := fmt.Sprintf("vscode://coder.coder-remote/open?%s", qp.Encode())
120+
_, _ = fmt.Fprintf(inv.Stdout, "Opening %s\n", strings.ReplaceAll(uri, apiKey.Key, "<REDACTED>"))
121+
122+
if testNoOpen {
123+
return nil
124+
}
125+
126+
err = open.Run(uri)
127+
if err != nil {
128+
return xerrors.Errorf("open: %w", err)
129+
}
130+
131+
return nil
132+
},
133+
}
134+
135+
cmd.Options = clibase.OptionSet{
136+
{
137+
Flag: "test.no-open",
138+
Description: "Don't run the open command.",
139+
Value: clibase.BoolOf(&testNoOpen),
140+
Hidden: true, // This is for testing!
141+
},
142+
}
143+
144+
return cmd
145+
}

cli/open_test.go

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package cli_test
2+
3+
import (
4+
"context"
5+
"net/url"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/coder/coder/v2/agent/agenttest"
13+
"github.com/coder/coder/v2/cli/clitest"
14+
"github.com/coder/coder/v2/coderd/coderdtest"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/provisionersdk/proto"
17+
"github.com/coder/coder/v2/pty/ptytest"
18+
"github.com/coder/coder/v2/testutil"
19+
)
20+
21+
func TestOpen(t *testing.T) {
22+
t.Parallel()
23+
24+
t.Run("VSCode", func(t *testing.T) {
25+
t.Parallel()
26+
27+
client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
28+
agents[0].Directory = "/tmp"
29+
agents[0].Name = "agent1"
30+
return agents
31+
})
32+
33+
inv, root := clitest.New(t, "open", "vscode", "--test.no-open", workspace.Name)
34+
clitest.SetupConfig(t, client, root)
35+
pty := ptytest.New(t)
36+
inv.Stdin = pty.Input()
37+
inv.Stderr = pty.Output()
38+
inv.Stdout = pty.Output()
39+
40+
_ = agenttest.New(t, client.URL, agentToken)
41+
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
42+
43+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
44+
defer cancel()
45+
46+
cmdDone := tGo(t, func() {
47+
err := inv.WithContext(ctx).Run()
48+
assert.NoError(t, err)
49+
})
50+
51+
me, err := client.User(ctx, codersdk.Me)
52+
require.NoError(t, err)
53+
54+
line := pty.ReadLine(ctx)
55+
56+
// Opening vscode://coder.coder-remote/open?...
57+
parts := strings.Split(line, " ")
58+
require.Len(t, parts, 2)
59+
require.Contains(t, parts[1], "vscode://")
60+
u, err := url.ParseRequestURI(parts[1])
61+
require.NoError(t, err)
62+
63+
qp := u.Query()
64+
assert.Equal(t, client.URL.String(), qp.Get("url"))
65+
assert.Equal(t, me.Username, qp.Get("owner"))
66+
assert.Equal(t, workspace.Name, qp.Get("workspace"))
67+
assert.Equal(t, "agent1", qp.Get("agent"))
68+
assert.Equal(t, "/tmp", qp.Get("folder"))
69+
70+
cancel()
71+
<-cmdDone
72+
})
73+
}

cli/root.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,18 @@ func (r *RootCmd) Core() []*clibase.Cmd {
101101
r.create(),
102102
r.deleteWorkspace(),
103103
r.list(),
104+
r.open(),
104105
r.ping(),
105106
r.rename(),
107+
r.restart(),
106108
r.schedules(),
107109
r.show(),
108110
r.speedtest(),
109111
r.ssh(),
110112
r.start(),
113+
r.stat(),
111114
r.stop(),
112115
r.update(),
113-
r.restart(),
114-
r.stat(),
115116

116117
// Hidden
117118
r.gitssh(),

cli/ssh.go

+1
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func (r *RootCmd) ssh() *clibase.Cmd {
205205
if xerrors.Is(err, context.Canceled) {
206206
return cliui.Canceled
207207
}
208+
return err
208209
}
209210

210211
if r.disableDirect {

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ require (
160160
github.com/prometheus/common v0.45.0
161161
github.com/quasilyte/go-ruleguard/dsl v0.3.21
162162
github.com/robfig/cron/v3 v3.0.1
163+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
163164
github.com/spf13/afero v1.10.0
164165
github.com/spf13/pflag v1.0.5
165166
github.com/sqlc-dev/pqtype v0.3.0

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
817817
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
818818
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
819819
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
820+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
821+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
820822
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
821823
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
822824
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=

0 commit comments

Comments
 (0)