diff --git a/.doc/doki/doc/command-line.md b/.doc/doki/doc/command-line.md index e2c2f2a..2fc2c12 100644 --- a/.doc/doki/doc/command-line.md +++ b/.doc/doki/doc/command-line.md @@ -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 [--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 diff --git a/cmd_config.go b/cmd_config.go deleted file mode 100644 index f3bc786..0000000 --- a/cmd_config.go +++ /dev/null @@ -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 - -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 -`) -} diff --git a/cmd_config_test.go b/cmd_config_test.go deleted file mode 100644 index 5c1aa1c..0000000 --- a/cmd_config_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/cmd_workflow.go b/cmd_workflow.go new file mode 100644 index 0000000..420815e --- /dev/null +++ b/cmd_workflow.go @@ -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 --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 + +Commands: + reset [target] [--scope] Reset config files to defaults + install [--scope] Install a workflow from the tiki repository + +Run 'tiki workflow --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 [--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 +`) +} diff --git a/cmd_workflow_test.go b/cmd_workflow_test.go new file mode 100644 index 0000000..6043a0f --- /dev/null +++ b/cmd_workflow_test.go @@ -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) + } +} diff --git a/config/init.go b/config/init.go index ff17307..06b81c0 100644 --- a/config/init.go +++ b/config/init.go @@ -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). diff --git a/config/install.go b/config/install.go new file mode 100644 index 0000000..f7684aa --- /dev/null +++ b/config/install.go @@ -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 +} diff --git a/config/install_test.go b/config/install_test.go new file mode 100644 index 0000000..fb607ae --- /dev/null +++ b/config/install_test.go @@ -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) + } + } +} diff --git a/config/reset.go b/config/reset.go index a557b8a..c29780f 100644 --- a/config/reset.go +++ b/config/reset.go @@ -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 { diff --git a/config/reset_test.go b/config/reset_test.go index 5c89c40..2ffb323 100644 --- a/config/reset_test.go +++ b/config/reset_test.go @@ -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() diff --git a/main.go b/main.go index 2a08944..c75f0b4 100644 --- a/main.go +++ b/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 '' 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 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