Skip to content

Commit fe5ab44

Browse files
committed
feat: interactive prompt support for bootstrap steps
1 parent 519359b commit fe5ab44

5 files changed

Lines changed: 106 additions & 5 deletions

File tree

config/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ type Config struct {
1818
}
1919

2020
type BootstrapStep struct {
21-
Name string `yaml:"name"`
22-
Dir string `yaml:"dir"`
23-
Check string `yaml:"check"`
24-
Run string `yaml:"run"`
21+
Name string `yaml:"name"`
22+
Dir string `yaml:"dir"`
23+
Check string `yaml:"check"`
24+
Run string `yaml:"run"`
25+
Prompt string `yaml:"prompt"`
2526
}
2627

2728
type Dep struct {

engine/bootstrap.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package engine
22

33
import (
4+
"bufio"
45
"context"
56
"fmt"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"path/filepath"
11+
12+
"golang.org/x/term"
613

714
"github.com/warriorscode/deck/config"
815
)
@@ -14,13 +21,71 @@ func RunBootstrap(ctx context.Context, dir string, steps []config.BootstrapStep,
1421
if CheckShell(ctx, d, step.Check, env) {
1522
continue
1623
}
17-
if err := RunShell(ctx, d, step.Run, env); err != nil {
24+
stepEnv := env
25+
if step.Prompt != "" {
26+
extra, err := handlePrompt(step)
27+
if err != nil {
28+
return fmt.Errorf("bootstrap %q: %w", step.Name, err)
29+
}
30+
stepEnv = append(append([]string{}, env...), extra...)
31+
}
32+
if err := RunShell(ctx, d, step.Run, stepEnv); err != nil {
1833
return fmt.Errorf("bootstrap %q: %w", step.Name, err)
1934
}
2035
}
2136
return nil
2237
}
2338

39+
// handlePrompt displays the prompt, reads multi-line input, writes it to a temp file,
40+
// and returns env vars pointing to the input.
41+
func handlePrompt(step config.BootstrapStep) ([]string, error) {
42+
if !term.IsTerminal(int(os.Stdin.Fd())) {
43+
slog.Warn("bootstrap prompt skipped (not a terminal)", "step", step.Name)
44+
return nil, fmt.Errorf("prompt requires an interactive terminal")
45+
}
46+
47+
fmt.Fprintf(os.Stderr, "\n%s\n", step.Prompt)
48+
49+
input, err := readMultiLine(os.Stdin)
50+
if err != nil {
51+
return nil, fmt.Errorf("reading input: %w", err)
52+
}
53+
54+
tmpFile, err := os.CreateTemp("", "deck-prompt-*")
55+
if err != nil {
56+
return nil, fmt.Errorf("creating temp file: %w", err)
57+
}
58+
if _, err := tmpFile.WriteString(input); err != nil {
59+
tmpFile.Close()
60+
os.Remove(tmpFile.Name())
61+
return nil, fmt.Errorf("writing temp file: %w", err)
62+
}
63+
tmpFile.Close()
64+
65+
absPath, _ := filepath.Abs(tmpFile.Name())
66+
return []string{
67+
"DECK_INPUT=" + input,
68+
"DECK_INPUT_FILE=" + absPath,
69+
}, nil
70+
}
71+
72+
// readMultiLine reads lines until an empty line or EOF.
73+
func readMultiLine(r io.Reader) (string, error) {
74+
scanner := bufio.NewScanner(r)
75+
var result string
76+
for scanner.Scan() {
77+
line := scanner.Text()
78+
if line == "" {
79+
break
80+
}
81+
if result != "" {
82+
result += "\n"
83+
}
84+
result += line
85+
}
86+
return result, scanner.Err()
87+
}
88+
2489
func stepDir(base, override string) string {
2590
if override != "" {
2691
return override

engine/bootstrap_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7+
"strings"
78
"testing"
89

10+
"github.com/stretchr/testify/assert"
911
"github.com/stretchr/testify/require"
1012

1113
"github.com/warriorscode/deck/config"
@@ -45,3 +47,33 @@ func TestBootstrapFailFast(t *testing.T) {
4547
_, err = os.Stat(marker)
4648
require.True(t, os.IsNotExist(err))
4749
}
50+
51+
func TestReadMultiLine(t *testing.T) {
52+
input := "line one\nline two\nline three\n\nignored"
53+
result, err := readMultiLine(strings.NewReader(input))
54+
require.NoError(t, err)
55+
assert.Equal(t, "line one\nline two\nline three", result)
56+
}
57+
58+
func TestReadMultiLineEOF(t *testing.T) {
59+
input := "single line"
60+
result, err := readMultiLine(strings.NewReader(input))
61+
require.NoError(t, err)
62+
assert.Equal(t, "single line", result)
63+
}
64+
65+
func TestReadMultiLineEmpty(t *testing.T) {
66+
result, err := readMultiLine(strings.NewReader("\n"))
67+
require.NoError(t, err)
68+
assert.Equal(t, "", result)
69+
}
70+
71+
func TestBootstrapPromptSkipsInNonTTY(t *testing.T) {
72+
steps := []config.BootstrapStep{
73+
{Name: "Needs input", Check: "false", Prompt: "Paste key:", Run: "true"},
74+
}
75+
// CI/test stdin is not a TTY, so prompt should fail gracefully.
76+
err := RunBootstrap(context.Background(), ".", steps, nil)
77+
require.Error(t, err)
78+
assert.Contains(t, err.Error(), "interactive terminal")
79+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ require (
205205
golang.org/x/mod v0.34.0 // indirect
206206
golang.org/x/sync v0.20.0 // indirect
207207
golang.org/x/sys v0.42.0 // indirect
208+
golang.org/x/term v0.41.0 // indirect
208209
golang.org/x/text v0.34.0 // indirect
209210
golang.org/x/tools v0.43.0 // indirect
210211
google.golang.org/protobuf v1.36.8 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
806806
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
807807
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
808808
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
809+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
810+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
809811
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
810812
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
811813
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

0 commit comments

Comments
 (0)