mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
tiki workflow command
This commit is contained in:
parent
6713b5b7c5
commit
56d3b64c22
11 changed files with 805 additions and 281 deletions
|
|
@ -32,16 +32,16 @@ tiki exec 'select where status = "ready" order by priority'
|
|||
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
|
||||
```
|
||||
|
||||
### config
|
||||
### workflow
|
||||
|
||||
Manage configuration files.
|
||||
Manage workflow configuration files.
|
||||
|
||||
#### config reset
|
||||
#### workflow reset
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
```bash
|
||||
tiki config reset [target] --scope
|
||||
tiki workflow reset [target] [--scope]
|
||||
```
|
||||
|
||||
**Targets** (omit to reset all three files):
|
||||
|
|
@ -49,7 +49,7 @@ tiki config reset [target] --scope
|
|||
- `workflow` — workflow.yaml
|
||||
- `new` — new.md (task template)
|
||||
|
||||
**Scopes** (required):
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
|
@ -60,13 +60,34 @@ For `--local` and `--current`, files are deleted so the next tier in the [preced
|
|||
|
||||
```bash
|
||||
# restore all global config to defaults
|
||||
tiki config reset --global
|
||||
tiki workflow reset --global
|
||||
|
||||
# remove project workflow overrides (falls back to global)
|
||||
tiki config reset workflow --local
|
||||
tiki workflow reset workflow --local
|
||||
|
||||
# remove cwd config override
|
||||
tiki config reset config --current
|
||||
tiki workflow reset config --current
|
||||
```
|
||||
|
||||
#### workflow install
|
||||
|
||||
Install a named workflow from the tiki repository. Downloads `workflow.yaml` and `new.md` into the scope directory, overwriting any existing files.
|
||||
|
||||
```bash
|
||||
tiki workflow install <name> [--scope]
|
||||
```
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
```bash
|
||||
# install the sprint workflow globally
|
||||
tiki workflow install sprint --global
|
||||
|
||||
# install the kanban workflow for the current project
|
||||
tiki workflow install kanban --local
|
||||
```
|
||||
|
||||
### demo
|
||||
|
|
|
|||
125
cmd_config.go
125
cmd_config.go
|
|
@ -1,125 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runConfig dispatches config subcommands. Returns an exit code.
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runConfigReset(args[1:])
|
||||
case "--help", "-h":
|
||||
printConfigUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runConfigReset implements `tiki config reset [target] --scope`.
|
||||
func runConfigReset(args []string) int {
|
||||
target, scope, err := parseConfigResetArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printConfigResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printConfigResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseConfigResetArgs parses arguments for `tiki config reset`.
|
||||
func parseConfigResetArgs(args []string) (config.ResetTarget, config.ResetScope, error) {
|
||||
var targetStr string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if targetStr != "" {
|
||||
return "", "", fmt.Errorf("multiple targets specified: %q and %q", targetStr, arg)
|
||||
}
|
||||
targetStr = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
return "", "", fmt.Errorf("scope required: --global, --local, or --current")
|
||||
}
|
||||
|
||||
target := config.ResetTarget(targetStr)
|
||||
switch target {
|
||||
case config.TargetAll, config.TargetConfig, config.TargetWorkflow, config.TargetNew:
|
||||
// valid
|
||||
default:
|
||||
return "", "", fmt.Errorf("unknown target: %q (use config, workflow, or new)", targetStr)
|
||||
}
|
||||
|
||||
return target, config.ResetScope(scopeStr), nil
|
||||
}
|
||||
|
||||
func printConfigUsage() {
|
||||
fmt.Print(`Usage: tiki config <command>
|
||||
|
||||
Commands:
|
||||
reset [target] --scope Reset config files to defaults
|
||||
|
||||
Run 'tiki config reset --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printConfigResetUsage() {
|
||||
fmt.Print(`Usage: tiki config reset [target] --scope
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (required):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
func TestParseConfigResetArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target config.ResetTarget
|
||||
scope config.ResetScope
|
||||
wantErr error // sentinel match (nil = no error)
|
||||
errSubstr string // substring match for non-sentinel errors
|
||||
}{
|
||||
{
|
||||
name: "global all",
|
||||
args: []string{"--global"},
|
||||
target: config.TargetAll,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local workflow",
|
||||
args: []string{"workflow", "--local"},
|
||||
target: config.TargetWorkflow,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "current config",
|
||||
args: []string{"--current", "config"},
|
||||
target: config.TargetConfig,
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "global new",
|
||||
args: []string{"new", "--global"},
|
||||
target: config.TargetNew,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope",
|
||||
args: []string{"config"},
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "unknown target",
|
||||
args: []string{"themes", "--global"},
|
||||
errSubstr: "unknown target",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple targets",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
args: nil,
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target, scope, err := parseConfigResetArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if target != tt.target {
|
||||
t.Errorf("target = %q, want %q", target, tt.target)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
180
cmd_workflow.go
Normal file
180
cmd_workflow.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runWorkflow dispatches workflow subcommands. Returns an exit code.
|
||||
func runWorkflow(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runWorkflowReset(args[1:])
|
||||
case "install":
|
||||
return runWorkflowInstall(args[1:])
|
||||
case "--help", "-h":
|
||||
printWorkflowUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown workflow command: %s\n", args[0])
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runWorkflowReset implements `tiki workflow reset [target] --scope`.
|
||||
func runWorkflowReset(args []string) int {
|
||||
positional, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
target := config.ResetTarget(positional)
|
||||
if !config.ValidResetTarget(target) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: unknown target: %q (use config, workflow, or new)\n", positional)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowInstall implements `tiki workflow install <name> --scope`.
|
||||
func runWorkflowInstall(args []string) int {
|
||||
name, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowInstallUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
results, err := config.InstallWorkflow(name, scope, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
fmt.Println("installed", r.Path)
|
||||
} else {
|
||||
fmt.Println("unchanged", r.Path)
|
||||
}
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
|
||||
// Returns errHelpRequested for --help/-h.
|
||||
func parseScopeArgs(args []string) (string, config.Scope, error) {
|
||||
var positional string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "local"
|
||||
}
|
||||
|
||||
return positional, config.Scope(scopeStr), nil
|
||||
}
|
||||
|
||||
func printWorkflowUsage() {
|
||||
fmt.Print(`Usage: tiki workflow <command>
|
||||
|
||||
Commands:
|
||||
reset [target] [--scope] Reset config files to defaults
|
||||
install <name> [--scope] Install a workflow from the tiki repository
|
||||
|
||||
Run 'tiki workflow <command> --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowResetUsage() {
|
||||
fmt.Print(`Usage: tiki workflow reset [target] [--scope]
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowInstallUsage() {
|
||||
fmt.Print(`Usage: tiki workflow install <name> [--scope]
|
||||
|
||||
Install a named workflow from the tiki repository.
|
||||
Downloads workflow.yaml and new.md into the scope directory,
|
||||
overwriting any existing files.
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
|
||||
Example:
|
||||
tiki workflow install sprint --global
|
||||
`)
|
||||
}
|
||||
281
cmd_workflow_test.go
Normal file
281
cmd_workflow_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// setupWorkflowTest creates a temp config dir for workflow commands.
|
||||
func setupWorkflowTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
config.ResetPathManager()
|
||||
t.Cleanup(config.ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
func overrideBaseURL(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := config.DefaultWorkflowBaseURL
|
||||
config.DefaultWorkflowBaseURL = url
|
||||
t.Cleanup(func() { config.DefaultWorkflowBaseURL = orig })
|
||||
}
|
||||
|
||||
func TestParseScopeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
scope config.Scope
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "global no positional",
|
||||
args: []string{"--global"},
|
||||
positional: "",
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local with positional",
|
||||
args: []string{"workflow", "--local"},
|
||||
positional: "workflow",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "scope before positional",
|
||||
args: []string{"--current", "config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope defaults to local",
|
||||
args: []string{"config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "multiple positional",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple positional arguments",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args defaults to local",
|
||||
args: nil,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, scope, err := parseScopeArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflow dispatch tests ---
|
||||
|
||||
func TestRunWorkflow_NoArgs(t *testing.T) {
|
||||
if code := runWorkflow(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_UnknownSubcommand(t *testing.T) {
|
||||
if code := runWorkflow([]string{"bogus"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_Help(t *testing.T) {
|
||||
if code := runWorkflow([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowReset integration tests ---
|
||||
|
||||
func TestRunWorkflowReset_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("custom"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code := runWorkflowReset([]string{"--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) == "custom" {
|
||||
t.Error("workflow.yaml was not reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_NothingToReset(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"config", "--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_InvalidTarget(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"themes", "--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowReset([]string{"config"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_Help(t *testing.T) {
|
||||
if code := runWorkflowReset([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowInstall integration tests ---
|
||||
|
||||
func TestRunWorkflowInstall_Success(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("sprint workflow"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("sprint template"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"sprint", "--global"}); code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "sprint workflow" {
|
||||
t.Errorf("workflow.yaml = %q, want %q", got, "sprint workflow")
|
||||
}
|
||||
got, _ = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if string(got) != "sprint template" {
|
||||
t.Errorf("new.md = %q, want %q", got, "sprint template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"../../etc", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"nonexistent", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowInstall([]string{"sprint"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_Help(t *testing.T) {
|
||||
if code := runWorkflowInstall([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ Press Esc to cancel project initialization.`
|
|||
Value(&opts.AITools),
|
||||
huh.NewConfirm().
|
||||
Title("Create sample tasks").
|
||||
Description("Seed project with example tikis (incompatible samples are skipped automatically)").
|
||||
Description("Create example tikis in project").
|
||||
Value(&opts.SampleTasks),
|
||||
),
|
||||
).WithTheme(theme).
|
||||
|
|
|
|||
100
config/install.go
Normal file
100
config/install.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 15 * time.Second
|
||||
maxResponseSize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
var DefaultWorkflowBaseURL = "https://raw.githubusercontent.com/boolean-maybe/tiki/main"
|
||||
|
||||
var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// InstallResult describes the outcome for a single installed file.
|
||||
type InstallResult struct {
|
||||
Path string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
var installFiles = []string{
|
||||
defaultWorkflowFilename,
|
||||
templateFilename,
|
||||
}
|
||||
|
||||
// InstallWorkflow fetches a named workflow from baseURL and writes its files
|
||||
// to the directory for the given scope, overwriting existing files.
|
||||
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
|
||||
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
|
||||
if !validWorkflowName.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
|
||||
}
|
||||
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched := make(map[string]string, len(installFiles))
|
||||
for _, filename := range installFiles {
|
||||
content, err := fetchWorkflowFile(baseURL, name, filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
|
||||
}
|
||||
fetched[filename] = content
|
||||
}
|
||||
|
||||
var results []InstallResult
|
||||
for _, filename := range installFiles {
|
||||
path := filepath.Join(dir, filename)
|
||||
changed, err := writeFileIfChanged(path, fetched[filename])
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
results = append(results, InstallResult{Path: path, Changed: changed})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
func fetchWorkflowFile(baseURL, name, filename string) (string, error) {
|
||||
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", fmt.Errorf("workflow %q not found (%s)", name, filename)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
154
config/install_test.go
Normal file
154
config/install_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallWorkflow_Success(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("---\ntitle:\n---\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on fresh install", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != "statuses:\n - key: todo\n" {
|
||||
t.Errorf("workflow.yaml content = %q", string(got))
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != "---\ntitle:\n---\n" {
|
||||
t.Errorf("new.md content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_Overwrites(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("old content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("new content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on overwrite", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "new content" {
|
||||
t.Errorf("workflow.yaml not overwritten: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("nonexistent", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AlreadyUpToDate(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("same content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := InstallWorkflow("sprint", ScopeGlobal, server.URL); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
t.Errorf("expected %s to be unchanged on repeat install", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_InvalidName(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
_, err := InstallWorkflow(name, ScopeGlobal, "http://unused")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("partial", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
|
||||
for _, filename := range []string{"workflow.yaml", "new.md"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tikiDir, filename)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should not exist after fetch failure", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,16 +7,16 @@ import (
|
|||
"path/filepath"
|
||||
)
|
||||
|
||||
// ResetScope identifies which config tier to reset.
|
||||
type ResetScope string
|
||||
// Scope identifies which config tier to operate on.
|
||||
type Scope string
|
||||
|
||||
// ResetTarget identifies which config file to reset.
|
||||
type ResetTarget string
|
||||
|
||||
const (
|
||||
ScopeGlobal ResetScope = "global"
|
||||
ScopeLocal ResetScope = "local"
|
||||
ScopeCurrent ResetScope = "current"
|
||||
ScopeGlobal Scope = "global"
|
||||
ScopeLocal Scope = "local"
|
||||
ScopeCurrent Scope = "current"
|
||||
|
||||
TargetAll ResetTarget = ""
|
||||
TargetConfig ResetTarget = "config"
|
||||
|
|
@ -40,7 +40,7 @@ var resetEntries = []resetEntry{
|
|||
|
||||
// ResetConfig resets configuration files for the given scope and target.
|
||||
// Returns the list of file paths that were actually modified or deleted.
|
||||
func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
|
||||
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -67,7 +67,7 @@ func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
|
|||
}
|
||||
|
||||
// resolveDir returns the directory path for the given scope.
|
||||
func resolveDir(scope ResetScope) (string, error) {
|
||||
func resolveDir(scope Scope) (string, error) {
|
||||
switch scope {
|
||||
case ScopeGlobal:
|
||||
return GetConfigDir(), nil
|
||||
|
|
@ -87,6 +87,16 @@ func resolveDir(scope ResetScope) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// ValidResetTarget reports whether target is a recognized reset target.
|
||||
func ValidResetTarget(target ResetTarget) bool {
|
||||
switch target {
|
||||
case TargetAll, TargetConfig, TargetWorkflow, TargetNew:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntries returns the reset entries matching the target.
|
||||
func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
||||
if target == TargetAll {
|
||||
|
|
@ -101,7 +111,7 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
|||
case TargetNew:
|
||||
filename = templateFilename
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown reset target: %q", target)
|
||||
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
|
||||
}
|
||||
for _, e := range resetEntries {
|
||||
if e.filename == filename {
|
||||
|
|
@ -114,21 +124,25 @@ func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
|||
// resetFile either overwrites or deletes a file depending on scope and available defaults.
|
||||
// For global scope with non-empty default content, the file is overwritten.
|
||||
// Otherwise the file is deleted. Returns true if the file was changed.
|
||||
func resetFile(path string, scope ResetScope, defaultContent string) (bool, error) {
|
||||
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
|
||||
if scope == ScopeGlobal && defaultContent != "" {
|
||||
return writeDefault(path, defaultContent)
|
||||
return writeFileIfChanged(path, defaultContent)
|
||||
}
|
||||
return deleteIfExists(path)
|
||||
}
|
||||
|
||||
// writeDefault writes defaultContent to path, creating parent dirs if needed.
|
||||
// Returns true if the file was actually changed (skips write when content already matches).
|
||||
func writeDefault(path string, content string) (bool, error) {
|
||||
// writeFileIfChanged writes content to path, skipping if the file already has identical content.
|
||||
// Returns true if the file was actually changed.
|
||||
func writeFileIfChanged(path string, content string) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
return writeFile(path, content)
|
||||
}
|
||||
|
||||
// writeFile writes content to path unconditionally, creating parent dirs if needed.
|
||||
func writeFile(path string, content string) (bool, error) {
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ func setupResetTest(t *testing.T) string {
|
|||
return tikiDir
|
||||
}
|
||||
|
||||
// writeFile is a test helper that writes content to path.
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
// writeTestFile is a test helper that writes content to path.
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -36,9 +36,9 @@ func TestResetConfig_GlobalAll(t *testing.T) {
|
|||
tikiDir := setupResetTest(t)
|
||||
|
||||
// seed all three files with custom content
|
||||
writeFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetAll)
|
||||
if err != nil {
|
||||
|
|
@ -87,7 +87,7 @@ func TestResetConfig_GlobalSingleTarget(t *testing.T) {
|
|||
t.Run(string(tt.target), func(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
writeFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, tt.target)
|
||||
if err != nil {
|
||||
|
|
@ -125,12 +125,12 @@ func TestResetConfig_LocalDeletesFiles(t *testing.T) {
|
|||
pm.projectRoot = projectDir
|
||||
|
||||
// seed project config files
|
||||
writeFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
|
||||
// also write global defaults so we can verify local doesn't overwrite
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err != nil {
|
||||
|
|
@ -165,7 +165,7 @@ func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
|
|||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
writeFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
|
||||
if err != nil {
|
||||
|
|
@ -222,7 +222,7 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
|||
tikiDir := setupResetTest(t)
|
||||
|
||||
// write the embedded default content — reset should detect no change
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
|
|
@ -233,6 +233,21 @@ func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidResetTarget(t *testing.T) {
|
||||
valid := []ResetTarget{TargetAll, TargetConfig, TargetWorkflow, TargetNew}
|
||||
for _, target := range valid {
|
||||
if !ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
invalid := []ResetTarget{"themes", "invalid", "reset"}
|
||||
for _, target := range invalid {
|
||||
if ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = true, want false", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
|
||||
// point projectRoot at a temp dir that has no .doc/tiki
|
||||
xdgDir := t.TempDir()
|
||||
|
|
|
|||
11
main.go
11
main.go
|
|
@ -62,9 +62,9 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle config command
|
||||
if len(os.Args) > 1 && os.Args[1] == "config" {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
// Handle workflow command
|
||||
if len(os.Args) > 1 && os.Args[1] == "workflow" {
|
||||
os.Exit(runWorkflow(os.Args[2:]))
|
||||
}
|
||||
|
||||
// Handle exec command: execute ruki statement and exit
|
||||
|
|
@ -88,7 +88,7 @@ func main() {
|
|||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
// "init" is reserved to prevent treating it as a markdown file
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "config": {}})
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "workflow": {}})
|
||||
if err != nil {
|
||||
if errors.Is(err, viewer.ErrMultipleInputs) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
|
@ -263,7 +263,8 @@ Usage:
|
|||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki exec '<statement>' Execute a ruki query and exit
|
||||
tiki config reset [target] Reset config files (--global, --local, --current)
|
||||
tiki workflow reset [target] Reset config files (--global, --local, --current)
|
||||
tiki workflow install <name> Install a workflow (--global, --local, --current)
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
|
|
|
|||
Loading…
Reference in a new issue