mirror of
https://github.com/chenasraf/vstask.git
synced 2026-05-17 17:38:04 +00:00
feat: support task inputs
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"npm.packageManager": "pnpm"
|
||||
}
|
||||
190
.vscode/tasks.json
vendored
Normal file
190
.vscode/tasks.json
vendored
Normal 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
2
go.mod
@@ -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
7
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user