feat: support task inputs

This commit is contained in:
2025-09-19 18:08:34 +03:00
parent e7e36a4453
commit 0b66040420
9 changed files with 573 additions and 15 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"npm.packageManager": "pnpm"
}

190
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,190 @@
{
"version": "2.0.0",
"inputs": [
{
"id": "username",
"type": "promptString",
"description": "Your display name",
"default": "Chen"
},
{
"id": "secret",
"type": "promptString",
"description": "Enter a secret (masked)",
"password": true
},
{
"id": "env",
"type": "pickString",
"description": "Select environment",
"options": [
"dev",
"staging",
"prod"
],
"default": "dev"
},
{
"id": "branch",
"type": "command",
"description": "Current git branch (auto-detected)",
"command": "git rev-parse --abbrev-ref HEAD",
"default": "main"
},
{
"id": "workdir",
"type": "promptString",
"description": "Relative working directory",
"default": "."
}
],
"tasks": [
{
"label": "PM: help",
"type": "npm",
"command": "help",
"problemMatcher": []
},
{
"label": "PM: config list",
"type": "npm",
"command": "config",
"args": [
"list"
],
"problemMatcher": []
},
{
"label": "Shell: sanity check",
"type": "shell",
"command": "echo '✅ tasks.json is wired up'",
"problemMatcher": []
},
{
"label": "Shell: interactive input",
"type": "shell",
"options": {
"shell": {
"executable": "/bin/bash"
}
},
"command": "bash -c 'read -p \"Enter your name: \" name; echo \"Hello, $name!\"'",
"presentation": {
"reveal": "always",
"panel": "dedicated",
"focus": true,
"echo": true
},
"problemMatcher": []
},
{
"label": "Shell: interactive input (bash)",
"type": "process",
"command": "/bin/bash",
"args": [
"-lc",
"printf 'Enter your name: '; IFS= read -r name; printf 'Hello, %s!\\n' \"$name\""
],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"focus": true,
"echo": true
},
"problemMatcher": []
},
{
"label": "01 Inputs: Prompt Name",
"type": "shell",
"options": {
"cwd": "${input:workdir}"
},
"command": "echo",
"args": [
"Hello, ${input:username} (from ${workspaceFolderBasename})"
]
},
{
"label": "02 Inputs: Pick Env",
"type": "shell",
"options": {
"cwd": "${input:workdir}",
"env": {
"APP_ENV": "${input:env}"
}
},
"command": "sh",
"args": [
"-lc",
"echo Selected env is \"$APP_ENV\"; echo CWD=$(pwd)"
]
},
{
"label": "03 Inputs: Command Branch",
"type": "shell",
"options": {
"cwd": "${input:workdir}"
},
"command": "echo",
"args": [
"Detected branch: ${input:branch}"
]
},
{
"label": "04 Inputs: Secret (masked echo count)",
"type": "shell",
"command": "sh",
"args": [
"-lc",
"printf 'Secret length: %s\\n' ${#SECRET}",
"${input:secret}"
],
"options": {
"cwd": "${input:workdir}",
"env": {
"SECRET": "${input:secret}"
}
}
},
{
"label": "Run: Demo (sequence)",
"type": "shell",
"dependsOn": {
"tasks": [
"01 Inputs: Prompt Name",
"02 Inputs: Pick Env",
"03 Inputs: Command Branch",
"04 Inputs: Secret (masked echo count)"
]
},
"dependsOrder": "sequence",
"command": "sh",
"args": [
"-lc",
"echo 'All inputs resolved. Username=${input:username}, Env=${input:env}, Branch=${input:branch}'; echo Done."
],
"options": {
"cwd": "${input:workdir}"
}
},
{
"label": "Run: Demo (parallel)",
"type": "shell",
"dependsOn": {
"tasks": [
"01 Inputs: Prompt Name",
"02 Inputs: Pick Env",
"03 Inputs: Command Branch"
]
},
"command": "sh",
"args": [
"-lc",
"echo 'Parallel deps done → ${input:username}@${input:env} on ${input:branch}'"
],
"options": {
"cwd": "${input:workdir}"
}
}
]
}

2
go.mod
View File

@@ -13,11 +13,13 @@ require (
)
require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.6.0 // indirect
github.com/ktr0731/go-ansisgr v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nsf/termbox-go v1.1.1 // indirect

7
go.sum
View File

@@ -1,3 +1,7 @@
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -21,6 +25,8 @@ github.com/ktr0731/go-fuzzyfinder v0.9.0 h1:JV8S118RABzRl3Lh/RsPhXReJWc2q0rbuipz
github.com/ktr0731/go-fuzzyfinder v0.9.0/go.mod h1:uybx+5PZFCgMCSDHJDQ9M3nNKx/vccPmGffsXPn2ad8=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -74,6 +80,7 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -14,6 +14,7 @@ import (
"github.com/chenasraf/vstask/utils"
)
// RunTask executes a task, resolving its dependsOn (sequence/parallel) and prompting for ${input:*}.
func RunTask(task tasks.Task) error {
// Load all tasks so we can resolve dependsOn by label.
all, err := tasks.GetTasks()
@@ -22,6 +23,13 @@ func RunTask(task tasks.Task) error {
}
index := indexByLabel(all)
// Load inputs (best effort; if not present we'll fallback to generic prompting).
var inputs []tasks.Input
if gi, err := tasks.GetInputs(); err == nil && gi != nil {
inputs = gi
}
resolver := NewInputResolver(inputs)
// Figure out workspace folder for substitutions.
root, err := utils.FindProjectRoot()
if err != nil {
@@ -37,7 +45,7 @@ func RunTask(task tasks.Task) error {
if !ok {
return fmt.Errorf("dependsOn: task %q not found", lbl)
}
if err := runSingleTaskWithDeps(dep, index, root); err != nil {
if err := runSingleTaskWithDeps(dep, index, root, resolver); err != nil {
return fmt.Errorf("dependency %q failed: %w", lbl, err)
}
}
@@ -53,7 +61,7 @@ func RunTask(task tasks.Task) error {
wg.Add(1)
go func() {
defer wg.Done()
if err := runSingleTaskWithDeps(dep, index, root); err != nil {
if err := runSingleTaskWithDeps(dep, index, root, resolver); err != nil {
errCh <- fmt.Errorf("dependency %q failed: %w", depLbl, err)
}
}()
@@ -69,19 +77,23 @@ func RunTask(task tasks.Task) error {
}
// Now run the main task.
return runSingleTask(task, root)
return runSingleTask(task, root, resolver)
}
func runSingleTask(t tasks.Task, workspace string) error {
func runSingleTask(t tasks.Task, workspace string, resolver *InputResolver) error {
eff := applyPlatformOverrides(t)
// ---- Prompt for all inputs referenced by this effective task BEFORE doing anything else ----
promptInputsForTask(eff, resolver)
// Prelim vars (process cwd)
preVars := buildVSCodeVarMapWithCWD(workspace, mustGetwd())
// Resolve the task's effective cwd
// Resolve the task's effective cwd (support ${input:*} + ${vscodeVar})
cwd := workspace
if eff.Options != nil && eff.Options.Cwd != "" {
cwdr := substituteVars(eff.Options.Cwd, preVars)
cwdr := replaceInputs(eff.Options.Cwd, resolver)
cwdr = substituteVars(cwdr, preVars)
if filepath.IsAbs(cwdr) {
cwd = cwdr
} else {
@@ -92,9 +104,12 @@ func runSingleTask(t tasks.Task, workspace string) error {
// Final vars with the effective cwd
vars := buildVSCodeVarMapWithCWD(workspace, cwd)
// Substitute ${...} in command/args using the final vars
// Substitute inputs then vscode vars in command/args
eff.Command = replaceInputs(eff.Command, resolver)
eff.Command = substituteVars(eff.Command, vars)
for i := range eff.Args {
eff.Args[i] = replaceInputs(eff.Args[i], resolver)
eff.Args[i] = substituteVars(eff.Args[i], vars)
}
@@ -103,7 +118,9 @@ func runSingleTask(t tasks.Task, workspace string) error {
if eff.Options != nil && len(eff.Options.Env) > 0 {
merged := make(map[string]string, len(eff.Options.Env))
for k, v := range eff.Options.Env {
merged[k] = substituteVars(v, vars)
val := replaceInputs(v, resolver)
val = substituteVars(val, vars)
merged[k] = val
}
env = mergeEnv(env, merged)
}
@@ -139,7 +156,7 @@ func runSingleTask(t tasks.Task, workspace string) error {
return err
}
func runSingleTaskWithDeps(t tasks.Task, index map[string]tasks.Task, root string) error {
func runSingleTaskWithDeps(t tasks.Task, index map[string]tasks.Task, root string, resolver *InputResolver) error {
// Avoid infinite recursion if someone misconfigured cyclic deps.
seen := map[string]bool{}
var run func(tasks.Task) error
@@ -148,6 +165,10 @@ func runSingleTaskWithDeps(t tasks.Task, index map[string]tasks.Task, root strin
return fmt.Errorf("cycle detected at %q", tt.Label)
}
seen[tt.Label] = true
// Prompt inputs early for this node in the graph
promptInputsForTask(applyPlatformOverrides(tt), resolver)
if tt.DependsOn != nil && len(tt.DependsOn.Tasks) > 0 {
switch strings.ToLower(tt.DependsOrder) {
case "sequence":
@@ -185,7 +206,7 @@ func runSingleTaskWithDeps(t tasks.Task, index map[string]tasks.Task, root strin
}
}
}
return runSingleTask(tt, root)
return runSingleTask(tt, root, resolver)
}
return run(t)
}

View File

@@ -1,14 +1,20 @@
package runner
import (
"bufio"
"bytes"
"fmt"
"io"
"maps"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/chenasraf/vstask/tasks"
"github.com/manifoldco/promptui"
)
func indexByLabel(ts []tasks.Task) map[string]tasks.Task {
@@ -71,6 +77,271 @@ func applyPlatformOverrides(t tasks.Task) tasks.Task {
return eff
}
// ----------------- Input resolution -----------------
// Expectation for tasks.Input (align with your tasks package):
// type Input struct {
// ID string `json:"id"`
// Type string `json:"type"` // "promptString" | "pickString" | "command"
// Description string `json:"description"`
// Default string `json:"default"`
// Password bool `json:"password"` // promptString only
// Options []string `json:"options"` // pickString only
// Command string `json:"command"` // command only
// }
type InputResolver struct {
byID map[string]tasks.Input
cache map[string]string
}
func NewInputResolver(inputs []tasks.Input) *InputResolver {
m := make(map[string]tasks.Input, len(inputs))
for _, in := range inputs {
m[in.ID] = in
}
return &InputResolver{
byID: m,
cache: map[string]string{},
}
}
var reInput = regexp.MustCompile(`\$\{input:([^}]+)\}`)
// promptInputsForTask scans the effective task for ${input:*} and resolves all before running.
func promptInputsForTask(t tasks.Task, r *InputResolver) {
ids := collectInputRefsFromTask(t)
for _, id := range ids {
_, _ = r.Resolve(id) // cache it
}
}
func collectInputRefsFromTask(t tasks.Task) []string {
seen := make(map[string]struct{})
grab := func(s string) {
for _, m := range reInput.FindAllStringSubmatch(s, -1) {
if len(m) == 2 {
seen[m[1]] = struct{}{}
}
}
}
grab(t.Command)
for _, a := range t.Args {
grab(a)
}
if t.Options != nil {
grab(t.Options.Cwd)
for _, v := range t.Options.Env {
grab(v)
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out
}
func replaceInputs(s string, r *InputResolver) string {
if s == "" || r == nil {
return s
}
return reInput.ReplaceAllStringFunc(s, func(m string) string {
sub := reInput.FindStringSubmatch(m)
if len(sub) == 2 {
val, _ := r.Resolve(sub[1])
return val
}
return m
})
}
// Resolve returns a value for an input id, prompting if necessary.
// Caches values so the same id is only prompted once.
func (r *InputResolver) Resolve(id string) (string, error) {
if v, ok := r.cache[id]; ok {
return v, nil
}
// Env override (handy for CI): VSTASK_INPUT_<UPPER_ID>
if env := os.Getenv("VSTASK_INPUT_" + strings.ToUpper(id)); env != "" {
r.cache[id] = env
return env, nil
}
in, ok := r.byID[id]
if !ok {
// Unknown input: fallback to simple line prompt.
val, err := simpleLinePrompt(fmt.Sprintf("Enter value for %s", id), "")
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
switch strings.ToLower(in.Type) {
case "promptstring":
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, in.Default, in.Password)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
case "pickstring":
if len(in.Options) == 0 {
// Degenerate case: no options → line prompt with default
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, in.Default, false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
val, err := promptSelect(in.DescriptionOrFallback(), in.Options, in.Default)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
case "command":
out := strings.TrimSpace(runInputShell(in.Command))
if out == "" {
// Fallback to default or prompt
if in.Default != "" {
r.cache[id] = in.Default
return in.Default, nil
}
lbl := in.Description
if strings.TrimSpace(lbl) == "" {
lbl = fmt.Sprintf("Enter %s", in.ID)
}
val, err := promptString(lbl, "", false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
r.cache[id] = out
return out, nil
default:
// Unknown type → prompt
val, err := promptString(fmt.Sprintf("Enter %s", in.ID), in.Default, false)
if err != nil {
return "", err
}
r.cache[id] = val
return val, nil
}
}
// --- tiny prompt helpers (not fullscreen) ---
// bellFilter strips ASCII BEL (\a) and implements io.WriteCloser.
// Close is a no-op so we never close stdout/stderr.
type bellFilter struct{ w io.Writer }
func (b bellFilter) Write(p []byte) (int, error) {
p = bytes.ReplaceAll(p, []byte{'\a'}, nil)
return b.w.Write(p)
}
func (b bellFilter) Close() error { return nil }
func promptString(label, def string, password bool) (string, error) {
p := promptui.Prompt{
Label: label,
Default: def,
Stdout: bellFilter{os.Stdout},
}
if password {
p.Mask = '*'
}
return p.Run()
}
func promptSelect(label string, options []string, def string) (string, error) {
idx := 0
if def != "" {
for i, o := range options {
if o == def {
idx = i
break
}
}
}
s := promptui.Select{
Label: label,
Items: options,
CursorPos: idx,
Size: minInt(8, maxInt(3, len(options))), // small window; never fullscreen
Stdout: bellFilter{os.Stdout},
}
_, val, err := s.Run()
return val, err
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func simpleLinePrompt(label, def string) (string, error) {
if def != "" {
fmt.Printf("%s [%s]: ", label, def)
} else {
fmt.Printf("%s: ", label)
}
br := bufio.NewReader(os.Stdin)
s, err := br.ReadString('\n')
if err != nil {
return "", err
}
s = strings.TrimRight(s, "\r\n")
if s == "" && def != "" {
return def, nil
}
return s, nil
}
func runInputShell(script string) string {
if strings.TrimSpace(script) == "" {
return ""
}
exe, args := defaultShell()
cmd := exec.Command(exe, append(args, script)...)
// Inherit env and CWD; capture stdout
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}
// ----------------- existing helpers -----------------
func substituteVars(s string, vars map[string]string) string {
if s == "" {
return s

View File

@@ -237,7 +237,8 @@ func TestRunSingleTaskWithDeps_Sequence(t *testing.T) {
// Build index and run
index := map[string]tasks.Task{"dep1": dep1, "dep2": dep2}
if err := runSingleTaskWithDeps(mainT, index, workspace); err != nil {
resolver := NewInputResolver(nil)
if err := runSingleTaskWithDeps(mainT, index, workspace, resolver); err != nil {
t.Fatalf("sequence deps failed: %v", err)
}
}
@@ -261,7 +262,8 @@ func TestRunSingleTaskWithDeps_Parallel(t *testing.T) {
}
index := map[string]tasks.Task{"dep1": dep1, "dep2": dep2}
if err := runSingleTaskWithDeps(mainT, index, workspace); err != nil {
resolver := NewInputResolver(nil)
if err := runSingleTaskWithDeps(mainT, index, workspace, resolver); err != nil {
t.Fatalf("parallel deps failed: %v", err)
}
}

View File

@@ -7,9 +7,43 @@ import (
// File is the root of .vscode/tasks.json
type File struct {
Version string `json:"version,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
Inputs []any `json:"inputs,omitempty"` // keep flexible; inputs can vary
Version string `json:"version,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
Inputs []Input `json:"inputs,omitempty"` // VS Code "inputs" array
}
// -------------------------
// Input (VS Code "inputs")
// -------------------------
//
// Matches VS Code's input types:
// - promptString: { "id", "type":"promptString", "description"?, "default"?, "password"? }
// - pickString: { "id", "type":"pickString", "description"?, "options":[...], "default"? }
// - command: { "id", "type":"command", "command":"...", "args"?: any, "description"?, "default"? }
//
// Note: We keep a superset struct; unused fields simply stay zero.
type Input struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"` // "promptString" | "pickString" | "command"
Description string `json:"description,omitempty"` // shown to the user
Default string `json:"default,omitempty"` // default value if user just presses Enter
Password bool `json:"password,omitempty"` // promptString only
Options []string `json:"options,omitempty"` // pickString only
// Command input
Command string `json:"command,omitempty"` // command to run; we use its stdout as value
Args json.RawMessage `json:"args,omitempty"` // optional args payload (not used by runner yet)
}
// DescriptionOrFallback returns a non-empty label for prompting.
func (in *Input) DescriptionOrFallback() string {
if d := in.Description; d != "" {
return d
}
if in.ID != "" {
return "Select " + in.ID
}
return "Select an option"
}
// Task represents a single VS Code task (2.0.0 schema).

View File

@@ -2,8 +2,11 @@ package tasks
import (
"bytes"
"fmt"
"os"
"path/filepath"
"github.com/chenasraf/vstask/utils"
"github.com/ktr0731/go-fuzzyfinder"
json "github.com/neilotoole/jsoncolor"
)
@@ -48,3 +51,28 @@ func PromptForTask() (Task, error) {
return taskList[idx], nil
}
// GetInputs loads .vscode/tasks.json from the nearest project root and returns the "inputs" array.
// If the file exists but has no inputs, it returns an empty slice (not nil).
func GetInputs() ([]Input, error) {
root, err := utils.FindProjectRoot()
if err != nil {
return nil, fmt.Errorf("find project root: %w", err)
}
p := filepath.Join(root, ".vscode", "tasks.json")
data, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read tasks.json: %w", err)
}
var f File
if err := json.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse tasks.json: %w", err)
}
if f.Inputs == nil {
return []Input{}, nil
}
return f.Inputs, nil
}