Skip to content

Commit fc36b98

Browse files
committed
feat: services across all workspaces, config set, interactive login
- ink services: shows all workspaces by default, -w to filter - ink config set workspace/project: save defaults to config - ink config show: display current config with sources - ink login: interactive menu (browser or API key)
1 parent 70c0554 commit fc36b98

4 files changed

Lines changed: 233 additions & 21 deletions

File tree

‎cmd/config.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/mldotink/cli/internal/config"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func init() {
11+
configSetCmd.Flags().Bool("global", true, "Save to global config (~/.config/ink/config)")
12+
configSetCmd.Flags().Bool("local", false, "Save to local config (.ink)")
13+
14+
configCmd.AddCommand(configSetCmd)
15+
configCmd.AddCommand(configShowCmd)
16+
rootCmd.AddCommand(configCmd)
17+
}
18+
19+
var configCmd = &cobra.Command{
20+
Use: "config",
21+
Short: "Manage CLI configuration",
22+
Example: `# Set default workspace
23+
ink config set workspace my-team
24+
25+
# Set default project
26+
ink config set project backend
27+
28+
# Save to local .ink file (project-scoped)
29+
ink config set workspace my-team --local
30+
31+
# Show current config
32+
ink config show`,
33+
}
34+
35+
var configSetCmd = &cobra.Command{
36+
Use: "set <key> <value>",
37+
Short: "Set a config value",
38+
Long: "Set workspace or project defaults. Saves to global config by default, use --local for project-scoped.",
39+
Args: exactArgs(2),
40+
Run: func(cmd *cobra.Command, args []string) {
41+
key, value := args[0], args[1]
42+
local, _ := cmd.Flags().GetBool("local")
43+
44+
c := &config.Config{}
45+
switch key {
46+
case "workspace":
47+
c.Workspace = value
48+
case "project":
49+
c.Project = value
50+
default:
51+
fatal(fmt.Sprintf("Unknown config key %q — use 'workspace' or 'project'", key))
52+
}
53+
54+
var err error
55+
if local {
56+
err = config.SaveLocal(c)
57+
} else {
58+
err = config.SaveGlobal(c)
59+
}
60+
if err != nil {
61+
fatal(fmt.Sprintf("Failed to save: %v", err))
62+
}
63+
64+
target := "~/.config/ink/config"
65+
if local {
66+
target = ".ink"
67+
}
68+
success(fmt.Sprintf("Set %s=%s in %s", key, bold.Render(value), target))
69+
},
70+
}
71+
72+
var configShowCmd = &cobra.Command{
73+
Use: "show",
74+
Short: "Show current config",
75+
Args: cobra.NoArgs,
76+
Run: func(cmd *cobra.Command, args []string) {
77+
fmt.Println()
78+
kv("API Key", maskKey(cfg.APIKey)+" "+dim.Render("("+sourceLabel(cfg.Sources["api_key"])+")"))
79+
if cfg.Workspace != "" {
80+
kv("Workspace", cfg.Workspace+" "+dim.Render("("+sourceLabel(cfg.Sources["workspace"])+")"))
81+
}
82+
if cfg.Project != "" {
83+
kv("Project", cfg.Project+" "+dim.Render("("+sourceLabel(cfg.Sources["project"])+")"))
84+
}
85+
fmt.Println()
86+
},
87+
}
88+
89+
func maskKey(key string) string {
90+
if key == "" {
91+
return dim.Render("(not set)")
92+
}
93+
if len(key) <= 12 {
94+
return key[:4] + "****"
95+
}
96+
return key[:8] + "…" + key[len(key)-4:]
97+
}

‎cmd/login.go‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"crypto/rand"
56
"crypto/sha256"
67
"encoding/base64"
@@ -9,6 +10,7 @@ import (
910
"net"
1011
"net/http"
1112
"net/url"
13+
"os"
1214
"os/exec"
1315
"runtime"
1416
"strings"
@@ -47,11 +49,41 @@ ink login --api-key dk_live_abc123`,
4749
}
4850
key = apiKey
4951
} else {
50-
k, err := oauthBrowserLogin()
51-
if err != nil {
52-
fatal(err.Error())
52+
fmt.Println()
53+
fmt.Println(" How would you like to authenticate?")
54+
fmt.Println()
55+
fmt.Println(" " + bold.Render("1") + " Log in with browser " + dim.Render("(recommended)"))
56+
fmt.Println(" " + bold.Render("2") + " Paste an API key")
57+
fmt.Println()
58+
fmt.Print(" Choice [1]: ")
59+
60+
scanner := bufio.NewScanner(os.Stdin)
61+
choice := "1"
62+
if scanner.Scan() {
63+
c := strings.TrimSpace(scanner.Text())
64+
if c != "" {
65+
choice = c
66+
}
67+
}
68+
69+
switch choice {
70+
case "1":
71+
k, err := oauthBrowserLogin()
72+
if err != nil {
73+
fatal(err.Error())
74+
}
75+
key = k
76+
case "2":
77+
fmt.Print(" API key: ")
78+
if scanner.Scan() {
79+
key = strings.TrimSpace(scanner.Text())
80+
}
81+
if !strings.HasPrefix(key, "dk_") {
82+
fatal("Invalid API key — keys start with dk_live_ or dk_test_")
83+
}
84+
default:
85+
fatal(fmt.Sprintf("Invalid choice %q", choice))
5386
}
54-
key = k
5587
}
5688

5789
c := &config.Config{APIKey: key}

‎cmd/root.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ var rootCmd = &cobra.Command{
147147
cfg = config.Resolve(apiKeyFlag, wsFlag, projectFlag)
148148

149149
switch cmd.Name() {
150-
case "login", "help", "completion", "workspaces", "whoami":
150+
case "login", "help", "completion", "workspaces", "whoami", "services", "config":
151151
// These commands don't operate within a workspace/project scope.
152152
default:
153153
if !jsonOutput {

‎cmd/services.go‎

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"fmt"
55

6+
"github.com/Khan/genqlient/graphql"
67
"github.com/mldotink/cli/internal/gql"
78
"github.com/spf13/cobra"
89
)
@@ -14,46 +15,128 @@ func init() {
1415
var listCmd = &cobra.Command{
1516
Use: "services",
1617
Short: "List services",
18+
Long: "Lists services across all workspaces by default. Use -w to filter by workspace.",
1719
Run: func(cmd *cobra.Command, args []string) {
1820
client := newClient()
1921

20-
result, err := gql.ListServices(ctx(), client, wsPtr())
22+
// If workspace explicitly set, list only that workspace
23+
if ws := wsPtr(); ws != nil {
24+
listServicesForWorkspace(client, *ws)
25+
return
26+
}
27+
28+
// Otherwise list across all workspaces
29+
wsResult, err := gql.ListWorkspaces(ctx(), client)
2130
if err != nil {
2231
fatal(err.Error())
2332
}
2433

34+
type svcRow struct {
35+
name, workspace, project, status, url, memory, vcpus string
36+
}
37+
var allRows []svcRow
38+
39+
for _, ws := range wsResult.WorkspaceList {
40+
result, err := gql.ListServices(ctx(), client, &ws.Slug)
41+
if err != nil {
42+
continue
43+
}
44+
projMap := make(map[string]string)
45+
for _, p := range result.ProjectList.Nodes {
46+
projMap[p.Id] = p.Slug
47+
}
48+
for _, s := range result.ServiceList.Nodes {
49+
url := dim.Render("—")
50+
if s.Fqdn != nil {
51+
url = *s.Fqdn
52+
}
53+
allRows = append(allRows, svcRow{
54+
name: deref(s.Name, ""),
55+
workspace: ws.Slug,
56+
project: projMap[s.ProjectId],
57+
status: s.Status,
58+
url: url,
59+
memory: s.Memory,
60+
vcpus: s.Vcpus,
61+
})
62+
}
63+
}
64+
2565
if jsonOutput {
26-
printJSON(result.ServiceList)
66+
printJSON(allRows)
2767
return
2868
}
2969

30-
nodes := result.ServiceList.Nodes
31-
if len(nodes) == 0 {
70+
if len(allRows) == 0 {
3271
fmt.Println(dim.Render(" No services"))
3372
return
3473
}
3574

36-
projMap := make(map[string]string)
37-
for _, p := range result.ProjectList.Nodes {
38-
projMap[p.Id] = p.Slug
75+
// If all services are in the same workspace, hide the workspace column
76+
singleWS := true
77+
for _, r := range allRows[1:] {
78+
if r.workspace != allRows[0].workspace {
79+
singleWS = false
80+
break
81+
}
3982
}
4083

4184
var rows [][]string
42-
for _, s := range nodes {
43-
url := dim.Render("—")
44-
if s.Fqdn != nil {
45-
url = *s.Fqdn
85+
if singleWS {
86+
for _, r := range allRows {
87+
rows = append(rows, []string{r.name, r.project, renderStatus(r.status), r.url, r.memory, r.vcpus})
88+
}
89+
fmt.Println()
90+
fmt.Println(styledTable([]string{"NAME", "PROJECT", "STATUS", "URL", "MEMORY", "vCPU"}, rows))
91+
} else {
92+
for _, r := range allRows {
93+
rows = append(rows, []string{r.name, r.workspace, r.project, renderStatus(r.status), r.url, r.memory, r.vcpus})
4694
}
47-
rows = append(rows, []string{deref(s.Name, ""), projMap[s.ProjectId], renderStatus(s.Status), url, s.Memory, s.Vcpus})
95+
fmt.Println()
96+
fmt.Println(styledTable([]string{"NAME", "WORKSPACE", "PROJECT", "STATUS", "URL", "MEMORY", "vCPU"}, rows))
4897
}
49-
50-
fmt.Println()
51-
fmt.Println(styledTable([]string{"NAME", "PROJECT", "STATUS", "URL", "MEMORY", "vCPU"}, rows))
52-
tableFooter(len(nodes), "service")
98+
tableFooter(len(allRows), "service")
5399
fmt.Println()
54100
},
55101
}
56102

103+
func listServicesForWorkspace(client graphql.Client, ws string) {
104+
result, err := gql.ListServices(ctx(), client, &ws)
105+
if err != nil {
106+
fatal(err.Error())
107+
}
108+
109+
if jsonOutput {
110+
printJSON(result.ServiceList)
111+
return
112+
}
113+
114+
nodes := result.ServiceList.Nodes
115+
if len(nodes) == 0 {
116+
fmt.Println(dim.Render(" No services"))
117+
return
118+
}
119+
120+
projMap := make(map[string]string)
121+
for _, p := range result.ProjectList.Nodes {
122+
projMap[p.Id] = p.Slug
123+
}
124+
125+
var rows [][]string
126+
for _, s := range nodes {
127+
url := dim.Render("—")
128+
if s.Fqdn != nil {
129+
url = *s.Fqdn
130+
}
131+
rows = append(rows, []string{deref(s.Name, ""), projMap[s.ProjectId], renderStatus(s.Status), url, s.Memory, s.Vcpus})
132+
}
133+
134+
fmt.Println()
135+
fmt.Println(styledTable([]string{"NAME", "PROJECT", "STATUS", "URL", "MEMORY", "vCPU"}, rows))
136+
tableFooter(len(nodes), "service")
137+
fmt.Println()
138+
}
139+
57140
func findService(name string) *gql.FindServiceServiceListServiceConnectionNodesService {
58141
client := newClient()
59142
result, err := gql.FindService(ctx(), client, wsPtr())

0 commit comments

Comments
 (0)