tiki workflow command

This commit is contained in:
booleanmaybe 2026-04-16 20:31:39 -04:00
parent 6713b5b7c5
commit 56d3b64c22
11 changed files with 805 additions and 281 deletions

View file

@ -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

View file

@ -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
`)
}

View file

@ -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
View 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
View 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)
}
}

View file

@ -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
View 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
View 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)
}
}
}

View file

@ -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 {

View file

@ -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
View file

@ -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