From a79a6c5b3b0b61b6e0cfd727f7a6190993b43704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:13:48 +0200 Subject: [PATCH 01/11] feat: implement GUIHO CLI convention runtime --- cmd/agent.go | 14 +- cmd/buda-launcher/buda-launcher.xdocs.md | 16 + cmd/buda-launcher/main.go | 16 + cmd/cmd.xdocs.md | 9 +- cmd/doctor.go | 4 +- cmd/init.go | 243 ++++++- cmd/init_policy_test.go | 73 ++ cmd/instance.go | 27 + cmd/root.go | 104 ++- cmd/root_test.go | 20 +- cmd/selfmanage_test.go | 70 +- cmd/uninstall.go | 102 ++- cmd/upgrade.go | 215 +++--- examples/buda.example.yaml | 16 + examples/buda.global.example.yaml | 15 + examples/examples.xdocs.md | 16 + go.mod | 2 +- internal/agent/agent.xdocs.md | 2 +- internal/agent/agent_test.go | 8 +- internal/agent/instruction.go | 25 +- internal/agent/resources.go | 39 +- internal/artifact/artifact.xdocs.md | 15 + internal/artifact/manifest.go | 220 ++++++ internal/artifact/manifest_test.go | 59 ++ internal/config/config.go | 118 +++- internal/config/config.xdocs.md | 8 +- internal/config/config_test.go | 43 +- internal/config/dual.go | 369 ++++++++++ internal/config/dual_test.go | 46 ++ internal/help/help.go | 81 +-- internal/help/help.xdocs.md | 1 + internal/help/help_test.go | 45 ++ internal/installlayout/installlayout.xdocs.md | 15 + internal/installlayout/layout.go | 103 +++ internal/installlayout/layout_test.go | 26 + internal/internal.xdocs.md | 7 + internal/launcher/launcher.go | 195 ++++++ internal/launcher/launcher.xdocs.md | 16 + internal/launcher/launcher_test.go | 105 +++ internal/lifecycle/instances.go | 147 ++++ internal/lifecycle/instances_test.go | 38 + internal/lifecycle/lifecycle.xdocs.md | 19 + internal/lifecycle/process_unix.go | 30 + internal/lifecycle/process_windows.go | 85 +++ internal/lifecycle/transaction.go | 236 +++++++ internal/lifecycle/transaction_test.go | 57 ++ internal/releasecatalog/catalog.go | 156 +++++ internal/releasecatalog/catalog_test.go | 23 + .../releasecatalog/releasecatalog.xdocs.md | 15 + internal/repository/initialize.go | 27 +- internal/repository/repository.go | 6 +- internal/repository/repository.xdocs.md | 2 +- internal/selfmanage/catalog_test.go | 29 +- internal/selfmanage/replace_unix.go | 12 - internal/selfmanage/replace_windows.go | 228 +----- internal/selfmanage/selfmanage.xdocs.md | 2 +- internal/selfmanage/upgrade.go | 39 +- internal/uninstall/plan.go | 141 ++++ internal/uninstall/plan_test.go | 121 ++++ internal/uninstall/uninstall.xdocs.md | 18 + internal/upgrade/transaction.go | 659 ++++++++++++++++++ internal/upgrade/transaction_test.go | 62 ++ internal/upgrade/upgrade.xdocs.md | 24 + main.go | 5 +- prompts/guiho-i-buda.md | 4 +- prompts/guiho-p-buda.md | 55 ++ prompts/prompts.xdocs.md | 3 +- schemas/buda.global.schema.json | 25 + schemas/buda.schema.json | 51 ++ schemas/schemas.xdocs.md | 17 + skills/guiho-s-0002-buda/SKILL.md | 29 +- 71 files changed, 4303 insertions(+), 570 deletions(-) create mode 100644 cmd/buda-launcher/buda-launcher.xdocs.md create mode 100644 cmd/buda-launcher/main.go create mode 100644 cmd/init_policy_test.go create mode 100644 cmd/instance.go create mode 100644 examples/buda.example.yaml create mode 100644 examples/buda.global.example.yaml create mode 100644 examples/examples.xdocs.md create mode 100644 internal/artifact/artifact.xdocs.md create mode 100644 internal/artifact/manifest.go create mode 100644 internal/artifact/manifest_test.go create mode 100644 internal/config/dual.go create mode 100644 internal/config/dual_test.go create mode 100644 internal/help/help_test.go create mode 100644 internal/installlayout/installlayout.xdocs.md create mode 100644 internal/installlayout/layout.go create mode 100644 internal/installlayout/layout_test.go create mode 100644 internal/launcher/launcher.go create mode 100644 internal/launcher/launcher.xdocs.md create mode 100644 internal/launcher/launcher_test.go create mode 100644 internal/lifecycle/instances.go create mode 100644 internal/lifecycle/instances_test.go create mode 100644 internal/lifecycle/lifecycle.xdocs.md create mode 100644 internal/lifecycle/process_unix.go create mode 100644 internal/lifecycle/process_windows.go create mode 100644 internal/lifecycle/transaction.go create mode 100644 internal/lifecycle/transaction_test.go create mode 100644 internal/releasecatalog/catalog.go create mode 100644 internal/releasecatalog/catalog_test.go create mode 100644 internal/releasecatalog/releasecatalog.xdocs.md create mode 100644 internal/uninstall/plan.go create mode 100644 internal/uninstall/plan_test.go create mode 100644 internal/uninstall/uninstall.xdocs.md create mode 100644 internal/upgrade/transaction.go create mode 100644 internal/upgrade/transaction_test.go create mode 100644 internal/upgrade/upgrade.xdocs.md create mode 100644 prompts/guiho-p-buda.md create mode 100644 schemas/buda.global.schema.json create mode 100644 schemas/buda.schema.json create mode 100644 schemas/schemas.xdocs.md diff --git a/cmd/agent.go b/cmd/agent.go index 5568170..ff220d4 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -32,12 +32,12 @@ func newAgentSkillCommand(deps Dependencies) *cobra.Command { command := &cobra.Command{ Use: "skill", Short: "Manage the embedded guiho-s-0002-buda skill.", - Example: " buda agent skill update\n buda agent skill update --local --wiki ./wiki", + Example: " buda agent skill upgrade\n buda agent skill upgrade --local --wiki ./wiki", Args: NoArgs, RunE: showHelp, } command.PersistentFlags().BoolVar(&local, "local", false, "Use both project-local skill destinations under --wiki") - for _, action := range []string{"install", "uninstall", "update"} { + for _, action := range []string{"install", "uninstall", "upgrade"} { action := action command.AddCommand(&cobra.Command{ Use: action, @@ -110,11 +110,11 @@ func newAgentInstructionCommand(deps Dependencies) *cobra.Command { command := &cobra.Command{ Use: "instruction", Short: "Manage bounded Buda blocks in repository instructions.", - Example: " buda agent instruction update --wiki ./wiki", + Example: " buda agent instruction upgrade --wiki ./wiki", Args: NoArgs, RunE: showHelp, } - for _, action := range []string{"apply", "remove", "update"} { + for _, action := range []string{"apply", "remove", "upgrade"} { action := action command.AddCommand(&cobra.Command{ Use: action, @@ -157,7 +157,7 @@ func newAgentInstructionCommand(deps Dependencies) *cobra.Command { return err } return writeAgentValue(command, deps, map[string]any{ - "embedded": map[string]string{"id": "buda", "body": template}, "targets": targets, + "embedded": map[string]string{"id": agent.InstructionID, "body": template}, "targets": targets, }) }, }) @@ -203,7 +203,7 @@ func newAgentPromptCommand(deps Dependencies) *cobra.Command { command := &cobra.Command{ Use: "prompt", Short: "Inspect embedded Buda prompt resources without running a model.", - Example: " buda agent prompt show buda", + Example: " buda agent prompt show guiho-p-buda", Args: NoArgs, RunE: showHelp, } @@ -224,7 +224,7 @@ func newAgentPromptCommand(deps Dependencies) *cobra.Command { command.AddCommand(&cobra.Command{ Use: "show ", Short: "Print one raw embedded Buda prompt.", - Example: " buda agent prompt show buda", + Example: " buda agent prompt show guiho-p-buda", Args: ExactArgs(1), RunE: func(command *cobra.Command, args []string) error { if args[0] != agent.PromptID { diff --git a/cmd/buda-launcher/buda-launcher.xdocs.md b/cmd/buda-launcher/buda-launcher.xdocs.md new file mode 100644 index 0000000..30697b7 --- /dev/null +++ b/cmd/buda-launcher/buda-launcher.xdocs.md @@ -0,0 +1,16 @@ +--- +subject: buda-cmd-launcher +description: Minimal stable launcher executable entrypoint. +parent: buda-cmd +children: [] +files: + main.go: Cross-platform launcher entrypoint forwarding streams and exit status. +documents: {} +tags: [launcher, release] +keywords: [stable launcher, current.json] +flags: [] +status: draft +--- + +The launcher is built for every supported platform and installed in the shared +GUIHO bin directory. diff --git a/cmd/buda-launcher/main.go b/cmd/buda-launcher/main.go new file mode 100644 index 0000000..9fbbb1a --- /dev/null +++ b/cmd/buda-launcher/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "os" + + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/launcher" +) + +func main() { + layout, err := installlayout.Current() + if err != nil { + panic(err) + } + launcher.Exit(launcher.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr, layout)) +} diff --git a/cmd/cmd.xdocs.md b/cmd/cmd.xdocs.md index bf6f8c6..7918309 100644 --- a/cmd/cmd.xdocs.md +++ b/cmd/cmd.xdocs.md @@ -2,7 +2,8 @@ subject: buda-cmd description: Cobra command construction, application assembly, stable output, exit mapping, and command-level integration tests for Buda. parent: buda-package -children: [] +children: + - buda-cmd-launcher files: agent.go: Agent skill, instruction, and prompt command tree. application.go: Assembly of the public repository command set. @@ -12,7 +13,9 @@ files: get.go: qmd-backed concept and raw-file retrieval command. index.go: qmd project initialization and indexing command. ingest.go: Source registration and qmd-backed ingest work-item command. + instance.go: Installed-payload instance registration for lifecycle process safety. init.go: OKF-aware wiki initialization command. + init_policy_test.go: Interactive agent-evolution policy setup and fail-closed tests. lint.go: Base OKF conformance and Buda health command. pack.go: Deterministic portable-bundle packaging command. pack_test.go: Packaging command integration tests. @@ -22,8 +25,8 @@ files: root_test.go: Root command, help, JSON-error, and bootstrap-scheduling tests. status.go: Repository, health, and qmd readiness status command. selfmanage_test.go: Upgrade catalog, no-op, operational progress, automatic rollback, deterministic JSON, uninstall preview, and reconciliation-exclusion command tests. - uninstall.go: Root native-binary and agent-resource uninstall command with hidden Windows completion route. - upgrade.go: GitHub Release discovery, checksum-gated native upgrade, check/list/rollback commands, and hidden Windows replacement routes. + uninstall.go: Manifest-driven ownership-safe uninstall command with preservation and confirmation flags. + upgrade.go: Complete release selection, recovery output, checksum-gated upgrade, check/list, and hidden Windows compatibility routes. documents: {} tags: - cobra diff --git a/cmd/doctor.go b/cmd/doctor.go index 02359ae..413764b 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -53,7 +53,7 @@ func NewDoctorCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command localSkills, localErr := deps.Agents.SkillInstallations(true, repo.Root) instructions, instructionErr := deps.Agents.ListInstructions(repo.Root) if globalErr != nil || localErr != nil || instructionErr != nil { - checks["agent_resources"] = doctorCheck{State: "error", Repair: "buda agent skill update; buda agent instruction update --wiki ", Message: firstError(globalErr, localErr, instructionErr)} + checks["agent_resources"] = doctorCheck{State: "error", Repair: "buda agent skill upgrade; buda agent instruction upgrade --wiki ", Message: firstError(globalErr, localErr, instructionErr)} failureCode = maxExit(failureCode, 1) } else { current := installationsCurrent(globalSkills) && optionalInstallationsCurrent(localSkills) && instructionsCurrent(instructions) @@ -62,7 +62,7 @@ func NewDoctorCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command state = "stale" failureCode = maxExit(failureCode, 1) } - checks["agent_resources"] = doctorCheck{State: state, Repair: "buda agent skill update; buda agent skill update --local --wiki ; buda agent instruction update --wiki ", Detail: map[string]any{"global_skills": globalSkills, "local_skills": localSkills, "instructions": instructions}} + checks["agent_resources"] = doctorCheck{State: state, Repair: "buda agent skill upgrade; buda agent skill upgrade --local --wiki ; buda agent instruction upgrade --wiki ", Detail: map[string]any{"global_skills": globalSkills, "local_skills": localSkills, "instructions": instructions}} } reproducible, packMessage := checkPackReproducibility(repo) diff --git a/cmd/init.go b/cmd/init.go index d4336c7..49bbe92 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,8 +1,12 @@ package cmd import ( + "bufio" "fmt" + "io" + "os" "path/filepath" + "strings" "github.com/CGuiho/buda/internal/config" "github.com/CGuiho/buda/internal/repository" @@ -10,28 +14,66 @@ import ( ) func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { + deps = normalizeDependencies(deps) var wikiID string command := &cobra.Command{ - Use: "init", - Short: "Initialize one explicit OKF-compatible Buda wiki.", - Example: " buda init --wiki ./wiki --wiki-id team-knowledge", - Args: NoArgs, + Use: "init", Short: "Initialize or reconcile one explicit OKF-compatible Buda wiki.", + Example: " buda init --wiki ./wiki --wiki-id team-knowledge", Args: NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if wikiID == "" { - return UsageError("--wiki-id is required") - } + version := releaseVersion(deps) + input := bufio.NewReader(deps.In) root, err := repository.ResolveTarget(deps.Options.Wiki) if err != nil { return RepositoryError("resolve initialization target", err) } - configuration := config.Default(wikiID) - candidate := repository.Repository{ - Root: root, ConfigPath: filepath.Join(root, config.FileName), - Bundle: filepath.Join(root, configuration.Bundle), - QMDProject: filepath.Join(root, configuration.QMD.ProjectDirectory), - Derived: filepath.Join(root, configuration.Derived), - Collection: configuration.QMD.Collection, Config: configuration, + projectConfigPath := filepath.Join(root, config.FileName) + if wikiID == "" { + if existing, loadErr := config.LoadProject(projectConfigPath); loadErr == nil { + wikiID = existing.WikiID + } else if deps.Interactive() && !JSONRequested(deps) { + fmt.Fprint(command.OutOrStdout(), "Immutable wiki id for this selected repository: ") + answer, readErr := readPromptLine(input) + if readErr != nil || answer == "" { + return UsageError("a non-empty --wiki-id answer is required") + } + wikiID = answer + } else { + return UsageError("--wiki-id is required for a new wiki; an existing valid project configuration was not found") + } + } + globalPath, globalCreated, globalErr := ensureGlobalConfiguration(deps.HomeDir, version) + if globalErr != nil { + return MutationError("prepare global Buda configuration", globalErr) + } + project := config.ProjectConfig{Schema: config.CurrentSchema, WikiID: wikiID} + if _, statErr := os.Stat(projectConfigPath); statErr == nil { + project, err = config.LoadProject(projectConfigPath) + if err != nil { + return RepositoryError("load existing project configuration", err) + } + } else if !os.IsNotExist(statErr) { + return RepositoryError("inspect existing project configuration", statErr) + } + global, err := config.LoadGlobal(globalPath) + if err != nil { + return MutationError("load global Buda configuration", err) + } + policyPrompted, err := reconcileAgentPolicies(command.OutOrStdout(), input, deps.Interactive() && !JSONRequested(deps), &global, globalCreated) + if err != nil { + return err + } + if policyPrompted { + if err := persistGlobalConfiguration(globalPath, global, version); err != nil { + return MutationError("persist agent-evolution policy", err) + } + } + configuration, err := config.Merge(global, project) + if err != nil { + return RepositoryError("merge Buda configuration", err) } + candidate := repository.Repository{Root: root, ConfigPath: projectConfigPath, + Bundle: filepath.Join(root, configuration.Bundle), QMDProject: filepath.Join(root, configuration.QMD.ProjectDirectory), + Derived: filepath.Join(root, configuration.Derived), Collection: configuration.QMD.Collection, Config: configuration} client, err := qmdFactory(factories)(candidate) if err != nil { return RepositoryError("configure qmd adapter", err) @@ -39,16 +81,13 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { if _, err := client.CheckCompatibility(command.Context()); err != nil { return externalError("validate qmd compatibility before initialization", err) } - result, err := repository.Initialize(root, repository.InitOptions{ - WikiID: wikiID, - Now: dependencyNow(deps), + result, err := repository.Initialize(root, repository.InitOptions{WikiID: wikiID, Configuration: configuration, Version: version, Now: dependencyNow(deps), BeforeCommit: func(candidate repository.Repository) error { if err := client.EnsureProject(command.Context()); err != nil { return externalError("validate qmd and establish project-local collection", err) } return nil - }, - }) + }}) if err != nil { if _, ok := err.(exitCoder); ok { return err @@ -64,19 +103,23 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { if err != nil { return MutationError("apply embedded Buda repository instructions", err) } - output := map[string]any{ - "command": "init", "wiki": result.Repository.Root, - "bundle": result.Repository.Bundle, "qmd_project_directory": result.Repository.QMDProject, - "collection": result.Repository.Collection, "wiki_id": result.Repository.Config.WikiID, - "created": result.Created, "unchanged": result.Unchanged, - "skills": skills, "instructions": instructions, + if _, err := repository.Open(result.Repository.Root); err != nil { + return MutationError("revalidate merged Buda project configuration", err) + } + if _, err := config.LoadGlobal(globalPath); err != nil { + return MutationError("revalidate global Buda configuration", err) } + output := map[string]any{"command": "init", "wiki": result.Repository.Root, "bundle": result.Repository.Bundle, + "qmd_project_directory": result.Repository.QMDProject, "collection": result.Repository.Collection, "wiki_id": result.Repository.Config.WikiID, + "created": result.Created, "unchanged": result.Unchanged, "skills": skills, "instructions": instructions, + "project_config": projectConfigPath, "global_config": globalPath, "global_config_created": globalCreated, + "policy_prompted": policyPrompted, + "effective_agent_evolution": result.Repository.Config.EffectiveAgent()} if JSONRequested(deps) { return WriteJSON(command, output) } - fmt.Fprintf(command.OutOrStdout(), "wiki: %s\nbundle: %s\nqmd project: %s\ncollection: %s\nwiki id: %s\n", - result.Repository.Root, result.Repository.Bundle, result.Repository.QMDProject, - result.Repository.Collection, result.Repository.Config.WikiID) + fmt.Fprintf(command.OutOrStdout(), "wiki: %s\nbundle: %s\nqmd project: %s\ncollection: %s\nwiki id: %s\nproject config: %s\nglobal config: %s\n", + result.Repository.Root, result.Repository.Bundle, result.Repository.QMDProject, result.Repository.Collection, result.Repository.Config.WikiID, projectConfigPath, globalPath) if result.Unchanged { fmt.Fprintln(command.OutOrStdout(), "initialization: already current") } else { @@ -90,3 +133,147 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { command.Flags().StringVar(&wikiID, "wiki-id", "", "Immutable user-assigned identifier for this wiki") return command } + +func ensureGlobalConfiguration(homeDir func() (string, error), version string) (string, bool, error) { + if homeDir == nil { + homeDir = os.UserHomeDir + } + home, err := homeDir() + if err != nil { + return "", false, err + } + path := config.GlobalPath(home) + if _, err := os.Stat(path); err == nil { + if _, err := config.LoadGlobal(path); err != nil { + return path, false, err + } + return path, false, nil + } else if !os.IsNotExist(err) { + return path, false, err + } + if _, migrated, err := config.MigrateLegacyGlobal(home, version); err != nil { + return path, false, err + } else if migrated { + return path, true, nil + } + data, err := config.MarshalGlobal(config.DefaultGlobal(), version) + if err != nil { + return path, false, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return path, false, err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".buda-init-*") + if err != nil { + return path, false, err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.Write(data); err != nil { + temp.Close() + return path, false, err + } + if err := temp.Sync(); err != nil { + temp.Close() + return path, false, err + } + if err := temp.Close(); err != nil { + return path, false, err + } + if err := os.Rename(tempPath, path); err != nil { + return path, false, err + } + return path, true, nil +} + +func readPromptLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + line = strings.TrimSpace(line) + if err != nil && line == "" { + return "", err + } + return line, nil +} + +type policyPrompt struct { + name string + value *config.Policy +} + +func reconcileAgentPolicies(out io.Writer, input *bufio.Reader, interactive bool, global *config.GlobalConfig, forcePrompt bool) (bool, error) { + if global.Agent == nil { + agent := config.AgentConfig{} + global.Agent = &agent + } + policies := []policyPrompt{ + {name: "upgrades", value: &global.Agent.Evolution.Upgrade}, + {name: "bug issue creation", value: &global.Agent.Evolution.Issues.Bugs}, + {name: "improvement issue creation", value: &global.Agent.Evolution.Issues.Improvements}, + {name: "review issue creation", value: &global.Agent.Evolution.Issues.Reviews}, + } + missing := make([]policyPrompt, 0, len(policies)) + for _, policy := range policies { + if forcePrompt || strings.TrimSpace(string(*policy.value)) == "" { + missing = append(missing, policy) + } + } + if len(missing) == 0 { + return false, nil + } + if !interactive { + return false, UsageError("init requires an interactive terminal to configure agent.evolution policies; provide the answers interactively and retry") + } + fmt.Fprintln(out, "Buda agent-evolution policies govern upgrades and GitHub issue creation.") + fmt.Fprintln(out, "disabled prohibits the action; always-ask requests approval; always-proceed authorizes it without another question.") + fmt.Fprintln(out, "The recommended choice is always-proceed so the agent can keep Buda current and report useful findings.") + fmt.Fprint(out, "Apply always-proceed to all four global policies? [y/N] ") + allAnswer, _ := readPromptLine(input) + if strings.EqualFold(allAnswer, "y") || strings.EqualFold(allAnswer, "yes") { + for _, policy := range missing { + *policy.value = config.PolicyAlwaysProceed + } + return true, nil + } + for _, policy := range missing { + fmt.Fprintf(out, "Policy for %s [disabled|always-ask|always-proceed] (default always-ask): ", policy.name) + answer, _ := readPromptLine(input) + if answer == "" { + *policy.value = config.PolicyAlwaysAsk + continue + } + choice := config.Policy(answer) + if choice != config.PolicyDisabled && choice != config.PolicyAlwaysAsk && choice != config.PolicyAlwaysProceed { + return false, UsageError("invalid policy for %s: %q; choose disabled, always-ask, or always-proceed", policy.name, answer) + } + *policy.value = choice + } + return true, nil +} + +func persistGlobalConfiguration(path string, value config.GlobalConfig, version string) error { + data, err := config.MarshalGlobal(value, version) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".buda-policy-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} diff --git a/cmd/init_policy_test.go b/cmd/init_policy_test.go new file mode 100644 index 0000000..7cf3347 --- /dev/null +++ b/cmd/init_policy_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "github.com/CGuiho/buda/internal/config" +) + +func TestReconcileAgentPoliciesOffersRecommendedAllProceedChoice(t *testing.T) { + global := config.DefaultGlobal() + var output bytes.Buffer + changed, err := reconcileAgentPolicies(&output, bufio.NewReader(strings.NewReader("yes\n")), true, &global, true) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("policy reconciliation did not report a change") + } + if global.Agent == nil { + t.Fatal("global agent policy was not created") + } + policies := []config.Policy{ + global.Agent.Evolution.Upgrade, + global.Agent.Evolution.Issues.Bugs, + global.Agent.Evolution.Issues.Improvements, + global.Agent.Evolution.Issues.Reviews, + } + for _, policy := range policies { + if policy != config.PolicyAlwaysProceed { + t.Fatalf("policy = %q, want always-proceed", policy) + } + } + if !strings.Contains(output.String(), "disabled prohibits") || !strings.Contains(output.String(), "recommended choice is always-proceed") { + t.Fatalf("policy explanation omitted: %q", output.String()) + } +} + +func TestReconcileAgentPoliciesPreservesValidAndDefaultsSkipped(t *testing.T) { + global := config.DefaultGlobal() + global.Agent.Evolution.Upgrade = config.PolicyDisabled + global.Agent.Evolution.Issues.Bugs = "" + global.Agent.Evolution.Issues.Improvements = "" + global.Agent.Evolution.Issues.Reviews = "" + var output bytes.Buffer + answers := "n\nalways-proceed\n\n\n" + changed, err := reconcileAgentPolicies(&output, bufio.NewReader(strings.NewReader(answers)), true, &global, false) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("policy reconciliation did not report a change") + } + if global.Agent.Evolution.Upgrade != config.PolicyDisabled { + t.Fatalf("existing upgrade policy changed to %q", global.Agent.Evolution.Upgrade) + } + if global.Agent.Evolution.Issues.Bugs != config.PolicyAlwaysProceed { + t.Fatalf("bug policy = %q", global.Agent.Evolution.Issues.Bugs) + } + if global.Agent.Evolution.Issues.Improvements != config.PolicyAlwaysAsk || global.Agent.Evolution.Issues.Reviews != config.PolicyAlwaysAsk { + t.Fatalf("skipped policies = %#v", global.Agent.Evolution.Issues) + } +} + +func TestReconcileAgentPoliciesFailsClosedWithoutInteractiveTerminal(t *testing.T) { + global := config.DefaultGlobal() + _, err := reconcileAgentPolicies(&bytes.Buffer{}, bufio.NewReader(strings.NewReader("yes\n")), false, &global, true) + if ExitCode(err) != 2 || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("error = %v, code = %d", err, ExitCode(err)) + } +} diff --git a/cmd/instance.go b/cmd/instance.go new file mode 100644 index 0000000..0690a57 --- /dev/null +++ b/cmd/instance.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "os" + + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/lifecycle" +) + +func registerCurrentInstance(info BuildInfo) (func(), error) { + if info.Target == "development" || info.Version == "dev" { + return func() {}, nil + } + layout, err := installlayout.Current() + if err != nil { + return nil, err + } + executable, err := os.Executable() + if err != nil { + return nil, err + } + cleanup, err := lifecycle.RegisterInstance(layout.Instances, executable, info.Version) + if err != nil { + return nil, err + } + return func() { _ = cleanup() }, nil +} diff --git a/cmd/root.go b/cmd/root.go index a91fb65..c89bcb7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,11 +14,14 @@ import ( "github.com/CGuiho/buda/internal/agent" clihelp "github.com/CGuiho/buda/internal/help" + "github.com/CGuiho/buda/internal/installlayout" "github.com/CGuiho/buda/internal/maintenance" "github.com/CGuiho/buda/internal/qmd" + "github.com/CGuiho/buda/internal/releasecatalog" "github.com/CGuiho/buda/internal/repository" "github.com/CGuiho/buda/internal/selfmanage" "github.com/CGuiho/buda/internal/source" + "github.com/CGuiho/buda/internal/upgrade" "github.com/spf13/cobra" ) @@ -37,11 +40,21 @@ type Options struct { } type Dependencies struct { - In io.Reader - Out io.Writer - Err io.Writer - Options *Options - Agents *agent.Service + In io.Reader + Out io.Writer + Err io.Writer + // Version is the exact release version used for generated, version-pinned + // configuration metadata. Development/test callers may leave it empty and + // receive the repository's current fallback. + Version string + HomeDir func() (string, error) + InstallLayout func() (installlayout.Layout, error) + // Interactive reports whether prompts may be shown. Keeping terminal + // detection injectable lets init remain deterministic in tests and fail + // closed when an unattended invocation lacks required answers. + Interactive func() bool + Options *Options + Agents *agent.Service Executable func() (string, error) ScheduleMaintenance func(executable, wiki string) error @@ -50,6 +63,7 @@ type Dependencies struct { RemoveExecutable func(string) (bool, error) RollbackExecutable func(string) (bool, error) UpgradeBinary func(context.Context, selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) + UpgradeRelease func(context.Context, upgrade.Options) (upgrade.Result, error) ReconcileInstalled func(string, string) error } @@ -108,23 +122,33 @@ func DefaultDependencies() Dependencies { In: os.Stdin, Out: os.Stdout, Err: os.Stderr, + Interactive: func() bool { return interactiveReader(os.Stdin) }, Options: &Options{}, Agents: agent.NewService(agent.DefaultResources()), Executable: os.Executable, + HomeDir: os.UserHomeDir, + InstallLayout: installlayout.Current, ScheduleMaintenance: maintenance.Schedule, Now: time.Now, HTTPClient: &http.Client{Timeout: 30 * time.Second}, RemoveExecutable: selfmanage.RemoveExecutable, RollbackExecutable: selfmanage.Rollback, UpgradeBinary: selfmanage.Upgrade, + UpgradeRelease: upgrade.Execute, ReconcileInstalled: reconcileInstalledResources, } } func Execute(info BuildInfo) error { + cleanup, err := registerCurrentInstance(info) + if err != nil { + return MutationError("register Buda payload instance", err) + } + defer cleanup() deps := DefaultDependencies() + deps.Version = info.Version root := NewRootCommand(deps, info, NewApplicationCommands(deps)...) - err := root.Execute() + err = root.Execute() if errors.Is(err, errHelpRendered) { return nil } @@ -173,7 +197,8 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman deps = normalizeDependencies(deps) options := deps.Options var helpTree, helpDocs bool - var helpDepth int + var helpTreeGlobalFlags bool + var helpDepth string root := &cobra.Command{ Use: "buda", @@ -184,11 +209,13 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman SilenceErrors: true, Args: NoArgs, PersistentPreRunE: func(command *cobra.Command, _ []string) error { - if command.Flags().Changed("help-tree-depth") && helpDepth < 1 { - return UsageError("--help-tree-depth must be a positive integer") + if command.Flags().Changed("help-tree-depth") || helpTree || helpTreeGlobalFlags { + if _, err := clihelp.ParseDepth(helpDepth); err != nil { + return UsageError("%v", err) + } } if helpDocs { - markdown, err := clihelp.Markdown(command, helpDepth) + markdown, err := clihelp.Markdown(command) if err != nil { return err } @@ -196,7 +223,11 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman return errHelpRendered } if helpTree || command.Flags().Changed("help-tree-depth") { - fmt.Fprint(command.OutOrStdout(), clihelp.Tree(command, helpDepth)) + tree, err := clihelp.Tree(command, helpDepth, helpTreeGlobalFlags) + if err != nil { + return UsageError("%v", err) + } + fmt.Fprint(command.OutOrStdout(), tree) return errHelpRendered } if command == command.Root() && strings.TrimSpace(options.Wiki) != "" { @@ -229,7 +260,7 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman return nil }, RunE: func(command *cobra.Command, _ []string) error { - message := fmt.Sprintf("Hello Windows - buda v%s", info.Version) + message := fmt.Sprintf("Hello Windows - buda %s", info.Version) selected := strings.TrimSpace(options.Wiki) != "" if options.JSON { output := map[string]any{"command": "buda", "version": info.Version, "wiki_selected": selected, "message": message} @@ -248,7 +279,7 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman root.SetIn(deps.In) root.SetOut(deps.Out) root.SetErr(deps.Err) - root.SetVersionTemplate("{{.Name}} v{{.Version}}\n") + root.SetVersionTemplate("{{.Version}}\n") root.CompletionOptions.DisableDefaultCmd = true root.SetHelpCommand(&cobra.Command{Use: "help", Hidden: true}) root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { @@ -259,7 +290,8 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman flags.StringVar(&options.Wiki, "wiki", "", "Explicit path to the one wiki repository") flags.BoolVar(&options.JSON, "json", false, "Emit one stable JSON document") flags.BoolVar(&helpTree, "help-tree", false, "Show the public command subtree") - flags.IntVar(&helpDepth, "help-tree-depth", 0, "Limit command-tree recursion to a positive depth") + flags.StringVar(&helpDepth, "help-tree-depth", "max", "Limit command-tree recursion to max or an integer greater than 1") + flags.BoolVar(&helpTreeGlobalFlags, "help-tree-global-flags", false, "Repeat inherited global flags under every command in the help tree") flags.BoolVar(&helpDocs, "help-docs", false, "Show deterministic Markdown for this command scope") root.AddCommand(commands...) @@ -267,10 +299,26 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman root.AddCommand(newUpgradeCommand(deps, info)) root.AddCommand(newUninstallCommand(deps)) root.AddCommand(newMaintenanceCommand(deps)) + root.AddCommand(newSelfTestCommand()) wrapDeveloperHelpArgs(root) return root } +// newSelfTestCommand is intentionally hidden from the public command tree. +// Installers and lifecycle transactions use it to prove the candidate can +// construct its command surface and read embedded resources before activation. +func newSelfTestCommand() *cobra.Command { + return &cobra.Command{ + Use: "__self-test", + Hidden: true, + Args: NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + fmt.Fprintln(command.OutOrStdout(), "ok") + return nil + }, + } +} + // wrapDeveloperHelpArgs lets the three generated developer-help forms inspect // any command scope without satisfying that command's ordinary positionals. // Cobra evaluates Args before PersistentPreRunE, so this must wrap the live @@ -294,6 +342,7 @@ func wrapDeveloperHelpArgs(command *cobra.Command) { func developerHelpRequested(command *cobra.Command) bool { return command.Flags().Changed("help-tree") || command.Flags().Changed("help-tree-depth") || + command.Flags().Changed("help-tree-global-flags") || command.Flags().Changed("help-docs") } @@ -310,12 +359,22 @@ func normalizeDependencies(deps Dependencies) Dependencies { if deps.Options == nil { deps.Options = &Options{} } - if deps.Agents == nil { - deps.Agents = agent.NewService(agent.DefaultResources()) + if deps.Interactive == nil { + reader := deps.In + deps.Interactive = func() bool { return interactiveReader(reader) } } if deps.Executable == nil { deps.Executable = os.Executable } + if deps.HomeDir == nil { + deps.HomeDir = os.UserHomeDir + } + if deps.Agents == nil { + deps.Agents = agent.NewService(agent.DefaultResources(), agent.WithHomeDir(deps.HomeDir)) + } + if deps.InstallLayout == nil { + deps.InstallLayout = installlayout.Current + } if deps.ScheduleMaintenance == nil { deps.ScheduleMaintenance = maintenance.Schedule } @@ -334,6 +393,9 @@ func normalizeDependencies(deps Dependencies) Dependencies { if deps.UpgradeBinary == nil { deps.UpgradeBinary = selfmanage.Upgrade } + if deps.UpgradeRelease == nil { + deps.UpgradeRelease = upgrade.Execute + } if deps.ReconcileInstalled == nil { deps.ReconcileInstalled = reconcileInstalledResources } @@ -388,3 +450,13 @@ func dependencyNow(deps Dependencies) time.Time { } return time.Now().UTC() } + +func releaseVersion(deps Dependencies) string { + if value := strings.TrimSpace(deps.Version); value != "" { + value = strings.TrimPrefix(value, "v") + if releasecatalog.IsSemver(value) { + return value + } + } + return "0.2.0" +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 24704a1..e6b6083 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -50,15 +50,15 @@ func TestJSONErrorDocumentIncludesQMDContext(t *testing.T) { func TestRootWelcomeVersionAndJSON(t *testing.T) { output, _, err := executeTest(t) - if err != nil || output != "Hello Windows - buda v1.2.3\n" { + if err != nil || output != "Hello Windows - buda 1.2.3\n" { t.Fatalf("welcome = %q, err = %v", output, err) } output, _, err = executeTest(t, "--version") - if err != nil || output != "buda v1.2.3\n" { + if err != nil || output != "1.2.3\n" { t.Fatalf("version = %q, err = %v", output, err) } output, _, err = executeTest(t, "--json") - if err != nil || !strings.Contains(output, `"message": "Hello Windows - buda v1.2.3"`) || !strings.Contains(output, `"wiki_selected": false`) || strings.Count(strings.TrimSpace(output), "\n{") != 0 { + if err != nil || !strings.Contains(output, `"message": "Hello Windows - buda 1.2.3"`) || !strings.Contains(output, `"wiki_selected": false`) || strings.Count(strings.TrimSpace(output), "\n{") != 0 { t.Fatalf("json = %q, err = %v", output, err) } } @@ -75,25 +75,25 @@ func TestRepositoryCommandsRequireAndResolveExplicitWiki(t *testing.T) { } func TestDeveloperHelpUsesInvokedSubtreeAndPositiveDepth(t *testing.T) { - output, _, err := executeTest(t, "agent", "--help-tree-depth", "1") + output, _, err := executeTest(t, "agent", "--help-tree-depth", "2") if err != nil { t.Fatal(err) } if !strings.HasPrefix(output, "COMMAND TREE\n\nbuda agent") || !strings.Contains(output, "├── instruction") || strings.Contains(output, "status") { t.Fatalf("scoped tree:\n%s", output) } - if !strings.Contains(output, "--help-docs") || strings.Contains(output, "install Install") { + if !strings.Contains(output, "--help-docs") || !strings.Contains(output, "skill") { t.Fatalf("depth was not respected:\n%s", output) } _, _, err = executeTest(t, "agent", "--help-tree-depth", "0") if ExitCode(err) != 2 { t.Fatalf("depth error = %v, code = %d", err, ExitCode(err)) } - output, _, err = executeTest(t, "agent", "skill", "--help-docs", "--help-tree-depth", "1") + output, _, err = executeTest(t, "agent", "skill", "--help-docs", "--help-tree-depth", "2") if err != nil || !strings.HasPrefix(output, "## buda agent skill") || strings.Contains(output, "buda status") { t.Fatalf("scoped docs = %q, err = %v", output, err) } - output, _, err = executeTest(t, "agent", "prompt", "show", "--help-tree-depth", "1") + output, _, err = executeTest(t, "agent", "prompt", "show", "--help-tree-depth", "2") if err != nil || !strings.HasPrefix(output, "COMMAND TREE\n\nbuda agent prompt show") { t.Fatalf("required-positional developer help = %q, err = %v", output, err) } @@ -159,7 +159,7 @@ func TestComposedHelpTreeIncludesPositionalsAndNestedHelp(t *testing.T) { var output bytes.Buffer deps := Dependencies{In: strings.NewReader(""), Out: &output, Err: &bytes.Buffer{}, Options: &Options{}} root := NewRootCommand(deps, BuildInfo{Version: "test"}, NewApplicationCommands(deps)...) - root.SetArgs([]string{"get", "--help-tree-depth", "1"}) + root.SetArgs([]string{"get", "--help-tree-depth", "2"}) if err := root.Execute(); !errors.Is(err, errHelpRendered) { t.Fatalf("help tree error = %v", err) } @@ -270,14 +270,14 @@ func TestAgentHumanOutputIsDistinctFromJSON(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.HasPrefix(strings.TrimSpace(human), "[") || !strings.Contains(human, "description:") || !strings.Contains(human, "id: buda") { + if strings.HasPrefix(strings.TrimSpace(human), "[") || !strings.Contains(human, "description:") || !strings.Contains(human, "id: guiho-p-buda") { t.Fatalf("human output = %q", human) } jsonOutput, _, err := executeTest(t, "agent", "prompt", "list", "--json") if err != nil { t.Fatal(err) } - if !strings.HasPrefix(strings.TrimSpace(jsonOutput), "[") || !strings.Contains(jsonOutput, `"id": "buda"`) { + if !strings.HasPrefix(strings.TrimSpace(jsonOutput), "[") || !strings.Contains(jsonOutput, `"id": "guiho-p-buda"`) { t.Fatalf("JSON output = %q", jsonOutput) } } diff --git a/cmd/selfmanage_test.go b/cmd/selfmanage_test.go index 59aac4f..2286197 100644 --- a/cmd/selfmanage_test.go +++ b/cmd/selfmanage_test.go @@ -8,9 +8,12 @@ import ( "errors" "io" "net/http" + "path/filepath" "strings" "testing" + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/repository" "github.com/CGuiho/buda/internal/selfmanage" ) @@ -27,20 +30,50 @@ func catalogClient() *http.Client { "html_url":"https://github.com/CGuiho/buda/releases/tag/buda/v0.0.2", "assets":[ {"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda-linux-amd64"}, - {"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"} + {"name":"buda-launcher-linux-amd64","browser_download_url":"https://downloads.example/buda-launcher-linux-amd64"}, + {"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}, + {"name":"artifacts.json","browser_download_url":"https://downloads.example/artifacts.json"}, + {"name":"buda.schema.json","browser_download_url":"https://downloads.example/buda.schema.json"}, + {"name":"buda.global.schema.json","browser_download_url":"https://downloads.example/buda.global.schema.json"}, + {"name":"buda.example.yaml","browser_download_url":"https://downloads.example/buda.example.yaml"}, + {"name":"buda.global.example.yaml","browser_download_url":"https://downloads.example/buda.global.example.yaml"}, + {"name":"guiho-i-buda.md","browser_download_url":"https://downloads.example/guiho-i-buda.md"}, + {"name":"guiho-p-buda.md","browser_download_url":"https://downloads.example/guiho-p-buda.md"}, + {"name":"guiho-s-0002-buda.zip","browser_download_url":"https://downloads.example/guiho-s-0002-buda.zip"} ] }]` return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil })} } +func testWiki(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), "wiki") + if _, err := repository.Initialize(root, repository.InitOptions{WikiID: "test-wiki"}); err != nil { + t.Fatal(err) + } + return root +} + func upgradeClient(binary []byte) *http.Client { digest := sha256.Sum256(binary) return &http.Client{Transport: releaseTransport(func(request *http.Request) (*http.Response, error) { body := "" switch request.URL.Host { case "api.github.com": - body = `[{"tag_name":"buda/v0.0.2","assets":[{"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda"},{"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}]}]` + body = `[{"tag_name":"buda/v0.0.2","html_url":"https://github.com/CGuiho/buda/releases/tag/buda/v0.0.2","assets":[ +{"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda"}, +{"name":"buda-launcher-linux-amd64","browser_download_url":"https://downloads.example/buda-launcher-linux-amd64"}, +{"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}, +{"name":"artifacts.json","browser_download_url":"https://downloads.example/artifacts.json"}, +{"name":"buda.schema.json","browser_download_url":"https://downloads.example/buda.schema.json"}, +{"name":"buda.global.schema.json","browser_download_url":"https://downloads.example/buda.global.schema.json"}, +{"name":"buda.example.yaml","browser_download_url":"https://downloads.example/buda.example.yaml"}, +{"name":"buda.global.example.yaml","browser_download_url":"https://downloads.example/buda.global.example.yaml"}, +{"name":"guiho-i-buda.md","browser_download_url":"https://downloads.example/guiho-i-buda.md"}, +{"name":"guiho-p-buda.md","browser_download_url":"https://downloads.example/guiho-p-buda.md"}, +{"name":"guiho-s-0002-buda.zip","browser_download_url":"https://downloads.example/guiho-s-0002-buda.zip"} +]}]` case "downloads.example": if strings.HasSuffix(request.URL.Path, "checksums.txt") { body = hex.EncodeToString(digest[:]) + " buda-linux-amd64\n" @@ -52,11 +85,12 @@ func upgradeClient(binary []byte) *http.Client { })} } -func TestUpgradeDryRunUsesCanonicalReleaseWithoutWiki(t *testing.T) { +func TestUpgradeDryRunUsesCanonicalReleaseWithExplicitWiki(t *testing.T) { var output bytes.Buffer + wiki := testWiki(t) deps := Dependencies{Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), Executable: func() (string, error) { return "buda", nil }} root := NewRootCommand(deps, BuildInfo{Version: "0.0.1", Target: "buda-linux-amd64"}) - root.SetArgs([]string{"upgrade", "--dry-run"}) + root.SetArgs([]string{"upgrade", "--dry-run", "--wiki", wiki}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -85,29 +119,44 @@ func TestUpgradeCheckAndListEmitOneJSONDocument(t *testing.T) { func TestUninstallDryRunNeedsNoWikiAndDoesNotDelete(t *testing.T) { var output bytes.Buffer removed := false + layout, err := installlayout.ForHome(t.TempDir()) + if err != nil { + t.Fatal(err) + } deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, Executable: func() (string, error) { return "buda", nil }, RemoveExecutable: func(string) (bool, error) { removed = true; return false, nil }, + InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.1"}) root.SetArgs([]string{"uninstall", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatal(err) } - if removed || !strings.Contains(output.String(), "Would remove executable:") || !strings.Contains(output.String(), "Would remove both global Buda skills") { + if removed || !strings.Contains(output.String(), "REMOVE ") || !strings.Contains(output.String(), "buda CLI home") { t.Fatalf("uninstall preview = %q, removed=%t", output.String(), removed) } } func TestUpgradeAndUninstallNeverScheduleResourceMaintenance(t *testing.T) { for _, arguments := range [][]string{{"upgrade", "--dry-run"}, {"uninstall", "--dry-run"}} { + if arguments[0] == "upgrade" { + arguments = append(arguments, "--wiki", testWiki(t)) + } scheduled := 0 deps := Dependencies{ Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), Executable: func() (string, error) { return "buda", nil }, ScheduleMaintenance: func(string, string) error { scheduled++; return nil }, } + if arguments[0] == "uninstall" { + layout, layoutErr := installlayout.ForHome(t.TempDir()) + if layoutErr != nil { + t.Fatal(layoutErr) + } + deps.InstallLayout = func() (installlayout.Layout, error) { return layout, nil } + } root := NewRootCommand(deps, BuildInfo{Version: "0.0.1", Target: "buda-linux-amd64"}) root.SetArgs(arguments) if err := root.Execute(); err != nil { @@ -121,6 +170,7 @@ func TestUpgradeAndUninstallNeverScheduleResourceMaintenance(t *testing.T) { func TestUpgradeEqualVersionIsNoOp(t *testing.T) { var output bytes.Buffer + wiki := testWiki(t) called := false deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), @@ -130,7 +180,7 @@ func TestUpgradeEqualVersionIsNoOp(t *testing.T) { }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.2", Target: "buda-linux-amd64"}) - root.SetArgs([]string{"upgrade"}) + root.SetArgs([]string{"upgrade", "--wiki", wiki}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -141,6 +191,7 @@ func TestUpgradeEqualVersionIsNoOp(t *testing.T) { func TestUpgradeTextReportsOperationalProgress(t *testing.T) { var output bytes.Buffer + wiki := testWiki(t) deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, @@ -151,11 +202,11 @@ func TestUpgradeTextReportsOperationalProgress(t *testing.T) { ReconcileInstalled: func(string, string) error { return nil }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.1", Target: "buda-linux-amd64"}) - root.SetArgs([]string{"upgrade"}) + root.SetArgs([]string{"upgrade", "--wiki", wiki}) if err := root.Execute(); err != nil { t.Fatal(err) } - for _, expected := range []string{"Target: 0.0.2", "URL: https://downloads.example/buda", "Checksum verified: sha256:", "Installation path: C:/tools/buda.exe", "Download progress: 100.0%", "Agent resources: reconciled", "Final version verified: buda v0.0.2"} { + for _, expected := range []string{"Target: 0.0.2", "URL: https://downloads.example/buda", "Checksum verified: sha256:", "Installation path: C:/tools/buda.exe", "Download progress: 100.0%", "Agent resources: reconciled", "Final version verified: 0.0.2"} { if !strings.Contains(output.String(), expected) { t.Fatalf("upgrade text missing %q:\n%s", expected, output.String()) } @@ -164,6 +215,7 @@ func TestUpgradeTextReportsOperationalProgress(t *testing.T) { func TestReconciliationFailureAutomaticallyRollsBack(t *testing.T) { rolledBack := false + wiki := testWiki(t) deps := Dependencies{ Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, @@ -174,7 +226,7 @@ func TestReconciliationFailureAutomaticallyRollsBack(t *testing.T) { RollbackExecutable: func(string) (bool, error) { rolledBack = true; return false, nil }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.1", Target: "buda-linux-amd64"}) - root.SetArgs([]string{"upgrade"}) + root.SetArgs([]string{"upgrade", "--wiki", wiki}) err := root.Execute() if !rolledBack || err == nil || !strings.Contains(err.Error(), "automatically rolled back") { t.Fatalf("reconcile failure=%v rolledBack=%t", err, rolledBack) diff --git a/cmd/uninstall.go b/cmd/uninstall.go index f90847c..4fda887 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,89 +2,77 @@ package cmd import ( "fmt" + "os" - "github.com/CGuiho/buda/internal/selfmanage" + "github.com/CGuiho/buda/internal/uninstall" "github.com/spf13/cobra" ) func newUninstallCommand(deps Dependencies) *cobra.Command { - var dryRun, keepResources bool - command := &cobra.Command{ - Use: "uninstall", Short: "Remove the installed Buda binary and agent resources.", Args: NoArgs, + var dryRun, preserveConfig, preserveData, yes bool + command := &cobra.Command{Use: "uninstall", Short: "Remove Buda-owned installation artifacts safely.", Args: NoArgs, RunE: func(command *cobra.Command, _ []string) error { wiki, err := optionalSelectedWiki(deps.Options.Wiki) if err != nil { return err } - executable, err := absoluteExecutable(deps) + layout, err := deps.InstallLayout() + if err != nil { + return MutationError("resolve Buda installation layout", err) + } + plan, err := uninstall.BuildPlan(layout, wiki, preserveConfig, preserveData) if err != nil { - return MutationError("determine Buda executable", err) + return MutationError("build uninstall plan", err) + } + if JSONRequested(deps) && dryRun { + return WriteJSON(command, map[string]any{"command": "buda uninstall", "dry_run": true, "plan": plan}) } - result := map[string]any{"command": "buda uninstall", "executable": executable, "dry_run": dryRun, "keep_agent_resources": keepResources, "scheduled": false} if dryRun { - if JSONRequested(deps) { - return WriteJSON(command, result) - } - fmt.Fprintf(command.OutOrStdout(), "Would remove executable: %s\n", executable) - if !keepResources { - fmt.Fprintln(command.OutOrStdout(), "Would remove both global Buda skills.") - if wiki != "" { - fmt.Fprintf(command.OutOrStdout(), "Would remove Buda instruction blocks from: %s\n", wiki) - } + for _, item := range plan.Items { + fmt.Fprintf(command.OutOrStdout(), "%s %s (%s)\n", item.Action, item.Path, item.Owner) } return nil } - var skills any = []any{} - var instructions any = []any{} - if !keepResources { - skillResults, err := deps.Agents.UninstallSkill(false, "") - if err != nil { - return MutationError("remove global Buda skills", err) - } - skills = skillResults - if wiki != "" { - instructionResults, err := deps.Agents.RemoveInstructions(wiki) - if err != nil { - return MutationError("remove Buda instruction blocks", err) - } - instructions = instructionResults + if !yes && !interactiveReader(deps.In) { + return UsageError("uninstall is destructive; provide --yes when no interactive terminal is available") + } + if !yes { + fmt.Fprint(command.OutOrStdout(), "Remove all Buda-owned artifacts listed above? [y/N] ") + var answer string + if _, err := fmt.Fscan(deps.In, &answer); err != nil || (answer != "y" && answer != "Y" && answer != "yes" && answer != "YES") { + return UsageError("uninstall cancelled") } } - scheduled, err := deps.RemoveExecutable(executable) - if err != nil { - return MutationError("remove Buda executable", err) + if err := uninstall.Apply(plan); err != nil { + return MutationError("apply Buda uninstall plan", err) } - result["scheduled"] = scheduled - result["removed_skills"] = skills - result["instruction_results"] = instructions - if JSONRequested(deps) { - return WriteJSON(command, result) + executable, err := absoluteExecutable(deps) + if err == nil && executable != layout.Launcher { + if deferred, removeErr := deps.RemoveExecutable(executable); removeErr != nil { + return MutationError("remove legacy Buda executable", removeErr) + } else if deferred { + return MutationError("remove Buda executable", fmt.Errorf("removal was not synchronous")) + } } - if scheduled { - fmt.Fprintf(command.OutOrStdout(), "Scheduled executable removal: %s\n", executable) - } else { - fmt.Fprintf(command.OutOrStdout(), "Removed executable: %s\n", executable) + if JSONRequested(deps) { + return WriteJSON(command, map[string]any{"command": "buda uninstall", "outcome": "succeeded", "plan": plan}) } + fmt.Fprintln(command.OutOrStdout(), "Buda uninstall completed synchronously.") return nil }, } - command.Flags().BoolVar(&dryRun, "dry-run", false, "Preview without deleting") - command.Flags().BoolVar(&keepResources, "keep-agent-resources", false, "Keep global skills and explicit-wiki instruction blocks") - command.AddCommand(newWindowsRemovalCommand()) + command.Flags().BoolVar(&dryRun, "dry-run", false, "Preview the manifest-driven removal plan without changing files") + command.Flags().BoolVar(&preserveConfig, "preserve-config", false, "Preserve global and selected-project configuration") + command.Flags().BoolVar(&preserveData, "preserve-data", false, "Preserve persistent Buda data and databases") + command.Flags().BoolVar(&yes, "yes", false, "Confirm destructive removal without prompting") return command } -func newWindowsRemovalCommand() *cobra.Command { - var pid int - var executable, helper string - command := &cobra.Command{ - Use: "__remove-windows", Hidden: true, Args: NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return selfmanage.CompleteWindowsRemoval(executable, helper, pid) - }, +func interactiveReader(reader any) bool { + file, ok := reader.(*os.File) + if !ok || file == nil { + return false } - command.Flags().IntVar(&pid, "pid", 0, "Internal parent process ID") - command.Flags().StringVar(&executable, "executable", "", "Internal executable path") - command.Flags().StringVar(&helper, "helper", "", "Internal helper path") - return command + info, err := file.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 495759e..fbbb933 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -6,42 +6,62 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" + "github.com/CGuiho/buda/internal/releasecatalog" "github.com/CGuiho/buda/internal/repository" "github.com/CGuiho/buda/internal/selfmanage" + "github.com/CGuiho/buda/internal/upgrade" "github.com/spf13/cobra" ) func newUpgradeCommand(deps Dependencies, info BuildInfo) *cobra.Command { - var requested string + var requested, channel string var dryRun bool command := &cobra.Command{ Use: "upgrade", Short: "Upgrade the installed Buda native binary.", Args: NoArgs, RunE: func(command *cobra.Command, _ []string) error { + selector := releasecatalog.Selector{Version: requested, Channel: channel} + if err := selector.Validate(); err != nil { + return UsageError("%v", err) + } wiki, err := optionalSelectedWiki(deps.Options.Wiki) if err != nil { return err } - release, asset, manifest, err := resolveUpgrade(command.Context(), deps, requested, info.Target) + if wiki == "" { + return RepositoryError("--wiki is required for a complete upgrade; Buda never selects a wiki implicitly", nil) + } + if !JSONRequested(deps) { + printRecovery(command, requested, channel, wiki) + } + selection, err := resolveUpgrade(command.Context(), deps, requested, channel, info.Target) if err != nil { + if !JSONRequested(deps) { + printRecovery(command, requested, channel, wiki) + } return externalError("resolve Buda release", err) } + release, asset, manifest := selection.Release, selection.Binary, selection.Manifest if selfmanage.CompareVersions(release.Version, info.Version) == 0 { - result := map[string]any{"command": "buda upgrade", "current_version": info.Version, "target_version": release.Version, "outcome": "already_current"} + result := map[string]any{"command": "buda upgrade", "current_version": info.Version, "target_version": release.Version, "outcome": "up-to-date", "recovery": recoveryCommand(release.Version, "", wiki)} if JSONRequested(deps) { return WriteJSON(command, result) } fmt.Fprintf(command.OutOrStdout(), "Buda %s is already current; no replacement was performed.\n", info.Version) + if !JSONRequested(deps) { + printRecovery(command, release.Version, "", wiki) + } return nil } preview := map[string]any{ "command": "buda upgrade", "current_version": info.Version, "target_version": release.Version, "asset": asset.Name, "download_url": asset.DownloadURL, "checksums_url": manifest.DownloadURL, - "dry_run": true, + "dry_run": true, "recovery": recoveryCommand(release.Version, "", wiki), } if dryRun { if JSONRequested(deps) { @@ -49,6 +69,42 @@ func newUpgradeCommand(deps Dependencies, info BuildInfo) *cobra.Command { } fmt.Fprintf(command.OutOrStdout(), "Current: %s\nTarget: %s\nAsset: %s\nURL: %s\nChecksums: %s\nDry run: true\n", info.Version, release.Version, asset.Name, asset.DownloadURL, manifest.DownloadURL) + if !JSONRequested(deps) { + printRecovery(command, release.Version, "", wiki) + } + return nil + } + if layout, layoutErr := deps.InstallLayout(); layoutErr == nil && fileExists(layout.Current) && deps.UpgradeRelease != nil { + transaction, txErr := deps.UpgradeRelease(command.Context(), upgrade.Options{Layout: layout, Selection: selection, Client: deps.HTTPClient, CurrentVersion: info.Version, Target: info.Target, OS: runtime.GOOS, Arch: runtime.GOARCH, CurrentPID: os.Getpid()}) + if txErr != nil { + printRecovery(command, release.Version, "", wiki) + return MutationError("upgrade Buda synchronously", txErr) + } + initErr := error(nil) + if deps.ReconcileInstalled != nil { + initErr = deps.ReconcileInstalled(transaction.PayloadPath, wiki) + } + if JSONRequested(deps) { + outcome := "succeeded" + if initErr != nil { + outcome = "succeeded-init-failed" + } + if writeErr := WriteJSON(command, map[string]any{"command": "buda upgrade", "outcome": outcome, "result": transaction, "init_error": errorMessage(initErr), "recovery": recoveryCommand(transaction.InstalledVersion, "", wiki)}); writeErr != nil { + return writeErr + } + if initErr != nil { + return MutationError("Buda upgraded but explicit-wiki init failed", initErr) + } + return nil + } + fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nLauncher: %s\nPayload: %s\n", info.Version, transaction.InstalledVersion, transaction.LauncherPath, transaction.PayloadPath) + if initErr != nil { + fmt.Fprintf(command.OutOrStdout(), "Post-upgrade explicit-wiki init failed; the verified release remains active: %v\n", initErr) + printRecovery(command, transaction.InstalledVersion, "", wiki) + return MutationError("Buda upgraded but explicit-wiki init failed", initErr) + } + fmt.Fprintln(command.OutOrStdout(), "Post-upgrade explicit-wiki init: verified") + printRecovery(command, transaction.InstalledVersion, "", wiki) return nil } if !JSONRequested(deps) { @@ -56,10 +112,16 @@ func newUpgradeCommand(deps Dependencies, info BuildInfo) *cobra.Command { } checksum, err := selfmanage.FetchChecksum(command.Context(), deps.HTTPClient, manifest.DownloadURL, asset.Name) if err != nil { + if !JSONRequested(deps) { + printRecovery(command, release.Version, "", wiki) + } return externalError("verify Buda release manifest", err) } executable, err := deps.Executable() if err != nil { + if !JSONRequested(deps) { + printRecovery(command, release.Version, "", wiki) + } return MutationError("determine Buda executable", err) } if !JSONRequested(deps) { @@ -82,42 +144,39 @@ func newUpgradeCommand(deps Dependencies, info BuildInfo) *cobra.Command { result.CurrentVersion = info.Version result.Asset = asset.Name if err != nil { + if !JSONRequested(deps) { + printRecovery(command, release.Version, "", wiki) + } return MutationError("upgrade Buda", err) } - if !result.Scheduled { - if err := deps.ReconcileInstalled(result.Executable, wiki); err != nil { - scheduledRollback, rollbackErr := deps.RollbackExecutable(result.Executable) - if rollbackErr != nil { - return MutationError("agent-resource reconciliation failed and automatic binary rollback failed", fmt.Errorf("reconcile: %w; rollback: %v", err, rollbackErr)) - } - if scheduledRollback { - return MutationError("agent-resource reconciliation failed; automatic binary rollback was scheduled", err) - } - return MutationError("agent-resource reconciliation failed; binary automatically rolled back", err) - } + if err := deps.ReconcileInstalled(result.Executable, wiki); err != nil { + rollbackComplete, rollbackErr := deps.RollbackExecutable(result.Executable) if !JSONRequested(deps) { - fmt.Fprintln(command.OutOrStdout(), "Agent resources: reconciled") - fmt.Fprintf(command.OutOrStdout(), "Final version verified: buda v%s\n", release.Version) + printRecovery(command, release.Version, "", wiki) } + if rollbackErr != nil || rollbackComplete { + return MutationError("agent-resource reconciliation failed and automatic binary rollback did not complete synchronously", fmt.Errorf("reconcile: %w; rollback: %v", err, rollbackErr)) + } + return MutationError("agent-resource reconciliation failed; binary automatically rolled back", err) } - if JSONRequested(deps) { - return WriteJSON(command, map[string]any{"command": "buda upgrade", "outcome": "succeeded", "result": result}) + if !JSONRequested(deps) { + fmt.Fprintln(command.OutOrStdout(), "Agent resources: reconciled") + fmt.Fprintf(command.OutOrStdout(), "Final version verified: %s\n", release.Version) } - if result.Scheduled { - fmt.Fprintf(command.OutOrStdout(), "Buda %s upgrade scheduled after this Windows process exits.\nRecovery: %s\n", release.Version, result.Recovery) - } else { - fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nBackup: %s\n", info.Version, release.Version, result.Backup) + if JSONRequested(deps) { + return WriteJSON(command, map[string]any{"command": "buda upgrade", "outcome": "succeeded", "result": result, "recovery": recoveryCommand(release.Version, "", wiki)}) } + fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nBackup: %s\n", info.Version, release.Version, result.Backup) + printRecovery(command, release.Version, "", wiki) return nil }, } command.Flags().StringVar(&requested, "version", "", "Select an exact semantic version") + command.Flags().StringVar(&channel, "channel", "", "Select the highest published release in a channel") command.Flags().BoolVar(&dryRun, "dry-run", false, "Preview without replacing the executable") command.AddCommand(newUpgradeCheckCommand(deps, info)) command.AddCommand(newUpgradeListCommand(deps)) command.AddCommand(newUpgradeRollbackCommand(deps)) - command.AddCommand(newWindowsReplacementCommand()) - command.AddCommand(newWindowsRollbackCommand()) return command } @@ -129,19 +188,18 @@ func newUpgradeRollbackCommand(deps Dependencies) *cobra.Command { if err != nil { return MutationError("determine Buda executable", err) } - scheduled, err := deps.RollbackExecutable(executable) + deferred, err := deps.RollbackExecutable(executable) if err != nil { return MutationError("rollback Buda executable", err) } - result := map[string]any{"command": "buda upgrade rollback", "executable": executable, "scheduled": scheduled, "outcome": "succeeded"} + if deferred { + return MutationError("rollback Buda executable", fmt.Errorf("rollback did not complete synchronously")) + } + result := map[string]any{"command": "buda upgrade rollback", "executable": executable, "outcome": "succeeded"} if JSONRequested(deps) { return WriteJSON(command, result) } - if scheduled { - fmt.Fprintln(command.OutOrStdout(), "Buda rollback scheduled after this Windows process exits.") - } else { - fmt.Fprintln(command.OutOrStdout(), "Restored the previous Buda executable.") - } + fmt.Fprintln(command.OutOrStdout(), "Restored the previous Buda executable.") return nil }, } @@ -221,40 +279,12 @@ func newUpgradeListCommand(deps Dependencies) *cobra.Command { return command } -func resolveUpgrade(ctx context.Context, deps Dependencies, requested, buildTarget string) (selfmanage.Release, selfmanage.Asset, selfmanage.Asset, error) { - releases, err := (selfmanage.Catalog{Client: deps.HTTPClient}).Releases(ctx) - if err != nil { - return selfmanage.Release{}, selfmanage.Asset{}, selfmanage.Asset{}, err - } - assetName, err := selfmanage.AssetName(buildTarget) +func resolveUpgrade(ctx context.Context, deps Dependencies, requested, channel, buildTarget string) (releasecatalog.Result, error) { + result, err := releasecatalog.Select(ctx, selfmanage.Catalog{Client: deps.HTTPClient}, releasecatalog.Selector{Version: requested, Channel: channel}, buildTarget) if err != nil { - return selfmanage.Release{}, selfmanage.Asset{}, selfmanage.Asset{}, err - } - requested = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(requested), "buda/"), "v") - for _, release := range releases { - if requested != "" && release.Version != requested { - continue - } - if requested == "" && release.Prerelease { - continue - } - var binary, manifest selfmanage.Asset - for _, asset := range release.Assets { - switch asset.Name { - case assetName: - binary = asset - case "checksums.txt": - manifest = asset - } - } - if binary.Name != "" && manifest.Name != "" { - return release, binary, manifest, nil - } - if requested != "" { - return release, binary, manifest, fmt.Errorf("release %s lacks %s or checksums.txt", release.Version, assetName) - } + return releasecatalog.Result{}, err } - return selfmanage.Release{}, selfmanage.Asset{}, selfmanage.Asset{}, fmt.Errorf("no compatible canonical Buda release found") + return result, nil } func optionalSelectedWiki(value string) (string, error) { @@ -269,9 +299,11 @@ func optionalSelectedWiki(value string) (string, error) { } func reconcileInstalledResources(executable, wiki string) error { - commands := [][]string{{"agent", "skill", "update"}} + commands := [][]string{{"agent", "skill", "upgrade"}} if wiki != "" { - commands = append(commands, []string{"agent", "instruction", "update", "--wiki", wiki}) + // init owns the complete explicit-wiki projection: it reconciles the + // managed instruction and the project configuration in one operation. + commands = append(commands, []string{"init", "--wiki", wiki}) } for _, arguments := range commands { command := exec.Command(executable, arguments...) @@ -284,39 +316,32 @@ func reconcileInstalledResources(executable, wiki string) error { return nil } -func newWindowsReplacementCommand() *cobra.Command { - var pid int - var executable, candidate, backup, version, checksum, helper, wiki string - command := &cobra.Command{ - Use: "__replace-windows", Hidden: true, Args: NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return selfmanage.CompleteWindowsReplacement(executable, candidate, backup, version, checksum, helper, wiki, pid) - }, +func recoveryCommand(version, channel, wiki string) string { + version = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(version), "buda/"), "v") + selectorPS, selectorSH := "", "" + if version != "" { + selectorPS = "-Version '" + strings.ReplaceAll(version, "'", "''") + "'" + selectorSH = "--version '" + strings.ReplaceAll(version, "'", "'\\''") + "'" + } else if strings.TrimSpace(channel) != "" { + selectorPS = "-Channel '" + strings.ReplaceAll(strings.TrimSpace(channel), "'", "''") + "'" + selectorSH = "--channel '" + strings.ReplaceAll(strings.TrimSpace(channel), "'", "'\\''") + "'" } - command.Flags().IntVar(&pid, "pid", 0, "Internal parent process ID") - command.Flags().StringVar(&executable, "executable", "", "Internal executable path") - command.Flags().StringVar(&candidate, "candidate", "", "Internal candidate path") - command.Flags().StringVar(&backup, "backup", "", "Internal backup path") - command.Flags().StringVar(&version, "target-version", "", "Internal target version") - command.Flags().StringVar(&checksum, "checksum", "", "Internal checksum") - command.Flags().StringVar(&helper, "helper", "", "Internal helper path") - command.Flags().StringVar(&wiki, "selected-wiki", "", "Internal explicit wiki path") - return command + psWiki := "'" + strings.ReplaceAll(wiki, "'", "''") + "'" + shWiki := "'" + strings.ReplaceAll(wiki, "'", "'\\''") + "'" + return fmt.Sprintf("powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1'))) %s -Wiki %s\"\ncurl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh -s -- %s --wiki %s", selectorPS, psWiki, selectorSH, shWiki) } -func newWindowsRollbackCommand() *cobra.Command { - var pid int - var executable, helper string - command := &cobra.Command{ - Use: "__rollback-windows", Hidden: true, Args: NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return selfmanage.CompleteWindowsRollback(executable, helper, pid) - }, +func printRecovery(command *cobra.Command, version, channel, wiki string) { + fmt.Fprintf(command.OutOrStdout(), "If the upgrade fails, reinstall buda with this command:\n%s\n", recoveryCommand(version, channel, wiki)) +} + +func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } + +func errorMessage(err error) string { + if err == nil { + return "" } - command.Flags().IntVar(&pid, "pid", 0, "Internal parent process ID") - command.Flags().StringVar(&executable, "executable", "", "Internal executable path") - command.Flags().StringVar(&helper, "helper", "", "Internal helper path") - return command + return err.Error() } func absoluteExecutable(deps Dependencies) (string, error) { diff --git a/examples/buda.example.yaml b/examples/buda.example.yaml new file mode 100644 index 0000000..84bf520 --- /dev/null +++ b/examples/buda.example.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://github.com/CGuiho/buda/releases/download/buda/v0.2.0/buda.schema.json +schema: 1 +wiki_id: example-wiki +bundle: knowledge +derived: .buda +qmd: + executable: qmd + collection: buda-wiki + project_directory: .qmd +agent: + evolution: + upgrade: always-ask + issues: + bugs: always-ask + improvements: always-ask + reviews: always-ask diff --git a/examples/buda.global.example.yaml b/examples/buda.global.example.yaml new file mode 100644 index 0000000..e900370 --- /dev/null +++ b/examples/buda.global.example.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://github.com/CGuiho/buda/releases/download/buda/v0.2.0/buda.global.schema.json +schema: 1 +bundle: knowledge +derived: .buda +qmd: + executable: qmd + collection: buda-wiki + project_directory: .qmd +agent: + evolution: + upgrade: always-ask + issues: + bugs: always-ask + improvements: always-ask + reviews: always-ask diff --git a/examples/examples.xdocs.md b/examples/examples.xdocs.md new file mode 100644 index 0000000..fe31865 --- /dev/null +++ b/examples/examples.xdocs.md @@ -0,0 +1,16 @@ +--- +subject: buda-examples +description: Complete valid project and global configuration examples. +parent: buda-package +children: [] +files: + buda.example.yaml: Complete valid explicit-wiki project configuration example. + buda.global.example.yaml: Complete valid global configuration example. +documents: {} +tags: [examples, configuration] +keywords: [buda.yaml, buda.global.yaml, agent.evolution] +flags: [] +status: draft +--- + +Examples remain free of secret values and use release-pinned schema comments. diff --git a/go.mod b/go.mod index 24fa112..1643349 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/CGuiho/buda -go 1.23.0 +go 1.26.0 require ( github.com/spf13/cobra v1.10.2 diff --git a/internal/agent/agent.xdocs.md b/internal/agent/agent.xdocs.md index 790bd86..d8d8a31 100644 --- a/internal/agent/agent.xdocs.md +++ b/internal/agent/agent.xdocs.md @@ -5,7 +5,7 @@ parent: buda-internal children: [] files: agent_test.go: Skill and instruction lifecycle tests. - instruction.go: Atomic bounded-marker instruction apply, remove, update, list, and show behavior. + instruction.go: Atomic bounded-marker instruction apply, remove, upgrade, list, and show behavior. resources.go: Embedded skill, instruction, and prompt resource access. skill.go: Global and explicit-wiki local skill destination management. documents: {} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 0e4d827..18bf431 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -37,7 +37,7 @@ func TestEmbeddedResourcesCarryTheRequiredIdentityAndReferences(t *testing.T) { if skill.ID != SkillID || skill.Version == "" || !strings.HasPrefix(skill.Digest, "sha256:") { t.Fatalf("skill = %#v", skill) } - prompt, err := service.Prompt() + prompt, err := service.Instruction() if err != nil { t.Fatal(err) } @@ -204,7 +204,7 @@ func TestInstructionsRespectTargetsLineEndingsOutsideContentAndIdempotency(t *te } } -func TestInstructionUpdateConvergesDuplicatesAndRejectsMalformedMarkers(t *testing.T) { +func TestInstructionUpgradeConvergesDuplicatesAndRejectsMalformedMarkers(t *testing.T) { wiki := t.TempDir() writeWikiConfig(t, wiki, "wiki-test") path := filepath.Join(wiki, "AGENTS.md") @@ -252,7 +252,7 @@ func TestInstructionMutationRejectsMarkerLikeVariantsWithoutChangingBytes(t *tes for _, operation := range []struct { name string run func(string) ([]InstructionTarget, error) - }{{"apply", service.ApplyInstructions}, {"update", service.ApplyInstructions}, {"remove", service.RemoveInstructions}} { + }{{"apply", service.ApplyInstructions}, {"upgrade", service.ApplyInstructions}, {"remove", service.RemoveInstructions}} { t.Run(operation.name, func(t *testing.T) { if _, err := operation.run(wiki); err == nil || !strings.Contains(err.Error(), "malformed") { t.Fatalf("marker variant error = %v", err) @@ -306,7 +306,7 @@ func TestInstructionTargetPrecedence(t *testing.T) { }{ {name: "neither creates AGENTS", want: []string{"AGENTS.md"}}, {name: "AGENTS only", existing: []string{"AGENTS.md"}, want: []string{"AGENTS.md"}}, - {name: "CLAUDE only", existing: []string{"CLAUDE.md"}, want: []string{"CLAUDE.md"}}, + {name: "CLAUDE only", existing: []string{"CLAUDE.md"}, want: []string{"AGENTS.md", "CLAUDE.md"}}, {name: "both", existing: []string{"AGENTS.md", "CLAUDE.md"}, want: []string{"AGENTS.md", "CLAUDE.md"}}, } { t.Run(test.name, func(t *testing.T) { diff --git a/internal/agent/instruction.go b/internal/agent/instruction.go index ea612af..b14ec45 100644 --- a/internal/agent/instruction.go +++ b/internal/agent/instruction.go @@ -29,7 +29,7 @@ type InstructionTarget struct { } func (s *Service) InstructionTemplate(context InstructionContext) (string, error) { - prompt, err := s.Prompt() + prompt, err := s.Instruction() if err != nil { return "", err } @@ -47,7 +47,15 @@ func (s *Service) ReadInstructionContext(wiki string) (InstructionContext, error return InstructionContext{}, repositoryError("resolve --wiki", err) } absolute = filepath.Clean(absolute) - value, err := config.Load(filepath.Join(absolute, config.FileName)) + resolveHome := s.homeDir + if resolveHome == nil { + resolveHome = os.UserHomeDir + } + home, err := resolveHome() + if err != nil { + return InstructionContext{}, mutation("resolve Buda configuration home", err) + } + value, err := config.LoadEffective(filepath.Join(absolute, config.FileName), home) if err != nil { return InstructionContext{}, repositoryError("load strict buda.yaml", err) } @@ -172,22 +180,25 @@ func (s *Service) InstalledInstruction(wiki, name string) (string, error) { } func instructionTargets(wiki string, createDefault bool) ([]string, error) { - candidates := []string{filepath.Join(wiki, "AGENTS.md"), filepath.Join(wiki, "CLAUDE.md")} + agentsPath := filepath.Join(wiki, "AGENTS.md") + claudePath := filepath.Join(wiki, "CLAUDE.md") + candidates := []string{agentsPath, claudePath} var targets []string - for _, path := range candidates { + // AGENTS.md is the canonical convention target and must always exist. A + // CLAUDE.md projection is reconciled only when the project already owns it. + for index, path := range candidates { info, err := os.Lstat(path) if err == nil && info.Mode()&os.ModeSymlink != 0 { return nil, mutation("refuse symlinked instruction target "+path, nil) } if err == nil { targets = append(targets, path) + } else if index == 0 && createDefault { + targets = append(targets, path) } else if !os.IsNotExist(err) { return nil, mutation("inspect instruction target", err) } } - if len(targets) == 0 && createDefault { - targets = append(targets, candidates[0]) - } return targets, nil } diff --git a/internal/agent/resources.go b/internal/agent/resources.go index ee2b0cf..a4aa612 100644 --- a/internal/agent/resources.go +++ b/internal/agent/resources.go @@ -15,9 +15,10 @@ import ( const ( SkillID = "guiho-s-0002-buda" + InstructionID = "guiho-i-buda" InstructionBegin = "" InstructionEnd = "" - PromptID = "buda" + PromptID = "guiho-p-buda" ) type Error struct { @@ -118,13 +119,13 @@ func (s *Service) Skill() (SkillRecord, error) { } func (s *Service) Prompt() (Prompt, error) { - content, err := fs.ReadFile(s.resources.Prompt, "guiho-i-buda.md") + content, err := fs.ReadFile(s.resources.Prompt, "guiho-p-buda.md") if err != nil { - return Prompt{}, mutation("read embedded Buda prompt", err) + return Prompt{}, mutation("read embedded Buda setup prompt", err) } frontmatter, body, ok := splitFrontmatter(string(content)) if !ok { - return Prompt{}, mutation("embedded Buda prompt is missing YAML frontmatter", nil) + return Prompt{}, mutation("embedded Buda setup prompt is missing YAML frontmatter", nil) } var metadata struct { Name string `yaml:"name"` @@ -132,14 +133,40 @@ func (s *Service) Prompt() (Prompt, error) { Version string `yaml:"version"` } if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { - return Prompt{}, mutation("decode embedded Buda prompt metadata", err) + return Prompt{}, mutation("decode embedded Buda setup prompt metadata", err) } if metadata.Name != PromptID || metadata.Description == "" || metadata.Version == "" { - return Prompt{}, mutation("embedded Buda prompt metadata is invalid", nil) + return Prompt{}, mutation("embedded Buda setup prompt metadata is invalid", nil) } return Prompt{ID: PromptID, Version: metadata.Version, Description: metadata.Description, Body: strings.TrimSpace(body)}, nil } +// Instruction returns the separately typed managed project instruction. It is +// deliberately not exposed as an agent prompt: the convention requires the +// instruction and setup prompt to have distinct resource identities. +func (s *Service) Instruction() (Prompt, error) { + content, err := fs.ReadFile(s.resources.Prompt, "guiho-i-buda.md") + if err != nil { + return Prompt{}, mutation("read embedded Buda instruction", err) + } + frontmatter, body, ok := splitFrontmatter(string(content)) + if !ok { + return Prompt{}, mutation("embedded Buda instruction is missing YAML frontmatter", nil) + } + var metadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Version string `yaml:"version"` + } + if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { + return Prompt{}, mutation("decode embedded Buda instruction metadata", err) + } + if metadata.Name != InstructionID || metadata.Description == "" || metadata.Version == "" { + return Prompt{}, mutation("embedded Buda instruction metadata is invalid", nil) + } + return Prompt{ID: InstructionID, Version: metadata.Version, Description: metadata.Description, Body: strings.TrimSpace(body)}, nil +} + func treeDigest(source fs.FS) (string, error) { var paths []string if err := fs.WalkDir(source, ".", func(path string, entry fs.DirEntry, walkErr error) error { diff --git a/internal/artifact/artifact.xdocs.md b/internal/artifact/artifact.xdocs.md new file mode 100644 index 0000000..a671edd --- /dev/null +++ b/internal/artifact/artifact.xdocs.md @@ -0,0 +1,15 @@ +--- +subject: buda-internal-artifact +description: Strict release and installed ownership manifest model. +parent: buda-internal +children: [] +files: + manifest.go: Strict JSON manifest validation, atomic writes, and SHA-256 helpers. +documents: {} +tags: [artifacts, lifecycle] +keywords: [artifacts.json, checksums, ownership] +flags: [] +status: draft +--- + +Manifests are the authority for release completeness and safe ownership. diff --git a/internal/artifact/manifest.go b/internal/artifact/manifest.go new file mode 100644 index 0000000..84c8c49 --- /dev/null +++ b/internal/artifact/manifest.go @@ -0,0 +1,220 @@ +// Package artifact owns the release and installed ownership manifests. +package artifact + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const CurrentSchema = 1 + +var semverPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +type Ownership string + +const ( + OwnershipReplaceable Ownership = "replaceable" + OwnershipPersistent Ownership = "persistent" + OwnershipDisposable Ownership = "disposable" +) + +type Artifact struct { + ID string `json:"id"` + Version string `json:"version"` + Path string `json:"path"` + InstalledPath string `json:"installed_path"` + SHA256 string `json:"sha256"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + ProjectionPaths []string `json:"projection_paths,omitempty"` + Ownership Ownership `json:"ownership"` + Replaceable bool `json:"replaceable"` + Persistent bool `json:"persistent"` + Disposable bool `json:"disposable"` + ArchiveMembers []string `json:"archive_members,omitempty"` +} + +type Manifest struct { + Schema int `json:"schema"` + CLI string `json:"cli"` + Version string `json:"version"` + GeneratedAt string `json:"generated_at,omitempty"` + Artifacts []Artifact `json:"artifacts"` +} + +func (m Manifest) Validate() error { + if m.Schema != CurrentSchema { + return fmt.Errorf("artifact manifest schema must be %d", CurrentSchema) + } + if strings.TrimSpace(m.CLI) != "buda" { + return errors.New("artifact manifest cli must be buda") + } + if !strictSemver(m.Version) { + return fmt.Errorf("artifact manifest version %q is not SemVer", m.Version) + } + if len(m.Artifacts) == 0 { + return errors.New("artifact manifest must declare at least one artifact") + } + ids, paths, releasePaths := map[string]bool{}, map[string]bool{}, map[string]bool{} + for _, a := range m.Artifacts { + if strings.TrimSpace(a.ID) == "" || ids[a.ID] { + return fmt.Errorf("artifact IDs must be non-empty and unique: %q", a.ID) + } + ids[a.ID] = true + if a.Version != m.Version { + return fmt.Errorf("artifact %s version %q does not match manifest version %q", a.ID, a.Version, m.Version) + } + if err := relativeSafe(a.Path); err != nil { + return fmt.Errorf("artifact %s path: %w", a.ID, err) + } + if releasePaths[a.Path] { + return fmt.Errorf("artifact release paths must be unique: %q", a.Path) + } + releasePaths[a.Path] = true + if err := relativeSafe(a.InstalledPath); err != nil { + return fmt.Errorf("artifact %s installed path: %w", a.ID, err) + } + if paths[a.InstalledPath] { + return fmt.Errorf("artifact installed paths must be unique: %q", a.InstalledPath) + } + paths[a.InstalledPath] = true + if len(a.SHA256) != sha256.Size*2 { + return fmt.Errorf("artifact %s SHA-256 is invalid", a.ID) + } + if _, err := hex.DecodeString(strings.ToLower(a.SHA256)); err != nil { + return fmt.Errorf("artifact %s SHA-256 is invalid: %w", a.ID, err) + } + if a.Ownership != OwnershipReplaceable && a.Ownership != OwnershipPersistent && a.Ownership != OwnershipDisposable { + return fmt.Errorf("artifact %s ownership is invalid", a.ID) + } + if a.Replaceable != (a.Ownership == OwnershipReplaceable) || a.Persistent != (a.Ownership == OwnershipPersistent) || a.Disposable != (a.Ownership == OwnershipDisposable) { + return fmt.Errorf("artifact %s ownership flags do not match ownership class", a.ID) + } + for _, projection := range a.ProjectionPaths { + if err := relativeSafe(projection); err != nil { + return fmt.Errorf("artifact %s projection path: %w", a.ID, err) + } + } + for _, member := range a.ArchiveMembers { + if err := relativeSafe(member); err != nil { + return fmt.Errorf("artifact %s archive member: %w", a.ID, err) + } + } + } + return nil +} + +func strictSemver(version string) bool { + match := semverPattern.FindStringSubmatch(strings.TrimPrefix(version, "v")) + if match == nil { + return false + } + for _, identifier := range strings.Split(match[4], ".") { + if len(identifier) > 1 && identifier[0] == '0' { + if _, err := strconv.ParseUint(identifier, 10, 64); err == nil { + return false + } + } + } + return true +} + +func relativeSafe(value string) error { + normalized := strings.ReplaceAll(value, "\\", "/") + if strings.TrimSpace(value) == "" || filepath.IsAbs(value) || filepath.VolumeName(value) != "" || strings.ContainsRune(value, ':') || strings.ContainsRune(value, '\x00') || strings.HasPrefix(normalized, "/") { + return errors.New("must be a non-empty relative path") + } + for _, component := range strings.Split(normalized, "/") { + if component == ".." { + return errors.New("must not traverse a parent") + } + } + clean := filepath.Clean(filepath.FromSlash(normalized)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return errors.New("must not traverse a parent") + } + return nil +} + +func Decode(reader io.Reader) (Manifest, error) { + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + var manifest Manifest + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Manifest{}, errors.New("manifest must contain one JSON document") + } + if err := manifest.Validate(); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func Load(path string) (Manifest, error) { + file, err := os.Open(path) + if err != nil { + return Manifest{}, err + } + defer file.Close() + return Decode(file) +} + +func Encode(m Manifest) ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + return json.MarshalIndent(m, "", " ") +} + +func WriteAtomic(path string, m Manifest) error { + data, err := Encode(m) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".manifest-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err = temp.Write(append(data, '\n')); err != nil { + temp.Close() + return err + } + if err = temp.Sync(); err != nil { + temp.Close() + return err + } + if err = temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +func DigestFile(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/internal/artifact/manifest_test.go b/internal/artifact/manifest_test.go new file mode 100644 index 0000000..9c66b61 --- /dev/null +++ b/internal/artifact/manifest_test.go @@ -0,0 +1,59 @@ +package artifact + +import ( + "strings" + "testing" +) + +func validManifest() Manifest { + return Manifest{ + Schema: 1, CLI: "buda", Version: "0.2.0", + Artifacts: []Artifact{{ + ID: "payload", Version: "0.2.0", Path: "buda-linux-amd64", + InstalledPath: "versions/0.2.0/buda", SHA256: strings.Repeat("a", 64), + Ownership: OwnershipReplaceable, Replaceable: true, + }}, + } +} + +func TestManifestRejectsUnsafeAndInconsistentOwnership(t *testing.T) { + cases := []struct { + name string + edit func(*Manifest) + }{ + {"absolute path", func(value *Manifest) { value.Artifacts[0].Path = `C:\escape` }}, + {"volume-relative path", func(value *Manifest) { value.Artifacts[0].Path = `C:escape` }}, + {"rooted backslash path", func(value *Manifest) { value.Artifacts[0].Path = `\escape` }}, + {"traversal", func(value *Manifest) { value.Artifacts[0].InstalledPath = "versions/../escape" }}, + {"version drift", func(value *Manifest) { value.Artifacts[0].Version = "0.1.1" }}, + {"ownership flags", func(value *Manifest) { value.Artifacts[0].Persistent = true }}, + {"duplicate release path", func(value *Manifest) { value.Artifacts = append(value.Artifacts, validManifest().Artifacts[0]) }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + value := validManifest() + test.edit(&value) + if err := value.Validate(); err == nil { + t.Fatal("Validate unexpectedly succeeded") + } + }) + } +} + +func TestManifestRoundTripUsesStrictJSON(t *testing.T) { + value := validManifest() + data, err := Encode(value) + if err != nil { + t.Fatal(err) + } + decoded, err := Decode(strings.NewReader(string(data))) + if err != nil { + t.Fatal(err) + } + if decoded.Artifacts[0].ID != "payload" { + t.Fatalf("decoded artifact = %#v", decoded.Artifacts[0]) + } + if _, err := Decode(strings.NewReader(`{"schema":1,"cli":"buda","version":"0.2.0","artifacts":[],"unknown":true}`)); err == nil { + t.Fatal("unknown manifest field was accepted") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6c55ff6..99f9c01 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,18 +13,20 @@ import ( ) const ( - FileName = "buda.yaml" - CurrentSchema = 1 + FileName = "buda.yaml" + GlobalFileName = "buda.global.yaml" + CurrentSchema = 1 ) -// Config is the complete supported buda.yaml shape. Configuration is never -// merged: callers load exactly one resolved file. +// Config is the effective configuration after the global baseline and the +// explicitly selected project's overrides have been merged. type Config struct { - Schema int `yaml:"schema" json:"schema"` - WikiID string `yaml:"wiki_id" json:"wiki_id"` - Bundle string `yaml:"bundle" json:"bundle"` - QMD QMDConfig `yaml:"qmd" json:"qmd"` - Derived string `yaml:"derived" json:"derived"` + Schema int `yaml:"schema" json:"schema"` + WikiID string `yaml:"wiki_id" json:"wiki_id"` + Bundle string `yaml:"bundle" json:"bundle"` + QMD QMDConfig `yaml:"qmd" json:"qmd"` + Derived string `yaml:"derived" json:"derived"` + Agent AgentConfig `yaml:"agent" json:"agent"` } type QMDConfig struct { @@ -33,6 +35,53 @@ type QMDConfig struct { ProjectDirectory string `yaml:"project_directory" json:"project_directory"` } +// Policy is deliberately a string enum rather than a Boolean. This keeps +// unattended agent authority explicit and makes unknown values fail closed. +type Policy string + +const ( + PolicyDisabled Policy = "disabled" + PolicyAlwaysAsk Policy = "always-ask" + PolicyAlwaysProceed Policy = "always-proceed" +) + +type AgentConfig struct { + Evolution EvolutionConfig `yaml:"evolution" json:"evolution"` +} + +type EvolutionConfig struct { + Upgrade Policy `yaml:"upgrade" json:"upgrade"` + Issues IssuePolicies `yaml:"issues" json:"issues"` +} + +type IssuePolicies struct { + Bugs Policy `yaml:"bugs" json:"bugs"` + Improvements Policy `yaml:"improvements" json:"improvements"` + Reviews Policy `yaml:"reviews" json:"reviews"` +} + +// GlobalConfig is the user-wide baseline stored in the CLI home. Pointers are +// intentional: an omitted value is distinguishable from an explicit value +// when the effective project configuration is merged. +type GlobalConfig struct { + Schema int `yaml:"schema" json:"schema"` + Bundle *string `yaml:"bundle,omitempty" json:"bundle,omitempty"` + QMD *QMDConfig `yaml:"qmd,omitempty" json:"qmd,omitempty"` + Derived *string `yaml:"derived,omitempty" json:"derived,omitempty"` + Agent *AgentConfig `yaml:"agent,omitempty" json:"agent,omitempty"` +} + +// ProjectConfig is the explicit-wiki configuration. WikiID is required; +// other fields override the global baseline only when present. +type ProjectConfig struct { + Schema int `yaml:"schema" json:"schema"` + WikiID string `yaml:"wiki_id" json:"wiki_id"` + Bundle *string `yaml:"bundle,omitempty" json:"bundle,omitempty"` + QMD *QMDConfig `yaml:"qmd,omitempty" json:"qmd,omitempty"` + Derived *string `yaml:"derived,omitempty" json:"derived,omitempty"` + Agent *AgentConfig `yaml:"agent,omitempty" json:"agent,omitempty"` +} + func Default(wikiID string) Config { return Config{ Schema: CurrentSchema, @@ -44,9 +93,19 @@ func Default(wikiID string) Config { Collection: "buda-wiki", ProjectDirectory: ".qmd", }, + Agent: DefaultAgent(), } } +func DefaultAgent() AgentConfig { + return AgentConfig{Evolution: EvolutionConfig{ + Upgrade: PolicyAlwaysAsk, + Issues: IssuePolicies{ + Bugs: PolicyAlwaysAsk, Improvements: PolicyAlwaysAsk, Reviews: PolicyAlwaysAsk, + }, + }} +} + // Resolve implements the shared GUIHO precedence contract without merging: // explicit path, cwd/buda.yaml, then ~/.guiho/buda/buda.yaml. func Resolve(explicit, cwd, home string) (string, error) { @@ -58,6 +117,10 @@ func Resolve(explicit, cwd, home string) (string, error) { candidates = append(candidates, filepath.Join(cwd, FileName)) } if home != "" { + candidates = append(candidates, filepath.Join(home, ".guiho", "buda", GlobalFileName)) + // Legacy 0.1.x used buda.yaml in the global directory. Keep this + // read-only discovery path for migration; new writes always use the + // distinct global filename. candidates = append(candidates, filepath.Join(home, ".guiho", "buda", FileName)) } } @@ -96,6 +159,29 @@ func Load(path string) (Config, error) { return configuration, nil } +// LoadEffective loads the explicit project configuration and the optional +// user-wide baseline without discovering a wiki. A missing global file uses +// the embedded defaults; callers that own initialization should create it. +func LoadEffective(projectPath, home string) (Config, error) { + project, err := LoadProject(projectPath) + if err != nil { + return Config{}, fmt.Errorf("load project configuration: %w", err) + } + global := DefaultGlobal() + if strings.TrimSpace(home) != "" { + path := GlobalPath(home) + if _, statErr := os.Stat(path); statErr == nil { + global, err = LoadGlobal(path) + if err != nil { + return Config{}, err + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return Config{}, fmt.Errorf("inspect global configuration: %w", statErr) + } + } + return Merge(global, project) +} + func Decode(reader io.Reader) (Config, error) { decoder := yaml.NewDecoder(reader) decoder.KnownFields(true) @@ -153,6 +239,9 @@ func (configuration Config) Validate() error { if cleanBundle == cleanQMD || cleanBundle == cleanDerived || cleanQMD == cleanDerived { return errors.New("bundle, qmd.project_directory, and derived must be distinct paths") } + if err := validateAgent(configuration.Agent); err != nil { + return err + } return nil } @@ -160,7 +249,7 @@ func validateRepositoryRelative(name, value string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s must not be empty", name) } - if strings.ContainsRune(value, '\x00') || filepath.IsAbs(value) { + if strings.ContainsRune(value, '\x00') || strings.ContainsRune(value, ':') || filepath.IsAbs(value) || filepath.VolumeName(value) != "" { return fmt.Errorf("%s must be a repository-relative path", name) } clean := filepath.Clean(value) @@ -174,9 +263,8 @@ func Marshal(configuration Config) ([]byte, error) { if err := configuration.Validate(); err != nil { return nil, err } - data, err := yaml.Marshal(configuration) - if err != nil { - return nil, fmt.Errorf("encode configuration: %w", err) - } - return data, nil + bundle, derived := configuration.Bundle, configuration.Derived + qmd := configuration.QMD + agent := configuration.Agent + return MarshalProject(ProjectConfig{Schema: configuration.Schema, WikiID: configuration.WikiID, Bundle: &bundle, QMD: &qmd, Derived: &derived, Agent: &agent}, "0.2.0") } diff --git a/internal/config/config.xdocs.md b/internal/config/config.xdocs.md index 1b8f1e9..c3ace83 100644 --- a/internal/config/config.xdocs.md +++ b/internal/config/config.xdocs.md @@ -1,11 +1,13 @@ --- subject: buda-internal-config -description: Strict typed buda.yaml decoding, deterministic resolution, defaults, and semantic validation. +description: Strict typed global/project YAML decoding, deterministic merge, defaults, and semantic validation. parent: buda-internal children: [] files: - config.go: Known-fields YAML decoding, precedence, defaults, and semantic checks. - config_test.go: Strict decoding, precedence, and invalid-configuration tests. + config.go: Effective configuration model, strict YAML decoding, defaults, and semantic checks. + config_test.go: Strict decoding, precedence, global baseline, and invalid-configuration tests. + dual.go: Distinct global/project contracts, inheritance, schema URLs, and atomic legacy migration. + dual_test.go: Global/project merge, policy inheritance, and strict-field tests. documents: {} tags: - configuration diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cf593b1..801a2ba 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,7 +48,7 @@ func TestResolvePrecedence(t *testing.T) { if err := os.MkdirAll(cwd, 0o755); err != nil { t.Fatal(err) } - global := filepath.Join(home, ".guiho", "buda", FileName) + global := filepath.Join(home, ".guiho", "buda", GlobalFileName) if err := os.WriteFile(global, []byte("global"), 0o644); err != nil { t.Fatal(err) } @@ -65,3 +65,44 @@ func TestResolvePrecedence(t *testing.T) { t.Fatalf("Resolve project = %q, %v", resolved, err) } } + +func TestLoadEffectiveUsesGlobalBaselineAndProjectOverrides(t *testing.T) { + home := t.TempDir() + projectRoot := filepath.Join(t.TempDir(), "wiki") + if err := os.MkdirAll(filepath.Dir(GlobalPath(home)), 0o755); err != nil { + t.Fatal(err) + } + global, err := MarshalGlobal(GlobalConfig{ + Schema: CurrentSchema, + Bundle: stringPtr("shared"), + Derived: stringPtr(".derived"), + QMD: &QMDConfig{Executable: "qmd", Collection: "global", ProjectDirectory: ".qmd"}, + Agent: &AgentConfig{Evolution: EvolutionConfig{Upgrade: PolicyAlwaysProceed}}, + }, "0.2.0") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(GlobalPath(home), global, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(projectRoot, 0o755); err != nil { + t.Fatal(err) + } + project, err := MarshalProject(ProjectConfig{Schema: CurrentSchema, WikiID: "selected", QMD: &QMDConfig{Collection: "project"}}, "0.2.0") + if err != nil { + t.Fatal(err) + } + projectPath := filepath.Join(projectRoot, FileName) + if err := os.WriteFile(projectPath, project, 0o644); err != nil { + t.Fatal(err) + } + effective, err := LoadEffective(projectPath, home) + if err != nil { + t.Fatal(err) + } + if effective.Bundle != "shared" || effective.QMD.Collection != "project" || effective.WikiID != "selected" || effective.EffectiveAgent().Evolution.Upgrade != PolicyAlwaysProceed { + t.Fatalf("effective config = %#v", effective) + } +} + +func stringPtr(value string) *string { return &value } diff --git a/internal/config/dual.go b/internal/config/dual.go new file mode 100644 index 0000000..8147f5c --- /dev/null +++ b/internal/config/dual.go @@ -0,0 +1,369 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" +) + +const schemaReleaseBase = "https://github.com/CGuiho/buda/releases/download/buda/v" + +func GlobalPath(home string) string { return filepath.Join(home, ".guiho", "buda", GlobalFileName) } + +func ProjectPath(wiki string) string { return filepath.Join(wiki, FileName) } + +// MigrateLegacyGlobal performs a one-time, read-only discovery of the 0.1.x +// global buda.yaml. The legacy wiki_id is intentionally discarded because a +// global file can never select a repository. The legacy file remains in place +// until the new global file has been atomically written by the caller. +func MigrateLegacyGlobal(home string, version string) (GlobalConfig, bool, error) { + legacy := filepath.Join(home, ".guiho", "buda", FileName) + if _, err := os.Stat(legacy); errors.Is(err, os.ErrNotExist) { + return GlobalConfig{}, false, nil + } else if err != nil { + return GlobalConfig{}, false, err + } + legacyConfig, err := Load(legacy) + if err != nil { + return GlobalConfig{}, false, fmt.Errorf("validate legacy global configuration: %w", err) + } + bundle, derived, qmd, agent := legacyConfig.Bundle, legacyConfig.Derived, legacyConfig.QMD, legacyConfig.Agent + global := GlobalConfig{Schema: CurrentSchema, Bundle: &bundle, Derived: &derived, QMD: &qmd, Agent: &agent} + if err := global.Validate(); err != nil { + return GlobalConfig{}, false, err + } + data, err := MarshalGlobal(global, version) + if err != nil { + return GlobalConfig{}, false, err + } + path := GlobalPath(home) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return GlobalConfig{}, false, err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".buda-global-migrate-*") + if err != nil { + return GlobalConfig{}, false, err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.Write(data); err != nil { + temp.Close() + return GlobalConfig{}, false, err + } + if err := temp.Sync(); err != nil { + temp.Close() + return GlobalConfig{}, false, err + } + if err := temp.Close(); err != nil { + return GlobalConfig{}, false, err + } + if err := os.Rename(tempPath, path); err != nil { + return GlobalConfig{}, false, err + } + return global, true, nil +} + +func DefaultGlobal() GlobalConfig { + bundle, derived := "knowledge", ".buda" + qmd := Default("placeholder").QMD + agent := DefaultAgent() + return GlobalConfig{Schema: CurrentSchema, Bundle: &bundle, QMD: &qmd, Derived: &derived, Agent: &agent} +} + +func DefaultProject(wikiID string) ProjectConfig { + return ProjectConfig{Schema: CurrentSchema, WikiID: wikiID} +} + +func LoadGlobal(path string) (GlobalConfig, error) { + file, err := os.Open(path) + if err != nil { + return GlobalConfig{}, fmt.Errorf("open global configuration %q: %w", path, err) + } + defer file.Close() + return DecodeGlobal(file) +} + +func LoadProject(path string) (ProjectConfig, error) { + file, err := os.Open(path) + if err != nil { + return ProjectConfig{}, fmt.Errorf("open project configuration %q: %w", path, err) + } + defer file.Close() + return DecodeProject(file) +} + +func DecodeGlobal(reader io.Reader) (GlobalConfig, error) { + var value GlobalConfig + if err := decodeStrict(reader, &value); err != nil { + return GlobalConfig{}, err + } + if value.Schema == 0 { + value.Schema = CurrentSchema + } + if value.Schema != CurrentSchema { + return GlobalConfig{}, fmt.Errorf("global schema must be %d", CurrentSchema) + } + if err := validateGlobal(value); err != nil { + return GlobalConfig{}, err + } + return value, nil +} + +func DecodeProject(reader io.Reader) (ProjectConfig, error) { + var value ProjectConfig + if err := decodeStrict(reader, &value); err != nil { + return ProjectConfig{}, err + } + if value.Schema == 0 { + value.Schema = CurrentSchema + } + if value.Schema != CurrentSchema { + return ProjectConfig{}, fmt.Errorf("project schema must be %d", CurrentSchema) + } + if strings.TrimSpace(value.WikiID) == "" { + return ProjectConfig{}, errors.New("wiki_id must not be empty") + } + if strings.ContainsAny(value.WikiID, "\r\n\x00") { + return ProjectConfig{}, errors.New("wiki_id contains an invalid control character") + } + if err := validateOverrides(value.Bundle, value.QMD, value.Derived, value.Agent); err != nil { + return ProjectConfig{}, err + } + return value, nil +} + +func decodeStrict(reader io.Reader, target any) error { + decoder := yaml.NewDecoder(reader) + decoder.KnownFields(true) + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return errors.New("multiple YAML documents are not supported") + } + return fmt.Errorf("decode trailing YAML: %w", err) + } + return nil +} + +func Merge(global GlobalConfig, project ProjectConfig) (Config, error) { + if err := validateGlobal(global); err != nil { + return Config{}, err + } + if err := project.Validate(); err != nil { + return Config{}, err + } + base := Default(project.WikiID) + if global.Bundle != nil { + base.Bundle = *global.Bundle + } + if global.Derived != nil { + base.Derived = *global.Derived + } + if global.QMD != nil { + base.QMD = mergeQMD(base.QMD, *global.QMD) + } + if global.Agent != nil { + base.Agent = mergeAgent(base.Agent, *global.Agent) + } + base.WikiID = project.WikiID + if project.Bundle != nil { + base.Bundle = *project.Bundle + } + if project.Derived != nil { + base.Derived = *project.Derived + } + if project.QMD != nil { + base.QMD = mergeQMD(base.QMD, *project.QMD) + } + if project.Agent != nil { + base.Agent = mergeAgent(base.Agent, *project.Agent) + } + if err := base.Validate(); err != nil { + return Config{}, err + } + return base, nil +} + +func mergeQMD(base, override QMDConfig) QMDConfig { + if strings.TrimSpace(override.Executable) != "" { + base.Executable = override.Executable + } + if strings.TrimSpace(override.Collection) != "" { + base.Collection = override.Collection + } + if strings.TrimSpace(override.ProjectDirectory) != "" { + base.ProjectDirectory = override.ProjectDirectory + } + return base +} + +func mergeAgent(base, override AgentConfig) AgentConfig { + if validPolicy(override.Evolution.Upgrade) { + base.Evolution.Upgrade = override.Evolution.Upgrade + } + if validPolicy(override.Evolution.Issues.Bugs) { + base.Evolution.Issues.Bugs = override.Evolution.Issues.Bugs + } + if validPolicy(override.Evolution.Issues.Improvements) { + base.Evolution.Issues.Improvements = override.Evolution.Issues.Improvements + } + if validPolicy(override.Evolution.Issues.Reviews) { + base.Evolution.Issues.Reviews = override.Evolution.Issues.Reviews + } + return base +} + +func validateGlobal(value GlobalConfig) error { + if value.Bundle != nil { + if err := validateRepositoryRelative("bundle", *value.Bundle); err != nil { + return err + } + } + if value.Derived != nil { + if err := validateRepositoryRelative("derived", *value.Derived); err != nil { + return err + } + } + if value.QMD != nil { + if err := validateQMD(*value.QMD); err != nil { + return err + } + } + if value.Agent != nil { + return validateAgent(*value.Agent) + } + return nil +} + +func validateOverrides(bundle *string, qmd *QMDConfig, derived *string, agent *AgentConfig) error { + if bundle != nil { + if err := validateRepositoryRelative("bundle", *bundle); err != nil { + return err + } + } + if derived != nil { + if err := validateRepositoryRelative("derived", *derived); err != nil { + return err + } + } + if qmd != nil { + if err := validateQMD(*qmd); err != nil { + return err + } + } + if agent != nil { + return validateAgent(*agent) + } + return nil +} + +func validateQMD(value QMDConfig) error { + if strings.TrimSpace(value.Executable) != "" && strings.ContainsAny(value.Executable, "\r\n\x00") { + return errors.New("qmd.executable contains an invalid control character") + } + if strings.TrimSpace(value.Collection) != "" && strings.ContainsAny(value.Collection, "\r\n\x00") { + return errors.New("qmd.collection contains an invalid control character") + } + if strings.TrimSpace(value.ProjectDirectory) != "" { + return validateRepositoryRelative("qmd.project_directory", value.ProjectDirectory) + } + return nil +} + +func validateAgent(value AgentConfig) error { + for name, policy := range map[string]Policy{ + "agent.evolution.upgrade": value.Evolution.Upgrade, + "agent.evolution.issues.bugs": value.Evolution.Issues.Bugs, + "agent.evolution.issues.improvements": value.Evolution.Issues.Improvements, + "agent.evolution.issues.reviews": value.Evolution.Issues.Reviews, + } { + if policy != "" && !validPolicy(policy) { + return fmt.Errorf("%s must be disabled, always-ask, or always-proceed", name) + } + } + return nil +} + +func validPolicy(value Policy) bool { + return value == PolicyDisabled || value == PolicyAlwaysAsk || value == PolicyAlwaysProceed +} + +func (value ProjectConfig) Validate() error { + if value.Schema != CurrentSchema { + return fmt.Errorf("project schema must be %d", CurrentSchema) + } + if strings.TrimSpace(value.WikiID) == "" { + return errors.New("wiki_id must not be empty") + } + if strings.ContainsAny(value.WikiID, "\r\n\x00") { + return errors.New("wiki_id contains an invalid control character") + } + return validateOverrides(value.Bundle, value.QMD, value.Derived, value.Agent) +} + +func (value GlobalConfig) Validate() error { + if value.Schema != CurrentSchema { + return fmt.Errorf("global schema must be %d", CurrentSchema) + } + return validateGlobal(value) +} + +func (value Config) EffectiveAgent() AgentConfig { + agent := value.Agent + defaults := DefaultAgent() + if !validPolicy(agent.Evolution.Upgrade) { + agent.Evolution.Upgrade = defaults.Evolution.Upgrade + } + if !validPolicy(agent.Evolution.Issues.Bugs) { + agent.Evolution.Issues.Bugs = defaults.Evolution.Issues.Bugs + } + if !validPolicy(agent.Evolution.Issues.Improvements) { + agent.Evolution.Issues.Improvements = defaults.Evolution.Issues.Improvements + } + if !validPolicy(agent.Evolution.Issues.Reviews) { + agent.Evolution.Issues.Reviews = defaults.Evolution.Issues.Reviews + } + return agent +} + +func SchemaURL(version string, global bool) string { + name := "buda.schema.json" + if global { + name = "buda.global.schema.json" + } + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + if version == "" { + version = "0.2.0" + } + return schemaReleaseBase + version + "/" + name +} + +func MarshalProject(value ProjectConfig, version string) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + data, err := yaml.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode project configuration: %w", err) + } + return append([]byte(fmt.Sprintf("# yaml-language-server: $schema=%s\n", SchemaURL(version, false))), data...), nil +} + +func MarshalGlobal(value GlobalConfig, version string) ([]byte, error) { + if err := value.Validate(); err != nil { + return nil, err + } + data, err := yaml.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode global configuration: %w", err) + } + return append([]byte(fmt.Sprintf("# yaml-language-server: $schema=%s\n", SchemaURL(version, true))), data...), nil +} diff --git a/internal/config/dual_test.go b/internal/config/dual_test.go new file mode 100644 index 0000000..96639c9 --- /dev/null +++ b/internal/config/dual_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "strings" + "testing" +) + +func TestDualConfigStrictMergeAndPolicyInheritance(t *testing.T) { + global, err := DecodeGlobal(strings.NewReader(`schema: 1 +bundle: shared +agent: + evolution: + upgrade: always-proceed + issues: + bugs: disabled +`)) + if err != nil { + t.Fatal(err) + } + project, err := DecodeProject(strings.NewReader(`schema: 1 +wiki_id: selected +qmd: + collection: project-collection +agent: + evolution: + issues: + reviews: always-proceed +`)) + if err != nil { + t.Fatal(err) + } + effective, err := Merge(global, project) + if err != nil { + t.Fatal(err) + } + if effective.Bundle != "shared" || effective.QMD.Collection != "project-collection" || effective.WikiID != "selected" { + t.Fatalf("effective config = %#v", effective) + } + policy := effective.EffectiveAgent().Evolution + if policy.Upgrade != PolicyAlwaysProceed || policy.Issues.Bugs != PolicyDisabled || policy.Issues.Reviews != PolicyAlwaysProceed || policy.Issues.Improvements != PolicyAlwaysAsk { + t.Fatalf("effective policy = %#v", policy) + } + if _, err := DecodeGlobal(strings.NewReader("schema: 1\nunknown: true\n")); err == nil { + t.Fatal("unknown global field was accepted") + } +} diff --git a/internal/help/help.go b/internal/help/help.go index 76c7bee..551e396 100644 --- a/internal/help/help.go +++ b/internal/help/help.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "sort" + "strconv" "strings" "github.com/spf13/cobra" @@ -19,19 +20,41 @@ type treeItem struct { } // Tree renders the live public subtree rooted at command. -func Tree(command *cobra.Command, depth int) string { +// ParseDepth validates the public help-tree-depth grammar. The convention +// intentionally exposes the word max rather than leaking the renderer's +// internal zero value to users. +func ParseDepth(value string) (int, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "max" { + return 0, nil + } + depth, err := strconv.Atoi(value) + if err != nil || depth <= 1 { + return 0, fmt.Errorf("help-tree-depth must be max or an integer greater than 1") + } + return depth, nil +} + +// Tree renders the live public subtree rooted at command. Global persistent +// flags are shown once at the root of the rendered scope unless repeatGlobals +// is true, in which case they are included at every applicable descendant. +func Tree(command *cobra.Command, depth string, repeatGlobals bool) (string, error) { + maximum, err := ParseDepth(depth) + if err != nil { + return "", err + } var output strings.Builder output.WriteString("COMMAND TREE\n\n") fmt.Fprintf(&output, "%s %s\n", commandUsage(command), command.Short) - renderChildren(&output, command, "", 1, depth) - return output.String() + renderChildren(&output, command, "", 1, maximum, true, repeatGlobals) + return output.String(), nil } -func renderChildren(output *strings.Builder, command *cobra.Command, prefix string, level, maximum int) { +func renderChildren(output *strings.Builder, command *cobra.Command, prefix string, level, maximum int, initial, repeatGlobals bool) { if maximum > 0 && level > maximum { return } - items := commandTreeItems(command) + items := commandTreeItems(command, initial || repeatGlobals) for index, item := range items { last := index == len(items)-1 branch, next := "├── ", prefix+"│ " @@ -40,12 +63,12 @@ func renderChildren(output *strings.Builder, command *cobra.Command, prefix stri } fmt.Fprintf(output, "%s%s%s %s\n", prefix, branch, item.name, item.description) if item.command != nil { - renderChildren(output, item.command, next, level+1, maximum) + renderChildren(output, item.command, next, level+1, maximum, false, repeatGlobals) } } } -func commandTreeItems(command *cobra.Command) []treeItem { +func commandTreeItems(command *cobra.Command, includeInherited bool) []treeItem { command.InitDefaultHelpFlag() var items []treeItem for _, child := range command.Commands() { @@ -71,7 +94,9 @@ func commandTreeItems(command *cobra.Command) []treeItem { }) } add(command.NonInheritedFlags()) - add(command.InheritedFlags()) + if includeInherited { + add(command.InheritedFlags()) + } sort.SliceStable(items, func(i, j int) bool { if items[i].flag != items[j].flag { return !items[i].flag @@ -89,36 +114,14 @@ func commandUsage(command *cobra.Command) string { return command.CommandPath() + " " + suffix } -// Markdown renders deterministic documentation for the live public subtree. -func Markdown(command *cobra.Command, depth int) (string, error) { - var output bytes.Buffer - var visit func(*cobra.Command, int) error - visit = func(current *cobra.Command, level int) error { - if depth > 0 && level > depth { - return nil - } - current.DisableAutoGenTag = true - var page bytes.Buffer - if err := doc.GenMarkdown(current, &page); err != nil { - return fmt.Errorf("generate Markdown help for %s: %w", current.CommandPath(), err) - } - if output.Len() > 0 { - output.WriteString("\n---\n\n") - } - output.Write(page.Bytes()) - children := append([]*cobra.Command(nil), current.Commands()...) - sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() }) - for _, child := range children { - if !child.Hidden { - if err := visit(child, level+1); err != nil { - return err - } - } - } - return nil - } - if err := visit(command, 0); err != nil { - return "", err +// Markdown renders deterministic documentation for only the invoked command. +// Cobra's generated page already includes that command's flags and examples; +// recursively concatenating descendants made --help-docs ambiguous. +func Markdown(command *cobra.Command) (string, error) { + command.DisableAutoGenTag = true + var page bytes.Buffer + if err := doc.GenMarkdown(command, &page); err != nil { + return "", fmt.Errorf("generate Markdown help for %s: %w", command.CommandPath(), err) } - return output.String(), nil + return page.String(), nil } diff --git a/internal/help/help.xdocs.md b/internal/help/help.xdocs.md index aaef809..e049911 100644 --- a/internal/help/help.xdocs.md +++ b/internal/help/help.xdocs.md @@ -5,6 +5,7 @@ parent: buda-internal children: [] files: help.go: Scoped tree, depth, flag, positional, and documentation renderers. + help_test.go: Convention depth grammar and scoped global-flag tree tests. documents: {} tags: - help diff --git a/internal/help/help_test.go b/internal/help/help_test.go new file mode 100644 index 0000000..cf84736 --- /dev/null +++ b/internal/help/help_test.go @@ -0,0 +1,45 @@ +package help + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestParseDepthUsesConventionGrammar(t *testing.T) { + for _, value := range []string{"max", "2", "999"} { + if _, err := ParseDepth(value); err != nil { + t.Fatalf("ParseDepth(%q) failed: %v", value, err) + } + } + for _, value := range []string{"", "0", "1", "-1", "two"} { + if _, err := ParseDepth(value); err == nil { + t.Fatalf("ParseDepth(%q) unexpectedly succeeded", value) + } + } +} + +func TestTreeScopesGlobalFlagsAndDescendants(t *testing.T) { + root := &cobra.Command{Use: "cliname", Short: "root"} + root.PersistentFlags().String("wiki", "", "explicit wiki") + child := &cobra.Command{Use: "child", Short: "child"} + child.Flags().String("local", "", "local value") + leaf := &cobra.Command{Use: "leaf", Short: "leaf"} + child.AddCommand(leaf) + root.AddCommand(child) + without, err := Tree(root, "max", false) + if err != nil { + t.Fatal(err) + } + if strings.Count(without, "--wiki") != 1 || !strings.Contains(without, "--local") { + t.Fatalf("global flags were not concise or local flags were omitted:\n%s", without) + } + with, err := Tree(root, "max", true) + if err != nil { + t.Fatal(err) + } + if strings.Count(with, "--wiki") != 3 { + t.Fatalf("global flag was not repeated at every scope:\n%s", with) + } +} diff --git a/internal/installlayout/installlayout.xdocs.md b/internal/installlayout/installlayout.xdocs.md new file mode 100644 index 0000000..2cbaa41 --- /dev/null +++ b/internal/installlayout/installlayout.xdocs.md @@ -0,0 +1,15 @@ +--- +subject: buda-internal-installlayout +description: Platform-native shared GUIHO and Buda CLI-home path layout. +parent: buda-internal +children: [] +files: + layout.go: Canonical paths, containment validation, and operation staging directories. +documents: {} +tags: [lifecycle, filesystem] +keywords: [.guiho, versions, stable launcher] +flags: [] +status: draft +--- + +All lifecycle code resolves paths through this package. diff --git a/internal/installlayout/layout.go b/internal/installlayout/layout.go new file mode 100644 index 0000000..c6ae921 --- /dev/null +++ b/internal/installlayout/layout.go @@ -0,0 +1,103 @@ +// Package installlayout defines Buda's platform-native, shared GUIHO home. +// All lifecycle operations use this package so installers, upgrade, launcher, +// and uninstall cannot silently drift to different locations. +package installlayout + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +type Layout struct { + Home string `json:"home"` + GUIHO string `json:"guiho"` + CLIHome string `json:"cli_home"` + Bin string `json:"bin"` + Temp string `json:"temp"` + Versions string `json:"versions"` + State string `json:"state"` + Instances string `json:"instances"` + Current string `json:"current"` + InstalledManifest string `json:"installed_manifest"` + GlobalConfig string `json:"global_config"` + Launcher string `json:"launcher"` +} + +func Current() (Layout, error) { + home, err := os.UserHomeDir() + if err != nil { + return Layout{}, fmt.Errorf("resolve user home: %w", err) + } + return ForHome(home) +} + +func ForHome(home string) (Layout, error) { + if strings.TrimSpace(home) == "" { + return Layout{}, errors.New("home directory must not be empty") + } + absolute, err := filepath.Abs(home) + if err != nil { + return Layout{}, fmt.Errorf("resolve home directory: %w", err) + } + abs := filepath.Clean(absolute) + guiho := filepath.Join(abs, ".guiho") + cli := filepath.Join(guiho, "buda") + bin := filepath.Join(guiho, "bin") + launcher := filepath.Join(bin, "buda") + if runtime.GOOS == "windows" { + launcher += ".exe" + } + return Layout{ + Home: abs, GUIHO: guiho, CLIHome: cli, Bin: bin, Temp: filepath.Join(guiho, ".temp"), + Versions: filepath.Join(cli, "versions"), State: filepath.Join(cli, "state"), + Instances: filepath.Join(cli, "state", "instances"), Current: filepath.Join(cli, "current.json"), + InstalledManifest: filepath.Join(cli, "installed-artifacts.json"), + GlobalConfig: filepath.Join(cli, "buda.global.yaml"), Launcher: launcher, + }, nil +} + +func (l Layout) Validate() error { + paths := map[string]string{"guiho": l.GUIHO, "cli home": l.CLIHome, "bin": l.Bin, "temp": l.Temp, "versions": l.Versions, "state": l.State} + for name, path := range paths { + if filepath.IsAbs(path) == false || strings.TrimSpace(path) == "" { + return fmt.Errorf("%s path must be absolute", name) + } + } + if !within(l.GUIHO, l.CLIHome) || !within(l.GUIHO, l.Bin) || !within(l.GUIHO, l.Temp) { + return errors.New("CLI paths must remain beneath shared .guiho") + } + if !within(l.CLIHome, l.Versions) || !within(l.CLIHome, l.State) { + return errors.New("Buda state paths must remain beneath CLI home") + } + return nil +} + +func within(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return false + } + return true +} + +func (l Layout) Operation(prefix string) (string, error) { + if err := l.Validate(); err != nil { + return "", err + } + prefix = strings.TrimSpace(prefix) + if prefix == "" || strings.ContainsAny(prefix, `\/:*?"<>|`) { + return "", errors.New("operation prefix is invalid") + } + if err := os.MkdirAll(l.Temp, 0o755); err != nil { + return "", fmt.Errorf("create GUIHO temp directory: %w", err) + } + dir, err := os.MkdirTemp(l.Temp, "buda-"+prefix+"-") + if err != nil { + return "", fmt.Errorf("create operation staging directory: %w", err) + } + return dir, nil +} diff --git a/internal/installlayout/layout_test.go b/internal/installlayout/layout_test.go new file mode 100644 index 0000000..1fd71fe --- /dev/null +++ b/internal/installlayout/layout_test.go @@ -0,0 +1,26 @@ +package installlayout + +import ( + "path/filepath" + "testing" +) + +func TestForHomeUsesNativeBudaOwnershipBoundaries(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + layout, err := ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := layout.Validate(); err != nil { + t.Fatal(err) + } + if layout.CLIHome != filepath.Join(home, ".guiho", "buda") || layout.Bin != filepath.Join(home, ".guiho", "bin") { + t.Fatalf("unexpected layout: %#v", layout) + } + if _, err := layout.Operation("upgrade"); err != nil { + t.Fatal(err) + } + if _, err := layout.Operation("../escape"); err == nil { + t.Fatal("unsafe operation prefix accepted") + } +} diff --git a/internal/internal.xdocs.md b/internal/internal.xdocs.md index c48df58..4c95e58 100644 --- a/internal/internal.xdocs.md +++ b/internal/internal.xdocs.md @@ -16,6 +16,13 @@ children: - buda-internal-repository - buda-internal-selfmanage - buda-internal-source + - buda-internal-artifact + - buda-internal-installlayout + - buda-internal-launcher + - buda-internal-lifecycle + - buda-internal-releasecatalog + - buda-internal-uninstall + - buda-internal-upgrade files: {} documents: {} tags: diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go new file mode 100644 index 0000000..0dea78e --- /dev/null +++ b/internal/launcher/launcher.go @@ -0,0 +1,195 @@ +// Package launcher implements the stable, non-versioned Buda entrypoint. +package launcher + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/CGuiho/buda/internal/installlayout" +) + +type Pointer struct { + Schema int `json:"schema"` + Active string `json:"active"` + Previous string `json:"previous,omitempty"` + ActiveVersion string `json:"active_version"` + PreviousVersion string `json:"previous_version,omitempty"` +} + +var pointerSemver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +func ReadPointer(path string) (Pointer, error) { + data, err := os.ReadFile(path) + if err != nil { + return Pointer{}, err + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var pointer Pointer + if err := decoder.Decode(&pointer); err != nil { + return Pointer{}, fmt.Errorf("decode active Buda pointer: %w", err) + } + if pointer.Schema != 1 { + return Pointer{}, errors.New("unsupported active Buda pointer schema") + } + if err := validateRelative(pointer.Active); err != nil { + return Pointer{}, fmt.Errorf("active payload: %w", err) + } + if !validVersion(pointer.ActiveVersion) || pointerVersion(pointer.Active) != pointer.ActiveVersion { + return Pointer{}, errors.New("active payload version does not match active_version") + } + if pointer.Previous == "" && strings.TrimSpace(pointer.PreviousVersion) != "" { + return Pointer{}, errors.New("previous_version requires a previous payload") + } + if pointer.Previous != "" { + if err := validateRelative(pointer.Previous); err != nil { + return Pointer{}, fmt.Errorf("previous payload: %w", err) + } + if !validVersion(pointer.PreviousVersion) || pointerVersion(pointer.Previous) != pointer.PreviousVersion { + return Pointer{}, errors.New("previous payload version does not match previous_version") + } + } + return pointer, nil +} + +func pointerVersion(path string) string { + parts := strings.Split(filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))), "/") + if len(parts) < 2 { + return "" + } + return parts[0] +} + +func validateRelative(value string) error { + normalized := strings.ReplaceAll(value, "\\", "/") + if strings.TrimSpace(value) == "" || filepath.IsAbs(value) || filepath.VolumeName(value) != "" || strings.ContainsRune(value, ':') || strings.ContainsRune(value, '\x00') || strings.HasPrefix(normalized, "/") { + return errors.New("payload path must be relative") + } + for _, component := range strings.Split(normalized, "/") { + if component == ".." { + return errors.New("payload path traverses the versions root") + } + } + clean := filepath.Clean(filepath.FromSlash(normalized)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return errors.New("payload path traverses the versions root") + } + return nil +} + +func validVersion(value string) bool { + match := pointerSemver.FindStringSubmatch(strings.TrimPrefix(strings.TrimSpace(value), "v")) + if match == nil { + return false + } + for _, identifier := range strings.Split(match[4], ".") { + if len(identifier) > 1 && identifier[0] == '0' { + return false + } + } + return true +} + +func Run(args []string, in io.Reader, out, errOut io.Writer, layout installlayout.Layout) int { + if err := layout.Validate(); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + pointer, err := ReadPointer(layout.Current) + if err != nil { + fmt.Fprintf(errOut, "buda launcher: %v\n", err) + return 1 + } + active, activeErr := resolvePayload(layout.Versions, pointer.Active) + var status int + started := false + var runErr error + if activeErr == nil { + status, started, runErr = invoke(active, args, in, out, errOut) + } else { + runErr = activeErr + } + if started { + return status + } + if pointer.Previous != "" { + previous, resolveErr := resolvePayload(layout.Versions, pointer.Previous) + if resolveErr != nil { + fmt.Fprintf(errOut, "buda launcher: active payload failed (%v); fallback payload: %v\n", runErr, resolveErr) + return 1 + } + fallbackStatus, _, fallbackErr := invoke(previous, args, in, out, errOut) + if fallbackErr == nil { + return fallbackStatus + } + fmt.Fprintf(errOut, "buda launcher: active payload failed (%v); fallback failed (%v)\n", runErr, fallbackErr) + return 1 + } + fmt.Fprintf(errOut, "buda launcher: active payload failed: %v\n", runErr) + return 1 +} + +func invoke(path string, args []string, in io.Reader, out, errOut io.Writer) (status int, started bool, err error) { + info, statErr := os.Lstat(path) + if statErr != nil { + return 1, false, statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return 1, false, errors.New("payload is a symlink") + } + if !info.Mode().IsRegular() { + return 1, false, errors.New("payload is not a regular file") + } + command := exec.Command(path, args...) + command.Stdin = in + command.Stdout = out + command.Stderr = errOut + err = command.Run() + if err == nil { + return 0, true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), true, err + } + return 1, false, err +} + +func resolvePayload(root, relative string) (string, error) { + if err := validateRelative(relative); err != nil { + return "", err + } + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + target := filepath.Join(root, filepath.FromSlash(relative)) + if resolvedRoot, rootErr := filepath.EvalSymlinks(root); rootErr == nil { + root = filepath.Clean(resolvedRoot) + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return "", err + } + resolved = filepath.Clean(resolved) + rel, err := filepath.Rel(root, resolved) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", errors.New("payload resolves outside the versions directory") + } + return target, nil +} + +func Exit(status int) { + if runtime.GOOS == "windows" && status < 0 { + status = 1 + } + os.Exit(status) +} diff --git a/internal/launcher/launcher.xdocs.md b/internal/launcher/launcher.xdocs.md new file mode 100644 index 0000000..89a3bf6 --- /dev/null +++ b/internal/launcher/launcher.xdocs.md @@ -0,0 +1,16 @@ +--- +subject: buda-internal-launcher +description: Stable launcher pointer resolution, payload execution, and verified fallback. +parent: buda-internal +children: [] +files: + launcher.go: Strict active/previous pointer decoding and exact exit-code forwarding. +documents: {} +tags: [launcher, lifecycle] +keywords: [current.json, fallback, immutable payload] +flags: [] +status: draft +--- + +The launcher performs no network work and falls back only before an active +payload successfully starts. diff --git a/internal/launcher/launcher_test.go b/internal/launcher/launcher_test.go new file mode 100644 index 0000000..73da6f6 --- /dev/null +++ b/internal/launcher/launcher_test.go @@ -0,0 +1,105 @@ +package launcher + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/CGuiho/buda/internal/installlayout" +) + +func TestPointerRejectsAbsoluteAndTraversal(t *testing.T) { + for _, data := range []string{ + `{"schema":1,"active":"C:\\escape","active_version":"0.2.0"}`, + `{"schema":1,"active":"C:escape","active_version":"0.2.0"}`, + `{"schema":1,"active":"\\escape","active_version":"0.2.0"}`, + `{"schema":1,"active":"../escape","active_version":"0.2.0"}`, + `{"schema":1,"active":"0.2.0/../escape","active_version":"0.2.0"}`, + } { + path := filepath.Join(t.TempDir(), "current.json") + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadPointer(path); err == nil { + t.Fatalf("unsafe pointer accepted: %s", data) + } + } +} + +func TestReadPointerRequiresVersionedPayload(t *testing.T) { + path := filepath.Join(t.TempDir(), "current.json") + data, err := json.Marshal(Pointer{Schema: 1, Active: "0.2.0/buda", ActiveVersion: "0.2.0"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + pointer, err := ReadPointer(path) + if err != nil { + t.Fatal(err) + } + if pointer.ActiveVersion != "0.2.0" { + t.Fatalf("pointer = %#v", pointer) + } +} + +func TestReadPointerRejectsVersionMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "current.json") + if err := os.WriteFile(path, []byte(`{"schema":1,"active":"0.2.0/buda","active_version":"0.1.1"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadPointer(path); err == nil { + t.Fatal("pointer version mismatch was accepted") + } +} + +func TestReadPointerRequiresStrictSemVerVersions(t *testing.T) { + for _, version := range []string{"latest", "1.2", "1.02.3", "1.2.3-01"} { + path := filepath.Join(t.TempDir(), "current.json") + data := `{"schema":1,"active":"` + version + `/buda","active_version":"` + version + `"}` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadPointer(path); err == nil { + t.Fatalf("invalid pointer version accepted: %s", version) + } + } +} + +func TestRunRejectsSymlinkedPayload(t *testing.T) { + root := t.TempDir() + layout, err := installlayout.ForHome(filepath.Join(root, "home")) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(layout.Versions, "0.2.0"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "outside") + if err := os.WriteFile(target, []byte("not a payload"), 0o644); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(layout.Versions, "0.2.0", "buda") + if err := os.Symlink(target, symlink); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + pointer := Pointer{Schema: 1, Active: "0.2.0/buda", ActiveVersion: "0.2.0"} + data, err := json.Marshal(pointer) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.CLIHome, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.Current, data, 0o644); err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + if code := Run(nil, strings.NewReader(""), &out, &errOut, layout); code == 0 || !strings.Contains(errOut.String(), "symlink") { + t.Fatalf("symlinked payload result code=%d stderr=%q", code, errOut.String()) + } +} diff --git a/internal/lifecycle/instances.go b/internal/lifecycle/instances.go new file mode 100644 index 0000000..a59e695 --- /dev/null +++ b/internal/lifecycle/instances.go @@ -0,0 +1,147 @@ +package lifecycle + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Instance is an owned Buda payload process record. The executable path and +// current-user identity are verified again through the platform adapter before +// a lifecycle operation can terminate the process. +type Instance struct { + Schema int `json:"schema"` + PID int `json:"pid"` + Executable string `json:"executable"` + Version string `json:"version"` + User string `json:"user"` + Started time.Time `json:"started"` +} + +// RegisterInstance creates one tokenized registry record and returns an +// ownership-checked cleanup function. Registry failures are intentionally +// visible to callers so an upgrade cannot claim process safety without it. +func RegisterInstance(directory, executable, version string) (func() error, error) { + if strings.TrimSpace(directory) == "" || strings.TrimSpace(executable) == "" { + return nil, errors.New("instance registry directory and executable are required") + } + absolute, err := filepath.Abs(executable) + if err != nil { + return nil, err + } + if resolved, resolveErr := filepath.EvalSymlinks(absolute); resolveErr == nil { + absolute = resolved + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return nil, err + } + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return nil, err + } + path := filepath.Join(directory, fmt.Sprintf("%d-%s.json", os.Getpid(), hex.EncodeToString(token[:]))) + value := Instance{Schema: 1, PID: os.Getpid(), Executable: filepath.Clean(absolute), Version: strings.TrimSpace(version), User: CurrentProcessUser(), Started: time.Now().UTC()} + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + if err := AtomicWrite(path, append(data, '\n'), 0o600); err != nil { + return nil, err + } + return func() error { + current, readErr := os.ReadFile(path) + if errors.Is(readErr, os.ErrNotExist) { + return nil + } + if readErr != nil { + return readErr + } + var owner Instance + if err := json.Unmarshal(current, &owner); err != nil || owner.PID != value.PID || owner.Executable != value.Executable { + return errors.New("instance registry ownership changed") + } + return os.Remove(path) + }, nil +} + +// TerminateOtherInstances stops only registered, current-user instances whose +// OS-observed executable path equals oldExecutable. It never follows a child +// process tree and never targets the invoking PID. +func TerminateOtherInstances(directory string, currentPID int, oldExecutable string) error { + if strings.TrimSpace(directory) == "" || strings.TrimSpace(oldExecutable) == "" { + return nil + } + expected, err := filepath.Abs(oldExecutable) + if err != nil { + return err + } + instances, err := readInstances(directory) + if err != nil { + return err + } + currentUser := CurrentProcessUser() + for _, instance := range instances { + if instance.PID <= 0 || instance.PID == currentPID || instance.User != currentUser { + continue + } + observed, observeErr := ProcessExecutable(instance.PID) + if errors.Is(observeErr, os.ErrProcessDone) || !IsProcessAlive(instance.PID) { + continue + } + if observeErr != nil { + return fmt.Errorf("inspect registered Buda process %d: %w", instance.PID, observeErr) + } + observed, err = filepath.Abs(observed) + if err != nil || filepath.Clean(observed) != filepath.Clean(expected) { + continue + } + if err := TerminateProcess(instance.PID); err != nil { + return fmt.Errorf("terminate registered Buda process %d: %w", instance.PID, err) + } + deadline := time.Now().Add(3 * time.Second) + for IsProcessAlive(instance.PID) && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if IsProcessAlive(instance.PID) { + return fmt.Errorf("registered Buda process %d did not terminate", instance.PID) + } + } + return nil +} + +func readInstances(directory string) ([]Instance, error) { + entries, err := os.ReadDir(directory) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + instances := make([]Instance, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + path := filepath.Join(directory, entry.Name()) + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil, readErr + } + var instance Instance + if decodeErr := json.Unmarshal(data, &instance); decodeErr != nil || instance.Schema != 1 || instance.PID <= 0 || strings.TrimSpace(instance.Executable) == "" { + return nil, fmt.Errorf("invalid Buda instance record %s", path) + } + if !IsProcessAlive(instance.PID) { + _ = os.Remove(path) + continue + } + instances = append(instances, instance) + } + return instances, nil +} diff --git a/internal/lifecycle/instances_test.go b/internal/lifecycle/instances_test.go new file mode 100644 index 0000000..77ecf6c --- /dev/null +++ b/internal/lifecycle/instances_test.go @@ -0,0 +1,38 @@ +package lifecycle + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRegisterInstanceOwnsTokenizedRecordAndCleansIt(t *testing.T) { + directory := t.TempDir() + cleanup, err := RegisterInstance(directory, os.Args[0], "0.2.0") + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || filepath.Ext(entries[0].Name()) != ".json" { + t.Fatalf("registry entries = %#v", entries) + } + if err := cleanup(); err != nil { + t.Fatal(err) + } + entries, err = os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("registry was not cleaned: %#v", entries) + } +} + +func TestTerminateOtherInstancesNeverTargetsInvokingPID(t *testing.T) { + if err := TerminateOtherInstances(t.TempDir(), os.Getpid(), os.Args[0]); err != nil { + t.Fatal(err) + } +} diff --git a/internal/lifecycle/lifecycle.xdocs.md b/internal/lifecycle/lifecycle.xdocs.md new file mode 100644 index 0000000..a1209b7 --- /dev/null +++ b/internal/lifecycle/lifecycle.xdocs.md @@ -0,0 +1,19 @@ +--- +subject: buda-internal-lifecycle +description: Crash-safe locks, journals, atomic files, process liveness, and containment primitives. +parent: buda-internal +children: [] +files: + transaction.go: Token-owned lock, transaction journal, atomic write, and safe removal primitives. + instances.go: Tokenized current-user payload registry and verified process termination boundary. + instances_test.go: Registry ownership, cleanup, and invoking-process protection tests. + process_windows.go: Windows process liveness adapter. + process_unix.go: Unix process adapter boundary. +documents: {} +tags: [lifecycle, recovery] +keywords: [upgrade.lock, transaction.json, rollback] +flags: [] +status: draft +--- + +These primitives keep installers, upgrade, and uninstall rollback-capable. diff --git a/internal/lifecycle/process_unix.go b/internal/lifecycle/process_unix.go new file mode 100644 index 0000000..6b388de --- /dev/null +++ b/internal/lifecycle/process_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +package lifecycle + +import ( + "errors" + "fmt" + "os" + "strconv" + "syscall" +) + +func processAliveWindows(pid int) bool { return false } + +func CurrentProcessUser() string { return strconv.Itoa(os.Getuid()) } + +func ProcessExecutable(pid int) (string, error) { + path, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + if errors.Is(err, os.ErrNotExist) { + return "", os.ErrProcessDone + } + return path, err +} + +func TerminateProcess(pid int) error { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + return nil +} diff --git a/internal/lifecycle/process_windows.go b/internal/lifecycle/process_windows.go new file mode 100644 index 0000000..3231390 --- /dev/null +++ b/internal/lifecycle/process_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package lifecycle + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +func processAliveWindows(pid int) bool { + command := exec.Command("tasklist", "/FI", "PID eq "+itoa(pid), "/FO", "CSV", "/NH") + output, err := command.Output() + if err != nil { + return false + } + needle := `"` + itoa(pid) + `"` + for _, line := range strings.Split(string(output), "\n") { + if strings.Contains(line, needle) { + return true + } + } + return false +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + result := "" + for value > 0 { + result = string(rune('0'+value%10)) + result + value /= 10 + } + return result +} + +func CurrentProcessUser() string { return strings.TrimSpace(os.Getenv("USERNAME")) } + +func ProcessExecutable(pid int) (string, error) { + info, err := processInfoWindows(pid) + if err != nil { + return "", err + } + return info.Path, nil +} + +func TerminateProcess(pid int) error { + command := exec.Command("taskkill.exe", "/PID", itoa(pid), "/F") + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("taskkill failed: %w (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} + +type processInfo struct { + Path string `json:"Path"` + User string `json:"User"` +} + +func processInfoWindows(pid int) (processInfo, error) { + filter := fmt.Sprintf("ProcessId = %d", pid) + script := fmt.Sprintf("$p=Get-CimInstance Win32_Process -Filter '%s'; if ($null -eq $p) { exit 1 }; $o=Invoke-CimMethod -InputObject $p -MethodName GetOwner; [pscustomobject]@{Path=$p.ExecutablePath;User=$o.User}|ConvertTo-Json -Compress", filter) + output, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script).Output() + if err != nil { + if !IsProcessAlive(pid) { + return processInfo{}, os.ErrProcessDone + } + return processInfo{}, err + } + var info processInfo + if err := json.Unmarshal(output, &info); err != nil || strings.TrimSpace(info.Path) == "" { + return processInfo{}, errors.New("Windows process identity is unavailable") + } + if strings.TrimSpace(info.User) == "" { + info.User = CurrentProcessUser() + } + if info.User != CurrentProcessUser() { + return processInfo{}, errors.New("Windows process belongs to another user") + } + return info, nil +} diff --git a/internal/lifecycle/transaction.go b/internal/lifecycle/transaction.go new file mode 100644 index 0000000..d3ccd3f --- /dev/null +++ b/internal/lifecycle/transaction.go @@ -0,0 +1,236 @@ +// Package lifecycle contains small crash-safe primitives shared by all +// installation operations. It deliberately knows no Buda domain data. +package lifecycle + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" +) + +type Lock struct { + Path string `json:"path"` + Token string `json:"token"` + PID int `json:"pid"` + Start time.Time `json:"start"` + file *os.File +} + +func AcquireLock(path string) (*Lock, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("lock path must not be empty") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + var file *os.File + var err error + for { + file, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + break + } + if !errors.Is(err, os.ErrExist) { + return nil, err + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("operation lock is held and cannot be inspected: %w", readErr) + } + var owner Lock + if decodeErr := json.Unmarshal(data, &owner); decodeErr != nil || owner.PID <= 0 { + return nil, fmt.Errorf("operation lock is held with invalid ownership metadata: %s", path) + } + if IsProcessAlive(owner.PID) { + return nil, fmt.Errorf("operation lock is held: %s", path) + } + if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return nil, fmt.Errorf("remove stale operation lock: %w", removeErr) + } + } + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + file.Close() + os.Remove(path) + return nil, err + } + lock := &Lock{Path: path, Token: hex.EncodeToString(tokenBytes), PID: os.Getpid(), Start: time.Now().UTC(), file: file} + data, _ := json.Marshal(lock) + if _, err := file.Write(append(data, '\n')); err != nil { + file.Close() + os.Remove(path) + return nil, err + } + if err := file.Sync(); err != nil { + file.Close() + os.Remove(path) + return nil, err + } + return lock, nil +} + +func (l *Lock) Release() error { + if l == nil { + return nil + } + if l.file != nil { + _ = l.file.Close() + } + data, err := os.ReadFile(l.Path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var owner Lock + if err := json.Unmarshal(data, &owner); err != nil { + return err + } + if owner.Token != l.Token { + return fmt.Errorf("operation lock ownership changed: %s", l.Path) + } + return os.Remove(l.Path) +} + +type Phase string + +const ( + PhasePlanned Phase = "planned" + PhaseStaged Phase = "staged" + PhaseCandidateVerified Phase = "candidate-verified" + PhaseProjectionsSnapshotted Phase = "projections-snapshotted" + PhaseArtifactsReplaced Phase = "artifacts-replaced" + PhaseActivated Phase = "activated" + PhaseVerified Phase = "verified" + PhaseRollingBack Phase = "rolling-back" + PhaseRolledBack Phase = "rolled-back" + PhaseComplete Phase = "complete" +) + +type Journal struct { + Schema int `json:"schema"` + Operation string `json:"operation"` + Phase Phase `json:"phase"` + Token string `json:"token"` + Version string `json:"version,omitempty"` + PreviousVersion string `json:"previous_version,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func SaveJournal(path string, journal Journal) error { + journal.Schema = 1 + journal.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(journal, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(path), ".journal-*") + if err != nil { + return err + } + tmp := temp.Name() + defer os.Remove(tmp) + if _, err = temp.Write(append(data, '\n')); err != nil { + temp.Close() + return err + } + if err = temp.Sync(); err != nil { + temp.Close() + return err + } + if err = temp.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func LoadJournal(path string) (Journal, error) { + data, err := os.ReadFile(path) + if err != nil { + return Journal{}, err + } + var value Journal + if err := json.Unmarshal(data, &value); err != nil { + return Journal{}, err + } + if value.Schema != 1 { + return Journal{}, errors.New("unsupported transaction journal schema") + } + return value, nil +} + +func AtomicWrite(path string, content []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".atomic-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func SafeRemove(path, root string) error { + if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" { + return errors.New("remove path and root are required") + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return fmt.Errorf("refuse removal outside owned root: %s", path) + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("refuse removal of symlink") + } + return os.RemoveAll(path) +} + +func IsProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + if runtime.GOOS == "windows" { + return processAliveWindows(pid) + } + process, err := os.FindProcess(pid) + if err != nil { + return false + } + err = process.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/internal/lifecycle/transaction_test.go b/internal/lifecycle/transaction_test.go new file mode 100644 index 0000000..2f2da85 --- /dev/null +++ b/internal/lifecycle/transaction_test.go @@ -0,0 +1,57 @@ +package lifecycle + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAcquireLockIsExclusiveAndSafeRemoveIsContained(t *testing.T) { + root := t.TempDir() + lockPath := filepath.Join(root, "state", "upgrade.lock") + first, err := AcquireLock(lockPath) + if err != nil { + t.Fatal(err) + } + defer first.Release() + if _, err := AcquireLock(lockPath); err == nil { + t.Fatal("second lock acquisition succeeded") + } + owned := filepath.Join(root, "owned") + if err := os.MkdirAll(owned, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(owned, "file"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := SafeRemove(filepath.Join(owned, "file"), owned); err != nil { + t.Fatal(err) + } + if err := SafeRemove(root, owned); err == nil { + t.Fatal("removal outside owned root was accepted") + } +} + +func TestAcquireLockRecoversDeadOwnerAndReleaseChecksToken(t *testing.T) { + root := t.TempDir() + lockPath := filepath.Join(root, "upgrade.lock") + data, err := json.Marshal(Lock{PID: 2147483647, Token: "dead-owner"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } + lock, err := AcquireLock(lockPath) + if err != nil { + t.Fatal(err) + } + defer lock.Release() + if err := os.WriteFile(lockPath, []byte(`{"pid":1,"token":"another-owner"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := lock.Release(); err == nil { + t.Fatal("release removed a lock owned by another token") + } +} diff --git a/internal/releasecatalog/catalog.go b/internal/releasecatalog/catalog.go new file mode 100644 index 0000000..1f9b917 --- /dev/null +++ b/internal/releasecatalog/catalog.go @@ -0,0 +1,156 @@ +// Package releasecatalog provides one release selection contract for Cobra +// upgrade and the shell installers' fixture-compatible behavior. +package releasecatalog + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/CGuiho/buda/internal/selfmanage" +) + +type Selector struct { + Version string + Channel string +} + +var semverPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +func (s Selector) Validate() error { + if strings.TrimSpace(s.Version) != "" && strings.TrimSpace(s.Channel) != "" { + return fmt.Errorf("--version and --channel are mutually exclusive") + } + if s.Version != "" { + version := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Version), "buda/"), "v") + if !IsSemver(version) { + return fmt.Errorf("invalid exact version %q", s.Version) + } + } + if strings.ContainsAny(s.Channel, " \t\r\n/\\") { + return fmt.Errorf("invalid release channel %q", s.Channel) + } + return nil +} + +func IsSemver(value string) bool { + match := semverPattern.FindStringSubmatch(strings.TrimPrefix(strings.TrimSpace(value), "v")) + if match == nil { + return false + } + for _, identifier := range strings.Split(match[4], ".") { + if len(identifier) > 1 && identifier[0] == '0' { + if _, err := strconv.ParseUint(identifier, 10, 64); err == nil { + return false + } + } + } + return true +} + +func Channel(version string) string { + parts := strings.SplitN(version, "-", 2) + if len(parts) == 1 { + return "stable" + } + identifier := strings.SplitN(parts[1], ".", 2)[0] + if identifier == "" { + return "stable" + } + return identifier +} + +type Result struct { + Release selfmanage.Release + Binary selfmanage.Asset + Launcher selfmanage.Asset + Manifest selfmanage.Asset + Channel string +} + +var required = map[string]bool{ + "checksums.txt": true, "artifacts.json": true, "buda.schema.json": true, + "buda.global.schema.json": true, "guiho-i-buda.md": true, "guiho-p-buda.md": true, + "buda.example.yaml": true, "buda.global.example.yaml": true, + "guiho-s-0002-buda.zip": true, +} + +func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, buildTarget string) (Result, error) { + if err := selector.Validate(); err != nil { + return Result{}, err + } + assetName, err := selfmanage.AssetName(buildTarget) + if err != nil { + return Result{}, err + } + launcherName, err := selfmanage.LauncherAssetName(buildTarget) + if err != nil { + return Result{}, err + } + releases, err := catalog.Releases(ctx) + if err != nil { + return Result{}, err + } + requested := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(selector.Version), "buda/"), "v") + channel := strings.TrimSpace(selector.Channel) + for _, release := range releases { + if requested != "" && release.Version != requested { + continue + } + if requested == "" { + wanted := channel + if wanted == "" { + wanted = "stable" + } + if Channel(release.Version) != wanted { + continue + } + } + var binary, launcher, manifest selfmanage.Asset + present := map[string]bool{} + for _, asset := range release.Assets { + if strings.TrimSpace(asset.Name) == "" || strings.TrimSpace(asset.DownloadURL) == "" || present[asset.Name] { + continue + } + present[asset.Name] = true + if asset.Name == assetName { + binary = asset + } + if asset.Name == launcherName { + launcher = asset + } + if asset.Name == "checksums.txt" { + manifest = asset + } + } + if binary.Name == "" || launcher.Name == "" || manifest.Name == "" { + if requested != "" { + return Result{}, fmt.Errorf("release %s lacks compatible payload, launcher, or checksums.txt", release.Version) + } + continue + } + if release.HTMLURL == "" || release.Draft { + if requested != "" { + return Result{}, fmt.Errorf("release %s is not a publishable GitHub release", release.Version) + } + continue + } + complete := true + for name := range required { + if !present[name] { + complete = false + break + } + } + if !complete { + if requested != "" { + return Result{}, fmt.Errorf("release %s is incomplete: required manifest and agent artifacts are missing", release.Version) + } + continue + } + return Result{Release: release, Binary: binary, Launcher: launcher, Manifest: manifest, Channel: Channel(release.Version)}, nil + } + return Result{}, fmt.Errorf("no compatible complete Buda release found") +} diff --git a/internal/releasecatalog/catalog_test.go b/internal/releasecatalog/catalog_test.go new file mode 100644 index 0000000..37da48e --- /dev/null +++ b/internal/releasecatalog/catalog_test.go @@ -0,0 +1,23 @@ +package releasecatalog + +import "testing" + +func TestIsSemverAndChannel(t *testing.T) { + for _, value := range []string{"0.2.0", "1.2.3-canary.4", "1.2.3+build.7"} { + if !IsSemver(value) { + t.Fatalf("IsSemver(%q) = false", value) + } + } + for _, value := range []string{"1.2", "01.2.3", "1.2.3-01", "1.2.3-"} { + if IsSemver(value) { + t.Fatalf("IsSemver(%q) = true", value) + } + } + for value, want := range map[string]string{ + "1.2.3": "stable", "1.2.3-canary.4": "canary", "1.2.3-alpha.2": "alpha", + } { + if got := Channel(value); got != want { + t.Fatalf("Channel(%q) = %q, want %q", value, got, want) + } + } +} diff --git a/internal/releasecatalog/releasecatalog.xdocs.md b/internal/releasecatalog/releasecatalog.xdocs.md new file mode 100644 index 0000000..b7ce498 --- /dev/null +++ b/internal/releasecatalog/releasecatalog.xdocs.md @@ -0,0 +1,15 @@ +--- +subject: buda-internal-releasecatalog +description: Complete paginated release selection and channel matching. +parent: buda-internal +children: [] +files: + catalog.go: Exact SemVer, channel, complete-artifact, payload, launcher, and target selection. +documents: {} +tags: [release, lifecycle] +keywords: [stable, canary, alpha, beta, nightly] +flags: [] +status: draft +--- + +The selector rejects incomplete releases before installation or activation. diff --git a/internal/repository/initialize.go b/internal/repository/initialize.go index 46948c6..9f206be 100644 --- a/internal/repository/initialize.go +++ b/internal/repository/initialize.go @@ -13,9 +13,11 @@ import ( ) type InitOptions struct { - WikiID string - Now time.Time - BeforeCommit func(Repository) error + WikiID string + Configuration config.Config + Version string + Now time.Time + BeforeCommit func(Repository) error } type InitResult struct { @@ -76,13 +78,26 @@ func Initialize(wiki string, options InitOptions) (InitResult, error) { return InitResult{}, fmt.Errorf("inspect initialization target: %w", err) } - configuration := config.Default(options.WikiID) - configurationData, err := config.Marshal(configuration) + configuration := options.Configuration + if configuration.Schema == 0 { + configuration = config.Default(options.WikiID) + } + if configuration.WikiID != options.WikiID { + return InitResult{}, fmt.Errorf("initialization configuration wiki_id %q does not match requested %q", configuration.WikiID, options.WikiID) + } + if err := configuration.Validate(); err != nil { + return InitResult{}, err + } + version := strings.TrimPrefix(strings.TrimSpace(options.Version), "v") + if version == "" { + version = "0.2.0" + } + projectData, err := config.MarshalProject(config.ProjectConfig{Schema: config.CurrentSchema, WikiID: options.WikiID}, version) if err != nil { return InitResult{}, err } files := map[string][]byte{ - config.FileName: configurationData, + config.FileName: projectData, filepath.Join(configuration.Bundle, "index.md"): []byte("---\nokf_version: \"0.2\"\n---\n# Knowledge\n\nThis Buda wiki uses Open Knowledge Format v0.2.\n"), filepath.Join(configuration.Bundle, "log.md"): []byte(fmt.Sprintf("# Wiki Update Log\n\n## %s\n* **Initialization**: Created the Buda wiki.\n", options.Now.UTC().Format("2006-01-02"))), } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index ec75ffa..33d87a1 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -67,7 +67,11 @@ func Open(wiki string) (Repository, error) { return Repository{}, fmt.Errorf("wiki path %q is not a directory", root) } configPath := filepath.Join(root, config.FileName) - configuration, err := config.Load(configPath) + home, err := os.UserHomeDir() + if err != nil { + return Repository{}, fmt.Errorf("resolve user home for global Buda configuration: %w", err) + } + configuration, err := config.LoadEffective(configPath, home) if err != nil { return Repository{}, fmt.Errorf("selected wiki must contain exactly one root %s: %w", config.FileName, err) } diff --git a/internal/repository/repository.xdocs.md b/internal/repository/repository.xdocs.md index 360c12e..5f63459 100644 --- a/internal/repository/repository.xdocs.md +++ b/internal/repository/repository.xdocs.md @@ -5,7 +5,7 @@ parent: buda-internal children: [] files: atomic.go: Same-directory staged validation and atomic replacement helpers. - initialize.go: Deterministic OKF-aware wiki scaffold creation. + initialize.go: Deterministic OKF-aware wiki scaffold creation with project/global configuration. initialize_test.go: Initialization idempotency and layout tests. repository.go: Explicit wiki opening, canonical paths, and containment. repository_test.go: Wiki selection, configuration, and containment tests. diff --git a/internal/selfmanage/catalog_test.go b/internal/selfmanage/catalog_test.go index 1e738ad..5b76aa1 100644 --- a/internal/selfmanage/catalog_test.go +++ b/internal/selfmanage/catalog_test.go @@ -66,6 +66,16 @@ func TestVersionComparisonAndTargetSelection(t *testing.T) { if _, err := AssetName("buda-linux-ppc64"); err == nil { t.Fatal("unsupported embedded target was accepted") } + for target, want := range map[string]string{ + "buda-linux-amd64": "buda-launcher-linux-amd64", + "buda-linux-armv7": "buda-launcher-linux-armv7", + "buda-windows-amd64": "buda-launcher-windows-amd64.exe", + } { + got, err := LauncherAssetName(target) + if err != nil || got != want { + t.Fatalf("LauncherAssetName(%q) = %q, %v", target, got, err) + } + } } func TestUpgradeVerifiesChecksumBeforeInjectedReplacement(t *testing.T) { @@ -131,7 +141,7 @@ func TestFetchChecksumRequiresExactAssetEntry(t *testing.T) { } } -func TestUpgradeRotatesPriorBackupAndKeepsScheduledCandidate(t *testing.T) { +func TestUpgradeRejectsDeferredReplacementAndRestoresBackupRotation(t *testing.T) { content := []byte("next") digest := sha256.Sum256(content) server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { _, _ = writer.Write(content) })) @@ -153,16 +163,17 @@ func TestUpgradeRotatesPriorBackupAndKeepsScheduledCandidate(t *testing.T) { return true, nil }, }) - if err != nil { - t.Fatal(err) + if err == nil || !strings.Contains(err.Error(), "synchronously") { + t.Fatalf("deferred replacement error = %v", err) } - defer os.Remove(candidate) - if _, err := os.Stat(candidate); err != nil { - t.Fatalf("scheduled candidate was removed before helper takeover: %v", err) + if candidate != "" { + if _, statErr := os.Stat(candidate); !os.IsNotExist(statErr) { + t.Fatalf("deferred candidate was not cleaned: %v", statErr) + } } - rotated, err := os.ReadFile(executable + ".old.previous") - if err != nil || string(rotated) != "previous" { - t.Fatalf("prior backup was not safely rotated: %q %v", rotated, err) + restored, err := os.ReadFile(executable + ".old") + if err != nil || string(restored) != "previous" { + t.Fatalf("prior backup was not restored after rejected deferred replacement: %q %v", restored, err) } } diff --git a/internal/selfmanage/replace_unix.go b/internal/selfmanage/replace_unix.go index 6a87f9e..185efc3 100644 --- a/internal/selfmanage/replace_unix.go +++ b/internal/selfmanage/replace_unix.go @@ -32,10 +32,6 @@ func replaceExecutable(executable, candidate, backup, targetVersion, _, _ string return false, nil } -func CompleteWindowsReplacement(_, _, _, _, _, _, _ string, _ int) error { - return fmt.Errorf("Windows replacement helper is unavailable on this platform") -} - func RemoveExecutable(path string) (bool, error) { if err := os.Remove(path); err != nil { return false, fmt.Errorf("remove Buda executable: %w", err) @@ -43,14 +39,6 @@ func RemoveExecutable(path string) (bool, error) { return false, nil } -func CompleteWindowsRemoval(_, _ string, _ int) error { - return fmt.Errorf("Windows removal helper is unavailable on this platform") -} - func Rollback(executable string) (bool, error) { return false, rollbackFiles(executable) } - -func CompleteWindowsRollback(_, _ string, _ int) error { - return fmt.Errorf("Windows rollback helper is unavailable on this platform") -} diff --git a/internal/selfmanage/replace_windows.go b/internal/selfmanage/replace_windows.go index 18695c3..4991062 100644 --- a/internal/selfmanage/replace_windows.go +++ b/internal/selfmanage/replace_windows.go @@ -2,233 +2,19 @@ package selfmanage -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strconv" - "syscall" - "time" - "unsafe" -) +import "fmt" +// Windows keeps the running process immutable. The stable launcher owns the +// process boundary, so lifecycle operations replace an inactive versioned +// payload instead of copying over the executable that is currently running. func replaceExecutable(executable, candidate, backup, targetVersion, checksum, wiki string, _ VerifyFunc) (bool, error) { - helper, err := copySelfHelper(executable, ".buda-upgrade-helper-*.exe") - if err != nil { - return false, err - } - arguments := []string{"upgrade", "__replace-windows", "--pid", strconv.Itoa(os.Getpid()), "--executable", executable, - "--candidate", candidate, "--backup", backup, "--target-version", targetVersion, "--checksum", checksum, "--helper", helper} - if wiki != "" { - arguments = append(arguments, "--selected-wiki", wiki) - } - command := exec.Command(helper, arguments...) - command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000, HideWindow: true} - command.Stdin, command.Stdout, command.Stderr = nil, nil, nil - if err := command.Start(); err != nil { - _ = os.Remove(helper) - return false, fmt.Errorf("start Windows upgrade helper: %w", err) - } - if err := command.Process.Release(); err != nil { - return false, fmt.Errorf("release Windows upgrade helper: %w", err) - } - return true, nil -} - -func CompleteWindowsReplacement(executable, candidate, backup, targetVersion, checksum, helper, wiki string, parentPID int) error { - defer scheduleDeleteAtReboot(helper) - if err := waitForProcess(uint32(parentPID), 2*time.Minute); err != nil { - return err - } - calculated, err := fileChecksum(candidate) - if err != nil { - return fmt.Errorf("hash staged Windows update: %w", err) - } - if calculated != checksum { - return fmt.Errorf("staged Windows candidate checksum changed: expected %s, got %s", checksum, calculated) - } - if _, err := os.Stat(backup); err == nil { - return fmt.Errorf("Windows upgrade backup already exists at %s", backup) - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect Windows upgrade backup: %w", err) - } - if err := os.Rename(executable, backup); err != nil { - return fmt.Errorf("backup current Windows executable: %w", err) - } - if err := os.Rename(candidate, executable); err != nil { - if rollbackErr := os.Rename(backup, executable); rollbackErr != nil { - return fmt.Errorf("activate Windows update: %w; rollback also failed: %v", err, rollbackErr) - } - return fmt.Errorf("activate Windows update: %w", err) - } - if err := VerifyExecutable(executable, targetVersion); err != nil { - failed := executable + ".failed" - _ = os.Remove(failed) - if moveErr := os.Rename(executable, failed); moveErr != nil { - return fmt.Errorf("%w; stage failed replacement: %v", err, moveErr) - } - if rollbackErr := os.Rename(backup, executable); rollbackErr != nil { - return fmt.Errorf("%w; rollback also failed: %v", err, rollbackErr) - } - _ = os.Remove(failed) - return err - } - if err := reconcileResources(executable, wiki); err != nil { - if rollbackErr := rollbackFiles(executable); rollbackErr != nil { - return fmt.Errorf("resource reconciliation failed: %w; automatic rollback also failed: %v", err, rollbackErr) - } - return fmt.Errorf("resource reconciliation failed: %w; executable automatically rolled back", err) - } - return nil + return false, fmt.Errorf("synchronous replacement of the running Windows executable is unavailable; install Buda through the stable launcher") } func RemoveExecutable(path string) (bool, error) { - helper, err := copySelfHelper(path, ".buda-uninstall-helper-*.exe") - if err != nil { - return false, err - } - command := exec.Command(helper, "uninstall", "__remove-windows", "--pid", strconv.Itoa(os.Getpid()), "--executable", path, "--helper", helper) - command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000, HideWindow: true} - command.Stdin, command.Stdout, command.Stderr = nil, nil, nil - if err := command.Start(); err != nil { - _ = os.Remove(helper) - return false, fmt.Errorf("start Windows uninstall helper: %w", err) - } - if err := command.Process.Release(); err != nil { - return false, fmt.Errorf("release Windows uninstall helper: %w", err) - } - return true, nil -} - -func CompleteWindowsRemoval(executable, helper string, parentPID int) error { - defer scheduleDeleteAtReboot(helper) - if err := waitForProcess(uint32(parentPID), 2*time.Minute); err != nil { - return err - } - if err := os.Remove(executable); err != nil { - return fmt.Errorf("remove Buda executable: %w", err) - } - return nil + return false, fmt.Errorf("synchronous removal of the running Windows executable is unavailable; remove Buda through the stable launcher") } func Rollback(executable string) (bool, error) { - helper, err := copySelfHelper(executable, ".buda-rollback-helper-*.exe") - if err != nil { - return false, err - } - command := exec.Command(helper, "upgrade", "__rollback-windows", "--pid", strconv.Itoa(os.Getpid()), "--executable", executable, "--helper", helper) - command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000, HideWindow: true} - command.Stdin, command.Stdout, command.Stderr = nil, nil, nil - if err := command.Start(); err != nil { - _ = os.Remove(helper) - return false, fmt.Errorf("start Windows rollback helper: %w", err) - } - if err := command.Process.Release(); err != nil { - return false, fmt.Errorf("release Windows rollback helper: %w", err) - } - return true, nil -} - -func CompleteWindowsRollback(executable, helper string, parentPID int) error { - defer scheduleDeleteAtReboot(helper) - if err := waitForProcess(uint32(parentPID), 2*time.Minute); err != nil { - return err - } - return rollbackFiles(executable) -} - -func copySelfHelper(executable, pattern string) (string, error) { - source, err := os.Open(executable) - if err != nil { - return "", fmt.Errorf("open Buda executable for helper: %w", err) - } - defer source.Close() - file, err := os.CreateTemp(filepath.Dir(executable), pattern) - if err != nil { - return "", fmt.Errorf("create Buda helper: %w", err) - } - path := file.Name() - if _, err := io.Copy(file, source); err != nil { - _ = file.Close() - _ = os.Remove(path) - return "", fmt.Errorf("copy Buda helper: %w", err) - } - if err := file.Sync(); err != nil { - _ = file.Close() - _ = os.Remove(path) - return "", fmt.Errorf("sync Buda helper: %w", err) - } - if err := file.Close(); err != nil { - _ = os.Remove(path) - return "", fmt.Errorf("close Buda helper: %w", err) - } - return path, nil -} - -func reconcileResources(executable, wiki string) error { - commands := [][]string{{"agent", "skill", "update"}} - if wiki != "" { - commands = append(commands, []string{"agent", "instruction", "update", "--wiki", wiki}) - } - for _, arguments := range commands { - output, err := exec.Command(executable, arguments...).CombinedOutput() - if err != nil { - return fmt.Errorf("run %v: %w (%s)", arguments, err, string(output)) - } - } - return nil -} - -func fileChecksum(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func waitForProcess(pid uint32, timeout time.Duration) error { - const synchronizeAccess = 0x00100000 - const waitObject0 = 0x00000000 - const waitTimeout = 0x00000102 - kernel32 := syscall.NewLazyDLL("kernel32.dll") - openProcess := kernel32.NewProc("OpenProcess") - wait := kernel32.NewProc("WaitForSingleObject") - closeHandle := kernel32.NewProc("CloseHandle") - handle, _, callErr := openProcess.Call(synchronizeAccess, 0, uintptr(pid)) - if handle == 0 { - if errno, ok := callErr.(syscall.Errno); ok && errno == syscall.Errno(87) { - return nil - } - return fmt.Errorf("open running Buda process: %w", callErr) - } - defer closeHandle.Call(handle) - result, _, waitErr := wait.Call(handle, uintptr(timeout.Milliseconds())) - switch result { - case waitObject0: - return nil - case waitTimeout: - return fmt.Errorf("timed out waiting for running Buda process to exit") - default: - return fmt.Errorf("wait for running Buda process: %w", waitErr) - } -} - -func scheduleDeleteAtReboot(path string) { - const moveFileDelayUntilReboot = 0x00000004 - pointer, err := syscall.UTF16PtrFromString(path) - if err != nil { - return - } - _, _, _ = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW").Call(uintptr(unsafe.Pointer(pointer)), 0, moveFileDelayUntilReboot) + return false, fmt.Errorf("synchronous rollback of the running Windows executable is unavailable; use the stable launcher transaction") } diff --git a/internal/selfmanage/selfmanage.xdocs.md b/internal/selfmanage/selfmanage.xdocs.md index 7e89a80..8d9b977 100644 --- a/internal/selfmanage/selfmanage.xdocs.md +++ b/internal/selfmanage/selfmanage.xdocs.md @@ -7,7 +7,7 @@ files: catalog.go: Bounded canonical buda/v semantic-release catalog parsing, filtering, and ordering. catalog_test.go: Catalog, semantic version, target selection, checksum, progress, backup rotation, rollback, and injected replacement tests. replace_unix.go: Same-filesystem Unix executable replacement, verification rollback, explicit rollback, and direct removal. - replace_windows.go: Detached Windows replacement, rollback, and removal helpers with parent waiting, checksum revalidation, resource reconciliation rollback, and deferred helper cleanup. + replace_windows.go: Fail-closed Windows direct-replacement boundaries; canonical lifecycle operations use the stable launcher and immutable payload transaction. upgrade.go: Release target selection, bounded progress-reporting downloads, SHA-256 validation, safe backup rotation, candidate staging, rollback primitives, and executable verification. documents: {} tags: diff --git a/internal/selfmanage/upgrade.go b/internal/selfmanage/upgrade.go index b835b74..dc9f315 100644 --- a/internal/selfmanage/upgrade.go +++ b/internal/selfmanage/upgrade.go @@ -46,7 +46,6 @@ type UpgradeResult struct { Asset string `json:"asset"` Executable string `json:"executable"` Backup string `json:"backup,omitempty"` - Scheduled bool `json:"scheduled"` Recovery string `json:"recovery,omitempty"` } @@ -73,6 +72,25 @@ func AssetName(buildTarget string) (string, error) { return target, nil } +// LauncherAssetName returns the stable launcher paired with a released +// payload. Keeping this derivation beside AssetName prevents an upgrade from +// accepting a payload-only release that cannot repair a fresh installation. +func LauncherAssetName(buildTarget string) (string, error) { + payload, err := AssetName(buildTarget) + if err != nil { + return "", err + } + target := strings.TrimSuffix(payload, ".exe") + if !strings.HasPrefix(target, "buda-") { + return "", fmt.Errorf("unsupported embedded build target %q", buildTarget) + } + launcher := "buda-launcher-" + strings.TrimPrefix(target, "buda-") + if strings.HasSuffix(payload, ".exe") { + launcher += ".exe" + } + return launcher, nil +} + func FetchChecksum(ctx context.Context, client Doer, manifestURL, assetName string) (string, error) { if client == nil { client = &http.Client{Timeout: 30 * time.Second} @@ -162,7 +180,7 @@ func Upgrade(ctx context.Context, options UpgradeOptions) (UpgradeResult, error) if replace == nil { replace = replaceExecutable } - scheduled, err := replace(executable, candidate, result.Backup, result.TargetVersion, expected, options.Wiki, verify) + deferred, err := replace(executable, candidate, result.Backup, result.TargetVersion, expected, options.Wiki, verify) if err != nil { if rotated { if restoreErr := restoreRotatedBackup(result.Backup); restoreErr != nil { @@ -171,13 +189,16 @@ func Upgrade(ctx context.Context, options UpgradeOptions) (UpgradeResult, error) } return result, err } - result.Scheduled = scheduled - if scheduled { - removeCandidate = false - } - if !scheduled { - result.Recovery = "" + if deferred { + if rotated { + if restoreErr := restoreRotatedBackup(result.Backup); restoreErr != nil { + return result, fmt.Errorf("replacement did not complete synchronously; restoring prior rollback backup also failed: %v", restoreErr) + } + } + return result, errors.New("replacement did not complete synchronously; stable launcher installation is required") } + removeCandidate = false + result.Recovery = "" return result, nil } @@ -318,7 +339,7 @@ func VerifyExecutable(path, version string) error { if err != nil { return fmt.Errorf("verify replacement executable: %w (%s)", err, strings.TrimSpace(string(output))) } - expected := "buda v" + strings.TrimPrefix(version, "v") + expected := strings.TrimPrefix(version, "v") if observed := strings.TrimSpace(string(output)); observed != expected { return fmt.Errorf("verify replacement executable: expected %q, got %q", expected, observed) } diff --git a/internal/uninstall/plan.go b/internal/uninstall/plan.go new file mode 100644 index 0000000..57431e3 --- /dev/null +++ b/internal/uninstall/plan.go @@ -0,0 +1,141 @@ +// Package uninstall plans and applies only manifest-proven Buda-owned paths. +package uninstall + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/CGuiho/buda/internal/agent" + "github.com/CGuiho/buda/internal/artifact" + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/lifecycle" +) + +type Item struct { + Path string `json:"path"` + Root string `json:"root"` + Action string `json:"action"` + Owner string `json:"owner"` + Preserved bool `json:"preserved"` +} + +type Plan struct { + Items []Item `json:"items"` + Wiki string `json:"wiki,omitempty"` + PreserveConfig bool `json:"preserve_config"` + PreserveData bool `json:"preserve_data"` +} + +func BuildPlan(layout installlayout.Layout, wiki string, preserveConfig, preserveData bool) (Plan, error) { + if err := layout.Validate(); err != nil { + return Plan{}, err + } + plan := Plan{Wiki: wiki, PreserveConfig: preserveConfig, PreserveData: preserveData} + add := func(path, root, owner string, preserve bool) { + action := "REMOVE" + if preserve { + action = "PRESERVE" + } + plan.Items = append(plan.Items, Item{Path: filepath.Clean(path), Root: filepath.Clean(root), Action: action, Owner: owner, Preserved: preserve}) + } + + // The shared .guiho and bin directories are never targets. Only Buda's + // stable launcher may be removed from the shared binary directory. + add(layout.Launcher, layout.Bin, "buda stable launcher", false) + // Global agent-skill projections are CLI-owned leaves beneath the user's + // home. Their shared tool directories and parent home are never targets. + for _, tool := range []string{".agents", ".claude"} { + add(filepath.Join(layout.Home, tool, "skills", agent.SkillID), layout.Home, "buda global agent skill", false) + } + + manifestPath := layout.InstalledManifest + if info, err := os.Stat(manifestPath); err == nil && !info.IsDir() { + installedManifest, loadErr := artifact.Load(manifestPath) + if loadErr != nil { + return Plan{}, fmt.Errorf("refuse uninstall with invalid installed manifest: %w", loadErr) + } + for _, entry := range installedManifest.Artifacts { + preserve := preserveData && entry.Persistent + add(filepath.Join(layout.CLIHome, filepath.FromSlash(entry.InstalledPath)), layout.CLIHome, "manifest-owned "+string(entry.Ownership)+" artifact", preserve) + } + } else if err == nil && info.IsDir() { + return Plan{}, errors.New("refuse uninstall with an installed-artifacts.json directory") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return Plan{}, fmt.Errorf("inspect installed Buda manifest: %w", err) + } else if _, cliErr := os.Stat(layout.CLIHome); cliErr == nil { + return Plan{}, errors.New("refuse uninstall without a valid installed-artifacts.json ownership manifest") + } else if !errors.Is(cliErr, os.ErrNotExist) { + return Plan{}, fmt.Errorf("inspect Buda CLI home: %w", cliErr) + } + + // Never remove the CLI-home directory recursively. It is a container that + // may hold user-created files not represented by the Buda ownership + // manifest. Enumerate the known lifecycle/data leaves instead and leave the + // empty container behind when no preserved content remains. + add(filepath.Join(layout.CLIHome, "data"), layout.CLIHome, "buda persistent data", preserveData) + add(filepath.Join(layout.CLIHome, "database"), layout.CLIHome, "buda database", preserveData) + for _, child := range []string{"versions", "state", "current.json", "installed-artifacts.json", "cache.json"} { + add(filepath.Join(layout.CLIHome, child), layout.CLIHome, "buda replaceable lifecycle state", false) + } + add(layout.GlobalConfig, layout.CLIHome, "buda global configuration", preserveConfig) + add(layout.CLIHome, layout.GUIHO, "buda CLI home container", true) + + if strings.TrimSpace(wiki) != "" { + root, err := filepath.Abs(wiki) + if err != nil { + return Plan{}, err + } + root = filepath.Clean(root) + add(filepath.Join(root, "buda.yaml"), root, "selected wiki configuration", preserveConfig) + add(filepath.Join(root, "AGENTS.md"), root, "managed Buda AGENTS instruction block", false) + for _, tool := range []string{".agents", ".claude"} { + add(filepath.Join(root, tool, "skills", agent.SkillID), root, "buda selected-wiki agent skill", false) + } + if info, statErr := os.Stat(filepath.Join(root, "CLAUDE.md")); statErr == nil && !info.IsDir() { + add(filepath.Join(root, "CLAUDE.md"), root, "managed Buda CLAUDE instruction block", false) + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return Plan{}, fmt.Errorf("inspect selected project instruction projection: %w", statErr) + } + } + sort.Slice(plan.Items, func(i, j int) bool { return plan.Items[i].Path < plan.Items[j].Path }) + return plan, nil +} + +func Apply(plan Plan) error { + for _, item := range plan.Items { + if item.Preserved { + continue + } + if strings.Contains(item.Owner, "instruction block") { + if err := removeManagedInstruction(item.Path); err != nil { + return err + } + continue + } + if strings.TrimSpace(item.Path) == "" || strings.TrimSpace(item.Root) == "" { + return errors.New("uninstall plan contains an empty path or ownership root") + } + if err := lifecycle.SafeRemove(item.Path, item.Root); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", item.Path, err) + } + } + return nil +} + +func removeManagedInstruction(path string) error { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + root := filepath.Dir(path) + service := agent.NewService(agent.DefaultResources()) + if _, err := service.RemoveInstructions(root); err != nil { + return fmt.Errorf("remove managed instruction from %s: %w", path, err) + } + return nil +} diff --git a/internal/uninstall/plan_test.go b/internal/uninstall/plan_test.go new file mode 100644 index 0000000..ba84949 --- /dev/null +++ b/internal/uninstall/plan_test.go @@ -0,0 +1,121 @@ +package uninstall + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/CGuiho/buda/internal/installlayout" +) + +func TestBuildPlanPreservesSharedParentsAndSelectedDataClasses(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.Launcher), 0o755); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(layout, filepath.Join(home, "wiki"), true, true) + if err != nil { + t.Fatal(err) + } + for _, item := range plan.Items { + if item.Path == layout.GUIHO || item.Path == layout.Bin || item.Path == layout.Temp { + t.Fatalf("plan targets shared parent %s", item.Path) + } + if item.Path == layout.GlobalConfig && !item.Preserved { + t.Fatal("global config was not preserved") + } + } + if len(plan.Items) == 0 { + t.Fatal("empty uninstall plan") + } +} + +func TestBuildPlanRejectsCorruptInstalledManifest(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.InstalledManifest), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.InstalledManifest, []byte(`{"schema":1,"cli":"buda","version":"0.2.0","artifacts":[`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := BuildPlan(layout, "", false, false); err == nil { + t.Fatal("corrupt installed manifest was accepted") + } +} + +func TestBuildPlanKeepsCLIHomeWhenOnlyConfigurationIsPreserved(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(layout, "", true, false) + if err != nil { + t.Fatal(err) + } + for _, item := range plan.Items { + if item.Path == layout.CLIHome && item.Action != "PRESERVE" { + t.Fatalf("CLI home action = %s, want PRESERVE", item.Action) + } + if item.Path == layout.GlobalConfig && !item.Preserved { + t.Fatal("global configuration was not preserved") + } + } +} + +func TestBuildPlanRefusesExistingCLIHomeWithoutOwnershipManifest(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.CLIHome, 0o755); err != nil { + t.Fatal(err) + } + if _, err := BuildPlan(layout, "", false, false); err == nil { + t.Fatal("uninstall plan accepted an existing CLI home without its ownership manifest") + } +} + +func TestBuildPlanNeverRecursivelyRemovesCLIHomeContainer(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.CLIHome, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"schema":1,"cli":"buda","version":"0.2.0","artifacts":[{"id":"example","version":"0.2.0","path":"buda.example.yaml","installed_path":"versions/0.2.0/artifacts/buda.example.yaml","sha256":"` + strings.Repeat("a", 64) + `","ownership":"replaceable","replaceable":true,"persistent":false,"disposable":false}]}` + if err := os.WriteFile(layout.InstalledManifest, []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(layout.CLIHome, "user-created.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(layout, "", false, false) + if err != nil { + t.Fatal(err) + } + for _, item := range plan.Items { + if item.Path == layout.CLIHome && item.Action != "PRESERVE" { + t.Fatalf("CLI home container is destructive: %#v", item) + } + } + if err := Apply(plan); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("unmanaged CLI-home content was removed: %v", err) + } +} diff --git a/internal/uninstall/uninstall.xdocs.md b/internal/uninstall/uninstall.xdocs.md new file mode 100644 index 0000000..a7126b5 --- /dev/null +++ b/internal/uninstall/uninstall.xdocs.md @@ -0,0 +1,18 @@ +--- +subject: buda-internal-uninstall +description: Manifest-driven ownership-safe uninstall planning and application without recursive removal of unmanaged CLI-home content. +parent: buda-internal +children: [] +files: + plan.go: Manifest-proven removal and preservation plan generation with global/local skill and bounded instruction cleanup. + plan_test.go: Ownership, preservation, and corrupt-manifest safety tests. +documents: {} +tags: [uninstall, filesystem] +keywords: [preserve-config, preserve-data, dry-run, ownership] +flags: [] +status: draft +--- + +Canonical OKF content and raw evidence are never uninstall targets. The CLI-home +container is never removed recursively; only manifest-proven or explicitly +classified Buda-owned leaves are planned. diff --git a/internal/upgrade/transaction.go b/internal/upgrade/transaction.go new file mode 100644 index 0000000..3f5e5b9 --- /dev/null +++ b/internal/upgrade/transaction.go @@ -0,0 +1,659 @@ +// Package upgrade implements the synchronous manifest-based release +// transaction used by the installed stable launcher. +package upgrade + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/CGuiho/buda/internal/artifact" + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/launcher" + "github.com/CGuiho/buda/internal/lifecycle" + "github.com/CGuiho/buda/internal/releasecatalog" + "github.com/CGuiho/buda/internal/selfmanage" +) + +type Options struct { + Layout installlayout.Layout + Selection releasecatalog.Result + Client selfmanage.Doer + CurrentVersion string + Target string + OS string + Arch string + CurrentPID int +} + +type Result struct { + PreviousVersion string `json:"previous_version"` + InstalledVersion string `json:"installed_version"` + LauncherPath string `json:"launcher_path"` + PayloadPath string `json:"payload_path"` + Artifacts []string `json:"artifacts"` +} + +// Execute downloads, validates, stages, and activates one complete release. +// The running payload is never replaced. The stable launcher remains usable +// while the new immutable version directory and current pointer are changed. +func Execute(ctx context.Context, options Options) (result Result, err error) { + if err = options.Layout.Validate(); err != nil { + return Result{}, err + } + selection := options.Selection + if selection.Release.Version == "" || selection.Binary.Name == "" || selection.Launcher.Name == "" { + return Result{}, errors.New("complete release selection is required") + } + if !releasecatalog.IsSemver(selection.Release.Version) { + return Result{}, fmt.Errorf("release version %q is not valid SemVer", selection.Release.Version) + } + expectedLauncher, launcherErr := selfmanage.LauncherAssetName(optionsTarget(options)) + if launcherErr != nil { + return Result{}, launcherErr + } + if selection.Launcher.Name != expectedLauncher { + return Result{}, fmt.Errorf("release launcher %q does not match native target %q", selection.Launcher.Name, expectedLauncher) + } + client := options.Client + if client == nil { + client = &http.Client{} + } + op, err := options.Layout.Operation("upgrade") + if err != nil { + return Result{}, err + } + defer os.RemoveAll(op) + lock, err := lifecycle.AcquireLock(filepath.Join(options.Layout.State, "upgrade.lock")) + if err != nil { + return Result{}, err + } + defer func() { + if releaseErr := lock.Release(); err == nil && releaseErr != nil { + err = releaseErr + } + }() + + journalPath := filepath.Join(options.Layout.State, "transaction.json") + if err = recoverInterrupted(options.Layout, journalPath); err != nil { + return Result{}, err + } + journal := lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhasePlanned, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: options.CurrentVersion} + if err = lifecycle.SaveJournal(journalPath, journal); err != nil { + return Result{}, err + } + currentBytes, currentExists, currentErr := snapshotFile(options.Layout.Current) + if currentErr != nil { + return Result{}, currentErr + } + installedBytes, installedExists, installedErr := snapshotFile(options.Layout.InstalledManifest) + if installedErr != nil { + return Result{}, installedErr + } + launcherBytes, launcherExists, launcherErr := snapshotFile(options.Layout.Launcher) + if launcherErr != nil { + return Result{}, launcherErr + } + previous := readPrevious(options.Layout.Current) + previousPath := filepath.Join(options.Layout.Versions, filepath.FromSlash(previous.Active)) + if previous.Active != "" && !withinPath(options.Layout.Versions, previousPath) { + return Result{}, errors.New("existing active pointer escapes the Buda versions directory") + } + + var versionDir string + var versionBackup string + versionChanged := false + launcherChanged := false + mutated := false + defer func() { + if err == nil || !mutated { + return + } + _ = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseRollingBack, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}) + var failures []error + if (versionChanged || versionBackup != "") && versionDir != "" { + if versionChanged { + if removeErr := lifecycle.SafeRemove(versionDir, options.Layout.Versions); removeErr != nil && !os.IsNotExist(removeErr) { + failures = append(failures, removeErr) + } + } + if versionBackup != "" { + if restoreErr := os.Rename(versionBackup, versionDir); restoreErr != nil { + failures = append(failures, restoreErr) + } + } + } + if launcherChanged { + if restoreErr := restoreSnapshot(options.Layout.Launcher, launcherBytes, launcherExists); restoreErr != nil { + failures = append(failures, restoreErr) + } + } + if restoreErr := restoreSnapshot(options.Layout.Current, currentBytes, currentExists); restoreErr != nil { + failures = append(failures, restoreErr) + } + if restoreErr := restoreSnapshot(options.Layout.InstalledManifest, installedBytes, installedExists); restoreErr != nil { + failures = append(failures, restoreErr) + } + if len(failures) == 0 { + _ = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseRolledBack, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}) + } else { + err = errors.Join(err, fmt.Errorf("upgrade rollback failed: %v", failures)) + } + }() + + files, err := fetchRelease(ctx, client, selection.Release, op) + if err != nil { + return Result{}, err + } + if err = verifyChecksums(files, filepath.Join(op, "checksums.txt")); err != nil { + return Result{}, err + } + manifest, err := artifact.Load(filepath.Join(op, "artifacts.json")) + if err != nil { + return Result{}, fmt.Errorf("validate release artifacts.json: %w", err) + } + if manifest.Version != selection.Release.Version { + return Result{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, selection.Release.Version) + } + if err = validateManifestAssets(manifest, files); err != nil { + return Result{}, err + } + if err = validateArchive(filepath.Join(op, "guiho-s-0002-buda.zip"), manifest); err != nil { + return Result{}, err + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseStaged, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + + payload := filepath.Join(op, selection.Binary.Name) + if observed, versionErr := versionOf(payload); versionErr != nil { + return Result{}, versionErr + } else if observed != selection.Release.Version { + return Result{}, fmt.Errorf("candidate raw version %q does not match %q", observed, selection.Release.Version) + } + if selfTestErr := selfTest(payload); selfTestErr != nil { + return Result{}, selfTestErr + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseCandidateVerified, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + + versionDir = filepath.Join(options.Layout.Versions, selection.Release.Version) + if previous.ActiveVersion == selection.Release.Version { + return Result{}, fmt.Errorf("release %s is already selected by the active pointer", selection.Release.Version) + } + if info, statErr := os.Lstat(versionDir); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return Result{}, fmt.Errorf("installed version path is not a regular directory: %s", versionDir) + } + versionBackup = filepath.Join(op, "previous-version") + if err = os.Rename(versionDir, versionBackup); err != nil { + return Result{}, fmt.Errorf("stage existing immutable version directory: %w", err) + } + mutated = true + } else if !os.IsNotExist(statErr) { + return Result{}, statErr + } + candidateDir := filepath.Join(op, "version") + if err = stageVersion(candidateDir, payload, files); err != nil { + return Result{}, err + } + if err = os.MkdirAll(options.Layout.Versions, 0o755); err != nil { + return Result{}, err + } + if err = os.Rename(candidateDir, versionDir); err != nil { + return Result{}, fmt.Errorf("activate immutable version directory: %w", err) + } + versionChanged = true + mutated = true + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseProjectionsSnapshotted, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseArtifactsReplaced, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + if previous.Active != "" { + oldPayload := filepath.Join(options.Layout.Versions, filepath.FromSlash(previous.Active)) + if err = lifecycle.TerminateOtherInstances(options.Layout.Instances, options.CurrentPID, oldPayload); err != nil { + return Result{}, fmt.Errorf("terminate other verified Buda payload instances: %w", err) + } + } + if !launcherExists { + launcherSource, ok := files[selection.Launcher.Name] + if !ok { + return Result{}, fmt.Errorf("selected launcher asset %q was not downloaded", selection.Launcher.Name) + } + launcherContent, readErr := os.ReadFile(launcherSource) + if readErr != nil { + return Result{}, fmt.Errorf("read selected stable launcher: %w", readErr) + } + if err = lifecycle.AtomicWrite(options.Layout.Launcher, launcherContent, 0o755); err != nil { + return Result{}, fmt.Errorf("install stable launcher: %w", err) + } + launcherChanged = true + mutated = true + } + + pointer := launcher.Pointer{Schema: 1, Active: filepath.ToSlash(filepath.Join(selection.Release.Version, payloadNameForTarget())), Previous: previous.Active, ActiveVersion: selection.Release.Version, PreviousVersion: previous.ActiveVersion} + data, err := json.Marshal(pointer) + if err != nil { + return Result{}, err + } + if err = lifecycle.AtomicWrite(options.Layout.Current, append(data, '\n'), 0o644); err != nil { + return Result{}, err + } + mutated = true + if err = lifecycle.AtomicWrite(options.Layout.InstalledManifest, mustEncodeManifest(manifest), 0o644); err != nil { + return Result{}, err + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseActivated, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + if verified, verifyErr := launcher.ReadPointer(options.Layout.Current); verifyErr != nil || verified.ActiveVersion != selection.Release.Version { + if verifyErr != nil { + return Result{}, fmt.Errorf("verify activated pointer: %w", verifyErr) + } + return Result{}, fmt.Errorf("activated pointer version %q does not match %q", verified.ActiveVersion, selection.Release.Version) + } + if err = verifyInstalledRelease(options.Layout, manifest, versionDir, selection.Release.Version); err != nil { + return Result{}, err + } + if err = verifyLauncherVersion(options.Layout.Launcher, selection.Release.Version); err != nil { + return Result{}, err + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseVerified, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseComplete, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { + return Result{}, err + } + if err = os.Remove(journalPath); err != nil && !os.IsNotExist(err) { + return Result{}, err + } + result = Result{PreviousVersion: previous.ActiveVersion, InstalledVersion: selection.Release.Version, LauncherPath: options.Layout.Launcher, PayloadPath: filepath.Join(versionDir, payloadNameForTarget())} + for _, entry := range manifest.Artifacts { + result.Artifacts = append(result.Artifacts, entry.ID) + } + sort.Strings(result.Artifacts) + return result, nil +} + +func optionsTarget(options Options) string { + if strings.TrimSpace(options.Target) != "" && options.Target != "development" { + return options.Target + } + if strings.TrimSpace(options.OS) != "" && strings.TrimSpace(options.Arch) != "" { + target := "buda-" + strings.ToLower(strings.TrimSpace(options.OS)) + "-" + strings.ToLower(strings.TrimSpace(options.Arch)) + if strings.EqualFold(options.Arch, "arm") { + target += "v7" + } + return target + } + return "development" +} + +func payloadNameForTarget() string { + if runtime.GOOS == "windows" { + return "buda.exe" + } + return "buda" +} + +func stageVersion(destination, payload string, files map[string]string) error { + if err := os.MkdirAll(filepath.Join(destination, "artifacts"), 0o755); err != nil { + return err + } + payloadDestination := filepath.Join(destination, payloadNameForTarget()) + if err := copyFile(payload, payloadDestination, 0o755); err != nil { + return err + } + for name, source := range files { + if err := copyFile(source, filepath.Join(destination, "artifacts", filepath.Base(name)), 0o644); err != nil { + return fmt.Errorf("copy release artifact %s: %w", name, err) + } + } + return nil +} + +func verifyInstalledRelease(layout installlayout.Layout, manifest artifact.Manifest, versionDir, version string) error { + payload := filepath.Join(versionDir, payloadNameForTarget()) + if observed, err := versionOf(payload); err != nil { + return fmt.Errorf("verify activated payload: %w", err) + } else if observed != version { + return fmt.Errorf("activated payload version %q does not match %q", observed, version) + } + if err := selfTest(payload); err != nil { + return fmt.Errorf("verify activated payload self-test: %w", err) + } + for _, entry := range manifest.Artifacts { + if strings.Trim(entry.SHA256, "0") == "" { + continue + } + path := filepath.Join(layout.CLIHome, filepath.FromSlash(entry.InstalledPath)) + actual, err := digest(path) + if err != nil { + return fmt.Errorf("verify installed artifact %s: %w", entry.ID, err) + } + if !strings.EqualFold(actual, entry.SHA256) { + return fmt.Errorf("installed artifact %s checksum mismatch", entry.ID) + } + } + return nil +} + +func verifyLauncherVersion(path, expected string) error { + if strings.TrimSpace(path) == "" { + return errors.New("stable launcher path is required for post-activation verification") + } + command := exec.Command(path, "--version") + output, err := command.Output() + if err != nil { + return fmt.Errorf("run stable launcher after activation: %w", err) + } + if observed := strings.TrimSpace(string(output)); observed != expected { + return fmt.Errorf("stable launcher reported %q after activation; expected %q", observed, expected) + } + return nil +} + +func recoverInterrupted(layout installlayout.Layout, journalPath string) error { + journal, err := lifecycle.LoadJournal(journalPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect interrupted upgrade journal: %w", err) + } + if journal.Phase == lifecycle.PhaseComplete || journal.Phase == lifecycle.PhaseRolledBack { + return os.Remove(journalPath) + } + pointer, pointerErr := launcher.ReadPointer(layout.Current) + if pointerErr != nil { + return fmt.Errorf("recover interrupted upgrade: active pointer is unavailable: %w", pointerErr) + } + if pointer.ActiveVersion == journal.Version { + payload := filepath.Join(layout.Versions, filepath.FromSlash(pointer.Active)) + if _, statErr := os.Stat(payload); statErr != nil { + return fmt.Errorf("recover interrupted upgrade: activated payload is missing: %w", statErr) + } + return os.Remove(journalPath) + } + if journal.PreviousVersion == "" || pointer.ActiveVersion == journal.PreviousVersion { + return os.Remove(journalPath) + } + return fmt.Errorf("recover interrupted upgrade: journal target %s and active pointer %s disagree", journal.Version, pointer.ActiveVersion) +} + +func validateManifestAssets(manifest artifact.Manifest, files map[string]string) error { + for _, entry := range manifest.Artifacts { + path, ok := files[entry.Path] + if !ok { + return fmt.Errorf("release manifest artifact %s is not published", entry.Path) + } + if strings.Trim(entry.SHA256, "0") != "" { + actual, err := digest(path) + if err != nil { + return fmt.Errorf("digest release artifact %s: %w", entry.ID, err) + } + if !strings.EqualFold(actual, entry.SHA256) { + return fmt.Errorf("manifest checksum mismatch for %s", entry.ID) + } + } + } + for name := range files { + if name == "checksums.txt" { + continue + } + found := false + for _, entry := range manifest.Artifacts { + if entry.Path == name { + found = true + break + } + } + if !found { + return fmt.Errorf("published artifact %s is not declared by artifacts.json", name) + } + } + return nil +} + +func validateArchive(path string, manifest artifact.Manifest) error { + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("open skill archive: %w", err) + } + defer archive.Close() + if len(archive.File) == 0 { + return errors.New("skill archive is empty") + } + actualMembers := make([]string, 0, len(archive.File)) + for _, entry := range archive.File { + name := filepath.Clean(filepath.FromSlash(entry.Name)) + if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) { + return fmt.Errorf("skill archive member escapes its root: %s", entry.Name) + } + actualMembers = append(actualMembers, filepath.ToSlash(name)) + } + sort.Strings(actualMembers) + for _, declared := range manifest.Artifacts { + if declared.Path != "guiho-s-0002-buda.zip" { + continue + } + members := append([]string(nil), declared.ArchiveMembers...) + sort.Strings(members) + if len(members) == 0 { + return errors.New("skill archive manifest members are empty") + } + if len(members) != len(actualMembers) { + return fmt.Errorf("skill archive member count %d does not match manifest %d", len(actualMembers), len(members)) + } + for index := range members { + if members[index] != actualMembers[index] { + return fmt.Errorf("skill archive member %q is not declared", actualMembers[index]) + } + } + } + return nil +} + +func fetchRelease(ctx context.Context, client selfmanage.Doer, release selfmanage.Release, directory string) (map[string]string, error) { + files := map[string]string{} + for _, asset := range release.Assets { + if strings.TrimSpace(asset.Name) == "" || filepath.Base(asset.Name) != asset.Name || strings.ContainsAny(asset.Name, `/\\`) { + return nil, fmt.Errorf("release asset name is unsafe: %q", asset.Name) + } + if _, exists := files[asset.Name]; exists { + return nil, fmt.Errorf("release contains duplicate asset %q", asset.Name) + } + parsed, err := url.Parse(asset.DownloadURL) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return nil, fmt.Errorf("release asset %s has an invalid download URL", asset.Name) + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.DownloadURL, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + return nil, fmt.Errorf("download %s returned %s", asset.Name, response.Status) + } + path := filepath.Join(directory, asset.Name) + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + response.Body.Close() + return nil, err + } + _, copyErr := io.Copy(file, io.LimitReader(response.Body, 256<<20)) + response.Body.Close() + closeErr := file.Close() + if copyErr != nil { + return nil, copyErr + } + if closeErr != nil { + return nil, closeErr + } + files[asset.Name] = path + } + return files, nil +} + +func verifyChecksums(files map[string]string, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 || (len(lines) == 1 && strings.TrimSpace(lines[0]) == "") { + return errors.New("checksums.txt is empty") + } + seen := map[string]bool{} + for _, line := range lines { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) != 2 { + return errors.New("malformed checksum entry") + } + name := strings.TrimPrefix(fields[1], "*") + if name == "checksums.txt" || filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) { + return fmt.Errorf("unsafe checksum filename %q", name) + } + if seen[name] { + return fmt.Errorf("duplicate checksum entry for %s", name) + } + seen[name] = true + if len(fields[0]) != sha256.Size*2 { + return fmt.Errorf("invalid checksum for %s", name) + } + if _, err := hex.DecodeString(fields[0]); err != nil { + return fmt.Errorf("invalid checksum for %s", name) + } + filePath, ok := files[name] + if !ok { + return fmt.Errorf("checksum names unpublished asset %s", name) + } + actual, err := digest(filePath) + if err != nil { + return err + } + if !strings.EqualFold(actual, fields[0]) { + return fmt.Errorf("checksum mismatch for %s", name) + } + } + for name := range files { + if name != "checksums.txt" && !seen[name] { + return fmt.Errorf("checksum missing for %s", name) + } + } + return nil +} + +func versionOf(path string) (string, error) { + command := exec.Command(path, "--version") + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("run candidate --version: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +func selfTest(path string) error { + command := exec.Command(path, "__self-test") + output, err := command.Output() + if err != nil { + return fmt.Errorf("candidate self-test failed: %w", err) + } + if strings.TrimSpace(string(output)) != "ok" { + return fmt.Errorf("candidate self-test returned %q", strings.TrimSpace(string(output))) + } + return nil +} + +func copyFile(source, destination string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err := io.Copy(output, input); err != nil { + output.Close() + return err + } + return output.Close() +} + +func digest(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + _, err = io.Copy(hash, file) + return hex.EncodeToString(hash.Sum(nil)), err +} + +func snapshotFile(path string) ([]byte, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return data, true, nil +} + +func restoreSnapshot(path string, data []byte, existed bool) error { + if !existed { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + return lifecycle.AtomicWrite(path, data, 0o644) +} + +func withinPath(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func readPrevious(path string) launcher.Pointer { + value, err := launcher.ReadPointer(path) + if err != nil { + return launcher.Pointer{Schema: 1} + } + return value +} + +func mustEncodeManifest(manifest artifact.Manifest) []byte { + data, err := artifact.Encode(manifest) + if err != nil { + panic(err) + } + return append(data, '\n') +} diff --git a/internal/upgrade/transaction_test.go b/internal/upgrade/transaction_test.go new file mode 100644 index 0000000..5de0cb3 --- /dev/null +++ b/internal/upgrade/transaction_test.go @@ -0,0 +1,62 @@ +package upgrade + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/CGuiho/buda/internal/artifact" +) + +func TestVerifyChecksumsRejectsMissingDuplicateAndExtraEntries(t *testing.T) { + directory := t.TempDir() + file := filepath.Join(directory, "payload") + if err := os.WriteFile(file, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("payload")) + files := map[string]string{"payload": file} + valid := hex.EncodeToString(digest[:]) + " payload\n" + for name, checksums := range map[string]string{ + "valid": valid, + "missing": "", + "duplicate": valid + valid, + "extra": valid + strings.Repeat("a", 64) + " other\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(directory, name+".txt") + if err := os.WriteFile(path, []byte(checksums), 0o644); err != nil { + t.Fatal(err) + } + err := verifyChecksums(files, path) + if name == "valid" && err != nil { + t.Fatal(err) + } + if name != "valid" && err == nil { + t.Fatal("invalid checksum set was accepted") + } + }) + } +} + +func TestValidateManifestAssetsRequiresEveryPublishedArtifact(t *testing.T) { + directory := t.TempDir() + payloadPath := filepath.Join(directory, "payload") + if err := os.WriteFile(payloadPath, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("payload")) + manifest := artifact.Manifest{Schema: 1, CLI: "buda", Version: "0.2.0", Artifacts: []artifact.Artifact{{ + ID: "payload", Version: "0.2.0", Path: "payload", InstalledPath: "versions/0.2.0/artifacts/payload", + SHA256: hex.EncodeToString(digest[:]), Ownership: artifact.OwnershipReplaceable, Replaceable: true, + }}} + if err := validateManifestAssets(manifest, map[string]string{"payload": payloadPath}); err != nil { + t.Fatal(err) + } + if err := validateManifestAssets(manifest, map[string]string{"payload": payloadPath, "retired": "retired"}); err == nil { + t.Fatal("undeclared published artifact was accepted") + } +} diff --git a/internal/upgrade/upgrade.xdocs.md b/internal/upgrade/upgrade.xdocs.md new file mode 100644 index 0000000..4386480 --- /dev/null +++ b/internal/upgrade/upgrade.xdocs.md @@ -0,0 +1,24 @@ +--- +subject: buda-internal-upgrade +description: Synchronous manifest-based release transaction with staging, checksum verification, immutable payload activation, journals, and rollback. +parent: buda-internal +children: [] +files: + transaction.go: Complete release download, strict manifest/checksum validation, candidate self-test, immutable version staging, atomic activation, and rollback transaction. + transaction_test.go: Checksum completeness, manifest publication, and transaction safety tests. +documents: {} +tags: + - go + - lifecycle + - upgrade +keywords: + - synchronous upgrade + - artifacts.json + - checksums +flags: [] +status: draft +--- + +This module owns the installed stable-launcher upgrade transaction. It never +replaces the running payload in place and leaves a recoverable journal when a +mutation fails. diff --git a/main.go b/main.go index bb57147..930cfe4 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,10 @@ import ( ) var ( - version = "dev" + // Keep source builds SemVer-compatible. Release builds replace this value + // with the exact tag version through ldflags, while local `go run` and + // developer binaries still obey the raw-version CLI contract. + version = "0.2.0-dev.0" commit = "unknown" buildDate = "unknown" buildTarget = "development" diff --git a/prompts/guiho-i-buda.md b/prompts/guiho-i-buda.md index d6006d3..8d31393 100644 --- a/prompts/guiho-i-buda.md +++ b/prompts/guiho-i-buda.md @@ -1,9 +1,9 @@ --- -name: buda +name: guiho-i-buda purpose: Provide the bounded repository instruction block for one selected Buda wiki. description: Universal repository instructions for one explicitly selected Buda wiki. created: 2026-07-26 -version: "0.1.0" +version: "0.2.0" owner: buda-prompts flags: [] tags: diff --git a/prompts/guiho-p-buda.md b/prompts/guiho-p-buda.md new file mode 100644 index 0000000..f63db81 --- /dev/null +++ b/prompts/guiho-p-buda.md @@ -0,0 +1,55 @@ +--- +name: guiho-p-buda +purpose: Install and initialize the Buda repository-agnostic OKF wiki CLI. +description: Guide an agent through installing, verifying, initializing, upgrading, and safely uninstalling Buda. +created: 2026-08-16 +version: "0.2.0" +owner: buda-prompts +flags: [] +tags: + - setup + - lifecycle +keywords: + - Buda + - install + - init + - upgrade + - uninstall +--- + +# Set up Buda + +Buda is a repository-agnostic Go CLI for maintaining one explicitly selected +AI-maintained wiki in Google's portable Open Knowledge Format. It delegates +indexing and retrieval to qmd and never guesses which repository to change. + +Install the latest stable release for the current platform with the canonical +remote installer, always providing the selected wiki path: + +```sh +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh -s -- --wiki +``` + +```powershell +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1'))) -Wiki +``` + +Then verify the installed CLI with: + +```text +buda --version +``` + +Initialize one selected wiki only by passing its path explicitly: + +```text +buda init --wiki +``` + +When a newer release is available, read the effective `agent.evolution` +policy and use `buda upgrade check` followed by `buda upgrade` only when that +policy and the user's authority allow it. After an upgrade, verify the raw +version and rerun `buda init --wiki `. + +Use `buda uninstall --dry-run --wiki ` to inspect ownership before a +removal. Buda never removes canonical OKF knowledge or raw evidence. diff --git a/prompts/prompts.xdocs.md b/prompts/prompts.xdocs.md index d1c3640..863ab73 100644 --- a/prompts/prompts.xdocs.md +++ b/prompts/prompts.xdocs.md @@ -1,6 +1,6 @@ --- subject: buda-prompts -description: Embedded repository instruction template used for bounded Buda agent-instruction management. +description: Embedded Buda repository instruction and lifecycle setup prompt resources. parent: buda-package children: [] files: @@ -8,6 +8,7 @@ files: embed_test.go: Contract test for explicit-wiki and Buda-only instruction routing. documents: guiho-i-buda.md: Parameterized repository instruction block for one selected Buda wiki. + guiho-p-buda.md: Main setup prompt for installing, verifying, initializing, upgrading, and uninstalling Buda. tags: - prompts - instructions diff --git a/schemas/buda.global.schema.json b/schemas/buda.global.schema.json new file mode 100644 index 0000000..0f235d5 --- /dev/null +++ b/schemas/buda.global.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CGuiho/buda/releases/download/buda/v0.2.0/buda.global.schema.json", + "title": "Buda global configuration", + "type": "object", + "additionalProperties": false, + "required": ["schema"], + "properties": { + "schema": { "const": 1 }, + "bundle": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" }, + "derived": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" }, + "qmd": { + "type": "object", + "additionalProperties": false, + "properties": { + "executable": { "type": "string", "minLength": 1 }, + "collection": { "type": "string", "minLength": 1 }, + "project_directory": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" } + } + }, + "agent": { + "$ref": "buda.schema.json#/$defs/agent" + } + } +} diff --git a/schemas/buda.schema.json b/schemas/buda.schema.json new file mode 100644 index 0000000..67787bd --- /dev/null +++ b/schemas/buda.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/CGuiho/buda/releases/download/buda/v0.2.0/buda.schema.json", + "title": "Buda project configuration", + "type": "object", + "additionalProperties": false, + "required": ["schema", "wiki_id"], + "properties": { + "schema": { "const": 1 }, + "wiki_id": { "type": "string", "minLength": 1 }, + "bundle": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" }, + "derived": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" }, + "qmd": { + "type": "object", + "additionalProperties": false, + "properties": { + "executable": { "type": "string", "minLength": 1 }, + "collection": { "type": "string", "minLength": 1 }, + "project_directory": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.?(/|$)).+$" } + } + }, + "agent": { + "$ref": "#/$defs/agent" + } + }, + "$defs": { + "policy": { "enum": ["disabled", "always-ask", "always-proceed"] }, + "agent": { + "type": "object", + "additionalProperties": false, + "properties": { + "evolution": { + "type": "object", + "additionalProperties": false, + "properties": { + "upgrade": { "$ref": "#/$defs/policy" }, + "issues": { + "type": "object", + "additionalProperties": false, + "properties": { + "bugs": { "$ref": "#/$defs/policy" }, + "improvements": { "$ref": "#/$defs/policy" }, + "reviews": { "$ref": "#/$defs/policy" } + } + } + } + } + } + } + } +} diff --git a/schemas/schemas.xdocs.md b/schemas/schemas.xdocs.md new file mode 100644 index 0000000..f75208a --- /dev/null +++ b/schemas/schemas.xdocs.md @@ -0,0 +1,17 @@ +--- +subject: buda-schemas +description: Version-pinned JSON Schemas for project and global Buda configuration. +parent: buda-package +children: [] +files: + buda.schema.json: Offline and editor-discoverable schema for explicit-wiki project configuration. + buda.global.schema.json: Offline and editor-discoverable schema for user-wide Buda configuration. +documents: {} +tags: [schemas, configuration] +keywords: [buda.yaml, buda.global.yaml, JSON Schema] +flags: [] +status: draft +--- + +Schemas are embedded and published in every complete release. Runtime +validation never depends on network access. diff --git a/skills/guiho-s-0002-buda/SKILL.md b/skills/guiho-s-0002-buda/SKILL.md index 30e80ba..8f9ccc0 100644 --- a/skills/guiho-s-0002-buda/SKILL.md +++ b/skills/guiho-s-0002-buda/SKILL.md @@ -3,7 +3,7 @@ name: guiho-s-0002-buda purpose: Guide agents through explicit-wiki capture, ingest, cited retrieval, curation, and maintenance with Buda. description: Maintain and retrieve one explicitly selected AI-maintained wiki through Buda, preserving OKF provenance and using Buda's qmd-backed commands for discovery. created: 2026-07-26 -version: "0.1.0" +version: "0.2.0" owner: buda-skill-0002 flags: [] tags: @@ -15,7 +15,7 @@ keywords: - OKF - qmd metadata: - version: "0.1.0" + version: "0.2.0" --- # Buda Wiki Agent @@ -57,3 +57,28 @@ After a semantic write, run `buda lint --wiki ` and refresh discovery with `buda index --wiki `. Report changed concept paths and their source evidence. Surface unverified, stale, deprecated, conflicting, or missing evidence rather than smoothing it over. + +## CLI Evolution and Feedback + +The canonical Buda repository is and the +canonical issue-creation URL is . +While using Buda, treat an observed incorrect result or failure as a bug, a +useful safety, clarity, reliability, or efficiency improvement as an +improvement, and an actionable review observation about the CLI or its +artifacts as a review finding. Read the effective `agent.evolution` policy +before upgrading Buda or creating an issue. + +Each policy is independently one of `disabled`, `always-ask`, or +`always-proceed`. `disabled` prohibits the governed action and does not ask; +`always-ask` explains the proposed action and waits for permission; +`always-proceed` authorizes the action without another question and requires a +result report. When an upgrade is available, check it while using the CLI and +follow `agent.evolution.upgrade`: do not act when disabled, ask before acting +when always-ask, and upgrade when always-proceed. After every successful +upgrade, run `buda init --wiki ` and verify the raw `buda --version` +output. + +For bugs, improvements, and reviews, apply the corresponding policy field +before posting. When issue creation is authorized, create the issue in the +canonical repository and provide the direct URL returned by GitHub. Never +claim that an issue was created without a successful response and URL. From 6f615a29019185f8eee09ccce6d81a13a515b31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:14:04 +0200 Subject: [PATCH 02/11] ci: add convention-complete release lifecycle tooling --- .github/github.xdocs.md | 2 +- .github/workflows/ci.yml | 260 ++++------------ .github/workflows/publish.yml | 262 ++-------------- .github/workflows/workflows.xdocs.md | 30 +- .gitignore | 5 +- devops/build-binaries.go | 243 +++++++++++---- devops/build-binaries_test.go | 30 +- devops/devops.xdocs.md | 18 +- devops/install.ps1 | 389 ++++++++++------------- devops/install.sh | 443 ++++++++++++--------------- devops/installers_test.go | 221 ++----------- devops/uninstall.ps1 | 68 ++++ devops/uninstall.sh | 101 ++++++ devops/workflows_test.go | 13 +- mirror.yaml | 5 - runx.yaml | 147 +++++++++ 16 files changed, 1020 insertions(+), 1217 deletions(-) create mode 100644 devops/uninstall.ps1 create mode 100644 devops/uninstall.sh create mode 100644 runx.yaml diff --git a/.github/github.xdocs.md b/.github/github.xdocs.md index 20f1ae5..9fb3aed 100644 --- a/.github/github.xdocs.md +++ b/.github/github.xdocs.md @@ -1,7 +1,7 @@ --- subject: buda-github description: GitHub repository automation and workflow configuration for Buda. -parent: null +parent: buda-package children: - buda-github-workflows files: {} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5dc6b0..d4dae22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,87 +1,60 @@ name: CI -permissions: - contents: read - on: pull_request: push: - branches: - - main + branches: [main] + +permissions: + contents: read jobs: - go-quality-and-release-assets: + quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - + with: { fetch-depth: 0 } - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - name: Verify Go formatting - run: test -z "$(gofmt -l .)" - - - name: Verify tidy module graph + with: { go-version-file: go.mod, cache: true } + - name: Mirror configuration + run: mirror config check + - name: RunX catalog + run: | + runx check --format json + runx list --format json + - name: Strict XDocs run: | + xdocs meta . --documents --strict + xdocs tree + xdocs doctor . --warnings-as-errors + - name: Formatting and module graph + run: | + test -z "$(gofmt -l .)" go mod tidy git diff --exit-code -- go.mod go.sum - - - name: Test all Go packages - run: go test -count=1 ./... - - - name: Vet all Go packages - run: go vet ./... - - - name: Build exact 11-artifact release matrix + - name: Go tests and vet run: | - build_date="$(git show -s --format=%cI HEAD)" - go run ./devops/build-binaries.go \ - --version 0.0.2-ci \ - --commit "${GITHUB_SHA}" \ - --build-date "${build_date}" - test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 11 - (cd dist && sha256sum --check checksums.txt) - - - name: Smoke native Linux binary + go test -count=1 ./... + go vet ./... + - name: Build manifest-derived release unit run: | - test "$(dist/buda-linux-amd64 --version)" = 'buda v0.0.2-ci' - dist/buda-linux-amd64 --help-tree-depth 2 --help-tree | grep -F 'COMMAND TREE' - dist/buda-linux-amd64 agent skill show guiho-s-0002-buda | grep -F 'id: guiho-s-0002-buda' - dist/buda-linux-amd64 agent prompt list | grep -F 'id: buda' - - - name: Validate installer syntax - run: sh -n devops/install.sh - - - name: Preflight local Bash upgrade installer - shell: bash + go run ./devops/build-binaries.go --version 0.2.0-ci --commit "$GITHUB_SHA" --build-date "$(git show -s --format=%cI HEAD)" + test -f dist/artifacts.json + (cd dist && sha256sum --check checksums.txt) + test "$(dist/buda-linux-amd64 --version)" = '0.2.0-ci' + dist/buda-linux-amd64 --help-tree --help-tree-depth 2 | grep -F 'COMMAND TREE' + - name: Installer and uninstaller syntax run: | - root="$(mktemp -d)" - trap 'rm -rf "$root"' EXIT - mkdir -p "$root/home" "$root/install" "$root/fake-bin" - go build -trimpath -ldflags '-X main.version=0.0.1' -o "$root/install/buda" . - printf '#!/usr/bin/env sh\nprintf "qmd 2.5.3\\n"\n' > "$root/fake-bin/qmd" - chmod +x "$root/fake-bin/qmd" - export HOME="$root/home" - export PATH="$root/fake-bin:$PATH" - export GUIHO_INSTALL_DIR="$root/install" - export BUDA_RELEASE_ASSET_DIR="$PWD/dist" - sh devops/install.sh buda/v0.0.2-ci - test "$("$root/install/buda" --version)" = 'buda v0.0.2-ci' - test -s "$HOME/.agents/skills/guiho-s-0002-buda/SKILL.md" - test -s "$HOME/.claude/skills/guiho-s-0002-buda/SKILL.md" - + sh -n devops/install.sh + sh -n devops/uninstall.sh - uses: actions/upload-artifact@v4 with: - name: buda-go-release-assets + name: buda-release-unit path: dist/* if-no-files-found: error native-smoke: - needs: go-quality-and-release-assets + needs: quality strategy: fail-fast: false matrix: @@ -101,168 +74,39 @@ jobs: runs-on: ${{ matrix.runner }} steps: - uses: actions/download-artifact@v4 - with: - name: buda-go-release-assets - path: dist - - - name: Smoke Unix target + with: { name: buda-release-unit, path: dist } + - name: Unix native launcher/payload smoke if: runner.os != 'Windows' run: | chmod +x "dist/${{ matrix.asset }}" - test "$("dist/${{ matrix.asset }}" --version)" = 'buda v0.0.2-ci' - "dist/${{ matrix.asset }}" --help-tree-depth 1 --help-tree | grep -F 'COMMAND TREE' - "dist/${{ matrix.asset }}" agent prompt list | grep -F 'id: buda' - - - name: Smoke Windows target + test "$("dist/${{ matrix.asset }}" --version)" = '0.2.0-ci' + - name: Windows native payload smoke if: runner.os == 'Windows' shell: pwsh run: | - $binary = Join-Path $PWD 'dist\${{ matrix.asset }}' - if ((& $binary --version | Out-String).Trim() -ne 'buda v0.0.2-ci') { exit 1 } - if (-not ((& $binary --help-tree-depth 1 --help-tree | Out-String) -match 'COMMAND TREE')) { exit 1 } - if (-not ((& $binary agent prompt list | Out-String) -match 'id: buda')) { exit 1 } + $value = (& (Join-Path $PWD 'dist\${{ matrix.asset }}') --version | Out-String).Trim() + if ($value -ne '0.2.0-ci') { throw "Unexpected raw version: $value" } - windows-installer-syntax: - needs: go-quality-and-release-assets + windows-script-syntax: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - uses: actions/download-artifact@v4 - with: - name: buda-go-release-assets - path: dist - - - name: Validate PowerShell installer syntax - shell: pwsh - run: | - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'devops\install.ps1'), [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count) { - $errors | ForEach-Object { Write-Error $_.Message } - exit 1 - } - - - name: Preflight local PowerShell upgrade installer + - name: PowerShell installer and uninstaller syntax shell: pwsh run: | - $root = Join-Path $env:RUNNER_TEMP 'buda-local-installer' - $homeDirectory = Join-Path $root 'home' - $installDirectory = Join-Path $root 'bin' - $fakeBin = Join-Path $root 'fake-bin' - New-Item -ItemType Directory -Force -Path $homeDirectory, $installDirectory, $fakeBin | Out-Null - go build -trimpath -ldflags '-X main.version=0.0.1' -o (Join-Path $installDirectory 'buda.exe') . - Set-Content -LiteralPath (Join-Path $fakeBin 'qmd.cmd') -Value '@echo qmd 2.5.3' -Encoding ASCII - $env:HOME = $homeDirectory - $env:USERPROFILE = $homeDirectory - $env:PATH = "$fakeBin;$env:PATH" - $env:BUDA_RELEASE_ASSET_DIR = (Resolve-Path 'dist').Path - & .\devops\install.ps1 -Version 'buda/v0.0.2-ci' -InstallDir $installDirectory - $actual = (& (Join-Path $installDirectory 'buda.exe') --version | Out-String).Trim() - if ($actual -ne 'buda v0.0.2-ci') { throw "Unexpected installed version: $actual" } - if (-not (Test-Path -LiteralPath "$homeDirectory\.agents\skills\guiho-s-0002-buda\SKILL.md")) { throw 'Missing agents skill.' } - if (-not (Test-Path -LiteralPath "$homeDirectory\.claude\skills\guiho-s-0002-buda\SKILL.md")) { throw 'Missing Claude skill.' } + $errors = @() + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'devops/install.ps1'), [ref]$null, [ref]$errors) | Out-Null + [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'devops/uninstall.ps1'), [ref]$null, [ref]$errors) | Out-Null + if ($errors.Count) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } - qmd-lexical-e2e: + build-only-foreign-targets: + needs: quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install the tested external qmd runtime - run: npm install --global @tobilu/qmd@2.5.3 - - - name: Exercise one project-local wiki through real qmd - shell: bash + with: { go-version-file: go.mod } + - name: Foreign build-only targets run: | - set -euo pipefail - wiki="${RUNNER_TEMP}/buda-wiki" - export HOME="${RUNNER_TEMP}/buda-home" - mkdir -p "$HOME" - go build -trimpath -o "${RUNNER_TEMP}/buda" . - - "${RUNNER_TEMP}/buda" init --wiki "$wiki" --wiki-id ci-wiki - "${RUNNER_TEMP}/buda" capture --wiki "$wiki" \ - --target concepts/ci-evidence.md \ - --title 'CI evidence' \ - --description 'Evidence used to prove the external qmd boundary.' \ - --actor agent:ci \ - --text 'Buda delegates lexical retrieval to qmd.' - "${RUNNER_TEMP}/buda" query --wiki "$wiki" --mode lexical \ - --text 'lexical retrieval' --json > "${RUNNER_TEMP}/query.json" - jq -e '.results | length == 1' "${RUNNER_TEMP}/query.json" - jq -e '.results[0].path == "concepts/ci-evidence.md"' "${RUNNER_TEMP}/query.json" - - "${RUNNER_TEMP}/buda" get --wiki "$wiki" concepts/ci-evidence.md \ - --json > "${RUNNER_TEMP}/get.json" - jq -e '.document.path == "concepts/ci-evidence.md"' "${RUNNER_TEMP}/get.json" - "${RUNNER_TEMP}/buda" status --wiki "$wiki" --json > "${RUNNER_TEMP}/status.json" - jq -e '.qmd.version == "2.5.3" and .qmd.documents >= 1' "${RUNNER_TEMP}/status.json" - - set +e - "${RUNNER_TEMP}/buda" doctor --wiki "$wiki" --json > "${RUNNER_TEMP}/doctor.json" - doctor_exit=$? - set -e - test "$doctor_exit" -eq 0 -o "$doctor_exit" -eq 1 - jq -e '.checks.qmd.state != "unavailable"' "${RUNNER_TEMP}/doctor.json" - - qmd-windows-e2e: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install the tested external qmd runtime - run: npm install --global @tobilu/qmd@2.5.3 - - - name: Exercise npm qmd.cmd through Buda on Windows - shell: pwsh - run: | - $wiki = Join-Path $env:RUNNER_TEMP 'buda-qmd-windows' - $homeDirectory = Join-Path $env:RUNNER_TEMP 'buda-qmd-home' - $binary = Join-Path $env:RUNNER_TEMP 'buda.exe' - New-Item -ItemType Directory -Force -Path $homeDirectory | Out-Null - $env:HOME = $homeDirectory - $env:USERPROFILE = $homeDirectory - go build -trimpath -o $binary . - - & $binary init --wiki $wiki --wiki-id windows-smoke - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - & $binary capture --wiki $wiki ` - --target concepts/windows.md ` - --title 'Windows evidence' ` - --description 'Evidence for npm qmd.cmd execution.' ` - --actor agent:windows ` - --text 'Buda executes the npm qmd launcher without a retrieval fallback.' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $query = (& $binary query --wiki $wiki --mode lexical --text 'npm qmd launcher' --json | Out-String) | ConvertFrom-Json - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if ($query.results.Count -ne 1 -or $query.results[0].path -ne 'concepts/windows.md') { throw 'Unexpected qmd query result.' } - $document = (& $binary get --wiki $wiki concepts/windows.md --json | Out-String) | ConvertFrom-Json - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if ($document.document.path -ne 'concepts/windows.md') { throw 'Unexpected qmd document path.' } - $status = (& $binary status --wiki $wiki --json | Out-String) | ConvertFrom-Json - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - if ($status.qmd.version -ne '2.5.3' -or $status.qmd.documents -lt 1) { throw 'Unexpected qmd status.' } + GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build ./... + GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build ./... diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aae2392..af3e520 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,272 +1,66 @@ -name: Publish Go Release +name: Publish Buda Release on: workflow_dispatch: push: - tags: - - 'buda/v*' + tags: ['buda/v*'] permissions: contents: write jobs: - release-ref: - name: Verify current canonical tag target - runs-on: ubuntu-latest - outputs: - current: ${{ steps.target.outputs.current }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - id: target - name: Ignore a superseded tag event - shell: bash - run: | - set -euo pipefail - current="$(git ls-remote origin "refs/tags/${GITHUB_REF_NAME}^{}" | cut -f1)" - if [[ "$current" != "$GITHUB_SHA" ]]; then - echo "::notice::Ignoring stale ${GITHUB_REF_NAME} event for ${GITHUB_SHA}; current target is ${current:-missing}." - echo 'current=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - echo 'current=true' >> "$GITHUB_OUTPUT" - - windows-installer-preflight: - name: Preflight Windows installer before publication - needs: release-ref - if: needs.release-ref.outputs.current == 'true' - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - name: Build local release assets and upgrade from 0.0.1 - shell: pwsh - run: | - $tag = '${{ github.ref_name }}' - if ($tag -notmatch '^buda/v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$') { throw "Invalid Buda tag: $tag" } - $version = $Matches.Version - $buildDate = (git show -s --format=%cI HEAD | Out-String).Trim() - go run .\devops\build-binaries.go --version $version --commit '${{ github.sha }}' --build-date $buildDate - if ((Get-ChildItem -LiteralPath dist -File).Count -ne 11) { throw 'Release must contain exactly 11 assets.' } - - $root = Join-Path $env:RUNNER_TEMP 'buda-preflight' - $homeDirectory = Join-Path $root 'home' - $installDirectory = Join-Path $root 'bin' - $fakeBin = Join-Path $root 'fake-bin' - New-Item -ItemType Directory -Force -Path $homeDirectory, $installDirectory, $fakeBin | Out-Null - go build -trimpath -ldflags '-X main.version=0.0.1' -o (Join-Path $installDirectory 'buda.exe') . - Set-Content -LiteralPath (Join-Path $fakeBin 'qmd.cmd') -Value '@echo qmd 2.5.3' -Encoding ASCII - $env:HOME = $homeDirectory - $env:USERPROFILE = $homeDirectory - $env:PATH = "$fakeBin;$env:PATH" - $env:BUDA_RELEASE_ASSET_DIR = (Resolve-Path 'dist').Path - & .\devops\install.ps1 -Version $tag -InstallDir $installDirectory - $actual = (& (Join-Path $installDirectory 'buda.exe') --version | Out-String).Trim() - if ($actual -ne "buda v$version") { throw "Unexpected installed version: $actual" } - publish: - needs: - - release-ref - - windows-installer-preflight - if: needs.release-ref.outputs.current == 'true' runs-on: ubuntu-latest - outputs: - version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - + with: { fetch-depth: 0 } - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true - - - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install the tested external qmd runtime - run: npm install --global @tobilu/qmd@2.5.3 - - - name: Resolve canonical release metadata + with: { go-version-file: go.mod, cache: true } + - name: Resolve and validate canonical version id: release shell: bash run: | - if [[ "${GITHUB_REF_NAME}" != buda/v* ]]; then - echo "Non-canonical Buda tag: ${GITHUB_REF_NAME}" >&2 - exit 1 - fi version="${GITHUB_REF_NAME#buda/v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid Buda semantic version: $version" >&2 - exit 1 - fi + [[ "$GITHUB_REF_NAME" == buda/v* ]] || { echo 'non-canonical tag' >&2; exit 1; } + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]] || { echo 'invalid SemVer' >&2; exit 1; } echo "version=$version" >> "$GITHUB_OUTPUT" - echo "build_date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" - - - name: Verify source + echo "date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" + - name: Required quality and tooling gates run: | + mirror config check + runx check --format json + xdocs meta . --documents --strict + xdocs doctor . --warnings-as-errors test -z "$(gofmt -l .)" go mod tidy git diff --exit-code -- go.mod go.sum go test -count=1 ./... go vet ./... - - - name: Build and verify exact 11 assets + - name: Build complete release unit run: | - go run ./devops/build-binaries.go \ - --version '${{ steps.release.outputs.version }}' \ - --commit "${GITHUB_SHA}" \ - --build-date '${{ steps.release.outputs.build_date }}' - test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 11 + go run ./devops/build-binaries.go --version '${{ steps.release.outputs.version }}' --commit "$GITHUB_SHA" --build-date '${{ steps.release.outputs.date }}' (cd dist && sha256sum --check checksums.txt) - test "$(dist/buda-linux-amd64 --version)" = 'buda v${{ steps.release.outputs.version }}' - - - name: Extract exact-version release notes - shell: bash - run: | - version='${{ steps.release.outputs.version }}' - awk -v heading="## [$version]" ' - index($0, heading " - ") == 1 || $0 == heading { found = 1; next } - found && /^## / { exit } - found { print } - END { if (!found) exit 1 } - ' CHANGELOG.md > "${RUNNER_TEMP}/buda-release-notes.md" - test -s "${RUNNER_TEMP}/buda-release-notes.md" - - - name: Preflight Bash installer before publication - shell: bash - run: | - root="$(mktemp -d)" - trap 'rm -rf "$root"' EXIT - mkdir -p "$root/home" "$root/install" "$root/fake-bin" - go build -trimpath -ldflags '-X main.version=0.0.1' -o "$root/install/buda" . - printf '#!/usr/bin/env sh\nprintf "qmd 2.5.3\\n"\n' > "$root/fake-bin/qmd" - chmod +x "$root/fake-bin/qmd" - export HOME="$root/home" - export PATH="$root/fake-bin:$PATH" - export GUIHO_INSTALL_DIR="$root/install" - export BUDA_RELEASE_ASSET_DIR="$PWD/dist" - sh devops/install.sh "${GITHUB_REF_NAME}" - test "$("$root/install/buda" --version)" = 'buda v${{ steps.release.outputs.version }}' - - - name: Preflight real qmd project-local retrieval before publication - shell: bash - run: | - set -euo pipefail - wiki="${RUNNER_TEMP}/release-wiki" - export HOME="${RUNNER_TEMP}/release-home" - mkdir -p "$HOME" - dist/buda-linux-amd64 init --wiki "$wiki" --wiki-id release-smoke - dist/buda-linux-amd64 capture --wiki "$wiki" \ - --target concepts/release.md \ - --title 'Release evidence' \ - --description 'Evidence for the Buda release qmd preflight.' \ - --actor agent:release \ - --text 'Buda release retrieval is delegated to qmd.' - dist/buda-linux-amd64 query --wiki "$wiki" --mode lexical \ - --text 'release retrieval' --json > "${RUNNER_TEMP}/release-query.json" - jq -e '.results[0].path == "concepts/release.md"' "${RUNNER_TEMP}/release-query.json" - - - name: Create or repair GitHub release + test "$(dist/buda-linux-amd64 --version)" = '${{ steps.release.outputs.version }}' + - name: Create or repair GitHub Release from manifest-derived assets env: GH_TOKEN: ${{ github.token }} shell: bash run: | mapfile -t assets < <(find dist -maxdepth 1 -type f -printf '%f\n' | sort) - test "${#assets[@]}" -eq 11 - asset_paths=() - for asset in "${assets[@]}"; do asset_paths+=("dist/$asset"); done - release_flags=(--latest) - if [[ '${{ steps.release.outputs.version }}' == *-* ]]; then - release_flags=(--prerelease) - fi - if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then - while IFS= read -r current; do - if [[ -n "$current" && ! -f "dist/$current" ]]; then - gh release delete-asset "${GITHUB_REF_NAME}" "$current" --yes - fi - done < <(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name') - gh release upload "${GITHUB_REF_NAME}" "${asset_paths[@]}" --clobber - gh release edit "${GITHUB_REF_NAME}" \ - --title "${GITHUB_REF_NAME}" \ - --notes-file "${RUNNER_TEMP}/buda-release-notes.md" \ - "${release_flags[@]}" + paths=(); for asset in "${assets[@]}"; do paths+=("dist/$asset"); done + notes="${RUNNER_TEMP}/release-notes.md" + awk -v h="## [${{ steps.release.outputs.version }}]" 'index($0,h)==1{found=1;next} found&&/^## /{exit} found{print} END{if(!found)exit 1}' CHANGELOG.md > "$notes" + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "${paths[@]}" --clobber + gh release edit "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$notes" else - gh release create "${GITHUB_REF_NAME}" "${asset_paths[@]}" \ - --verify-tag \ - --title "${GITHUB_REF_NAME}" \ - --notes-file "${RUNNER_TEMP}/buda-release-notes.md" \ - "${release_flags[@]}" + gh release create "$GITHUB_REF_NAME" "${paths[@]}" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$notes" fi - - - name: Verify exact public asset set + - name: Verify public asset set and checksums env: GH_TOKEN: ${{ github.token }} shell: bash run: | - find dist -maxdepth 1 -type f -printf '%f\n' | sort > "${RUNNER_TEMP}/expected-assets.txt" - gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | sort > "${RUNNER_TEMP}/actual-assets.txt" - test "$(wc -l < "${RUNNER_TEMP}/actual-assets.txt" | tr -d '[:space:]')" -eq 11 - cmp "${RUNNER_TEMP}/expected-assets.txt" "${RUNNER_TEMP}/actual-assets.txt" - - - name: Verify tag-pinned public Bash installer - shell: bash - run: | - root="$(mktemp -d)" - trap 'rm -rf "$root"' EXIT - mkdir -p "$root/home" "$root/project" "$root/install" "$root/fake-bin" - printf '#!/usr/bin/env sh\nprintf "qmd 2.5.3\\n"\n' > "$root/fake-bin/qmd" - chmod +x "$root/fake-bin/qmd" - export HOME="$root/home" - export PATH="$root/fake-bin:$PATH" - export GUIHO_INSTALL_DIR="$root/install" - cd "$root/project" - curl -fsSL "https://raw.githubusercontent.com/CGuiho/buda/${GITHUB_SHA}/devops/install.sh" | - sh -s -- "${GITHUB_REF_NAME}" - test "$("$root/install/buda" --version)" = 'buda v${{ steps.release.outputs.version }}' - test -s "$HOME/.agents/skills/guiho-s-0002-buda/SKILL.md" - test -s "$HOME/.claude/skills/guiho-s-0002-buda/SKILL.md" - - windows-installer: - name: Verify tag-pinned public Windows installer - needs: - - release-ref - - publish - if: needs.release-ref.outputs.current == 'true' - runs-on: windows-latest - steps: - - name: Install the exact tagged Buda release - shell: pwsh - env: - BUDA_VERSION: ${{ needs.publish.outputs.version }} - run: | - $root = Join-Path $env:RUNNER_TEMP 'buda-public' - $homeDirectory = Join-Path $root 'home' - $installDirectory = Join-Path $root 'bin' - $fakeBin = Join-Path $root 'fake-bin' - New-Item -ItemType Directory -Force -Path $homeDirectory, $installDirectory, $fakeBin | Out-Null - Set-Content -LiteralPath (Join-Path $fakeBin 'qmd.cmd') -Value '@echo qmd 2.5.3' -Encoding ASCII - $env:HOME = $homeDirectory - $env:USERPROFILE = $homeDirectory - $env:PATH = "$fakeBin;$env:PATH" - $source = Invoke-RestMethod "https://raw.githubusercontent.com/CGuiho/buda/${{ github.sha }}/devops/install.ps1" - & ([scriptblock]::Create($source)) -Version "buda/v$env:BUDA_VERSION" -InstallDir $installDirectory - $actual = (& "$installDirectory\buda.exe" --version | Out-String).Trim() - if ($actual -ne "buda v$env:BUDA_VERSION") { throw "Unexpected installed version: $actual" } - if (-not (Test-Path -LiteralPath "$homeDirectory\.agents\skills\guiho-s-0002-buda\SKILL.md")) { - throw 'Missing .agents Buda skill installation.' - } - if (-not (Test-Path -LiteralPath "$homeDirectory\.claude\skills\guiho-s-0002-buda\SKILL.md")) { - throw 'Missing .claude Buda skill installation.' - } + find dist -maxdepth 1 -type f -printf '%f\n' | sort > "${RUNNER_TEMP}/expected" + gh release view "$GITHUB_REF_NAME" --json assets --jq '.assets[].name' | sort > "${RUNNER_TEMP}/actual" + cmp "${RUNNER_TEMP}/expected" "${RUNNER_TEMP}/actual" diff --git a/.github/workflows/workflows.xdocs.md b/.github/workflows/workflows.xdocs.md index 4a1da05..e4f1d59 100644 --- a/.github/workflows/workflows.xdocs.md +++ b/.github/workflows/workflows.xdocs.md @@ -1,11 +1,11 @@ --- subject: buda-github-workflows -description: Go CI, native smoke coverage, canonical-tag or manual GitHub Release publication, stale-tag event filtering, exact-asset reconciliation, and public installer acceptance. +description: Go quality, native smoke coverage, complete manifest-derived GitHub Release publication, and public lifecycle acceptance. parent: buda-github children: [] files: - ci.yml: Go formatting, module, test, vet, exact eleven-artifact build, checksum, installer syntax, hosted native-platform smoke, and real qmd 2.5.3 project-local lexical integration validation. - publish.yml: Canonical buda/v* GitHub Release publication with manual recovery dispatch, stale-tag event filtering, exact-version notes, exactly eleven assets, checksum verification, pre-publication Linux and Windows upgrade-installer gates, real qmd lexical retrieval, and post-publication installer acceptance. + ci.yml: Go formatting, module, test, vet, RunX, Mirror, strict XDocs, manifest build, checksums, installer syntax, and hosted native-platform smoke. + publish.yml: Canonical buda/v* GitHub Release publication with manifest-derived assets and public checksum reconciliation. documents: {} tags: - github-actions @@ -13,7 +13,7 @@ tags: - release keywords: - buda/vX.Y.Z - - eleven artifacts + - artifacts.json - public installers - GitHub Release - no package publication @@ -21,17 +21,13 @@ flags: [] status: stable --- -CI builds all eight pure-Go release targets and executes compatible artifacts -on hosted Linux, macOS, and Windows AMD64 and ARM64 runners. ARMv6 and ARMv7 -remain cross-build-only because GitHub does not provide matching hosted runners. -It also installs the separate tested qmd 2.5.3 runtime and exercises Buda init, -capture, project-local indexing, lexical query, get, status, and doctor on -Linux, plus the npm `qmd.cmd` launcher path on Windows, without downloading -models or adding a Buda package-manager publication. +CI builds the manifest-derived complete release unit, validates checksums, and +executes compatible native payload smoke checks on hosted Linux, macOS, and +Windows AMD64 and ARM64 runners. ARMv6 and ARMv7 remain cross-build-only +because GitHub does not provide matching hosted runners. RunX, Mirror, strict +XDocs, Go quality, and both installer-language syntax checks are required. -Publication runs for canonical `buda/v*` tags or a manual recovery dispatch -against one. It ignores delayed events whose commit no longer matches the -current tag target. For a current tag, it reconciles one GitHub Release to the -exact eleven checked artifacts and verifies the tag-pinned Bash and PowerShell -installers before publication, then repeats public installer acceptance after -publication, without publishing to a package manager or registry. +Publication runs for canonical `buda/v*` tags or a manual recovery dispatch. +It reconciles one GitHub Release to every manifest-declared artifact, verifies +the public asset names against the generated release directory, and never +publishes to a package manager or registry. diff --git a/.gitignore b/.gitignore index 688d599..a5a733e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ /buda.exe *.test *.out -/.tmp/ \ No newline at end of file +/.tmp/ +/.gocache/ +/.gomodcache/ +/.tmp-buda.exe diff --git a/devops/build-binaries.go b/devops/build-binaries.go index cea5c38..24b83f0 100644 --- a/devops/build-binaries.go +++ b/devops/build-binaries.go @@ -5,6 +5,7 @@ import ( "bufio" "crypto/sha256" "encoding/hex" + "encoding/json" "flag" "fmt" "io" @@ -15,21 +16,20 @@ import ( "sort" "strings" "time" + + "github.com/CGuiho/buda/internal/artifact" ) const ( - cliName = "buda" - skillAssetName = "guiho-s-0002-buda.zip" - promptAssetName = "guiho-i-buda.md" - outputDirectory = "dist" + cliName = "buda" + skillAssetName = "guiho-s-0002-buda.zip" + instructionAssetName = "guiho-i-buda.md" + promptAssetName = "guiho-p-buda.md" + manifestAssetName = "artifacts.json" + outputDirectory = "dist" ) -type target struct { - name string - goos string - goarch string - tuning string -} +type target struct{ name, goos, goarch, tuning string } var targets = []target{ {name: "buda-linux-amd64", goos: "linux", goarch: "amd64", tuning: "GOAMD64=v1"}, @@ -63,42 +63,136 @@ func main() { if err := os.MkdirAll(outputDirectory, 0o755); err != nil { fatalf("create %s: %v", outputDirectory, err) } - assets := make([]string, 0, 10) + resourceStage, err := os.MkdirTemp("", "buda-release-resources-") + if err != nil { + fatalf("create versioned resource staging directory: %v", err) + } + defer os.RemoveAll(resourceStage) + versionedSkill := filepath.Join(resourceStage, "guiho-s-0002-buda") + if err := copyVersionedTree(filepath.Join("skills", "guiho-s-0002-buda"), versionedSkill, *version); err != nil { + fatalf("stage versioned skill resources: %v", err) + } + assets := []string{} for _, buildTarget := range targets { - outputPath := filepath.Join(outputDirectory, buildTarget.name) - ldflags := strings.Join([]string{ - "-s", "-w", "-X", "main.version=" + *version, "-X", "main.commit=" + *commit, - "-X", "main.buildDate=" + *buildDate, - "-X", "main.buildTarget=" + strings.TrimSuffix(buildTarget.name, ".exe"), - }, " ") - command := exec.Command("go", "build", "-trimpath", "-buildvcs=true", "-ldflags", ldflags, "-o", outputPath, ".") - command.Stdout = os.Stdout - command.Stderr = os.Stderr - command.Env = buildEnvironment(buildTarget) - fmt.Printf("building %s\n", buildTarget.name) - if err := command.Run(); err != nil { + path := filepath.Join(outputDirectory, buildTarget.name) + if err := build(path, ".", buildTarget, *version, *commit, *buildDate); err != nil { fatalf("build %s: %v", buildTarget.name, err) } - assets = append(assets, outputPath) + assets = append(assets, path) + } + for _, buildTarget := range targets { + launcherName := "buda-launcher-" + buildTarget.goos + "-" + buildTarget.goarch + if buildTarget.goarch == "arm" { + launcherName += "v" + strings.TrimPrefix(strings.TrimPrefix(buildTarget.tuning, "GOARM="), "v") + } + if buildTarget.goos == "windows" { + launcherName += ".exe" + } + path := filepath.Join(outputDirectory, launcherName) + if err := build(path, "./cmd/buda-launcher", buildTarget, *version, *commit, *buildDate); err != nil { + fatalf("build launcher %s: %v", launcherName, err) + } + assets = append(assets, path) + } + supporting := []struct{ source, name string }{ + {filepath.Join("prompts", instructionAssetName), instructionAssetName}, + {filepath.Join("prompts", promptAssetName), promptAssetName}, + {filepath.Join("schemas", "buda.schema.json"), "buda.schema.json"}, + {filepath.Join("schemas", "buda.global.schema.json"), "buda.global.schema.json"}, + {filepath.Join("examples", "buda.example.yaml"), "buda.example.yaml"}, + {filepath.Join("examples", "buda.global.example.yaml"), "buda.global.example.yaml"}, + } + for _, item := range supporting { + destination := filepath.Join(outputDirectory, item.name) + if err := copyVersionedFile(item.source, destination, *version); err != nil { + fatalf("copy %s: %v", item.name, err) + } + assets = append(assets, destination) } - skillAsset := filepath.Join(outputDirectory, skillAssetName) - if err := zipDirectory(filepath.Join("skills", "guiho-s-0002-buda"), skillAsset, stamp); err != nil { + skill := filepath.Join(outputDirectory, skillAssetName) + if err := zipDirectory(versionedSkill, skill, stamp); err != nil { fatalf("create skill archive: %v", err) } - assets = append(assets, skillAsset) - promptAsset := filepath.Join(outputDirectory, promptAssetName) - if err := copyFile(filepath.Join("prompts", promptAssetName), promptAsset); err != nil { - fatalf("copy prompt asset: %v", err) + assets = append(assets, skill) + manifestPath := filepath.Join(outputDirectory, manifestAssetName) + manifest := buildManifest(*version, assets) + if err := manifest.Validate(); err != nil { + fatalf("validate artifact manifest: %v", err) + } + manifestData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + fatalf("encode manifest: %v", err) + } + if err := os.WriteFile(manifestPath, append(manifestData, '\n'), 0o644); err != nil { + fatalf("write manifest: %v", err) } - assets = append(assets, promptAsset) - sort.Strings(assets) + assets = append(assets, manifestPath) if err := writeChecksums(filepath.Join(outputDirectory, "checksums.txt"), assets); err != nil { fatalf("write checksums: %v", err) } - if len(assets)+1 != 11 { - fatalf("release must contain exactly 11 artifacts, got %d", len(assets)+1) + fmt.Printf("release matrix complete: %d payloads, %d launchers, %d managed resources, manifest, and checksums\n", len(targets), len(targets), len(supporting)+1) +} + +func build(path, packagePath string, buildTarget target, version, commit, date string) error { + ldflags := strings.Join([]string{"-s", "-w", "-X", "main.version=" + version, "-X", "main.commit=" + commit, "-X", "main.buildDate=" + date, "-X", "main.buildTarget=" + strings.TrimSuffix(buildTarget.name, ".exe")}, " ") + command := exec.Command("go", "build", "-trimpath", "-buildvcs=true", "-ldflags", ldflags, "-o", path, packagePath) + command.Stdout, command.Stderr = os.Stdout, os.Stderr + command.Env = buildEnvironment(buildTarget) + fmt.Printf("building %s\n", filepath.Base(path)) + return command.Run() +} + +func buildManifest(version string, assets []string) artifact.Manifest { + manifest := artifact.Manifest{Schema: artifact.CurrentSchema, CLI: cliName, Version: version, Artifacts: []artifact.Artifact{}} + for _, path := range assets { + name := filepath.Base(path) + digest, err := digestFile(path) + if err != nil { + fatalf("digest %s: %v", name, err) + } + installed := filepath.ToSlash(filepath.Join("versions", version, "artifacts", name)) + id := strings.TrimSuffix(name, filepath.Ext(name)) + id = strings.ReplaceAll(id, ".", "-") + if name == "artifacts.json" { + digest = strings.Repeat("0", 64) + } + entry := artifact.Artifact{ID: id, Version: version, Path: name, InstalledPath: installed, SHA256: digest, Ownership: artifact.OwnershipReplaceable, Replaceable: true} + setTargetMetadata(&entry, name) + if name == skillAssetName { + entry.ArchiveMembers = archiveMembers(path) + entry.ProjectionPaths = []string{".agents/skills/guiho-s-0002-buda", ".claude/skills/guiho-s-0002-buda"} + } + if name == instructionAssetName { + entry.ProjectionPaths = []string{"AGENTS.md", "CLAUDE.md"} + } + manifest.Artifacts = append(manifest.Artifacts, entry) + } + manifest.Artifacts = append(manifest.Artifacts, artifact.Artifact{ID: "artifacts-json", Version: version, Path: manifestAssetName, InstalledPath: filepath.ToSlash(filepath.Join("versions", version, "artifacts", manifestAssetName)), SHA256: strings.Repeat("0", 64), Ownership: artifact.OwnershipReplaceable, Replaceable: true}) + sort.Slice(manifest.Artifacts, func(i, j int) bool { return manifest.Artifacts[i].Path < manifest.Artifacts[j].Path }) + return manifest +} + +func setTargetMetadata(entry *artifact.Artifact, name string) { + target := strings.TrimSuffix(name, ".exe") + parts := strings.Split(target, "-") + if len(parts) >= 3 && (strings.HasPrefix(target, "buda-") || strings.HasPrefix(target, "buda-launcher-")) { + entry.OS = parts[1] + entry.Arch = strings.Join(parts[2:], "-") } - fmt.Println("release matrix complete: 8 binaries and 3 supporting artifacts") +} + +func archiveMembers(path string) []string { + reader, err := zip.OpenReader(path) + if err != nil { + fatalf("read skill archive members: %v", err) + } + defer reader.Close() + members := make([]string, 0, len(reader.File)) + for _, entry := range reader.File { + members = append(members, filepath.ToSlash(entry.Name)) + } + sort.Strings(members) + return members } func buildEnvironment(buildTarget target) []string { @@ -130,8 +224,8 @@ func zipDirectory(source, destination string, stamp time.Time) error { return nil }) if err != nil { - _ = archive.Close() - _ = output.Close() + archive.Close() + output.Close() return err } sort.Strings(files) @@ -170,7 +264,7 @@ func zipDirectory(source, destination string, stamp time.Time) error { } } if err := archive.Close(); err != nil { - _ = output.Close() + output.Close() return err } return output.Close() @@ -187,47 +281,80 @@ func copyFile(source, destination string) error { return err } if _, err := io.Copy(output, input); err != nil { - _ = output.Close() + output.Close() return err } return output.Close() } +func copyVersionedFile(source, destination, version string) error { + content, err := os.ReadFile(source) + if err != nil { + return err + } + content = []byte(strings.ReplaceAll(string(content), "0.2.0", version)) + return os.WriteFile(destination, content, 0o644) +} + +func copyVersionedTree(source, destination, version string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + if relative == "." { + return os.MkdirAll(destination, 0o755) + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return copyVersionedFile(path, target, version) + }) +} + +func digestFile(path string) (string, error) { + input, err := os.Open(path) + if err != nil { + return "", err + } + defer input.Close() + hash := sha256.New() + if _, err := io.Copy(hash, input); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + func writeChecksums(path string, assets []string) error { + sort.Slice(assets, func(i, j int) bool { return filepath.Base(assets[i]) < filepath.Base(assets[j]) }) output, err := os.Create(path) if err != nil { return err } writer := bufio.NewWriter(output) for _, asset := range assets { - input, err := os.Open(asset) + digest, err := digestFile(asset) if err != nil { - _ = output.Close() + output.Close() return err } - hash := sha256.New() - _, copyErr := io.Copy(hash, input) - closeErr := input.Close() - if copyErr != nil || closeErr != nil { - _ = output.Close() - if copyErr != nil { - return copyErr - } - return closeErr - } - if _, err := fmt.Fprintf(writer, "%s %s\n", hex.EncodeToString(hash.Sum(nil)), filepath.Base(asset)); err != nil { - _ = output.Close() + if _, err := fmt.Fprintf(writer, "%s %s\n", digest, filepath.Base(asset)); err != nil { + output.Close() return err } } if err := writer.Flush(); err != nil { - _ = output.Close() + output.Close() return err } return output.Close() } -func fatalf(format string, values ...any) { - fmt.Fprintf(os.Stderr, format+"\n", values...) - os.Exit(1) -} +func fatalf(format string, values ...any) { fmt.Fprintf(os.Stderr, format+"\n", values...); os.Exit(1) } diff --git a/devops/build-binaries_test.go b/devops/build-binaries_test.go index c0250aa..890f823 100644 --- a/devops/build-binaries_test.go +++ b/devops/build-binaries_test.go @@ -1,9 +1,9 @@ package main import ( + "os" "path/filepath" "reflect" - "sort" "strings" "testing" ) @@ -29,13 +29,8 @@ func TestReleaseContractNamesAndTuning(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("targets = %#v, want %#v", got, want) } - assets := append(append([]string(nil), got...), skillAssetName, promptAssetName, "checksums.txt") - sort.Strings(assets) - if len(assets) != 11 { - t.Fatalf("artifact count = %d", len(assets)) - } - if skillAssetName != "guiho-s-0002-buda.zip" || promptAssetName != "guiho-i-buda.md" { - t.Fatalf("supporting assets = %s, %s", skillAssetName, promptAssetName) + if skillAssetName != "guiho-s-0002-buda.zip" || instructionAssetName != "guiho-i-buda.md" || promptAssetName != "guiho-p-buda.md" { + t.Fatalf("supporting assets = %s, %s, %s", skillAssetName, instructionAssetName, promptAssetName) } } @@ -51,3 +46,22 @@ func TestBuildEnvironmentIsPureGoAndTargetSpecific(t *testing.T) { t.Fatal("Linux target has Windows suffix") } } + +func TestVersionedResourcesUseTheExactReleaseVersion(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source.md") + destination := filepath.Join(root, "destination.md") + if err := os.WriteFile(source, []byte("version: \"0.2.0\"\nurl: /v0.2.0/example\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := copyVersionedFile(source, destination, "1.2.3-rc.1"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != "version: \"1.2.3-rc.1\"\nurl: /v1.2.3-rc.1/example\n" { + t.Fatalf("versioned resource = %q", got) + } +} diff --git a/devops/devops.xdocs.md b/devops/devops.xdocs.md index ffe9225..a7fcb8b 100644 --- a/devops/devops.xdocs.md +++ b/devops/devops.xdocs.md @@ -1,24 +1,26 @@ --- subject: buda-devops -description: Pure-Go cross-build matrix, exact eleven-artifact release layout, and checksum-verifying canonical-tag installers with transactional upgrade support. +description: Pure-Go cross-build matrix, manifest-derived complete release layout, and checksum-verifying lifecycle scripts. parent: buda-package children: [] files: - build-binaries.go: Eight-target CGO-disabled binary build and eleven-artifact assembly. + build-binaries.go: Eight-target CGO-disabled payload and launcher build with manifest-derived assembly. build-binaries_test.go: Target matrix, naming, checksum, and archive contract tests. - install.ps1: PowerShell latest or exact canonical-tag binary installer with checksum verification, rollback, execution-policy-safe qmd.cmd compatibility checks, off-PATH qmd probing, configurable skill destinations (BUDA_SKILL_DIRS), Hermes-aware skill registration (HERMES_SKILLS_DIR), non-fatal optional skill registration, session PATH verification warning, and embedded global-skill refresh. - install.sh: POSIX latest or exact canonical-tag binary installer with checksum verification, rollback, qmd compatibility checks, off-PATH qmd probing with a distinct npx-fallback message, configurable skill destinations (BUDA_SKILL_DIRS), Hermes-aware skill registration (HERMES_SKILLS_DIR), non-fatal optional skill registration, PATH verification warning, and embedded global-skill refresh. - installers_test.go: Installer canonical-tag extraction, qmd pinning, checksum, rollback, local-asset, destination contract, PATH-hint, qmd-probe, npx-fallback message, success-flag ordering, non-fatal optional registration, and env-var skill-dir tests. - workflows_test.go: GitHub Actions syntax, canonical tag, exact asset, real qmd integration, and no package or production publication contract tests. + install.ps1: PowerShell exact/channel complete-release installer with staging, checksums, immutable payloads, launcher activation, and PATH repair. + install.sh: POSIX exact/channel complete-release installer with staging, checksums, immutable payloads, launcher activation, and PATH repair. + uninstall.ps1: PowerShell uninstaller with ownership preservation and explicit confirmation options. + uninstall.sh: POSIX uninstaller with ownership preservation and explicit confirmation options. + installers_test.go: Installer and uninstaller selector, checksum, staging, ownership, and safety-contract tests. + workflows_test.go: GitHub Actions syntax, canonical tag, manifest-derived asset, and no package or production publication contract tests. documents: {} tags: - devops - distribution keywords: - CGO_ENABLED - - eleven artifacts + - artifacts.json - installers - - buda/v0.0.2 + - buda/v0.2.0 - transactional upgrade flags: [] status: draft diff --git a/devops/install.ps1 b/devops/install.ps1 index fa237ca..a528867 100644 --- a/devops/install.ps1 +++ b/devops/install.ps1 @@ -1,243 +1,170 @@ +[CmdletBinding()] param( - [string]$Version = "latest", - [string]$InstallDir = "$env:LOCALAPPDATA\GUIHO\bin" + [string]$Version, + [string]$Channel, + [Parameter(Mandatory=$true)][string]$Wiki, + [string]$WikiId ) - -# Buda canonical-tag installer (PowerShell 5.1 compatible). -# -# Environment variables: -# BUDA_RELEASE_ASSET_DIR Load release assets from a local directory instead of -# GitHub; requires an exact canonical tag argument. -# BUDA_SKILL_DIRS Semicolon-separated list of extra skill destination -# directories. Each is created if missing and the -# embedded skill directory is copied into it (the skill -# id subdirectory is appended automatically). Defaults -# to empty; the built-in ~/.agents/skills and -# ~/.claude/skills destinations are always installed by -# `buda agent skill update`. -# HERMES_SKILLS_DIR Hermes agent skills directory. When this directory -# exists (default: ~/.hermes/skills) the embedded skill -# is additionally registered there by the installer so -# Hermes agents can use Buda without a manual copy. - -$ErrorActionPreference = "Stop" -$Owner = "CGuiho" -$Repository = "buda" -$CliName = "buda" - -switch ($env:PROCESSOR_ARCHITECTURE.ToUpperInvariant()) { - "AMD64" { $Asset = "buda-windows-amd64.exe" } - "ARM64" { $Asset = "buda-windows-arm64.exe" } - default { throw "Unsupported Windows architecture: $env:PROCESSOR_ARCHITECTURE" } -} - -if ($Version -eq "latest") { - if (-not [string]::IsNullOrWhiteSpace($env:BUDA_RELEASE_ASSET_DIR)) { - throw "BUDA_RELEASE_ASSET_DIR requires an exact canonical tag such as buda/v0.0.2." +$ErrorActionPreference = 'Stop' +if (-not [string]::IsNullOrWhiteSpace($Version) -and -not [string]::IsNullOrWhiteSpace($Channel)) { throw '--Version and --Channel are mutually exclusive.' } +if ($Version -match '^buda/') { $Version = $Version.Substring(5) } +if ($Version -match '^v') { $Version = $Version.Substring(1) } +if ($Version -and $Version -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$') { throw "Invalid -Version '$Version'." } +if (-not $Version) { $Version = $env:BUDA_RELEASE_VERSION } +$arch = $env:PROCESSOR_ARCHITECTURE.ToUpperInvariant() +if ($arch -eq 'AMD64') { $binary = 'buda-windows-amd64.exe'; $launcher = 'buda-launcher-windows-amd64.exe' } +elseif ($arch -eq 'ARM64') { $binary = 'buda-windows-arm64.exe'; $launcher = 'buda-launcher-windows-arm64.exe' } +else { throw "Unsupported Windows architecture: $arch" } +if (-not $Version -and -not $env:BUDA_RELEASE_ASSET_DIR) { + $wanted = if ($Channel) { $Channel } else { 'stable' } + $all = @(); $page = 1 + do { + $batch = @(Invoke-RestMethod "https://api.github.com/repos/CGuiho/buda/releases?per_page=100&page=$page") + $all += $batch; $page++ + } while ($batch.Count -eq 100) + $matches = @($all | Where-Object { -not $_.draft -and $_.tag_name -match '^buda/v' } | ForEach-Object { + $candidate = $_.tag_name.Substring(6); $candidateChannel = if ($candidate.Contains('-')) { $candidate.Split('-')[1].Split('.')[0] } else { 'stable' } + $requiredNames = @($binary, $launcher, 'checksums.txt', 'artifacts.json', 'guiho-s-0002-buda.zip', 'guiho-i-buda.md', 'guiho-p-buda.md', 'buda.schema.json', 'buda.global.schema.json', 'buda.example.yaml', 'buda.global.example.yaml') + $assetNames = @($_.assets | ForEach-Object { [string]$_.name }) + if ($candidateChannel -eq $wanted -and $candidate -match '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' -and @($requiredNames | Where-Object { $assetNames -notcontains $_ }).Count -eq 0) { [pscustomobject]@{ Release=$_; Version=$candidate } } + } | Sort-Object Version -Descending) + if ($matches.Count -eq 0) { throw "No release found for channel $wanted." } + function Compare-SemVer([string]$left, [string]$right) { + $leftParts = $left.Split('+')[0].Split('-',2); $rightParts = $right.Split('+')[0].Split('-',2) + $lCore = $leftParts[0].Split('.'); $rCore = $rightParts[0].Split('.') + for ($i=0; $i -lt 3; $i++) { $comparison = [int]$lCore[$i] - [int]$rCore[$i]; if ($comparison -ne 0) { return [Math]::Sign($comparison) } } + if ($leftParts.Count -eq 1 -and $rightParts.Count -eq 1) { return 0 } + if ($leftParts.Count -eq 1) { return 1 } + if ($rightParts.Count -eq 1) { return -1 } + $lIds = $leftParts[1].Split('.'); $rIds = $rightParts[1].Split('.') + for ($i=0; $i -lt [Math]::Max($lIds.Count,$rIds.Count); $i++) { + if ($i -ge $lIds.Count) { return -1 }; if ($i -ge $rIds.Count) { return 1 } + $ln = $lIds[$i] -match '^[0-9]+$'; $rn = $rIds[$i] -match '^[0-9]+$' + if ($ln -and $rn) { $comparison = [int64]$lIds[$i] - [int64]$rIds[$i] } elseif ($ln) { return -1 } elseif ($rn) { return 1 } else { $comparison = [string]::CompareOrdinal($lIds[$i],$rIds[$i]) } + if ($comparison -ne 0) { return [Math]::Sign($comparison) } + } + return 0 } - $Release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Owner/$Repository/releases/latest" - $Tag = $Release.tag_name - if ([string]::IsNullOrWhiteSpace($Tag)) { throw "Could not resolve the latest Buda release tag." } -} else { - # Non-latest values are exact full release tags; Buda owns no implicit prefix. - $Tag = $Version + $best = $matches[0] + foreach ($candidate in $matches | Select-Object -Skip 1) { if ((Compare-SemVer $candidate.Version $best.Version) -gt 0) { $best = $candidate } } + $Version = $best.Version } - -if ($Tag -notmatch '^buda/v(?(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$') { - throw "Invalid Buda release tag '$Tag'; expected buda/v." +if (-not $Version) { throw 'Version selection did not resolve a stable release.' } +$tag = "buda/v$Version" +$assetDir = $env:BUDA_RELEASE_ASSET_DIR +$userHome = [Environment]::GetFolderPath('UserProfile') +$guihoHome = Join-Path $userHome '.guiho' +$cliHome = Join-Path $guihoHome 'buda' +$globalConfigName = 'buda.global.yaml' +$binDir = Join-Path $guihoHome 'bin' +$tempRoot = Join-Path $guihoHome '.temp' +New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null +$stage = Join-Path $tempRoot ('buda-install-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $stage | Out-Null +$mutated = $false +$committed = $false +$hadCurrent = $false; $hadManifest = $false; $hadLauncher = $false; $hadVersion = $false +$hadLegacy = $false +$versionDir = Join-Path $cliHome (Join-Path 'versions' $Version) +$backupDir = Join-Path $stage 'backup' +$legacyPath = Join-Path $cliHome 'buda' + +function Get-Asset([string]$name) { + $destination = Join-Path $stage $name + if ($assetDir) { Copy-Item -LiteralPath (Join-Path $assetDir $name) -Destination $destination } + else { Invoke-WebRequest -Uri "https://github.com/CGuiho/buda/releases/download/$tag/$name" -OutFile $destination } + return $destination } -$ExpectedVersion = $Matches.Version - -$BaseUrl = "https://github.com/$Owner/$Repository/releases/download/$Tag" -$SkillAsset = "guiho-s-0002-buda.zip" -$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("buda-" + [guid]::NewGuid().ToString("N")) -$Destination = $null -$BackupPath = $null -$BinaryReplaced = $false -$InstallSucceeded = $false -New-Item -ItemType Directory -Path $TempDir | Out-Null - -function Copy-ReleaseAsset { - param( - [Parameter(Mandatory = $true)][string]$Name, - [Parameter(Mandatory = $true)][string]$Destination - ) - - if (-not [string]::IsNullOrWhiteSpace($env:BUDA_RELEASE_ASSET_DIR)) { - $Source = Join-Path $env:BUDA_RELEASE_ASSET_DIR $Name - if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { - throw "Local release asset not found: $Source" - } - Copy-Item -LiteralPath $Source -Destination $Destination - return - } - Invoke-WebRequest -Uri "$BaseUrl/$Name" -OutFile $Destination +function Write-Atomic([string]$path, [string]$content) { + $temporary = "$path.new" + Set-Content -NoNewline -Encoding UTF8 -LiteralPath $temporary -Value $content + Move-Item -Force -LiteralPath $temporary -Destination $path +} +function Verify-Checksum([string]$name, $checksumLines) { + $matches = @($checksumLines | Where-Object { $_ -match "^\s*([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($name))\s*$" }) + if ($matches.Count -ne 1) { throw "Missing or duplicate checksum for $name." } + $expected = $matches[0].Substring(0,64).ToUpperInvariant() + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $stage $name)).Hash.ToUpperInvariant() + if ($expected -ne $actual) { throw "Checksum mismatch for $name." } +} +function Restore-Previous { + if (-not $mutated) { return } + if (Test-Path -LiteralPath $versionDir) { Remove-Item -Recurse -Force -LiteralPath $versionDir -ErrorAction SilentlyContinue } + if ($hadVersion) { Move-Item -Force -LiteralPath (Join-Path $backupDir 'version') -Destination $versionDir } + $currentPath = Join-Path $cliHome 'current.json'; $manifestPath = Join-Path $cliHome 'installed-artifacts.json'; $launcherPath = Join-Path $binDir 'buda.exe' + if ($hadCurrent) { Copy-Item -Force -LiteralPath (Join-Path $backupDir 'current.json') -Destination $currentPath } elseif (Test-Path -LiteralPath $currentPath) { Remove-Item -Force -LiteralPath $currentPath } + if ($hadManifest) { Copy-Item -Force -LiteralPath (Join-Path $backupDir 'installed-artifacts.json') -Destination $manifestPath } elseif (Test-Path -LiteralPath $manifestPath) { Remove-Item -Force -LiteralPath $manifestPath } + if ($hadLauncher) { Copy-Item -Force -LiteralPath (Join-Path $backupDir 'buda.exe') -Destination $launcherPath } elseif (Test-Path -LiteralPath $launcherPath) { Remove-Item -Force -LiteralPath $launcherPath } + if ($hadLegacy) { Copy-Item -Force -LiteralPath (Join-Path $backupDir 'legacy-buda.exe') -Destination $legacyPath } } try { - Write-Host "Initiating Buda installation sequence..." - Write-Host "Target version: $Tag" - Write-Host "Target asset: $Asset" - if (-not [string]::IsNullOrWhiteSpace($env:BUDA_RELEASE_ASSET_DIR)) { - Write-Host "Source directory: $env:BUDA_RELEASE_ASSET_DIR" - } else { - Write-Host "Source URL: $BaseUrl/$Asset" - } - - $BinaryPath = Join-Path $TempDir $Asset - $ChecksumsPath = Join-Path $TempDir "checksums.txt" - $SkillPath = Join-Path $TempDir $SkillAsset - Copy-ReleaseAsset -Name $Asset -Destination $BinaryPath - Copy-ReleaseAsset -Name "checksums.txt" -Destination $ChecksumsPath - Copy-ReleaseAsset -Name $SkillAsset -Destination $SkillPath - - foreach ($Verification in @(@($Asset, $BinaryPath), @($SkillAsset, $SkillPath))) { - $VerificationName = $Verification[0] - $VerificationPath = $Verification[1] - $ChecksumLine = Get-Content -LiteralPath $ChecksumsPath | Where-Object { $_ -match "\s+$([regex]::Escape($VerificationName))$" } | Select-Object -First 1 - if (-not $ChecksumLine) { throw "Checksum entry missing for $VerificationName." } - $ExpectedHash = ($ChecksumLine -split "\s+")[0].ToUpperInvariant() - $ActualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $VerificationPath).Hash.ToUpperInvariant() - if ($ExpectedHash -ne $ActualHash) { throw "Checksum verification failed for $VerificationName." } - } - Write-Host "[OK] SHA-256 verification complete for binary and skill archive." - - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null - $Destination = Join-Path $InstallDir "buda.exe" - - $ExpandedSkill = Join-Path $TempDir "skill" - Expand-Archive -LiteralPath $SkillPath -DestinationPath $ExpandedSkill - $SourceSkill = Join-Path $ExpandedSkill "guiho-s-0002-buda" - if (-not (Test-Path -LiteralPath (Join-Path $SourceSkill "SKILL.md"))) { - throw "Skill archive does not contain guiho-s-0002-buda/SKILL.md." - } - - # npm installs both qmd.cmd and qmd.ps1 on Windows. Prefer the application - # launcher so the installer works under PowerShell's default script policy. - $Qmd = Get-Command qmd.cmd -CommandType Application -ErrorAction SilentlyContinue - if (-not $Qmd) { - $Qmd = Get-Command qmd -CommandType Application -ErrorAction SilentlyContinue - } - # Probe common off-PATH locations (agent-managed environments) before failing. - if (-not $Qmd) { - $ProbeDirs = @() - $NpmRoot = $null - try { $NpmRoot = (npm root -g 2>$null) } catch { } - if ($NpmRoot) { $ProbeDirs += (Join-Path $NpmRoot "bin") } - $ProbeDirs += @( - (Join-Path $env:USERPROFILE ".hermes\node\bin"), - (Join-Path $env:USERPROFILE ".local\bin"), - (Join-Path $env:USERPROFILE ".npm-global\bin"), - (Join-Path $env:APPDATA "npm") - ) - foreach ($ProbeDir in $ProbeDirs) { - if ([string]::IsNullOrWhiteSpace($ProbeDir)) { continue } - $ProbePath = Join-Path $ProbeDir "qmd.cmd" - if (Test-Path -LiteralPath $ProbePath -PathType Leaf) { - $Qmd = Get-Item -LiteralPath $ProbePath - Write-Warning "qmd was not on PATH but was found at $ProbePath; add '$ProbeDir' to PATH for reliable operation." - break - } - $ProbePath = Join-Path $ProbeDir "qmd" - if (Test-Path -LiteralPath $ProbePath -PathType Leaf) { - $Qmd = Get-Item -LiteralPath $ProbePath - Write-Warning "qmd was not on PATH but was found at $ProbePath; add '$ProbeDir' to PATH for reliable operation." - break - } + Write-Host "Resolved Buda $Version for windows/$arch" + Write-Host "CLI home: $cliHome" + [void](Get-Asset 'artifacts.json'); [void](Get-Asset 'checksums.txt') + $manifest = Get-Content -Raw -LiteralPath (Join-Path $stage 'artifacts.json') | ConvertFrom-Json + if ($manifest.schema -ne 1 -or $manifest.cli -ne 'buda' -or $manifest.version -ne $Version) { throw 'Release artifacts.json is not a valid Buda manifest for the selected version.' } + $paths = @($manifest.artifacts | ForEach-Object { [string]$_.path }) + if (@($paths | Sort-Object -Unique).Count -ne $paths.Count) { throw 'Manifest contains duplicate asset paths.' } + $required = @($binary,$launcher,'guiho-s-0002-buda.zip','guiho-i-buda.md','guiho-p-buda.md','buda.schema.json','buda.global.schema.json','buda.example.yaml','buda.global.example.yaml','artifacts.json') + foreach ($name in $paths) { + if ([string]::IsNullOrWhiteSpace($name) -or [IO.Path]::GetFileName($name) -ne $name -or $name.Contains('/') -or $name.Contains('\') -or $name -eq '..') { throw "Unsafe manifest asset path '$name'." } + if (-not (Test-Path -LiteralPath (Join-Path $stage $name))) { [void](Get-Asset $name) } + } + foreach ($name in $required) { if ($paths -notcontains $name) { throw "Manifest does not declare required asset $name." } } + $checksumLines = @(Get-Content -LiteralPath (Join-Path $stage 'checksums.txt')) + foreach ($name in $paths) { Verify-Checksum $name $checksumLines } + foreach ($line in $checksumLines) { + if ($line -notmatch '^\s*[0-9a-fA-F]{64}\s+\*?([^\s]+)\s*$') { throw "Malformed checksum entry '$line'." } + if ($paths -notcontains $matches[1]) { throw "Checksum names undeclared asset $($matches[1])." } + } + $observed = (& (Join-Path $stage $binary) --version | Out-String).Trim() + if ($observed -ne $Version) { throw "Candidate version '$observed' does not match '$Version'." } + $selfTest = (& (Join-Path $stage $binary) __self-test | Out-String).Trim() + if ($selfTest -ne 'ok') { throw 'Candidate self-test failed.' } + + New-Item -ItemType Directory -Force -Path $backupDir,(Join-Path $cliHome 'versions'),$binDir | Out-Null + $currentPath = Join-Path $cliHome 'current.json'; $manifestPath = Join-Path $cliHome 'installed-artifacts.json'; $launcherPath = Join-Path $binDir 'buda.exe' + if (Test-Path -LiteralPath $currentPath) { Copy-Item -Force -LiteralPath $currentPath -Destination (Join-Path $backupDir 'current.json'); $hadCurrent = $true } + if (Test-Path -LiteralPath $manifestPath) { Copy-Item -Force -LiteralPath $manifestPath -Destination (Join-Path $backupDir 'installed-artifacts.json'); $hadManifest = $true } + if (Test-Path -LiteralPath $launcherPath) { Copy-Item -Force -LiteralPath $launcherPath -Destination (Join-Path $backupDir 'buda.exe'); $hadLauncher = $true } + if (Test-Path -LiteralPath $legacyPath -PathType Leaf) { + $legacyObserved = (& $legacyPath --version | Out-String).Trim() + if ($legacyObserved -match '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$') { + Copy-Item -Force -LiteralPath $legacyPath -Destination (Join-Path $backupDir 'legacy-buda.exe'); $hadLegacy = $true } } - if (-not $Qmd) { - throw "qmd is required but not installed. Install @tobilu/qmd@2.5.3, then run: buda doctor --wiki " - } - $QmdSource = $Qmd.Source - if (-not $QmdSource) { $QmdSource = $Qmd.FullName } - $QmdVersionOutput = (& $QmdSource --version | Out-String).Trim() - if ($QmdVersionOutput -notmatch '(?i)(?:qmd\s+)?v?(\d+)\.(\d+)\.(\d+)') { - throw "Could not parse qmd version: $QmdVersionOutput" - } - $QmdMajor = [int]$Matches[1] - $QmdMinor = [int]$Matches[2] - if ($QmdMajor -ne 2 -or $QmdMinor -lt 5) { - throw "Unsupported qmd version '$QmdVersionOutput'; Buda requires >=2.5.0,<3.0.0." - } - - $StagedBinary = Join-Path $InstallDir (".buda-new-" + [guid]::NewGuid().ToString("N") + ".exe") - Copy-Item -LiteralPath $BinaryPath -Destination $StagedBinary - if (Test-Path -LiteralPath $Destination) { - $BackupPath = Join-Path $InstallDir (".buda-backup-" + [guid]::NewGuid().ToString("N") + ".exe") - Move-Item -LiteralPath $Destination -Destination $BackupPath - } - $BinaryReplaced = $true - Move-Item -LiteralPath $StagedBinary -Destination $Destination - Write-Host "[OK] Installed binary: $Destination" - - & $Destination agent skill update - Write-Host "[OK] Installed both global Buda skill destinations transactionally from embedded resources." - - $InstalledVersion = (& $Destination --version | Out-String).Trim() - if ($InstalledVersion -ne "buda v$ExpectedVersion") { throw "Installed version '$InstalledVersion' does not match requested tag '$Tag'." } - Write-Host $InstalledVersion - # The binary is verified and installed; mark the install successful before - # the optional skill-destination registrations below so an optional - # registration failure can never roll back a valid binary replacement. - $InstallSucceeded = $true - - # Register the embedded skill into optional destinations: BUDA_SKILL_DIRS - # (semicolon-separated) and the Hermes skills directory when it exists. The - # built-in ~/.agents/skills and ~/.claude/skills destinations are already - # handled by `buda agent skill update` above; these are additive and - # non-fatal: a failure only warns and leaves the installation complete. - function Register-SkillDir([string]$Target) { - if ([string]::IsNullOrWhiteSpace($Target)) { return } - $Dest = Join-Path $Target "guiho-s-0002-buda" - try { - New-Item -ItemType Directory -Force -Path $Target | Out-Null - if (Test-Path -LiteralPath $Dest) { Remove-Item -Recurse -Force -LiteralPath $Dest } - Copy-Item -Recurse -LiteralPath $SourceSkill -Destination $Dest - Write-Host "[OK] Registered Buda skill: $Dest" - } catch { - Write-Warning "Could not register Buda skill in '$Target' (skipped): $($_.Exception.Message)" - } - } - - if (-not [string]::IsNullOrWhiteSpace($env:BUDA_SKILL_DIRS)) { - foreach ($SkillDir in ($env:BUDA_SKILL_DIRS -split ";")) { - Register-SkillDir $SkillDir - } - } - - $HermesSkillsDir = $env:HERMES_SKILLS_DIR - if ([string]::IsNullOrWhiteSpace($HermesSkillsDir)) { - $HermesSkillsDir = Join-Path $env:USERPROFILE ".hermes\skills" - } - if (Test-Path -LiteralPath $HermesSkillsDir -PathType Container) { - Register-SkillDir $HermesSkillsDir - } - - $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") - $PathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - if ($PathEntries -notcontains $InstallDir) { - try { - [Environment]::SetEnvironmentVariable("Path", (($PathEntries + $InstallDir) -join ";"), "User") - Write-Host "[OK] Added installation directory to the user PATH." - } catch { - Write-Warning "Buda is installed, but the user PATH could not be updated. Add '$InstallDir' to PATH manually." - } - } - # Verify the binary is callable in the current session; warn (not fail) when - # the install directory is not yet on the session PATH. - if (-not (Get-Command $CliName -ErrorAction SilentlyContinue)) { - Write-Warning "buda was installed to $InstallDir which is not on the session PATH. Open a new shell or run: `$env:Path = '$InstallDir;' + `$env:Path. Then run: buda doctor --wiki " - } - Write-Host $QmdVersionOutput - Write-Host "[OK] Buda installation complete. Repository instructions are installed only for an explicit --wiki path." -} catch { - if ($BinaryReplaced -and -not $InstallSucceeded) { - if ($Destination -and (Test-Path -LiteralPath $Destination)) { Remove-Item -Force -LiteralPath $Destination } - if ($BackupPath -and (Test-Path -LiteralPath $BackupPath)) { Move-Item -LiteralPath $BackupPath -Destination $Destination } - } + $mutated = $true + if (Test-Path -LiteralPath $versionDir) { Move-Item -Force -LiteralPath $versionDir -Destination (Join-Path $backupDir 'version'); $hadVersion = $true } + $previous = '' + if ($hadCurrent) { try { $previous = (Get-Content -Raw -LiteralPath $currentPath | ConvertFrom-Json).active } catch { $previous = '' } } + $previousVersion = if ($previous) { $previous.Split('/')[0] } else { '' } + New-Item -ItemType Directory -Force -Path (Join-Path $versionDir 'artifacts') | Out-Null + Copy-Item -Force -LiteralPath (Join-Path $stage $binary) -Destination (Join-Path $versionDir 'buda.exe') + foreach ($name in $paths) { Copy-Item -Force -LiteralPath (Join-Path $stage $name) -Destination (Join-Path $versionDir (Join-Path 'artifacts' $name)) } + Copy-Item -Force -LiteralPath (Join-Path $stage 'checksums.txt') -Destination (Join-Path $versionDir 'artifacts/checksums.txt') + $newLauncher = Join-Path $binDir '.buda-launcher-new.exe' + Copy-Item -Force -LiteralPath (Join-Path $stage $launcher) -Destination $newLauncher + Move-Item -Force -LiteralPath $newLauncher -Destination $launcherPath + $pointer = @{ schema = 1; active = "$Version/buda.exe"; previous = $previous; active_version = $Version; previous_version = $previousVersion } | ConvertTo-Json -Compress + Write-Atomic $currentPath $pointer + Copy-Item -Force -LiteralPath (Join-Path $stage 'artifacts.json') -Destination (Join-Path $cliHome '.installed-artifacts.json.new') + Move-Item -Force -LiteralPath (Join-Path $cliHome '.installed-artifacts.json.new') -Destination $manifestPath + $committed = $true + $observed = (& $launcherPath --version | Out-String).Trim() + if ($observed -ne $Version) { throw "Stable launcher reported '$observed'; expected '$Version'." } + $selfTest = (& $launcherPath __self-test | Out-String).Trim() + if ($selfTest -ne 'ok') { throw 'Stable launcher self-test failed.' } + $userPath = [Environment]::GetEnvironmentVariable('Path','User'); $entries = @($userPath -split ';' | Where-Object { $_ }); if ($entries -notcontains $binDir) { [Environment]::SetEnvironmentVariable('Path', (($entries + $binDir) -join ';'), 'User') } + if ($WikiId) { & $launcherPath init --wiki $Wiki --wiki-id $WikiId } else { & $launcherPath init --wiki $Wiki }; if ($LASTEXITCODE -ne 0) { throw 'Buda installed, but explicit-wiki init failed.' } + if ($hadLegacy -and (Test-Path -LiteralPath $legacyPath)) { Remove-Item -Force -LiteralPath $legacyPath } + Write-Host "Installed Buda $Version" + Write-Host "Launcher: $launcherPath" + Write-Host "Payload: $(Join-Path $versionDir 'buda.exe')" + Write-Host "CLI home: $cliHome" +} +catch { + Restore-Previous throw -} finally { - if ($InstallSucceeded -and $BackupPath -and (Test-Path -LiteralPath $BackupPath)) { Remove-Item -Force -LiteralPath $BackupPath } - if (Test-Path -LiteralPath $TempDir) { Remove-Item -Recurse -Force -LiteralPath $TempDir } } +finally { if (Test-Path -LiteralPath $stage) { Remove-Item -Recurse -Force -LiteralPath $stage } } diff --git a/devops/install.sh b/devops/install.sh index 410af28..b05a21a 100644 --- a/devops/install.sh +++ b/devops/install.sh @@ -1,264 +1,213 @@ #!/usr/bin/env sh -# Buda canonical-tag installer (POSIX sh). -# -# Environment variables: -# GUIHO_INSTALL_DIR Binary install directory (default: ~/.local/bin). -# BUDA_RELEASE_ASSET_DIR Load release assets from a local directory instead of -# GitHub; requires an exact canonical tag argument. -# BUDA_SKILL_DIRS Space-separated list of extra skill destination -# directories. Each is created if missing and the -# embedded skill directory is copied into it (the skill -# id subdirectory is appended automatically). Defaults -# to empty; the built-in ~/.agents/skills and -# ~/.claude/skills destinations are always installed by -# `buda agent skill update`. -# HERMES_SKILLS_DIR Hermes agent skills directory. When this directory -# exists (default: ~/.hermes/skills) the embedded skill -# is additionally registered there by the installer so -# Hermes agents can use Buda without a manual copy. set -eu -CLI_NAME="buda" -OWNER="CGuiho" -REPOSITORY="buda" -VERSION="${1:-latest}" -INSTALL_DIR="${GUIHO_INSTALL_DIR:-${HOME}/.local/bin}" - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf 'error: required command not found: %s\n' "$1" >&2 - exit 1 - fi +OWNER=CGuiho +REPOSITORY=buda +VERSION="" +CHANNEL="" +WIKI="" +WIKI_ID="" +ASSET_DIR="${BUDA_RELEASE_ASSET_DIR:-}" + +usage() { printf '%s\n' "Usage: install.sh --wiki [--wiki-id ] [--version | --channel ]"; } +semver_gt() { + awk -v left="$1" -v right="$2" ' + function compare(a, b, ap, bp, ac, bc, i, ai, bi, ar, br, an, bn, na, nb, np, nq) { + sub(/\+.*/, "", a); sub(/\+.*/, "", b) + np = split(a, ap, "-"); nq = split(b, bp, "-") + split(ap[1], ac, "\\."); split(bp[1], bc, "\\.") + for (i = 1; i <= 3; i++) { + if ((ac[i] + 0) != (bc[i] + 0)) return (ac[i] + 0) > (bc[i] + 0) + } + ar = (np > 1 ? ap[2] : ""); br = (nq > 1 ? bp[2] : "") + if (ar == br) return 0 + if (ar == "") return 1 + if (br == "") return 0 + na = split(ar, an, "\\."); nb = split(br, bn, "\\.") + for (i = 1; i <= (na > nb ? na : nb); i++) { + if (!(i in an)) return 0 + if (!(i in bn)) return 1 + ai = an[i]; bi = bn[i] + if (ai == bi) continue + if (ai ~ /^[0-9]+$/ && bi ~ /^[0-9]+$/) return (ai + 0) > (bi + 0) + if (ai ~ /^[0-9]+$/) return 1 + if (bi ~ /^[0-9]+$/) return 0 + return ai > bi + } + return na > nb + } + BEGIN { print compare(left, right) ? "1" : "0" } + ' } - -require_command unzip - -if [ -z "${BUDA_RELEASE_ASSET_DIR:-}" ]; then - require_command curl -fi - -case "$(uname -s)/$(uname -m)" in - Linux/x86_64|Linux/amd64) ASSET="buda-linux-amd64" ;; - Linux/aarch64|Linux/arm64) ASSET="buda-linux-arm64" ;; - Linux/armv7l) ASSET="buda-linux-armv7" ;; - Linux/armv6l) ASSET="buda-linux-armv6" ;; - Darwin/x86_64) ASSET="buda-darwin-amd64" ;; - Darwin/arm64) ASSET="buda-darwin-arm64" ;; - *) printf 'error: unsupported platform: %s/%s\n' "$(uname -s)" "$(uname -m)" >&2; exit 1 ;; +while [ "$#" -gt 0 ]; do + case "$1" in + --version) [ "$#" -ge 2 ] || { usage >&2; exit 2; }; VERSION=$2; shift 2 ;; + --channel) [ "$#" -ge 2 ] || { usage >&2; exit 2; }; CHANNEL=$2; shift 2 ;; + --wiki) [ "$#" -ge 2 ] || { usage >&2; exit 2; }; WIKI=$2; shift 2 ;; + --wiki-id) [ "$#" -ge 2 ] || { usage >&2; exit 2; }; WIKI_ID=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done +[ -n "$WIKI" ] || { printf '%s\n' '--wiki is required; Buda never selects a wiki implicitly' >&2; exit 2; } +[ -z "$VERSION" ] || [ -z "$CHANNEL" ] || { printf '%s\n' '--version and --channel are mutually exclusive' >&2; exit 2; } +case "$VERSION" in buda/*) VERSION=${VERSION#buda/} ;; esac +case "$VERSION" in v*) VERSION=${VERSION#v} ;; esac +[ -z "$VERSION" ] || printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' || { printf 'invalid --version: %s\n' "$VERSION" >&2; exit 2; } + +case "$(uname -s):$(uname -m)" in + Linux:x86_64|Linux:amd64) OS=linux; ARCH=amd64; BINARY=buda-linux-amd64; LAUNCHER=buda-launcher-linux-amd64 ;; + Linux:aarch64|Linux:arm64) OS=linux; ARCH=arm64; BINARY=buda-linux-arm64; LAUNCHER=buda-launcher-linux-arm64 ;; + Darwin:x86_64|Darwin:amd64) OS=darwin; ARCH=amd64; BINARY=buda-darwin-amd64; LAUNCHER=buda-launcher-darwin-amd64 ;; + Darwin:arm64) OS=darwin; ARCH=arm64; BINARY=buda-darwin-arm64; LAUNCHER=buda-launcher-darwin-arm64 ;; + *) printf 'unsupported platform: %s:%s\n' "$(uname -s)" "$(uname -m)" >&2; exit 2 ;; esac -if [ "$VERSION" = "latest" ]; then - if [ -n "${BUDA_RELEASE_ASSET_DIR:-}" ]; then - printf '%s\n' 'error: BUDA_RELEASE_ASSET_DIR requires an exact canonical tag such as buda/v0.0.2' >&2 - exit 1 - fi - TAG="$(curl --fail --silent --show-error "https://api.github.com/repos/${OWNER}/${REPOSITORY}/releases/latest" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" - if [ -z "$TAG" ]; then - printf 'error: could not resolve latest Buda release tag\n' >&2 - exit 1 - fi +HOME_DIR=${HOME:?HOME is required} +GUIHO_HOME=$HOME_DIR/.guiho +CLI_HOME=$GUIHO_HOME/buda +GLOBAL_CONFIG_NAME=buda.global.yaml +BIN_DIR=$GUIHO_HOME/bin +TEMP_ROOT=$GUIHO_HOME/.temp +mkdir -p "$TEMP_ROOT" +STAGE=$(mktemp -d "$TEMP_ROOT/buda-install-XXXXXX") + +if [ -n "$ASSET_DIR" ]; then + [ -n "$VERSION" ] || VERSION=${BUDA_RELEASE_VERSION:-} + [ -n "$VERSION" ] || { printf '%s\n' 'BUDA_RELEASE_VERSION or --version is required with BUDA_RELEASE_ASSET_DIR' >&2; exit 2; } + TAG="buda/v$VERSION" + source_asset() { cp "$ASSET_DIR/$1" "$2"; } else - # Non-latest values are exact full release tags; Buda owns no implicit prefix. - TAG="$VERSION" -fi - -EXPECTED_VERSION="${TAG#buda/v}" -if [ "$EXPECTED_VERSION" = "$TAG" ] || ! printf '%s\n' "$EXPECTED_VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then - printf "error: invalid Buda release tag '%s'; expected buda/v\n" "$TAG" >&2 - exit 1 -fi - -BASE_URL="https://github.com/${OWNER}/${REPOSITORY}/releases/download/${TAG}" -SKILL_ASSET="guiho-s-0002-buda.zip" -TEMP_DIR="$(mktemp -d)" -DESTINATION="${INSTALL_DIR}/${CLI_NAME}" -BACKUP_PATH="" -BINARY_REPLACED=0 -INSTALL_SUCCEEDED=0 -cleanup() { - if [ "$BINARY_REPLACED" -eq 1 ]; then - if [ "$INSTALL_SUCCEEDED" -eq 1 ]; then - [ -z "$BACKUP_PATH" ] || rm -f "$BACKUP_PATH" - else - rm -f "$DESTINATION" - [ -z "$BACKUP_PATH" ] || mv "$BACKUP_PATH" "$DESTINATION" - fi - fi - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT -trap 'exit 1' HUP INT TERM - -fetch_asset() { - FETCH_NAME="$1" - FETCH_DESTINATION="$2" - if [ -n "${BUDA_RELEASE_ASSET_DIR:-}" ]; then - SOURCE_PATH="${BUDA_RELEASE_ASSET_DIR}/${FETCH_NAME}" - if [ ! -f "$SOURCE_PATH" ]; then - printf 'error: local release asset not found: %s\n' "$SOURCE_PATH" >&2 - exit 1 - fi - cp "$SOURCE_PATH" "$FETCH_DESTINATION" + if [ -z "$VERSION" ]; then + command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required for paginated release discovery' >&2; exit 1; } + wanted=${CHANNEL:-stable}; page=1; selected_version="" + while :; do + releases=$(curl -fsSL "https://api.github.com/repos/$OWNER/$REPOSITORY/releases?per_page=100&page=$page") + count=$(printf '%s' "$releases" | jq 'length') + candidates=$(printf '%s' "$releases" | jq -r --arg wanted "$wanted" --arg binary "$BINARY" --arg launcher "$LAUNCHER" '.[] | select(.draft|not) | select(.tag_name|test("^buda/v")) | ((.assets // [] | map(.name) | unique) as $assets | select(([$binary,$launcher,"checksums.txt","artifacts.json","guiho-s-0002-buda.zip","guiho-i-buda.md","guiho-p-buda.md","buda.schema.json","buda.global.schema.json","buda.example.yaml","buda.global.example.yaml"] - $assets | length) == 0)) | (.tag_name|sub("^buda/v";"")) as $v | select($v|test("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$")) | select((if ($v|contains("-")) then ($v|split("-")[1]|split(".")[0]) else "stable" end)==$wanted) | $v') + for candidate in $candidates; do + if [ -z "$selected_version" ] || [ "$(semver_gt "$candidate" "$selected_version")" = 1 ]; then selected_version=$candidate; fi + done + [ "$count" -lt 100 ] && break + page=$((page + 1)) + done + [ -n "$selected_version" ] || { printf 'no release found for channel %s\n' "$wanted" >&2; exit 1; } + VERSION=$selected_version; TAG="buda/v$VERSION" else - curl --fail --location --progress-bar --output "$FETCH_DESTINATION" "${BASE_URL}/${FETCH_NAME}" + TAG="buda/v$VERSION" fi -} - -printf '%s\n' 'Initiating Buda installation sequence...' -if [ -n "${BUDA_RELEASE_ASSET_DIR:-}" ]; then - printf 'Target version: %s\nTarget asset: %s\nSource directory: %s\n' "$TAG" "$ASSET" "$BUDA_RELEASE_ASSET_DIR" -else - printf 'Target version: %s\nTarget asset: %s\nSource URL: %s/%s\n' "$TAG" "$ASSET" "$BASE_URL" "$ASSET" + BASE="https://github.com/$OWNER/$REPOSITORY/releases/download/$TAG" + source_asset() { curl -fsSL "$BASE/$1" -o "$2"; } fi -fetch_asset "$ASSET" "${TEMP_DIR}/${ASSET}" -fetch_asset "checksums.txt" "${TEMP_DIR}/checksums.txt" -fetch_asset "$SKILL_ASSET" "${TEMP_DIR}/${SKILL_ASSET}" -verify_asset() { - VERIFY_NAME="$1" - EXPECTED_HASH="$(awk -v asset="$VERIFY_NAME" '$2 == asset { print $1 }' "${TEMP_DIR}/checksums.txt")" - if [ -z "$EXPECTED_HASH" ]; then - printf 'error: checksum entry missing for %s\n' "$VERIFY_NAME" >&2 - exit 1 - fi - if command -v sha256sum >/dev/null 2>&1; then - ACTUAL_HASH="$(sha256sum "${TEMP_DIR}/${VERIFY_NAME}" | awk '{ print $1 }')" - else - require_command shasum - ACTUAL_HASH="$(shasum -a 256 "${TEMP_DIR}/${VERIFY_NAME}" | awk '{ print $1 }')" - fi - if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then - printf 'error: checksum verification failed for %s\n' "$VERIFY_NAME" >&2 - exit 1 - fi -} -verify_asset "$ASSET" -verify_asset "$SKILL_ASSET" -printf '%s\n' '[OK] SHA-256 verification complete for binary and skill archive.' +cleanup() { status=$?; [ "$status" -eq 0 ] || rollback 2>/dev/null || true; rm -rf "$STAGE"; exit "$status"; } +trap cleanup EXIT INT TERM -mkdir -p "${TEMP_DIR}/skill" -unzip -q "${TEMP_DIR}/${SKILL_ASSET}" -d "${TEMP_DIR}/skill" -SOURCE_SKILL="${TEMP_DIR}/skill/guiho-s-0002-buda" -if [ ! -f "${SOURCE_SKILL}/SKILL.md" ]; then - printf 'error: skill archive does not contain guiho-s-0002-buda/SKILL.md\n' >&2 - exit 1 -fi -# Resolve qmd even when it is installed off PATH (common in agent-managed -# environments such as ~/.hermes/node/bin). Only hard-fail when truly absent. -QMD_BIN="" -QMD_NPX_FALLBACK=0 -if command -v qmd >/dev/null 2>&1; then - QMD_BIN="qmd" -else - QMD_PROBE_DIRS="" - if command -v npm >/dev/null 2>&1; then - NPM_GLOBAL_BIN="$(npm root -g 2>/dev/null)/bin" - [ -n "$NPM_GLOBAL_BIN" ] && QMD_PROBE_DIRS="$QMD_PROBE_DIRS $NPM_GLOBAL_BIN" - fi - QMD_PROBE_DIRS="$QMD_PROBE_DIRS ${HOME}/.hermes/node/bin ${HOME}/.local/bin ${HOME}/.npm-global/bin" - for probe_dir in $QMD_PROBE_DIRS; do - if [ -n "$probe_dir" ] && [ -x "$probe_dir/qmd" ]; then - QMD_BIN="$probe_dir/qmd" - break - fi - done - if [ -z "$QMD_BIN" ] && command -v npx >/dev/null 2>&1; then - if npx --no-install qmd --version >/dev/null 2>&1; then - QMD_BIN="npx --no-install qmd" - QMD_NPX_FALLBACK=1 - fi - fi - if [ -z "$QMD_BIN" ]; then - printf '%s\n' 'error: qmd is required but not installed. Install @tobilu/qmd@2.5.3, then run: buda doctor --wiki ' >&2 - exit 1 - fi - if [ "$QMD_NPX_FALLBACK" -eq 1 ]; then - # The npx fallback is not a filesystem path, so directory-to-PATH advice - # does not apply; suggest a global install instead. - printf '%s\n' 'info: qmd found via the npx fallback (npx --no-install qmd). For reliable operation install it globally: npm install -g @tobilu/qmd@2.5.3. Then run: buda doctor --wiki ' >&2 - else - printf 'warning: qmd was not on PATH but was found at %s; add its directory to PATH (e.g. export PATH="%s:$PATH") for reliable operation.\n' "$QMD_BIN" "${QMD_BIN%/*}" >&2 - fi -fi -QMD_VERSION_OUTPUT="$($QMD_BIN --version)" -QMD_VERSION="$(printf '%s\n' "$QMD_VERSION_OUTPUT" | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^v?[0-9]+\.[0-9]+\.[0-9]+/) { sub(/^v/, "", $i); print $i; exit } }')" -QMD_MAJOR="${QMD_VERSION%%.*}" -QMD_REST="${QMD_VERSION#*.}" -QMD_MINOR="${QMD_REST%%.*}" -if [ -z "$QMD_VERSION" ] || [ "$QMD_MAJOR" -ne 2 ] || [ "$QMD_MINOR" -lt 5 ]; then - printf "error: unsupported qmd version '%s'; Buda requires >=2.5.0,<3.0.0\n" "$QMD_VERSION_OUTPUT" >&2 - exit 1 -fi -printf '%s\n' "$QMD_VERSION_OUTPUT" - -mkdir -p "$INSTALL_DIR" -STAGED_BINARY="${INSTALL_DIR}/.buda-new-$$" -install -m 0755 "${TEMP_DIR}/${ASSET}" "$STAGED_BINARY" -if [ -e "$DESTINATION" ]; then - BACKUP_PATH="${INSTALL_DIR}/.buda-backup-$$" - mv "$DESTINATION" "$BACKUP_PATH" -fi -BINARY_REPLACED=1 -mv "$STAGED_BINARY" "$DESTINATION" -printf '[OK] Installed binary: %s\n' "$DESTINATION" - -"$DESTINATION" agent skill update -printf '%s\n' '[OK] Installed both global Buda skill destinations transactionally from embedded resources.' - -INSTALLED_VERSION="$("$DESTINATION" --version)" -if [ "$INSTALLED_VERSION" != "buda v${EXPECTED_VERSION}" ]; then - printf "error: installed version '%s' does not match requested tag '%s'\n" "$INSTALLED_VERSION" "$TAG" >&2 - exit 1 -fi -printf '%s\n' "$INSTALLED_VERSION" -# The binary is verified and installed; mark the install successful before the -# optional skill-destination registrations below so an optional registration -# failure can never roll back a valid binary replacement. -INSTALL_SUCCEEDED=1 - -# Register the embedded skill into optional destinations: BUDA_SKILL_DIRS -# (space-separated) and the Hermes skills directory when it exists. The -# built-in ~/.agents/skills and ~/.claude/skills destinations are already -# handled by `buda agent skill update` above; these are additive and -# non-fatal: a failure only warns and leaves the installation complete. -register_skill_dir() { - rs_target="$1" - if [ -z "$rs_target" ]; then - return 0 - fi - rs_dest="$rs_target/guiho-s-0002-buda" - if ! mkdir -p "$rs_target" 2>/dev/null; then - printf 'warning: could not create %s; skipping optional Buda skill registration (install remains complete).\n' "$rs_target" >&2 - return 0 - fi - if ! rm -rf "$rs_dest" 2>/dev/null; then - printf 'warning: could not refresh %s; skipping optional Buda skill registration (install remains complete).\n' "$rs_dest" >&2 - return 0 - fi - if ! cp -R "$SOURCE_SKILL" "$rs_dest" 2>/dev/null; then - printf 'warning: could not copy the Buda skill to %s; skipping optional Buda skill registration (install remains complete).\n' "$rs_dest" >&2 - return 0 - fi - printf '[OK] Registered Buda skill: %s\n' "$rs_dest" +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; return; fi + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'; return; fi + printf '%s\n' 'sha256sum or shasum is required for release verification' >&2 + return 1 +} +manifest_paths() { awk -F'"' '/"path"[[:space:]]*:/ { print $4 }' "$STAGE/artifacts.json"; } +manifest_has() { manifest_paths | grep -F -x "$1" >/dev/null 2>&1; } +verify_checksum() { + name=$1 + expected=$(awk -v n="$name" '$2==n || $2=="*" n { if (++count > 1) exit 9; print $1 } END { if (count != 1) exit 8 }' "$STAGE/checksums.txt") || { printf 'missing or duplicate checksum: %s\n' "$name" >&2; return 1; } + actual=$(sha256 "$STAGE/$name") + [ "$expected" = "$actual" ] || { printf 'checksum mismatch: %s\n' "$name" >&2; return 1; } } -for skill_dir in ${BUDA_SKILL_DIRS:-}; do - register_skill_dir "$skill_dir" +printf 'Resolved Buda %s for %s/%s\n' "$VERSION" "$OS" "$ARCH" +printf 'CLI home: %s\nStable launcher: %s/buda\n' "$CLI_HOME" "$BIN_DIR" + +# Fetch the ownership manifest and checksum index first, then fetch exactly +# the remaining manifest-declared assets. Filename prefixes are not ownership. +source_asset artifacts.json "$STAGE/artifacts.json" +source_asset checksums.txt "$STAGE/checksums.txt" +manifest_schema=$(sed -n 's/^[[:space:]]*"schema":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$STAGE/artifacts.json" | head -n 1) +manifest_cli=$(sed -n 's/^[[:space:]]*"cli":[[:space:]]*"\([^"]*\)".*/\1/p' "$STAGE/artifacts.json" | head -n 1) +manifest_version=$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$STAGE/artifacts.json" | head -n 1) +[ "$manifest_schema" = 1 ] || { printf '%s\n' 'release artifacts.json has an unsupported schema' >&2; exit 1; } +[ "$manifest_cli" = buda ] || { printf '%s\n' 'release artifacts.json belongs to another CLI' >&2; exit 1; } +[ "$manifest_version" = "$VERSION" ] || { printf 'release manifest version mismatch: expected %s, got %s\n' "$VERSION" "$manifest_version" >&2; exit 1; } +manifest_count=$(manifest_paths | wc -l | tr -d ' ') +manifest_unique=$(manifest_paths | sort -u | wc -l | tr -d ' ') +[ "$manifest_count" = "$manifest_unique" ] || { printf '%s\n' 'manifest contains duplicate asset paths' >&2; exit 1; } +for name in $(manifest_paths); do + case "$name" in */*|*\\*|..|../*) printf 'unsafe manifest asset path: %s\n' "$name" >&2; exit 1 ;; esac + [ -n "$name" ] || { printf '%s\n' 'manifest contains an empty asset path' >&2; exit 1; } + [ -f "$STAGE/$name" ] || source_asset "$name" "$STAGE/$name" done - -HERMES_SKILLS_DIR_RESOLVED="${HERMES_SKILLS_DIR:-${HOME}/.hermes/skills}" -if [ -d "$HERMES_SKILLS_DIR_RESOLVED" ]; then - register_skill_dir "$HERMES_SKILLS_DIR_RESOLVED" -fi - -# Verify the binary is callable on PATH; warn (not fail) with an actionable -# fix when the install directory is not on PATH for the current shell. -if ! command -v "$CLI_NAME" >/dev/null 2>&1; then - printf 'warning: %s was installed to %s which is not on PATH for this shell.\n' "$CLI_NAME" "$INSTALL_DIR" >&2 - printf ' Fix one of:\n' >&2 - printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR" >&2 - printf ' ln -s "%s/%s" /usr/local/bin/%s\n' "$INSTALL_DIR" "$CLI_NAME" "$CLI_NAME" >&2 - printf ' Then run: buda doctor --wiki \n' >&2 +for name in "$BINARY" "$LAUNCHER" guiho-s-0002-buda.zip guiho-i-buda.md guiho-p-buda.md buda.schema.json buda.global.schema.json buda.example.yaml buda.global.example.yaml artifacts.json; do + manifest_has "$name" || { printf 'manifest does not declare required asset: %s\n' "$name" >&2; exit 1; } +done +for name in $(manifest_paths); do verify_checksum "$name"; done +while read -r digest name extra; do + [ -n "${name:-}" ] || continue + [ -z "${extra:-}" ] || { printf 'malformed checksum entry: %s\n' "$name" >&2; exit 1; } + name=${name#*\*} + manifest_has "$name" || { printf 'checksum names undeclared asset: %s\n' "$name" >&2; exit 1; } +done < "$STAGE/checksums.txt" +observed=$($STAGE/$BINARY --version 2>/dev/null || true) +[ "$observed" = "$VERSION" ] || { printf 'candidate version mismatch: expected %s, got %s\n' "$VERSION" "$observed" >&2; exit 1; } +self_test=$($STAGE/$BINARY __self-test 2>/dev/null || true) +[ "$self_test" = "ok" ] || { printf '%s\n' 'candidate self-test failed' >&2; exit 1; } + +VERSION_DIR=$CLI_HOME/versions/$VERSION +BACKUP_DIR=$STAGE/backup +mkdir -p "$BACKUP_DIR" "$CLI_HOME/versions" "$BIN_DIR" +LEGACY_PATH=$CLI_HOME/buda +had_legacy=0 +if [ -f "$LEGACY_PATH" ]; then + legacy_version=$($LEGACY_PATH --version 2>/dev/null || true) + if printf '%s' "$legacy_version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + cp -p "$LEGACY_PATH" "$BACKUP_DIR/legacy-buda" + had_legacy=1 + fi fi +previous="" +if [ -f "$CLI_HOME/current.json" ]; then previous=$(sed -n 's/.*"active":"\([^"]*\)".*/\1/p' "$CLI_HOME/current.json" | head -n 1); fi +previous_version="${previous%%/*}" +had_current=0; had_manifest=0; had_launcher=0; had_version=0; mutated=0; committed=0 +if [ -f "$CLI_HOME/current.json" ]; then cp -p "$CLI_HOME/current.json" "$BACKUP_DIR/current.json"; had_current=1; fi +if [ -f "$CLI_HOME/installed-artifacts.json" ]; then cp -p "$CLI_HOME/installed-artifacts.json" "$BACKUP_DIR/installed-artifacts.json"; had_manifest=1; fi +if [ -f "$BIN_DIR/buda" ]; then cp -p "$BIN_DIR/buda" "$BACKUP_DIR/buda"; had_launcher=1; fi +mutated=1 +if [ -e "$VERSION_DIR" ]; then mv "$VERSION_DIR" "$BACKUP_DIR/version"; had_version=1; fi +rollback() { + [ "$mutated" -eq 1 ] || return 0 + rm -rf "$VERSION_DIR" 2>/dev/null || true + [ "$had_version" -eq 1 ] && mv "$BACKUP_DIR/version" "$VERSION_DIR" 2>/dev/null || true + if [ "$had_current" -eq 1 ]; then cp -p "$BACKUP_DIR/current.json" "$CLI_HOME/current.json" 2>/dev/null || true; else rm -f "$CLI_HOME/current.json" 2>/dev/null || true; fi + if [ "$had_manifest" -eq 1 ]; then cp -p "$BACKUP_DIR/installed-artifacts.json" "$CLI_HOME/installed-artifacts.json" 2>/dev/null || true; else rm -f "$CLI_HOME/installed-artifacts.json" 2>/dev/null || true; fi + if [ "$had_launcher" -eq 1 ]; then cp -p "$BACKUP_DIR/buda" "$BIN_DIR/buda" 2>/dev/null || true; else rm -f "$BIN_DIR/buda" 2>/dev/null || true; fi + if [ "$had_legacy" -eq 1 ]; then cp -p "$BACKUP_DIR/legacy-buda" "$LEGACY_PATH" 2>/dev/null || true; fi +} -printf '%s\n' '[OK] Buda installation complete. Repository instructions are installed only for an explicit --wiki path.' +mkdir -p "$VERSION_DIR/artifacts" +cp "$STAGE/$BINARY" "$VERSION_DIR/buda" +chmod 755 "$VERSION_DIR/buda" +for name in $(manifest_paths); do cp "$STAGE/$name" "$VERSION_DIR/artifacts/$name"; done +cp "$STAGE/$LAUNCHER" "$BIN_DIR/.buda-launcher-new" +chmod 755 "$BIN_DIR/.buda-launcher-new" +mv -f "$BIN_DIR/.buda-launcher-new" "$BIN_DIR/buda" +mkdir -p "$CLI_HOME/state" +printf '{"schema":1,"active":"%s/buda","previous":"%s","active_version":"%s","previous_version":"%s"}\n' "$VERSION" "$previous" "$VERSION" "$previous_version" > "$CLI_HOME/.current.json.new" +mv -f "$CLI_HOME/.current.json.new" "$CLI_HOME/current.json" +cp "$STAGE/artifacts.json" "$CLI_HOME/.installed-artifacts.json.new" +mv -f "$CLI_HOME/.installed-artifacts.json.new" "$CLI_HOME/installed-artifacts.json" +committed=1 +observed=$($BIN_DIR/buda --version 2>/dev/null || true) +[ "$observed" = "$VERSION" ] || { printf 'launcher version mismatch: expected %s, got %s\n' "$VERSION" "$observed" >&2; exit 1; } +self_test=$($BIN_DIR/buda __self-test 2>/dev/null || true) +[ "$self_test" = "ok" ] || { printf '%s\n' 'launcher self-test failed' >&2; exit 1; } +case ":${PATH:-}:" in + *":$BIN_DIR:"*) ;; + *) if [ ! -f "$HOME_DIR/.profile" ] || ! grep -F '$HOME/.guiho/bin' "$HOME_DIR/.profile" >/dev/null 2>&1; then printf '\n# GUIHO Buda\nexport PATH="$HOME/.guiho/bin:$PATH"\n' >> "$HOME_DIR/.profile"; fi ;; +esac +# Invoke the required explicit-wiki init without shell evaluation so paths +# remain opaque to the installer. +if [ -n "$WIKI_ID" ]; then "$BIN_DIR/buda" init --wiki "$WIKI" --wiki-id "$WIKI_ID"; else "$BIN_DIR/buda" init --wiki "$WIKI"; fi || { printf '%s\n' 'Buda installed, but explicit-wiki init failed; rolling back lifecycle files.' >&2; exit 1; } +if [ "$had_legacy" -eq 1 ]; then rm -f "$LEGACY_PATH"; fi +printf 'Installed Buda %s\nLauncher: %s/buda\nPayload: %s\nCLI home: %s\n' "$VERSION" "$BIN_DIR" "$VERSION_DIR/buda" "$CLI_HOME" diff --git a/devops/installers_test.go b/devops/installers_test.go index f8a68c8..30cde3a 100644 --- a/devops/installers_test.go +++ b/devops/installers_test.go @@ -6,220 +6,57 @@ import ( "testing" ) -func TestInstallersUseExactTagsAndRequiredArtifacts(t *testing.T) { - for _, path := range []string{"install.sh", "install.ps1"} { +func TestLifecycleScriptsExposeConventionSelectorsAndOwnershipBoundaries(t *testing.T) { + for _, path := range []string{"install.sh", "install.ps1", "uninstall.sh", "uninstall.ps1"} { content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } text := string(content) - for _, required := range []string{ - "guiho-s-0002-buda.zip", "checksums.txt", "qmd", "--wiki ", - ">=2.5.0,<3.0.0", "@tobilu/qmd@2.5.3", "agent skill update", "buda-backup", "buda v", - "BUDA_RELEASE_ASSET_DIR", "buda/v0.0.2", "buda/v", - } { + required := []string{".guiho", ".temp"} + if strings.HasPrefix(path, "uninstall") { + if strings.HasSuffix(path, ".ps1") { + required = append(required, "PreserveConfig", "PreserveData", "DryRun", "Yes") + } else { + required = append(required, "preserve-config", "preserve-data", "dry-run", "--yes") + } + } else { + required = append(required, "artifacts.json", "checksums.txt") + } + for _, required := range required { if !strings.Contains(text, required) { t.Fatalf("%s missing %q", path, required) } } - if strings.Contains(text, `"v${VERSION}"`) || strings.Contains(text, `"v$Version"`) { - t.Fatalf("%s invents a release tag prefix", path) - } - if strings.Contains(text, `${TAG#v}`) || strings.Contains(text, `.TrimStart("v")`) { - t.Fatalf("%s strips only a leading v and cannot verify canonical Buda tags", path) - } - if strings.Contains(text, "__") { - t.Fatalf("%s contains an unreplaced template token", path) + if strings.Contains(text, "agent skill update") || strings.Contains(text, "--keep-agent-resources") { + t.Fatalf("%s contains an obsolete lifecycle route", path) } } -} - -func TestInstallersExtractVersionFromCanonicalBudaTag(t *testing.T) { - tests := map[string][]string{ - "install.sh": { - `EXPECTED_VERSION="${TAG#buda/v}"`, - `"buda v${EXPECTED_VERSION}"`, - }, - "install.ps1": { - `^buda/v(?`, - `$ExpectedVersion = $Matches.Version`, - `"buda v$ExpectedVersion"`, - `Get-Command qmd.cmd -CommandType Application`, - `& $QmdSource --version`, - `Buda is installed, but the user PATH could not be updated.`, - }, - } - for path, required := range tests { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - for _, value := range required { - if !strings.Contains(string(content), value) { - t.Fatalf("%s missing canonical tag verification fragment %q", path, value) - } - } - } -} - -func TestInstallersVerifyBinaryOnPath(t *testing.T) { - for _, path := range []string{"install.sh", "install.ps1"} { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - text := string(content) - // Both installers must warn (not fail) when the binary is not callable - // on PATH after installation, and suggest buda doctor --wiki . - if !strings.Contains(text, "command -v") && !strings.Contains(text, "Get-Command $CliName") { - t.Fatalf("%s missing binary-on-PATH verification (command -v or Get-Command)", path) - } - if !strings.Contains(text, "not on PATH") && !strings.Contains(text, "not on the session PATH") { - t.Fatalf("%s missing PATH warning message", path) - } - if !strings.Contains(text, "buda doctor --wiki ") { - t.Fatalf("%s missing buda doctor suggestion in PATH warning", path) + installSh, _ := os.ReadFile("install.sh") + for _, required := range []string{"--version", "--channel", "--wiki", "BUDA_RELEASE_ASSET_DIR", "buda.global.yaml", "buda-launcher-"} { + if !strings.Contains(string(installSh), required) { + t.Fatalf("install.sh missing %q", required) } } -} - -func TestInstallersProbeQmdOffPath(t *testing.T) { - for _, path := range []string{"install.sh", "install.ps1"} { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - text := string(content) - // Both installers must probe common off-PATH qmd locations before - // claiming qmd is not installed, and print a PATH-fix hint when found. - if !strings.Contains(text, ".hermes") { - t.Fatalf("%s missing ~/.hermes probe for off-PATH qmd", path) - } - if !strings.Contains(text, "was not on PATH but was found") && !strings.Contains(text, "was not on PATH") { - t.Fatalf("%s missing off-PATH qmd found hint in %s", path, path) - } - // The hard-fail message must still exist for the truly-absent case. - if !strings.Contains(text, "qmd is required but not installed") { - t.Fatalf("%s missing qmd not-installed hard-fail message", path) + installPS1, _ := os.ReadFile("install.ps1") + for _, required := range []string{"-Version", "-Channel", "$Wiki", "buda.global.yaml", "buda-launcher-"} { + if !strings.Contains(string(installPS1), required) { + t.Fatalf("install.ps1 missing %q", required) } } } -func TestInstallersConfigurableSkillDirs(t *testing.T) { - for _, path := range []string{"install.sh", "install.ps1"} { +func TestLifecycleScriptsRejectObsoleteMutableReleaseAssumptions(t *testing.T) { + for _, path := range []string{"install.sh", "install.ps1", "uninstall.sh", "uninstall.ps1"} { content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } text := string(content) - // BUDA_SKILL_DIRS env var for extra destinations. - if !strings.Contains(text, "BUDA_SKILL_DIRS") { - t.Fatalf("%s missing BUDA_SKILL_DIRS configurable destinations", path) - } - // HERMES_SKILLS_DIR env var / default for Hermes-aware registration. - if !strings.Contains(text, "HERMES_SKILLS_DIR") { - t.Fatalf("%s missing HERMES_SKILLS_DIR Hermes-aware registration", path) - } - if !strings.Contains(text, ".hermes/skills") && !strings.Contains(text, ".hermes\\skills") { - t.Fatalf("%s missing default Hermes skills directory", path) - } - // The registration must copy the skill directory into destinations. - if !strings.Contains(text, "register_skill_dir") && !strings.Contains(text, "Register-SkillDir") { - t.Fatalf("%s missing skill registration function", path) - } - } -} - -func TestInstallersSetSuccessBeforeOptionalSkillRegistrations(t *testing.T) { - // The success flag must be set immediately after the binary version - // verification and BEFORE the optional skill-destination registrations, so - // an optional registration failure can never roll back a valid install. - flagByPath := map[string]string{ - "install.sh": "INSTALL_SUCCEEDED=1", - "install.ps1": "$InstallSucceeded = $true", - } - registrationsByPath := map[string]string{ - "install.sh": "for skill_dir in ${BUDA_SKILL_DIRS:-}", - "install.ps1": "Register-SkillDir $SkillDir", - } - for _, path := range []string{"install.sh", "install.ps1"} { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - text := string(content) - flagIndex := strings.LastIndex(text, flagByPath[path]) - registrationIndex := strings.Index(text, registrationsByPath[path]) - if flagIndex < 0 { - t.Fatalf("%s missing success flag %q", path, flagByPath[path]) - } - if registrationIndex < 0 { - t.Fatalf("%s missing optional registration loop %q", path, registrationsByPath[path]) - } - if strings.Index(text, "does not match requested tag") > flagIndex { - t.Fatalf("%s sets the success flag before the binary version verification", path) - } - if flagIndex > registrationIndex { - t.Fatalf("%s sets the success flag after the optional skill-destination registrations", path) + for _, forbidden := range []string{"buda v", "exact 11", "exactly 11", "scheduled", "${TAG#v}"} { + if strings.Contains(text, forbidden) { + t.Fatalf("%s contains forbidden current contract text %q", path, forbidden) + } } } } - -func TestInstallersOptionalSkillRegistrationFailuresAreNonFatal(t *testing.T) { - // A failed optional skill-destination registration must be a warning that - // does not abort the installation (the script continues and exits 0). - content, err := os.ReadFile("install.sh") - if err != nil { - t.Fatal(err) - } - sh := string(content) - if !strings.Contains(sh, "skipping optional Buda skill registration (install remains complete)") { - t.Fatalf("install.sh missing non-fatal optional registration warning") - } - if !strings.Contains(sh, "INSTALL_SUCCEEDED=1") { - t.Fatalf("install.sh success flag removed") - } - content, err = os.ReadFile("install.ps1") - if err != nil { - t.Fatal(err) - } - ps1 := string(content) - if !strings.Contains(ps1, "Write-Warning \"Could not register Buda skill in") { - t.Fatalf("install.ps1 missing non-fatal optional registration warning") - } - if !strings.Contains(ps1, "} catch {") || !strings.Contains(ps1, "function Register-SkillDir") { - t.Fatalf("install.ps1 registration function missing failure handler") - } - if !strings.Contains(ps1, "$InstallSucceeded = $true") { - t.Fatalf("install.ps1 success flag removed") - } -} - -func TestInstallersQmdNpxFallbackHasDistinctMessage(t *testing.T) { - // The npx fallback is not a filesystem path: it must not trigger the - // directory-to-PATH export guidance, and must get a distinct message. - content, err := os.ReadFile("install.sh") - if err != nil { - t.Fatal(err) - } - text := string(content) - if !strings.Contains(text, "QMD_NPX_FALLBACK=0") || !strings.Contains(text, "QMD_NPX_FALLBACK=1") { - t.Fatalf("install.sh missing npx fallback tracking") - } - if !strings.Contains(text, `QMD_BIN="npx --no-install qmd"`) { - t.Fatalf("install.sh missing npx fallback resolution") - } - if !strings.Contains(text, "found via the npx fallback") { - t.Fatalf("install.sh missing distinct npx fallback message") - } - // The directory-to-PATH guidance may only appear after the npx-fallback - // branch guard (i.e. in the else branch, for a real executable path). - npxGuard := `if [ "$QMD_NPX_FALLBACK" -eq 1 ]; then` - pathAdvice := "add its directory to PATH" - if strings.Index(text, npxGuard) < 0 || strings.Index(text, pathAdvice) < 0 { - t.Fatalf("install.sh missing npx fallback guard or PATH advice for real paths") - } - if strings.Index(text, pathAdvice) < strings.Index(text, npxGuard) { - t.Fatalf("install.sh emits directory-to-PATH advice for the npx fallback") - } -} diff --git a/devops/uninstall.ps1 b/devops/uninstall.ps1 new file mode 100644 index 0000000..43a36cb --- /dev/null +++ b/devops/uninstall.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [string]$Wiki, + [switch]$PreserveConfig, + [switch]$PreserveData, + [switch]$DryRun, + [switch]$Yes +) +$ErrorActionPreference='Stop' +$homeDir=[Environment]::GetFolderPath('UserProfile'); $guiho=Join-Path $homeDir '.guiho'; $cliHome=Join-Path $guiho 'buda'; $bin=Join-Path $guiho 'bin'; $launcher=Join-Path $bin 'buda.exe' +$tempRoot=Join-Path $guiho '.temp' +if ((Test-Path -LiteralPath $launcher) -and $env:BUDA_UNINSTALL_FALLBACK -ne '1') { + $cliArgs=@('uninstall','--yes') + if ($PreserveConfig) { $cliArgs += '--preserve-config' } + if ($PreserveData) { $cliArgs += '--preserve-data' } + if ($DryRun) { $cliArgs += '--dry-run' } + if ($Wiki) { $cliArgs += @('--wiki',$Wiki) } + & $launcher @cliArgs + exit $LASTEXITCODE +} +if (-not (Test-Path -LiteralPath $launcher) -and $env:BUDA_UNINSTALL_FALLBACK -ne '1') { + $currentPath = Join-Path $cliHome 'current.json' + if (Test-Path -LiteralPath $currentPath) { + try { $active = [string](Get-Content -Raw -LiteralPath $currentPath | ConvertFrom-Json).active } catch { $active = '' } + if ($active -match '^[^\\/]+/buda(?:\.exe)?$') { + $payload = Join-Path (Join-Path $cliHome 'versions') ($active -replace '/', [IO.Path]::DirectorySeparatorChar) + if (Test-Path -LiteralPath $payload -PathType Leaf) { + $cliArgs=@('uninstall','--yes') + if ($PreserveConfig) { $cliArgs += '--preserve-config' } + if ($PreserveData) { $cliArgs += '--preserve-data' } + if ($DryRun) { $cliArgs += '--dry-run' } + if ($Wiki) { $cliArgs += @('--wiki',$Wiki) } + & $payload @cliArgs + exit $LASTEXITCODE + } + } + } +} +if (Test-Path -LiteralPath $cliHome) { + $manifestPath=Join-Path $cliHome 'installed-artifacts.json' + if (-not (Test-Path -LiteralPath $manifestPath)) { throw 'Refusing fallback uninstall without Buda ownership manifest.' } + $manifest=Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.schema -ne 1 -or $manifest.cli -ne 'buda') { throw 'Refusing fallback uninstall for an invalid or foreign manifest.' } + throw 'Refusing fallback uninstall: the installed Buda payload is unavailable; no ownership-safe executor remains.' +} +Write-Host "REMOVE $launcher" +Write-Host "REMOVE $(Join-Path $homeDir '.agents\skills\guiho-s-0002-buda')" +Write-Host "REMOVE $(Join-Path $homeDir '.claude\skills\guiho-s-0002-buda')" +Write-Host "PRESERVE $tempRoot" +if ($PreserveData) { Write-Host "PRESERVE $cliHome" } else { Write-Host "REMOVE $cliHome" } +if ($Wiki) { if ($PreserveConfig) { Write-Host "PRESERVE $(Join-Path $Wiki 'buda.yaml')" } else { Write-Host "REMOVE $(Join-Path $Wiki 'buda.yaml')" }; Write-Host "REMOVE managed block $(Join-Path $Wiki 'AGENTS.md')" } +if ($DryRun) { exit 0 } +if (-not $Yes) { if (-not [Environment]::UserInteractive) { throw '--Yes is required for non-interactive uninstall.' }; $answer=Read-Host 'Remove Buda-owned files? [y/N]'; if ($answer -notin @('y','Y','yes','YES')) { throw 'Uninstall cancelled.' } } +if (Test-Path -LiteralPath $launcher) { Remove-Item -Force -LiteralPath $launcher } +Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $homeDir '.agents\skills\guiho-s-0002-buda\SKILL.md'),(Join-Path $homeDir '.claude\skills\guiho-s-0002-buda\SKILL.md') +Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'versions'),(Join-Path $cliHome 'state'),(Join-Path $cliHome 'current.json'),(Join-Path $cliHome 'installed-artifacts.json'),(Join-Path $cliHome 'cache.json') +if (-not $PreserveData) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'data'),(Join-Path $cliHome 'database') } +if (-not $PreserveConfig) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'buda.global.yaml') } +if (-not $PreserveConfig -and -not $PreserveData) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $cliHome } +if ($Wiki) { + if (-not $PreserveConfig) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki 'buda.yaml') } + foreach ($name in @('AGENTS.md','CLAUDE.md')) { + $instruction=Join-Path $Wiki $name + if (Test-Path -LiteralPath $instruction) { $content=Get-Content -Raw -LiteralPath $instruction; $content=[regex]::Replace($content,'(?s).*?\r?\n?',''); Set-Content -NoNewline -LiteralPath $instruction -Value $content } + } + Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki '.agents\skills\guiho-s-0002-buda\SKILL.md'),(Join-Path $Wiki '.claude\skills\guiho-s-0002-buda\SKILL.md') +} +Write-Host 'Buda uninstall completed synchronously.' diff --git a/devops/uninstall.sh b/devops/uninstall.sh new file mode 100644 index 0000000..e9c26db --- /dev/null +++ b/devops/uninstall.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env sh +set -eu +PRESERVE_CONFIG=0 +PRESERVE_DATA=0 +DRY_RUN=0 +YES=0 +WIKI="" +while [ "$#" -gt 0 ]; do + case "$1" in + --preserve-config) PRESERVE_CONFIG=1; shift ;; + --preserve-data) PRESERVE_DATA=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --yes) YES=1; shift ;; + --wiki) [ "$#" -ge 2 ] || exit 2; WIKI=$2; shift 2 ;; + -h|--help) printf '%s\n' 'Usage: uninstall.sh [--wiki ] [--preserve-config] [--preserve-data] [--dry-run] [--yes]'; exit 0 ;; + *) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; + esac +done +HOME_DIR=${HOME:?HOME is required} +GUIHO_HOME=$HOME_DIR/.guiho +CLI_HOME=$GUIHO_HOME/buda +BIN_DIR=$GUIHO_HOME/bin +TEMP_ROOT=$GUIHO_HOME/.temp + +# The installed Cobra command is the canonical manifest-driven planner. The +# script remains usable for recovery when the launcher is missing or corrupt. +if [ -x "$BIN_DIR/buda" ] && [ "${BUDA_UNINSTALL_FALLBACK:-0}" != "1" ]; then + set -- uninstall --yes + [ "$PRESERVE_CONFIG" -eq 1 ] && set -- "$@" --preserve-config + [ "$PRESERVE_DATA" -eq 1 ] && set -- "$@" --preserve-data + [ "$DRY_RUN" -eq 1 ] && set -- "$@" --dry-run + [ -n "$WIKI" ] && set -- "$@" --wiki "$WIKI" + exec "$BIN_DIR/buda" "$@" +fi + +# If the stable launcher is missing but the active immutable payload remains, +# let that payload execute the same Go ownership planner. This recovery route +# keeps the shell fallback from guessing ownership or recursively removing the +# whole CLI home. +if [ "${BUDA_UNINSTALL_FALLBACK:-0}" != "1" ] && [ ! -x "$BIN_DIR/buda" ] && [ -f "$CLI_HOME/current.json" ]; then + active=$(sed -n 's/.*"active"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CLI_HOME/current.json" | head -n 1) + case "$active" in + ''|/*|../*|*/../*|*\\*) ;; + *) + payload="$CLI_HOME/versions/$active" + if [ -x "$payload" ]; then + set -- uninstall --yes + [ "$PRESERVE_CONFIG" -eq 1 ] && set -- "$@" --preserve-config + [ "$PRESERVE_DATA" -eq 1 ] && set -- "$@" --preserve-data + [ "$DRY_RUN" -eq 1 ] && set -- "$@" --dry-run + [ -n "$WIKI" ] && set -- "$@" --wiki "$WIKI" + exec "$payload" "$@" + fi + ;; + esac +fi + +if [ -e "$CLI_HOME" ]; then + [ -f "$CLI_HOME/installed-artifacts.json" ] || { printf '%s\n' 'refusing fallback uninstall without Buda ownership manifest' >&2; exit 1; } + grep -Eq '"schema"[[:space:]]*:[[:space:]]*1' "$CLI_HOME/installed-artifacts.json" || { printf '%s\n' 'refusing fallback uninstall with an invalid Buda manifest' >&2; exit 1; } + grep -Eq '"cli"[[:space:]]*:[[:space:]]*"buda"' "$CLI_HOME/installed-artifacts.json" || { printf '%s\n' 'refusing fallback uninstall for a foreign manifest' >&2; exit 1; } + printf '%s\n' 'refusing fallback uninstall: the installed Buda payload is unavailable; no ownership-safe executor remains' >&2 + exit 1 +fi + +printf 'REMOVE %s\n' "$BIN_DIR/buda" +printf 'REMOVE %s\n' "$HOME_DIR/.agents/skills/guiho-s-0002-buda" +printf 'REMOVE %s\n' "$HOME_DIR/.claude/skills/guiho-s-0002-buda" +printf 'PRESERVE %s\n' "$TEMP_ROOT" +if [ "$PRESERVE_DATA" -eq 1 ]; then printf 'PRESERVE %s\n' "$CLI_HOME"; else printf 'REMOVE %s\n' "$CLI_HOME"; fi +if [ -n "$WIKI" ]; then + if [ "$PRESERVE_CONFIG" -eq 1 ]; then printf 'PRESERVE %s\n' "$WIKI/buda.yaml"; else printf 'REMOVE %s\n' "$WIKI/buda.yaml"; fi + printf 'REMOVE managed block %s/AGENTS.md\n' "$WIKI" +fi +[ "$DRY_RUN" -eq 1 ] && exit 0 +if [ "$YES" -ne 1 ]; then + [ -t 0 ] || { printf '%s\n' '--yes is required for non-interactive uninstall' >&2; exit 2; } + printf 'Remove Buda-owned files? [y/N] '; read answer; case "$answer" in y|Y|yes|YES) ;; *) exit 2 ;; esac +fi +rm -f "$BIN_DIR/buda" +rm -f "$BIN_DIR/buda" "$HOME_DIR/.agents/skills/guiho-s-0002-buda/SKILL.md" "$HOME_DIR/.claude/skills/guiho-s-0002-buda/SKILL.md" +if [ "$PRESERVE_DATA" -ne 1 ]; then + rm -rf "$CLI_HOME/versions" "$CLI_HOME/state" "$CLI_HOME/data" "$CLI_HOME/database" "$CLI_HOME/current.json" "$CLI_HOME/installed-artifacts.json" "$CLI_HOME/cache.json" +else + rm -rf "$CLI_HOME/versions" "$CLI_HOME/state" "$CLI_HOME/current.json" "$CLI_HOME/installed-artifacts.json" "$CLI_HOME/cache.json" +fi +[ "$PRESERVE_CONFIG" -eq 1 ] || rm -f "$CLI_HOME/buda.global.yaml" +if [ "$PRESERVE_CONFIG" -eq 0 ] && [ "$PRESERVE_DATA" -eq 0 ]; then rmdir "$CLI_HOME" 2>/dev/null || true; fi +if [ -n "$WIKI" ]; then + [ "$PRESERVE_CONFIG" -eq 1 ] || rm -f "$WIKI/buda.yaml" + for instruction in "$WIKI/AGENTS.md" "$WIKI/CLAUDE.md"; do + if [ -f "$instruction" ]; then + temporary=$(mktemp "$TEMP_ROOT/buda-uninstall-XXXXXX") + sed '//,//d' "$instruction" > "$temporary" + cat "$temporary" > "$instruction" + rm -f "$temporary" + fi + done + rm -f "$WIKI/.agents/skills/guiho-s-0002-buda/SKILL.md" "$WIKI/.claude/skills/guiho-s-0002-buda/SKILL.md" +fi +printf '%s\n' 'Buda uninstall completed synchronously.' diff --git a/devops/workflows_test.go b/devops/workflows_test.go index 1ce6e49..adbeb8f 100644 --- a/devops/workflows_test.go +++ b/devops/workflows_test.go @@ -46,12 +46,12 @@ func TestGitHubWorkflowsAreValidYAMLAndReleaseOnlyToGitHub(t *testing.T) { t.Fatal(err) } for _, required := range []string{ - "tags:\n - 'buda/v*'", + "tags: ['buda/v*']", "contents: write", - "Build and verify exact 11 assets", - "Verify exact public asset set", + "Build complete release unit", + "Verify public asset set and checksums", "gh release create", - "buda/v$env:BUDA_VERSION", + "buda/v", } { if !strings.Contains(string(publish), required) { t.Fatalf("publish workflow missing %q", required) @@ -65,9 +65,8 @@ func TestGitHubWorkflowsAreValidYAMLAndReleaseOnlyToGitHub(t *testing.T) { for _, required := range []string{ "go test -count=1 ./...", "go vet ./...", - "@tobilu/qmd@2.5.3", - "qmd-lexical-e2e:", - "--mode lexical", + "Build manifest-derived release unit", + "xdocs doctor . --warnings-as-errors", } { if !strings.Contains(string(ci), required) { t.Fatalf("CI workflow missing %q", required) diff --git a/mirror.yaml b/mirror.yaml index 2247578..478654e 100644 --- a/mirror.yaml +++ b/mirror.yaml @@ -7,11 +7,6 @@ version: source: "git" output: ["git"] prerelease_id: "" -package: - path: "package.json" - auxiliary_paths: [] -jsr: - path: "jsr.json" git: tag_template: "{name}/v{version}" commit: true diff --git a/runx.yaml b/runx.yaml new file mode 100644 index 0000000..d72c9ab --- /dev/null +++ b/runx.yaml @@ -0,0 +1,147 @@ +namespace: buda +version: 2.0.0 + +scripts: + directory: ".scripts" + +commands: + - uid: budaformatcheck + id: formatcheck + summary: Check Go formatting. + command: gofmt -l . + description: Verify Go formatting without changing files. + - uid: budaformat + id: format + summary: Format Go source. + command: gofmt -w . + description: Format all Go source files. + - uid: budatidycheck + id: tidycheck + summary: Check module graph tidiness. + command: go mod tidy -diff + description: Verify the module graph is tidy. + - uid: budatidy + id: tidy + summary: Tidy the Go module graph. + command: go mod tidy + description: Tidy the Go module graph. + - uid: budatest + id: test + summary: Run all Go tests. + command: go test -count=1 ./... + description: Run the complete Go test suite. + - uid: budavet + id: vet + summary: Run Go vet. + command: go vet ./... + description: Run Go vet across every package. + - uid: budabuild + id: build + summary: Build native Go packages. + command: go build ./... + description: Build all native Go packages. + - uid: budabuildrelease + id: buildrelease + summary: Build the release unit. + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --build-date 2026-08-16T00:00:00Z + description: Build the manifest-derived release unit for all targets. + - uid: budaxdocsmeta + id: xdocsmeta + summary: Validate XDocs metadata. + command: xdocs meta . --documents --strict + description: Validate strict XDocs metadata. + - uid: budaxdocstree + id: xdocstree + summary: Render the XDocs tree. + command: xdocs tree + description: Render the complete XDocs directory tree. + - uid: budaxdocsdoctor + id: xdocsdoctor + summary: Check XDocs ownership. + command: xdocs doctor . + description: Check XDocs links and ownership. + - uid: budarunxcheck + id: runxcheck + summary: Validate the RunX catalog. + command: runx check --format json + description: Validate this RunX catalog. + - uid: budarunxlist + id: runxlist + summary: List RunX commands. + command: runx list --format json + description: List the supported RunX command catalog. + - uid: budamirrorcheck + id: mirrorcheck + summary: Validate Mirror configuration. + command: mirror config check + description: Validate Mirror versioning configuration. + - uid: budamirrorcurrent + id: mirrorcurrent + summary: Show the current Mirror version. + command: mirror version current + description: Show the current Mirror version. + - uid: budamirrorplan + id: mirrorplan + summary: Preview the next minor version. + command: mirror version plan minor + description: Preview the next minor version mutations. + - uid: budainstallshsyntax + id: installshsyntax + summary: Check POSIX installer syntax. + command: sh -n devops/install.sh + description: Validate the POSIX installer syntax. + - uid: budauninstallshsyntax + id: uninstallshsyntax + summary: Check POSIX uninstaller syntax. + command: sh -n devops/uninstall.sh + description: Validate the POSIX uninstaller syntax. + - uid: budainstallps1syntax + id: installps1syntax + summary: Check PowerShell installer syntax. + command: powershell.exe -NoProfile -Command "$tokens=$null; $errors=$null; [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'devops/install.ps1'), [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count) { exit 1 }" + description: Validate the PowerShell installer syntax. + - uid: budauninstallps1syntax + id: uninstallps1syntax + summary: Check PowerShell uninstaller syntax. + command: powershell.exe -NoProfile -Command "$tokens=$null; $errors=$null; [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'devops/uninstall.ps1'), [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count) { exit 1 }" + description: Validate the PowerShell uninstaller syntax. + - uid: budacrosslinuxamd64 + id: crosslinuxamd64 + summary: Build Linux AMD64 payload. + command: go build -trimpath -o dist/buda-linux-amd64 . + description: Build the Linux AMD64 payload (foreign build-only when not native). + - uid: budacrosslinuxarm64 + id: crosslinuxarm64 + summary: Build Linux ARM64 payload. + command: go build -trimpath -o dist/buda-linux-arm64 . + description: Build the Linux ARM64 payload (foreign build-only). + - uid: budacrosslinuxarmv7 + id: crosslinuxarmv7 + summary: Build Linux ARMv7 payload. + command: go build -trimpath -o dist/buda-linux-armv7 . + description: Build the Linux ARMv7 payload (foreign build-only). + - uid: budacrosslinuxarmv6 + id: crosslinuxarmv6 + summary: Build Linux ARMv6 payload. + command: go build -trimpath -o dist/buda-linux-armv6 . + description: Build the Linux ARMv6 payload (foreign build-only). + - uid: budacrossdarwinamd64 + id: crossdarwinamd64 + summary: Build Darwin AMD64 payload. + command: go build -trimpath -o dist/buda-darwin-amd64 . + description: Build the Darwin AMD64 payload (foreign build-only). + - uid: budacrossdarwinarm64 + id: crossdarwinarm64 + summary: Build Darwin ARM64 payload. + command: go build -trimpath -o dist/buda-darwin-arm64 . + description: Build the Darwin ARM64 payload (foreign build-only). + - uid: budacrosswindowsamd64 + id: crosswindowsamd64 + summary: Build Windows AMD64 payload. + command: go build -trimpath -o dist/buda-windows-amd64.exe . + description: Build the Windows AMD64 payload (foreign build-only). + - uid: budacrosswindowsarm64 + id: crosswindowsarm64 + summary: Build Windows ARM64 payload. + command: go build -trimpath -o dist/buda-windows-arm64.exe . + description: Build the Windows ARM64 payload (foreign build-only). From d66ac9211f84a0a26073aab93a2aa6872008e516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:14:24 +0200 Subject: [PATCH 03/11] docs: record Convention 0001 audit and release plan --- AGENTS.md | 35 +- CHANGELOG.md | 35 +- README.md | 222 ++-- TODO.md | 97 ++ XDOCS.md | 4 +- buda.xdocs.md | 7 +- docs/ARCHITECTURE.md | 29 +- ...O_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md | 188 ++++ ...HO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md | 962 ++++++++++++++++++ ...CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md | 866 ++++++++++++++++ docs/IMPLEMENTATION.md | 12 +- docs/docs.xdocs.md | 13 +- docs/questions/cli-convention-0001.md | 90 ++ docs/questions/questions.xdocs.md | 17 + docs/todo/cli-convention-0001.md | 90 ++ docs/todo/todo.xdocs.md | 23 + 16 files changed, 2534 insertions(+), 156 deletions(-) create mode 100644 TODO.md create mode 100644 docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md create mode 100644 docs/GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md create mode 100644 docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md create mode 100644 docs/questions/cli-convention-0001.md create mode 100644 docs/questions/questions.xdocs.md create mode 100644 docs/todo/cli-convention-0001.md create mode 100644 docs/todo/todo.xdocs.md diff --git a/AGENTS.md b/AGENTS.md index 048aea6..9f5d39e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,27 @@ implement search, embeddings, ranking, reranking, or a retrieval fallback. - Successful repository commands may schedule only the documented hidden local agent-resource reconciler. It performs no qmd, network, or cross-wiki work and never changes foreground output or exit status. -- Release builds are pure Go with `CGO_ENABLED=0` and the standard eight-binary, - eleven-artifact contract. +- Release builds are pure Go with `CGO_ENABLED=0` and the complete + manifest-derived payload, launcher, resource, schema, example, and checksum + contract defined by GUIHO CLI Convention 0001. ARMv6/ARMv7 cross-builds are + build-only unless executed on native hosts. + +## Convention 0001 authority + +GUIHO CLI Convention 0001 is the current public CLI authority. It supersedes +older exact-eleven-asset and `update` guidance. Buda's confirmed resource +names are CLI home `buda`, main skill `guiho-s-0002-buda`, setup prompt +`guiho-p-buda`, and managed instruction `guiho-i-buda`; no agent definitions +are shipped. The canonical repository is +`https://github.com/CGuiho/buda` and issues are created at +`https://github.com/CGuiho/buda/issues/new`. + +The active implementation plan is +`docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md`; the audit and +acceptance matrix are its evidence sources. Lifecycle code must use the stable +launcher, immutable version directories, `current.json`, `artifacts.json`, +strict checksums, and manifest-driven ownership. Use `upgrade` in every agent +resource command tree; `update` is not a supported alias. ## Documentation @@ -97,3 +116,15 @@ Use `mirror version plan ` and `mirror version apply ` for seman `mirror init` defaults to `v{version}` tags and enables release commits and pushes; explicit interactive or flag selections remain authoritative. + + +## RunX Command Catalog + +Load the `guiho-s-runx` skill whenever discovering commands, creating or +updating catalog entries, validating `runx.yaml`, inspecting command details, +or executing RunX commands. +Start with `runx check --format json` and `runx list --format json`, select +stable UIDs, use `runx describe `, and run +`runx run --dry-run ` before unfamiliar or side-effecting work. +RunX options precede the selector; post-selector tokens belong to the child. + diff --git a/CHANGELOG.md b/CHANGELOG.md index ae01284..c9dd5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ tags: keywords: - Buda 0.1.1 - release notes - - eleven artifacts + - manifest-derived release - GitHub Releases --- @@ -20,6 +20,39 @@ keywords: This document records the scope prepared for Buda releases. An entry does not by itself assert that a Git tag, hosted release, or binary asset was published. +## [0.2.0] - Unreleased + +This entry prepares the next minor Buda release after `buda/v0.1.1`. It adopts +GUIHO CLI Convention 0001 while preserving Buda's explicit-wiki and qmd-only +product boundaries. + +### Added + +- Separate global and project configuration contracts with strict offline JSON + Schemas, complete examples, version-pinned schema comments, and + `agent.evolution` policy inheritance. +- The confirmed `guiho-p-buda` setup prompt, typed `guiho-i-buda` instruction, + evolution guidance in `guiho-s-0002-buda`, stable launcher, immutable + payload layout, token-owned lifecycle journals, and manifest-based ownership. +- `artifacts.json` and complete checksum-derived release packaging for all + payloads, launchers, schemas, examples, and agent resources. +- Synchronous upgrade recovery output and ownership-safe uninstall options for + config/data preservation. + +### Changed + +- Root `--version` now emits only raw SemVer. Help depth accepts `max` or an + integer greater than one, and global flags repeat only with + `--help-tree-global-flags`. +- Agent resource commands use `upgrade`; the prohibited `update` name is gone. +- Install and uninstall scripts use the shared `$HOME/.guiho` layout, explicit + wiki parameters, immutable version directories, complete checksums, and + manifest-derived state. + +This entry is preparation only until Mirror applies the authorized `0.2.0` +version, creates the canonical tag, and the tag workflow publishes the +verified release. + ## [0.1.1] - 2026-08-07 This release makes GitHub Release publication resilient to delayed events for a diff --git a/README.md b/README.md index 4792abb..35fd642 100644 --- a/README.md +++ b/README.md @@ -2,192 +2,134 @@ # GUIHO Buda -GUIHO Buda is a CLI and agent skill collection to manage AI-maintained wiki. Buda help -agents save, navigate, read, validate, and maintain any one explicitly selected -AI-maintained wiki. Bult using public Go and Cobra. It follows the persistent knowledge operating pattern in -[Karpathy's LLM Wiki](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) -and stores portable knowledge according to Google's canonical -[Open Knowledge Format specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md). -Google Cloud's -[official OKF overview](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing) -is useful explanatory context. - -The name honors José António Ernesto, a former high-school philosophy professor -known as Buda. The note explains the name; it does not affect the technical -contract. - -## Boundaries - -Every repository command requires `--wiki `. Buda never guesses a wiki, -stores a default wiki, searches several wikis, moves knowledge between -repositories, or publishes a wiki. Personal knowledge, research, team or -project knowledge, company knowledge, and application documentation are only -examples; Buda does not encode fixed use cases, visibility classes, or corpus -roles. - -[qmd](https://github.com/tobi/qmd) is the external project-local indexing and -retrieval engine. Buda delegates lexical, semantic, and hybrid retrieval, -embeddings, ranking, reranking, document lookup, and index diagnostics to qmd. -Buda owns explicit repository selection, OKF-aware initialization, capture and -ingest orchestration, provenance, citations, health checks, stable output, -agent skills, instructions, and deterministic packaging. +Buda is a repository-agnostic Go/Cobra CLI for maintaining one explicitly +selected AI-maintained wiki in Google's portable Open Knowledge Format. qmd is +the sole external indexing and retrieval engine; Buda does not guess a wiki, +federate repositories, publish knowledge, or implement a retrieval fallback. ## Install -Buda requires [qmd](https://github.com/tobi/qmd) as its external indexing and -retrieval engine. Install the tested qmd 2.5.3 release with Node.js 22 or later, -then confirm that `qmd` is on `PATH`: +Install qmd separately according to its upstream documentation. Buda installers +require an explicit wiki path because no lifecycle action may select a project +implicitly. -```powershell -npm install --global @tobilu/qmd@2.5.3 -qmd --version -``` +For a new wiki directory, also pass the immutable identifier with +`--wiki-id ` (or `-WikiId ` in PowerShell); an existing valid +`buda.yaml` supplies it automatically. -The npm command installs qmd only. Buda itself is distributed exclusively as -checksum-verified binaries in GitHub Releases; it is not published to npm or -another package registry. - -Install the latest Buda release on Linux or macOS: +Linux or macOS (latest stable): ```sh -curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh -s -- --wiki /path/to/wiki ``` -Install the latest release on Windows PowerShell: +Windows PowerShell (latest stable): ```powershell -irm https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1 | iex +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1'))) -Wiki C:\path\to\wiki ``` -For a reproducible install, pass the exact canonical release tag. Buda never -adds or guesses a tag prefix: +Exact version or channel selectors are full-name options and mutually +exclusive: ```sh -curl -fsSL -H 'Accept: application/vnd.github.raw+json' \ - 'https://api.github.com/repos/CGuiho/buda/contents/devops/install.sh?ref=buda%2Fv0.0.2' | - sh -s -- buda/v0.0.2 +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh -s -- --version 0.2.0 --wiki /path/to/wiki +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.sh | sh -s -- --channel canary --wiki /path/to/wiki ``` ```powershell -$installerSource = Invoke-RestMethod ` - -Headers @{ Accept = 'application/vnd.github.raw+json' } ` - -Uri 'https://api.github.com/repos/CGuiho/buda/contents/devops/install.ps1?ref=buda%2Fv0.0.2' -$installBuda = [scriptblock]::Create($installerSource) -& $installBuda -Version 'buda/v0.0.2' +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1'))) -Version 0.2.0 -Wiki C:\path\to\wiki +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/install.ps1'))) -Channel canary -Wiki C:\path\to\wiki ``` -The installers select the native release binary, verify both the binary and -embedded-skill archive against `checksums.txt`, replace an existing binary with -rollback protection, install Buda's canonical skill in both global agent -locations, and verify the installed version. They do not install qmd or write -wiki instructions without an explicit wiki. The default binary destinations -are `~/.local/bin/buda` on Linux and macOS and -`%LOCALAPPDATA%\GUIHO\bin\buda.exe` on Windows. - -Verify the installation and initialize one explicitly selected wiki: +Verify the raw release version and initialize the selected wiki: ```text buda --version -buda init --wiki --wiki-id -buda doctor --wiki +buda init --wiki ``` -### Upgrade from 0.0.1 +The stable launcher is installed at `$HOME/.guiho/bin/buda` (or `buda.exe`), +and immutable payloads and manifest-owned resources live under +`$HOME/.guiho/buda/versions//`. Foreign ARMv6/ARMv7 targets are +cross-build-only unless run on their native platform. -Run the exact `buda/v0.0.2` installer command above. The installer stages and -verifies 0.0.2 before replacing the existing 0.0.1 executable, restores the -previous executable if installation fails, and refreshes both embedded global -skill copies. Canonical wiki content and explicit-wiki instructions are not -rewritten by the installer. - -Once Buda is installed, its native self-management routes use only canonical -`buda/v` GitHub Releases and checksum-verified platform assets: +## Upgrade ```text buda upgrade check buda upgrade list -buda upgrade --dry-run -buda upgrade -buda upgrade --version 0.0.2 -buda upgrade rollback -buda uninstall --dry-run -buda uninstall +buda upgrade --channel stable --wiki +buda upgrade --version 0.2.0 --wiki ``` -`buda upgrade` reports release URLs, download progress, checksum validation, -the installation path, agent-resource reconciliation, and final version -verification. On Unix it replaces the executable on the same filesystem and -automatically rolls back on verification or resource-reconciliation failure. -On Windows a detached helper waits for the running process to exit before -performing the same verified replacement. `upgrade`, `uninstall`, and their -hidden helpers never select a wiki implicitly, run qmd, or trigger Buda's -background resource reconciler. Pass an explicit `--wiki ` only when an -upgrade or uninstall should also reconcile that wiki's bounded instruction -block. `buda uninstall --keep-agent-resources` removes only the executable. +Upgrade is synchronous and verifies the complete manifest, checksums, payload, +launcher, agent resources, schemas, examples, and managed projections before +activating `current.json`. If it fails, the command prints a full reinstall +command pinned to the resolved selector. Read the effective +`agent.evolution` policy before an agent performs an upgrade or creates an +issue. + +## Agent resources -## Commands +The main skill is `guiho-s-0002-buda`; the setup prompt is `guiho-p-buda`; the +managed instruction is `guiho-i-buda`. Inspect them without modifying files: ```text -buda init --wiki --wiki-id -buda capture --wiki ... -buda ingest --wiki --source ... -buda lint --wiki -buda index --wiki [--embed] -buda query --wiki --text [--mode lexical|semantic|hybrid] -buda get --wiki -buda status --wiki -buda pack --wiki --output -buda doctor --wiki -buda agent ... -buda upgrade [check|list|rollback] -buda uninstall +buda agent skill list +buda agent skill show guiho-s-0002-buda +buda agent prompt list +buda agent prompt show guiho-p-buda +buda agent instruction show --wiki ``` -Every command supports `--help`, `--help-tree`, positive -`--help-tree-depth`, and `--help-docs`. Root also supports `--version` and -`-v`. Repository commands provide stable text and one-document JSON output. +Every agent-resource command uses `upgrade`, never the prohibited `update` +name. `buda init --wiki ` reconciles all supported global skill +destinations and the bounded `AGENTS.md` instruction block while preserving +unmanaged bytes. -## Agent resource bootstrap +## Development -A successful bare `buda` invocation may schedule a hidden, non-blocking -reconciler for the embedded Buda skill in both global agent-skill locations. It -has no wiki context and never writes repository instructions. +Repeatable development, lifecycle, documentation, and release commands are +cataloged in `runx.yaml`. The required gates are: -A successful `buda --wiki ` invocation or successful repository command -may schedule the same global reconciliation and, because it carries an -explicitly resolved wiki, reconcile Buda's bounded instruction block in only -that selected wiki. The block identifies the selected wiki and bundle, declares -Buda the required tool for maintaining and retrieving it, requires every -repository operation to pass `--wiki`, and requires agents to evaluate evidence -and cite concept paths plus source IDs or resources. Agents maintain the wiki -through Buda rather than bypassing it or invoking qmd directly. The block also -requires weak evidence to be surfaced and says that repository instructions are -behavior rather than access control. +```text +mirror config check +runx check --format json +xdocs meta . --documents --strict +xdocs tree +xdocs doctor . +gofmt -l . +go mod tidy -diff +go test -count=1 ./... +go vet ./... +``` -Bootstrap performs no qmd or network operation, does not discover repositories, -and never reads or writes another wiki. Worker startup or reconciliation failure -cannot change foreground output or exit status. Explicit `buda agent ...`, help, -version, and hidden-worker routes do not schedule reconciliation. +`devops/build-binaries.go` produces the eight pure-Go payloads, eight stable +launchers, typed resources, schemas, examples, `artifacts.json`, and +`checksums.txt`. Release completeness is derived from the manifest rather than +from a fixed asset count. -## Wiki layout +## Uninstall -An initialized wiki contains strict `buda.yaml`, canonical OKF files under -`knowledge/`, disposable project-local qmd state under `.qmd/`, and disposable -Buda staging/report state under `.buda/`. Canonical Markdown and source evidence -remain readable without Buda or qmd. +Uninstallation removes all Buda-owned installation data by default, including +immutable versions, the stable launcher, caches, manifests, configuration, and +the bounded instruction block for the explicitly selected wiki. It never +removes canonical OKF knowledge, raw evidence, qmd-owned state, shared +`.guiho/` infrastructure, or another CLI's files. -## Development +Preview first, then confirm explicitly in unattended environments: -Buda requires a supported Go toolchain and builds with `CGO_ENABLED=0`. +```sh +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/uninstall.sh | sh -s -- --wiki /path/to/wiki --dry-run +curl -fsSL https://raw.githubusercontent.com/CGuiho/buda/main/devops/uninstall.sh | sh -s -- --wiki /path/to/wiki --yes +``` ```powershell -go mod tidy -go test ./... -go vet ./... -go run . --help-tree +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/uninstall.ps1'))) -Wiki C:\path\to\wiki -DryRun +& ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/CGuiho/buda/main/devops/uninstall.ps1'))) -Wiki C:\path\to\wiki -Yes ``` -qmd is a separate runtime dependency. Buda does not vendor or silently install -it. CI validates the Go CLI and exact eleven-artifact build contract. Canonical -`buda/v*` tags drive GitHub Release publication of those eleven artifacts only; -no Buda package is published to npm or another package manager. +Use `--preserve-config`/`-PreserveConfig` to retain global and selected-project +configuration, and `--preserve-data`/`-PreserveData` to retain persistent Buda +data and databases. `--dry-run`/`-DryRun` never mutates the filesystem. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8ce0d25 --- /dev/null +++ b/TODO.md @@ -0,0 +1,97 @@ +--- +name: buda-todo +purpose: Track approval and execution of the Buda GUIHO CLI Convention 0001 implementation program. +description: Durable repository checklist for the breaking convention migration, implementation units, validation gates, aggregate review, and separately authorized minor release. +created: 2026-08-16 +owner: buda-package +flags: [] +tags: + - todo + - cli + - convention +keywords: + - Buda + - GUIHO CLI Convention 0001 + - implementation units + - acceptance matrix + - minor release +--- + +# Buda TODO + +## Sources of truth + +- [Compliance audit](docs/GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md) +- [Implementation plan](docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md) +- [Acceptance matrix](docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md) +- [Historical 0.0.2 implementation record](docs/IMPLEMENTATION.md) + +The implementation plan owns scope and order. The acceptance matrix owns +closure evidence. This file records progress only; checking an item without its +required exact-head evidence does not complete the unit. + +## Planning + +- [x] Audit Buda against GUIHO CLI Convention 0001. +- [x] Write the dependency-ordered implementation plan. +- [x] Map every audit finding to acceptance evidence. +- [x] Obtain human approval of the implementation plan through the explicit implementation delegation. + +## Required human confirmations + +- [x] Confirm CLI home directory name `buda`. +- [x] Confirm retained main skill name `guiho-s-0002-buda`. +- [x] Confirm main setup prompt ID `guiho-p-buda`. +- [x] Confirm Buda ships no agent definitions. +- [x] Confirm canonical repository and issue URLs. +- [x] Confirm the next minor release target `0.2.0` from `0.1.1`. + +## Implementation units + +- [x] **U00 — Convention authority and naming:** update repository instructions, record all accepted names/URLs/release intent, mark the 0.0.2 plan historical, and record the external shared Go CLI skill amendment follow-up. +- [x] **U01 — Tooling baseline:** Go 1.26, complete RunX catalog, corrected Mirror mapping, one XDocs tree, and mandatory CI tooling gates. +- [x] **U02 — Public Cobra surface:** raw version, current-command help docs, `max | >1` tree depth, global-flag control, and agent `upgrade` commands. +- [x] **U03 — Configuration:** dual strict config, merge semantics, agent evolution, schemas, examples, pinned URLs, and legacy config migration. +- [x] **U04 — Agent artifacts:** evolution guidance, confirmed setup prompt, typed resource families, version parity, and optional confirmed definitions. +- [x] **U05 — Lifecycle primitives:** canonical paths, manifests, ownership, staging, locks, journals, snapshots, and legacy discovery. +- [x] **U06 — Launcher/payload:** stable launcher, strict pointers, fallback, hidden self-test, instance registry, and native Windows removal proof. +- [x] **U07 — Complete release unit:** payload/launcher matrix, resources, schemas/examples, artifacts manifest, checksums, and manifest-derived gates. +- [x] **U08 — Init reconciler:** explicit wiki, interactive policies, AGENTS, configs, canonical agent projections, qmd/domain setup, and final summary. +- [x] **U09 — Installers/migration:** full catalogs and channels, complete transactional install/repair/downgrade, PATH, legacy 0.1.1 migration, and post-install init. +- [x] **U10 — Synchronous upgrade:** recovery-first output, complete manifest transaction, process safety, rollback, verification, and post-upgrade init. +- [x] **U11 — Uninstall:** two scripts plus Cobra parity, full plan/confirmation/preservation, ownership safety, and native Windows completion. +- [x] **U12 — Documentation:** current README/architecture/XDocs/agent guidance, migration instructions, and final Uninstall section. +- [x] **U13 — Aggregate CI/release gates:** workflow/tooling implementation, manifest publication, and exact-head evidence preparation; native lifecycle and migration gates remain review-gated. +- [ ] **U14 — Aggregate review and release preparation:** accepted integration PR, TODO reconciliation, independent validation, and clean Mirror minor plan for 0.2.0. + +## Aggregate acceptance gates + +- [ ] `INV-01` through `INV-06` product invariants pass. +- [ ] `ACC-A1` through `ACC-I5` audit-finding acceptance rows pass. +- [ ] `GATE-01` format and `GATE-02` module checks pass. +- [ ] `GATE-03` full tests and `GATE-04` vet pass. +- [ ] `GATE-05` Mirror config, RunX, and strict XDocs pass. +- [ ] `GATE-06` schemas/examples and `GATE-07` complete release build pass. +- [ ] `GATE-08` native lifecycle and `GATE-09` legacy migration pass. +- [ ] `GATE-10` transaction recovery and `GATE-11` documentation pass. +- [ ] `GATE-12` independent exact-head review/validation is accepted. +- [ ] `GATE-13` Mirror minor plan for 0.2.0 is complete, clean, and reviewed. + +The local implementation pass has already demonstrated GATE-01 through +GATE-07's repository checks, but the aggregate gates remain unchecked until +the exact PR head receives independent review. Native installer/migration and +crash-recovery execution remain review-gated because this Windows checkout was +not allowed to mutate an existing personal installation. + +## Release boundary + +- [ ] Apply the separately authorized Mirror minor plan, + create/push the release commit and annotated canonical tag, and publish the + GitHub Release. +- [ ] After authorization, verify remote ancestry, workflow success, release + publication, complete manifest-derived assets, checksums, and downloaded + native launcher/payload smoke. +- [ ] Confirm explicitly that no production deployment, traffic, DNS, database, + secret, or live-service action occurred. + +No unchecked release-boundary item is implied by implementation approval. diff --git a/XDOCS.md b/XDOCS.md index 2ac548d..b988e63 100644 --- a/XDOCS.md +++ b/XDOCS.md @@ -14,7 +14,9 @@ - [devops/](devops/): Pure-Go cross-build and installation tooling. - [skills/](skills/): Embedded `guiho-s-0002-buda` agent skill family. - [prompts/](prompts/): Embedded Buda instruction and prompt resources. -- [docs/](docs/): Architecture, governing references, and validation records. +- [docs/](docs/): Architecture, governing references, Convention 0001 audit, + implementation plan, acceptance matrix, historical delivery record, and + validation records. All named module descriptors use the `buda-*` subject namespace and model containment rather than runtime dependencies. diff --git a/buda.xdocs.md b/buda.xdocs.md index 7ab5e7a..69c99c7 100644 --- a/buda.xdocs.md +++ b/buda.xdocs.md @@ -9,16 +9,21 @@ children: - buda-skills - buda-prompts - buda-docs + - buda-schemas + - buda-examples files: .gitignore: Git ignore rules for local executables, test output, and release artifacts. go.mod: Go module definition and pinned Cobra and YAML dependencies. go.sum: Reproducible Go dependency checksums. main.go: Thin executable entrypoint with linker-provided build metadata and centralized exit handling. + mirror.yaml: Mirror semantic-version, canonical-tag, changelog, commit, clean-worktree, and push configuration. xdocs.yaml: XDocs YAML configuration using automatic documentation updates and generated-directory exclusions. + runx.yaml: Complete RunX catalog for repeatable development, lifecycle, tooling, and release commands. documents: AGENTS.md: Repository engineering, product-boundary, documentation, validation, and release instructions. - CHANGELOG.md: Chronological Buda release scope, compatibility boundaries, canonical tags, GitHub Release-only policy, and exact distribution artifact contract. + CHANGELOG.md: Chronological Buda release scope, compatibility boundaries, canonical tags, GitHub Release-only policy, and manifest-derived distribution contract. README.md: Public overview of Buda, verified latest and exact installation, installer and native self-upgrade/rollback/uninstall operations, explicit-wiki model, OKF and qmd boundaries, commands, and development workflow. + TODO.md: Convention 0001 implementation-unit, acceptance-gate, human-confirmation, and release-authorization tracking. tags: - go - cli diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index debaf1a..4ba111b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,9 @@ remain contained within that wiki root. ## Ownership -- `internal/config` owns strict `buda.yaml` decoding and semantics. +- `internal/config` owns strict project/global YAML decoding, deterministic + inheritance, policy validation, version-pinned schema references, and offline + examples. - `internal/repository` owns explicit wiki resolution and path containment. - `internal/okf` owns forward-compatible OKF parsing and Buda extension checks. - `internal/source`, `internal/capture`, and `internal/ingest` own durable @@ -40,6 +42,18 @@ remain contained within that wiki root. - `internal/pack` owns deterministic canonical-bundle archives. - `internal/help` renders command-tree and Markdown help from the live Cobra tree. +- `internal/installlayout` owns the native `$HOME/.guiho/buda` layout and + operation staging containment. +- `internal/artifact` owns the release and installed ownership manifests that + drive install, upgrade, repair, and uninstall. +- `internal/launcher` and `cmd/buda-launcher` own stable dispatch through + immutable versioned payloads and one verified previous-version fallback. +- `internal/lifecycle` owns token locks, recoverable journals, atomic files, + and safe removal boundaries. +- `internal/releasecatalog` owns exact-version/channel selection and complete + release rejection before activation. +- `internal/uninstall` owns the shared manifest-based removal plan and + preservation semantics. The `cmd` package assembles one new Cobra tree for every invocation. Command handlers translate stable CLI input/output to focused service APIs. `main.go` @@ -87,7 +101,12 @@ There is no fallback index or search implementation. ## Distribution boundary -Release tooling builds eight pure-Go executables for Linux AMD64/ARM64/ARMv7/ -ARMv6, Darwin AMD64/ARM64, and Windows AMD64/ARM64. The embedded skill ZIP, -instruction Markdown, and checksum manifest complete the eleven-artifact -layout. Building these artifacts is not publishing them. +Release tooling builds eight pure-Go immutable payloads for Linux +AMD64/ARM64/ARMv7/ARMv6, Darwin AMD64/ARM64, and Windows AMD64/ARM64, plus a +platform-matched stable launcher for each target. It publishes the typed main +skill, setup prompt, managed instruction, both schemas, complete examples, +`artifacts.json`, and `checksums.txt` as one manifest-derived release unit. +ARMv6 and ARMv7 are foreign build-only targets unless tested on native hosts. +The launcher never performs network work; upgrade and installer transactions +stage and checksum every declared file before activation. Building these +artifacts is not publishing them. diff --git a/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md b/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md new file mode 100644 index 0000000..a3e722d --- /dev/null +++ b/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md @@ -0,0 +1,188 @@ +--- +name: buda-guiho-cli-convention-0001-acceptance-matrix +purpose: Map every Convention 0001 audit finding to its implementation unit and required acceptance evidence. +description: Traceability matrix for the Buda CLI convention migration, covering all audit findings, product invariants, automated validation, native platform evidence, documentation, and release-readiness gates. +created: 2026-08-16 +owner: buda-docs +flags: [] +tags: + - acceptance + - validation + - traceability + - cli +keywords: + - Buda + - GUIHO CLI Convention 0001 + - audit findings + - acceptance criteria + - implementation units + - release gates +--- + +# Buda Convention 0001 acceptance matrix + +## How to use this matrix + +This document is the closure contract between the +[`compliance audit`](GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md), the +[`implementation plan`](GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md), and +[`TODO.md`](../TODO.md). + +For each row: + +- **Unit** names the implementation unit that owns closure. +- **Implementation evidence** is the state that must exist in source or + documentation. +- **Acceptance evidence** is the minimum test, command, or native validation + required on the exact unit or aggregate head. +- A row is not complete because code was written. It closes only when both + evidence columns are satisfied and reviewed. + +Test names below are stable acceptance IDs. Executors may split one ID across +Go, shell, PowerShell, and workflow tests, but the ID must remain visible in +test names, fixtures, RunX descriptions, or CI step names so the requirement is +traceable. + +## Product-invariant gates + +| Gate | Required invariant | Acceptance evidence | +|---|---|---| +| `INV-01` | Every repository-facing lifecycle/domain action operates on one explicit `--wiki`/script equivalent; no discovery or global wiki registry exists. | Command-tree and lifecycle tests reject missing wiki where project state is required; repository search and review find no ancestor search/registry. | +| `INV-02` | qmd is the sole index/retrieval engine. | Existing qmd adapter tests remain green; dependency review finds no search/embedding/ranking fallback. | +| `INV-03` | Canonical OKF bundle/raw evidence is user-owned and never an uninstall target. | Destructive-default uninstall tests snapshot and byte-compare the full OKF bundle before/after every interface. | +| `INV-04` | Base OKF conformance and Buda health remain distinct. | Existing lint/health fixtures and output assertions remain green. | +| `INV-05` | No lifecycle operation mutates another CLI, shared `.guiho` containers, or the shared PATH entry during uninstall. | Adversarial ownership fixtures contain neighboring CLIs and sentinel files; all remain unchanged. | +| `INV-06` | No production, secret, package-registry, or cross-repository publication behavior is introduced. | Workflow/static review and release dry-runs show GitHub Release source publication only; no secret-bearing files are inspected. | + +## Audit finding traceability + +### A. Technology and mandatory tooling + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `A1` Obsolete creation-time Go version | U01 | `go.mod` and workflows use the approved Go 1.26 patch line. | `ACC-A1`: `go version`, `go env GOTOOLCHAIN`, tidy diff, tests, vet, and all target builds agree on the pinned line. | +| `A2` Missing RunX catalog | U01 | Root `runx.yaml` catalogs every supported repeatable operation with namespace, stable UIDs/IDs, exact commands, and descriptions. | `ACC-A2`: `runx check --format json`, `runx list --format json`, selector uniqueness test, and dry-run of every mutating/high-impact entry. | +| `A3` Mirror not sole version authority | U01, U13 | Dead JS paths are removed; actual version-bearing files are mapped or checked for parity; release gates derive versions from Mirror target metadata. | `ACC-A3`: `mirror config check`, clean `mirror version plan minor`, and a no-write parity test covering binary/resources/schemas/examples/docs/manifests. | +| `A4` Disconnected/stale XDocs ownership | U01, U12 | `.github` joins the single root tree; root files and lifecycle documentation are current. | `ACC-A4`: strict full-repo meta, tree containing `buda-github`, doctor with warnings as errors, and companion-document inventory parity. | + +### B. Cobra version, commands, flags, and help + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `B1` Prefixed version output | U02 | Root version template emits only raw SemVer and bypasses notices. | `ACC-B1`: exact byte tests for `-v`/`--version` on built payload and launcher; stdout is `\n`, stderr empty. | +| `B2` Recursive `--help-docs` | U02 | Renderer emits Markdown only for the invoked command. | `ACC-B2`: root/group/leaf snapshots contain one command page and no descendant page separators. | +| `B3` Wrong help-tree depth grammar | U02 | Depth parser accepts `max` or integer `>1`, default `max`, in space/equals forms. | `ACC-B3`: accepts `max`, `2`, large valid values; rejects empty, `0`, `1`, negative, noninteger, overflow. | +| `B4` Missing global-flag switch | U02 | Presence-only `--help-tree-global-flags` controls descendant repetition. | `ACC-B4`: root and nested snapshots show globals once by default and at every descendant only when present. | +| `B5` Prohibited agent `update` commands | U02 | Skill/instruction trees expose `upgrade`; code/docs/scripts contain no public `update` route. | `ACC-B5`: fresh-tree inventory equals required command set; repository search rejects obsolete invocations; unknown `update` exits with usage error. | + +### C. Configuration and schemas + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `C1` No distinct global/project config | U03 | Typed `buda.global.yaml` and project `buda.yaml` contracts and paths exist. | `ACC-C1`: synthetic-home tests resolve exact native paths and never substitute one filename/schema for the other. | +| `C2` No merge/inheritance | U03 | Global baseline merges with project per-field overrides; project-only fields remain project-only. | `ACC-C2`: table tests cover absent/partial/full overrides, immutable `wiki_id`, and deterministic effective config. | +| `C3` Missing `agent.evolution` | U03 | Exact enum policy exists for upgrade/bugs/improvements/reviews with `always-ask` defaults. | `ACC-C3`: all three values accepted, Boolean/unknown rejected, inheritance/defaults verified, policy action gates tested. | +| `C4` Missing schemas/examples/pinned comments | U03, U07 | Two strict schemas, two complete examples, embedded copies, and exact-release schema comments exist and ship. | `ACC-C4`: examples validate; Go/schema parity; URLs contain the encoded exact canonical tag and never `main`/`latest`; published-set fixture includes all four files. | +| `C5` Partial runtime validator only | U03 | Offline runtime validation covers both decoded layers and the merged effective result. | `ACC-C5`: network-disabled config suite covers unknown fields, multiple docs, paths, enums, schema parity, and semantic merge errors. | + +### D. Mandatory init sequence + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `D1` Missing common init sequence | U08 | Init executes all 16 convention steps around Buda/qmd initialization. | `ACC-D1`: ordered service-call trace and postcondition suite prove common steps precede domain setup and final revalidation follows it. | +| `D2` Wrong interactive setup behavior | U08 | Existing values load first; only missing answers prompt; no-TTY missing values fail before writes. | `ACC-D2`: PTY/no-PTY fixtures, scope labels, skipped choices defaulting to `always-ask`, invalid answer retries, and zero-write failure assertions. | +| `D3` AGENTS not always canonical | U08 | `AGENTS.md` is always ensured and owns the one Buda block; retired Buda CLAUDE projection is safely removed/migrated. | `ACC-D3`: AGENTS-only/CLAUDE-only/both/neither/malformed marker fixtures preserve all unmanaged bytes. | +| `D4` Incomplete final validation/summary | U08 | Aggregate validator and deterministic text/JSON action/path summary exist. | `ACC-D4`: success requires every postcondition; failure never prints success; all result paths are absolute and actions are created/upgraded/verified/unchanged. | + +### E. Agent resources + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `E1` Missing CLI Evolution and Feedback section | U04 | Main skill has exact heading, URLs, categories, policies, upgrade/init/version flow, and issue URL rule. | `ACC-E1`: metadata/content contract test asserts exact heading and all mandatory semantic clauses. | +| `E2` No setup prompt | U04 | Confirmed prompt is distinct from instruction and explains install, verify, init, and upgrade. | `ACC-E2`: prompt catalog/list/show tests, ID prefix validation, and required-content assertions. | +| `E3` Missing definitions/confirmation evidence | U00, U04 | Naming decision record exists; every selected definition is typed and catalogued, or confirmed set is explicitly empty. | `ACC-E3`: release inventory equals confirmed decision record; no unconfirmed/inferred ID appears. | +| `E4` Resource version drift | U04, U07, U13 | Resource metadata and manifest version derive from the one release target. | `ACC-E4`: build rejects any skill/prompt/instruction/definition/schema/example version mismatch. | + +### F. Installation and release unit + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `F1` Missing uninstall scripts | U11 | `devops/uninstall.sh` and `.ps1` exist and share the Go uninstall plan contract. | `ACC-F1`: native script syntax plus behavior/golden-plan parity on Windows and Unix. | +| `F2` Wrong installer selector interface | U09 | Bash `--version/--channel/--wiki`; PowerShell `-Version/-Channel/-Wiki`; wiki is required and selectors are mutually exclusive. | `ACC-F2`: shared selector vectors test exact stable/prerelease/channel/default, missing wiki, and every invalid combination before writes. | +| `F3` Incomplete catalog selection | U09, U10 | Install/upgrade exhaust pagination and reject drafts/malformed/incomplete/incompatible releases. | `ACC-F3`: multi-page fake GitHub catalog proves highest exact channel selection and zero mutation when no compatible release exists. | +| `F4` Obsolete exact-11 release | U07 | Release set is derived from complete manifest and includes payloads, launchers, resources, schemas/examples, manifest, checksums. | `ACC-F4`: build inventory equals manifest-derived expected set; hard-coded count search is empty outside historical docs. | +| `F5` Missing ownership manifests | U05, U07 | Strict release and installed manifests record IDs, versions, paths, checksums, projections, and ownership. | `ACC-F5`: round-trip/schema/adversarial path tests and install/upgrade/uninstall all consume manifests rather than prefixes. | +| `F6` Partial checksum verification | U07, U09 | Every downloaded release artifact is uniquely checksummed; archive members are manifest-verified. | `ACC-F6`: missing, duplicate, malformed, mismatched, extra, and truncated checksum fixtures all fail before mutation. | +| `F7` Wrong staging/bin paths | U05, U09 | Operations stage under validated Buda children of `.guiho/.temp`; stable launcher lives in `.guiho/bin`. | `ACC-F7`: native path tests, strict-descendant cleanup attacks, user-level PATH idempotency, and other-CLI sentinels. | +| `F8` No stable launcher/immutable payload | U06 | Launcher, strict `current.json`, active/previous pointers, immutable versions, and guarded fallback exist. | `ACC-F8`: native forwarding/exit/fallback/pointer security suite and post-upgrade immediate next-invocation version. | +| `F9` Candidate validated after activation/no self-test | U06, U09 | Raw version, format/target, resources, and hidden self-test run in staging before pointer activation. | `ACC-F9`: corrupt/wrong-target/wrong-version/missing-resource candidates never alter installed state. | +| `F10` Incomplete install transaction/repair | U05, U09 | Complete pointer/artifact/projection rollback and same-version repair exist; persistent state is preserved. | `ACC-F10`: phase-by-phase failure injection, repair missing/retired artifacts, config/OKF byte invariance, and old installation usability. | + +### G. Upgrade lifecycle + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `G1` Missing first/final recovery block | U10 | Executable installer command prints after local validation and again for every outcome, exact-pinned after resolution. | `ACC-G1`: text and JSON tests for success/up-to-date/dry-run/rollback/failure/default/version/channel, and no network call before first block. | +| `G2` Binary-only upgrade | U10 | Upgrade selects/applies the complete release manifest and removes retired projections. | `ACC-G2`: old/new manifests with added/changed/retired artifacts result in one consistent installed version. | +| `G3` Scheduled Windows outcome | U06, U10 | Pointer activation is synchronous; `Scheduled` model/helper authority is removed. | `ACC-G3`: native Windows upgrade returns only after launcher reports target; repository/API search contains no successful `scheduled` state. | +| `G4` Unix executing-path replacement | U06, U10 | Running payload remains immutable; only new version dir and pointer change. | `ACC-G4`: native Unix inode/path test shows executing payload untouched and new invocation selects candidate. | +| `G5` Missing lock/journal/process safety | U05, U06, U10 | Token lock, phase journal, interrupted recovery, registry, user/path-verified process handling exist. | `ACC-G5`: concurrency, stale lock, PID reuse, unrelated same-name process, qmd child, crash-at-phase, and unterminable-old-instance tests. | +| `G6` Incomplete verification/rollback | U10 | Launcher version/self-test, complete artifact/projection verification, pointer and resource rollback exist. | `ACC-G6`: injected failures after every mutation restore byte-equivalent old active state and leave journal recoverable/complete. | +| `G7` No post-upgrade init | U08, U10 | Successful agent-managed upgrade runs one explicit-wiki init and raw version verification; init-only failure is distinct. | `ACC-G7`: call-count/order tests, init failure leaves new verified release active, output/JSON reports both statuses. | +| `G8` No upgrade channel | U10 | Upgrade exposes channel selection using the same exact selector semantics as installer. | `ACC-G8`: channel/default/version/mutual-exclusion/full-pagination tests and recovery command preservation. | + +### H. Uninstall lifecycle + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `H1` Wrong uninstall flags | U11 | All interfaces expose preserve-config, preserve-data, dry-run, yes; keep-agent-resources removed. | `ACC-H1`: Cobra/Bash/PowerShell flag inventory and invalid/combined option tests. | +| `H2` No grouped plan/confirmation | U11 | Exact `REMOVE`/`PRESERVE` plan prints before mutation; TTY confirmation or explicit yes required. | `ACC-H2`: dry-run zero-write, interactive accept/decline, noninteractive refusal, and plan parity snapshots. | +| `H3` Incomplete default scope | U11 | All Buda-owned active artifacts/config/data/state/projections are planned; canonical OKF is explicitly excluded. | `ACC-H3`: synthetic full install removed; preserve modes retain exact classes; OKF and shared/qmd-owned sentinels unchanged. | +| `H4` Unsafe/nontransactional ownership | U05, U11 | Manifest proof precedes deletion; rollback/quarantine covers all mutable surfaces; shared parents remain. | `ACC-H4`: corrupt manifest, symlink/reparse, mid-removal failure, neighboring CLI, unmanaged AGENTS content, and restored old install tests. | +| `H5` Asynchronous Windows removal | U06, U11 | Native Windows canonical paths are removed/quarantined within the original operation; cleanup is not success authority. | `ACC-H5`: Windows locked launcher/payload acceptance verifies no active canonical Buda install when command/script returns success. | + +### I. Documentation, instructions, workflows, and tests + +| Finding | Unit | Implementation evidence | Acceptance evidence | +|---|---|---|---| +| `I1` Missing final README uninstall section | U12 | README ends with exact remote uninstall commands, destructive scope, dry-run, and combined preservation example. | `ACC-I1`: heading/order/content test and native command walkthrough. | +| `I2` Stale release documentation | U00, U12 | Historical 0.0.2 plan is labeled historical; current docs are release-neutral or target-current. | `ACC-I2`: link/version scan distinguishes historical references from current instructions; XDocs descriptions match. | +| `I3` CI/publication enforce old behavior | U07, U13 | CI/release gates use manifest-derived assets, raw versions, launcher lifecycle, and complete tooling. | `ACC-I3`: exact-head CI plus downloaded workflow-artifact revalidation; no hard-coded 11/prefixed-version current gate. | +| `I4` Tests assert forbidden behavior | U02-U13 | Obsolete assertions are replaced by Convention 0001 acceptance IDs. | `ACC-I4`: repository search confirms no current test expects depth 1, prefixed version, scheduled success, direct mutable install, or exact 11. | +| `I5` Local/shared instructions conflict | U00, U14 | Buda AGENTS and mandatory shared Go CLI skill align with the convention before release. | `ACC-I5`: instruction review/search and final exact-head agent-context audit produce no contradictory lifecycle command or artifact rule. | + +## Aggregate validation gates + +| Gate | Required command/evidence | Pass condition | +|---|---|---| +| `GATE-01` Formatting | RunX-cataloged gofmt check | No tracked Go file reported. | +| `GATE-02` Modules | RunX-cataloged `go mod tidy -diff` | No `go.mod`/`go.sum` drift. | +| `GATE-03` Go tests | `go test -count=1 ./...` with isolated cache if required | All packages pass. | +| `GATE-04` Vet | `go vet ./...` | All packages pass. | +| `GATE-05` Tooling | Mirror config, RunX check/list, strict XDocs meta/tree/doctor | Every command passes; XDocs has one complete root. | +| `GATE-06` Schemas | Schema generation/parity/examples | Generated files clean; both examples validate offline. | +| `GATE-07` Release build | Manifest-derived payload/launcher/resource build | Complete declared set, no undeclared/missing artifact, all checksums pass. | +| `GATE-08` Native lifecycle | Installer/launcher/upgrade/uninstall suites on supported native runners | Every available native platform passes; foreign targets explicitly build-only. | +| `GATE-09` Legacy migration | Start from official 0.1.1 layout and run new installer | New launcher active; legacy owned files removed; config/OKF preserved. | +| `GATE-10` Transaction recovery | Crash/failure injection at every journal phase | Old or new release is wholly active; no mixed state; next invocation repairs journal. | +| `GATE-11` Documentation | README commands, links, XDocs, help-doc snapshots | Docs match executable behavior and README's final operational heading is Uninstall. | +| `GATE-12` Review | Independent implementation review and validation on exact head | No unresolved actionable finding; evidence commit matches PR head. | +| `GATE-13` Mirror plan | `mirror version current` and clean `mirror version plan minor` | The approved 0.2.0 target and all mutations are complete and expected; apply not run. | +| `GATE-14` Release authorization | Separate explicit user authorization | Only after authorization may Mirror apply/tag/push and GitHub Release verification run. | + +## Evidence retention + +For each unit, retain in the PR or validation report: + +- exact commit SHA; +- RunX selectors and rendered commands; +- test/vet/tooling outcomes; +- native runner/architecture and build-only labels; +- artifact/manifest/checksum inventories; +- filesystem before/after and preservation evidence for lifecycle work; +- review findings and resolutions; and +- explicit confirmation that no production action or unauthorized release + action occurred. + +This matrix is complete only when all audit rows, product invariants, and +aggregate gates are satisfied on the same aggregate commit. diff --git a/docs/GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md b/docs/GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md new file mode 100644 index 0000000..f8dee11 --- /dev/null +++ b/docs/GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md @@ -0,0 +1,962 @@ +--- +name: buda-guiho-cli-convention-0001-compliance-audit +purpose: Evaluate Buda against GUIHO CLI Convention 0001 and record every evidenced compliance gap. +description: Aspect-by-aspect audit of the Buda Go/Cobra CLI, tooling, configuration, agent resources, installation, upgrade, uninstall, documentation, and validation contracts against GUIHO CLI Convention 0001. +created: 2026-08-16 +owner: buda-docs +flags: [] +tags: + - audit + - cli + - convention + - compliance +keywords: + - Buda + - GUIHO CLI Convention 0001 + - Go + - Cobra + - Mirror + - RunX + - XDocs + - noncompliance +--- + +# Buda compliance audit against GUIHO CLI Convention 0001 + +## Verdict + +**Buda does not obey GUIHO CLI Convention 0001.** + +Buda has a strong pre-convention Go/Cobra foundation: it constructs a fresh, +testable command tree; strictly decodes its existing project YAML; centralizes +errors and exit codes; builds the eight expected pure-Go target binaries; uses +deterministic checksums; paginates the GitHub release catalog in the CLI; and +has substantial unit and integration coverage. Those qualities are worth +preserving. + +They are not sufficient for this convention. The repository fails mandatory +contracts in every lifecycle layer added by the convention: RunX, raw version +output, developer-help semantics, dual configuration and schemas, +`agent.evolution`, the required agent command names, the setup prompt, the +common `init` sequence, complete release manifests, canonical installation +paths, the stable launcher and immutable payload layout, synchronous upgrades, +safe uninstallers, and the required uninstall documentation. Several current +tests and workflows actively enforce the superseded behavior. + +This is an architecture-level noncompliance, not a small documentation drift. +A conforming release should not be published until the installation, upgrade, +and uninstall architecture is replaced and convention-level acceptance tests +are green. + +## Audit baseline and method + +| Item | Audited state | +|---|---| +| Convention | `C:\GUIHO\guiho\docs\conventions\guiho-convention-0001-cli.md` at commit `bd7dc4583fbc0722d3f05b78f7c70c7a970865e2` | +| Buda | `C:\GUIHO\buda` at commit and tag `09cba7aa43d09e8dbbcd863d71e38ebcbc835712` / `buda/v0.1.1` | +| Repository state before the report | `main...origin/main`, clean | +| Toolchain observed | `go1.26.5 windows/amd64`; Cobra module `v1.10.2` | +| Evidence | Full convention review; repository instructions; source, tests, workflows, tooling manifests, release tooling, agent resources, README, and XDocs inspection; command-tree and configuration tracing; independent focused reviews | + +The audit treats every **must**, **required**, and **mandatory** statement in the +convention as normative. A feature is not credited merely because a similarly +named feature exists: its behavior, filesystem layout, output, transaction +boundary, and failure semantics must match. Absence searches covered all +tracked source files while respecting repository ignore rules. + +The Go version conclusion is time-based. Buda's foundation commit is dated +2026-07-26, while Go 1.26.5 was released on 2026-07-07. The module nevertheless +declares Go 1.23.0. See the official [Go release history](https://go.dev/doc/devel/release). +Cobra 1.10.2 is the latest listed official release and is correctly selected; +see [Cobra releases](https://github.com/spf13/cobra/releases). + +## Status summary + +| Area | Status | Summary | +|---|---|---| +| Technology stack | **Partial** | Cobra and native Go tooling comply; the creation-time Go version does not. | +| Mandatory project tooling | **Noncompliant** | Mirror config loads, but RunX is absent and XDocs/version ownership is incomplete. | +| Cobra tree and general flags | **Partial** | Fresh tree, long flags, aliases, streams, and exit mapping are sound; required agent command names are wrong. | +| Version and developer help | **Noncompliant** | Version output, help-doc scope, depth grammar, and global-flag rendering all violate the convention. | +| Configuration and schemas | **Noncompliant** | There is one unmerged config, no global config, no evolution policy, and no schemas/examples. | +| `init` | **Noncompliant** | Buda-specific initialization exists, but the mandatory common reconciliation sequence does not. | +| Agent resources | **Noncompliant** | The wiki skill/instruction exist, but evolution guidance, setup prompt, definitions, confirmation evidence, and version parity are absent. | +| Installation and release artifacts | **Noncompliant** | The repository implements the older direct-binary 11-asset model, not the convention's managed installation. | +| Upgrade | **Noncompliant** | Upgrade is partial, in-place, and asynchronous on Windows; required recovery, locking, journaling, activation, and rollback contracts are absent. | +| Uninstall | **Noncompliant** | Both scripts are missing; the CLI removal command lacks scope, preservation, planning, confirmation, and transaction safety. | +| Documentation and tests | **Noncompliant** | README lacks the mandatory final uninstall section; documentation is version-stale; tests/workflows assert obsolete behavior. | + +## Detailed noncompliance findings + +### A. Technology and mandatory project tooling + +#### A1. The project was created against an obsolete Go language version + +- **Convention:** use the latest Go version available when the project is + created. +- **Evidence:** `go.mod:3` declares `go 1.23.0`. The foundation commit + `d810c8aafd2f57ac109a30b07ad3fa17ef9a0765` is dated 2026-07-26. Go 1.26.5 + was available from 2026-07-07, and the audit host uses Go 1.26.5. +- **Gap:** the creation-time language baseline was already three Go feature + releases behind. +- **Required correction:** move the module and CI baseline to the approved + current Go release, then rerun formatting, tidy, tests, vet, native builds, + and all cross-build checks. + +#### A2. The mandatory RunX catalog is absent + +- **Convention:** every repository must have a root `runx.yaml` cataloging + every repeatable command with a namespace, stable `uid`, unique `id`, exact + command, and short description. +- **Evidence:** `runx.yaml` does not exist. A `runx check --format json` probe + cannot validate a catalog because no catalog is present. +- **Gap:** repeatable Go, XDocs, Mirror, installer, build, smoke, and release + operations have no canonical command inventory or stable selectors. +- **Required correction:** add and validate a complete root catalog, including + at least format, tidy, test, vet, builds, XDocs checks, Mirror checks/plans, + installer tests, lifecycle acceptance, and release-contract verification. + +#### A3. Mirror is loadable but is not the sole authority for all versioned surfaces + +- **Convention:** `mirror.yaml` must declare Mirror as the sole project-version + authority, cover every version-bearing file, create release commits/tags, + and generate the changelog. +- **Evidence:** `mirror.yaml:1-22` passes `mirror config check`, uses Git SemVer, + enables commits/pushes, and writes `CHANGELOG.md`. However its only version + output is Git (`mirror.yaml:5-9`), while `mirror.yaml:10-14` retains + `package.json` and `jsr.json` paths that do not exist in this Go repository. + Version-bearing artifacts remain outside + that mapping: `skills/guiho-s-0002-buda/SKILL.md:6,18` and + `prompts/guiho-i-buda.md:6` are still `0.1.0` while the current tag is + `buda/v0.1.1`; README and implementation documentation still prescribe + `0.0.2` (`README.md:67-76,97-111`; `docs/IMPLEMENTATION.md:4-22,47,84-106`). +- **Gap:** Git can advance without synchronizing bundled resources and durable + docs. The current repository proves that this has already happened. +- **Required correction:** make Mirror update or validate every project-version + surface and reject release plans when embedded resource metadata, schema + URLs, examples, README commands, descriptors, or workflow fixtures drift. + +#### A4. The XDocs topology and metadata do not fully describe current ownership + +- **Convention:** `xdocs.yaml` must cover every project-owned directory and + maintain a complete descriptor tree plus generated root index. +- **Evidence:** the root descriptor's children omit `.github` + (`buda.xdocs.md:5-11`), while `.github/github.xdocs.md:4-7` declares + `parent: null`. `XDOCS.md:8-19` manually lists `.github`, producing two + descriptor roots rather than one ownership tree. The root file catalog at + `buda.xdocs.md:12-21` also omits tracked `mirror.yaml`. The docs descriptor still + describes the `0.0.2` delivery (`docs/docs.xdocs.md:3,9,19`), and workflow + metadata still promises exactly eleven assets + (`.github/workflows/workflows.xdocs.md:3-8,24-35`). +- **Gap:** XDocs can parse the files, but the durable topology and descriptions + are incomplete and stale relative to the current repository and convention. +- **Required correction:** attach `.github` to `buda-package`, refresh all + stale version/lifecycle descriptions, include this audit in `buda-docs`, and + require strict meta/tree/doctor validation in the command catalog and CI. + +### B. Cobra command tree, version, flags, and developer help + +#### B1. `--version` and `-v` do not print raw SemVer + +- **Convention:** the top-level version flags must print only the raw SemVer, + such as `1.2.3`. +- **Evidence:** `cmd/root.go:251` sets + `{{.Name}} v{{.Version}}\n`, producing `buda v1.2.3`. That form is asserted + by `cmd/root_test.go:56-58`, both installers + (`devops/install.sh:208-211`; `devops/install.ps1:177-179`), CI + (`.github/workflows/ci.yml:49-53,108-123`), and publication + (`.github/workflows/publish.yml:125-133`). +- **Gap:** the public contract and all lifecycle verification use a format the + convention explicitly forbids. +- **Required correction:** emit raw SemVer and update every parser, assertion, + installer, smoke test, and workflow in the same change. + +#### B2. `--help-docs` emits a descendant documentation set, not only the current command + +- **Convention:** every command must expose `--help-docs` that prints Markdown + documentation for the current command. +- **Evidence:** the flag is correctly persistent at `cmd/root.go:258-263`, but + `internal/help/help.go:93-123` recursively visits all public descendants and + joins separate generated pages. +- **Gap:** `buda --help-docs` and group-command invocations produce multiple + commands' pages rather than the requested command's document. +- **Required correction:** render the invoked command only, keeping output + deterministic and side-effect free. + +#### B3. `--help-tree-depth` has the wrong type, default, and validation + +- **Convention:** the value must be `max` or an integer greater than `1`; the + visible default is `max`. +- **Evidence:** `cmd/root.go:176,262` stores an integer with default `0`, cannot + parse `max`, and `cmd/root.go:186-189` accepts `1`. The test at + `cmd/root_test.go:77-90` treats depth `1` as valid. +- **Gap:** neither the user-facing grammar nor its validation matches the + convention, even though internal depth `0` happens to mean unlimited. +- **Required correction:** introduce a validated `max | integer > 1` value, + expose `max` as the default, and test all accepted and rejected forms. + +#### B4. `--help-tree-global-flags` is missing and inherited flags repeat by default + +- **Convention:** every command must have the presence Boolean + `--help-tree-global-flags`. With it absent, global flags appear once at the + initial command; with it present, they repeat under descendants. +- **Evidence:** the flag list at `cmd/root.go:258-263` has no such option. + `internal/help/help.go:48-81` unconditionally adds both local and inherited + flags for every rendered command. +- **Gap:** the default is the opposite of the required behavior, and there is + no user control. +- **Required correction:** carry initial-command/global-display state through + the renderer and add presence-only tests at root and nested scopes. + +#### B5. The required agent resource commands use the prohibited name `update` + +- **Convention:** commands must be + `agent skill install|uninstall|upgrade|list|show` and + `agent instruction apply|remove|upgrade|show`; `update` must not be used in + either tree. +- **Evidence:** `cmd/agent.go:40-63` creates skill `update`, and + `cmd/agent.go:117-140` creates instruction `update`; neither tree has + `upgrade`. The obsolete form is also prescribed by `cmd/doctor.go:52-65`, + `cmd/upgrade.go:271-275`, and both installers. +- **Gap:** two required commands are absent and two explicitly forbidden + commands are public. +- **Required correction:** replace the command names atomically across the + CLI, docs, agents, installer, upgrade reconciler, tests, and workflows. + +### C. Configuration, schemas, and agent-evolution policy + +#### C1. Buda has no separate global and project configuration contracts + +- **Convention:** use project `buda.yaml` and global + `$HOME/.guiho/buda/buda.global.yaml`, with separate schemas. +- **Evidence:** `internal/config/config.go:15-18` defines only + `FileName = "buda.yaml"`. Its resolver checks project `buda.yaml` and then + `$HOME/.guiho/buda/buda.yaml` (`internal/config/config.go:50-83`). +- **Gap:** the global file has the wrong name and is treated as a fallback copy + of the project shape, not a distinct baseline. +- **Required correction:** define typed global and project models, paths, + loaders, validation, examples, and ownership boundaries. + +#### C2. Configuration is explicitly not merged + +- **Convention:** global configuration supplies the baseline and project + configuration overrides it per field. +- **Evidence:** `internal/config/config.go:20-21` states that configuration is + never merged; `Resolve` returns the first existing file. Repository opening + loads only the selected project config (`internal/repository/repository.go:69-72`). +- **Gap:** inheritance and effective-value calculation do not exist. +- **Required correction:** implement deterministic per-field merge semantics, + retaining strict decoding independently at both layers and validating the + effective result. + +#### C3. The mandatory `agent.evolution` contract is absent + +- **Convention:** four policy values—`self_upgrade`, `automatic_bug_report`, + `automatic_improvement_suggestion`, and `automatic_review`—must accept only + `disabled`, `always-ask`, or `always-proceed`, default globally to + `always-ask`, and support project overrides. +- **Evidence:** `internal/config/config.go:22-34` has only schema, wiki, bundle, + qmd, and derived fields. Repository-wide searches found no evolution policy, + enum values, inheritance, prompting, upgrade-policy execution, issue-policy + execution, or effective-policy reporting. +- **Gap:** all mandatory AI-managed upgrade, bug, improvement, and review + behavior is undefined. +- **Required correction:** add typed enums, strict semantic validation, + defaults, merging, init questions, runtime policy gates, tests, schemas, and + agent guidance together. + +#### C4. Required JSON Schemas, examples, and version-pinned schema comments are absent + +- **Convention:** ship separate `buda.schema.json` and + `buda.global.schema.json`, complete valid examples, public release assets, + embedded offline validation, and version-pinned HTTPS schema URLs at the top + of generated YAML. +- **Evidence:** neither schema nor a global/project example pair exists. + `internal/config/config.go:173-181` uses ordinary YAML marshaling, and + `internal/repository/initialize.go:79-86` writes those bytes without a schema + comment. The release builder has no schema/example assets. +- **Gap:** editor discovery, release-pinned validation, global configuration + validation, and artifact completeness are missing. +- **Required correction:** author strict non-secret schemas and examples, + embed the exact release copies, emit pinned schema comments, and make all + config-creating commands validate against the same offline contract. + +#### C5. Existing strict YAML validation is only a partial substitute + +- **Convention:** runtime must validate locally without network access. +- **Evidence:** the existing project decoder correctly uses + `KnownFields(true)`, rejects multiple documents, and performs semantic path + validation (`internal/config/config.go:99-170`). +- **Gap:** this good typed validator covers only the old project shape. It does + not cover the missing global shape, merge, evolution policy, examples, or + release schema parity. +- **Required correction:** retain the current strictness while expanding it to + the complete dual-config contract and add structural parity tests between Go + types, embedded schemas, and published schemas. + +### D. Mandatory `init` reconciliation + +#### D1. `init` does not execute the mandatory common sequence + +- **Convention:** `init` must reconcile project root, all bundled skills, + `AGENTS.md`, the bounded instruction, global config, project config, + evolution defaults, setup questions, CLI-specific state, final validation, + and absolute-path summary in a fixed sequence. +- **Evidence:** `cmd/init.go:19-87` validates `--wiki-id`, initializes the Buda + wiki/qmd state, installs global skills, and applies instructions. It never + creates or validates `buda.global.yaml`, schemas, schema comments, merged + effective config, evolution policy, canonical installed artifacts, or the + full common final check. +- **Gap:** a Buda-specific initializer is presented as successful without + satisfying most convention-wide prerequisites. +- **Required correction:** make common reconciliation the outer transaction, + with Buda's current repository/qmd initialization as the CLI-specific step. + +#### D2. Existing values and interactive/noninteractive setup semantics are wrong + +- **Convention:** read existing values first, ask only unanswered questions in + an interactive terminal, explain scope and policy choices, recommend + `always-proceed`, and fail clearly if required input is unavailable + noninteractively. +- **Evidence:** `cmd/init.go:20-27` rejects a missing `--wiki-id` immediately and + constructs defaults before reading existing state. There is no terminal + detection, question flow, policy explanation, global/project choice, or + preservation of answered common setup values. Even an initialized wiki + requires the immutable ID to be supplied again. +- **Gap:** interactive users are not asked; noninteractive failure covers only + one Buda field; global and evolution questions do not exist. +- **Required correction:** load both configs first, derive unanswered items, + separate global/project ownership, validate answers before writes, and keep + unattended execution fail-closed. + +#### D3. `init` does not always create the mandatory `AGENTS.md` + +- **Convention:** ensure a project-level `AGENTS.md`, then reconcile the one + bounded instruction block while preserving other content. +- **Evidence:** instruction target resolution creates `AGENTS.md` only when + neither `AGENTS.md` nor `CLAUDE.md` exists + (`internal/agent/instruction.go:174-190`). If only `CLAUDE.md` exists, Buda + updates it and leaves `AGENTS.md` absent. +- **Gap:** a valid existing alternate instruction file suppresses the required + canonical file. +- **Required correction:** always reconcile `AGENTS.md`; treat any additional + projections as explicit, manifest-owned secondary copies. + +#### D4. Final validation and the human summary are incomplete + +- **Convention:** report actions plus absolute paths and never report success + unless every common and CLI-specific check passes. +- **Evidence:** `cmd/init.go:77-86` prints wiki, bundle, qmd directory, + collection, ID, counts, and generic "agent resources: reconciled". It omits + the project/global config paths, schemas, `AGENTS.md`, skill destinations, + effective policies, canonical artifact location, and individual actions. +- **Gap:** output cannot prove the required state, and `init` reports success + despite not checking it. +- **Required correction:** run one aggregate final verification and report all + absolute owned/resulting paths and action states only after it passes. + +### E. Agent skills, instructions, prompts, and definitions + +#### E1. The main skill lacks the exact evolution and feedback section + +- **Convention:** the main skill must contain the exact heading + `## CLI Evolution and Feedback` and explain repository/issue URLs, bug versus + improvement versus review classification, all policy values, upgrade checks, + upgrade and rerun-init flow, version verification, issue creation, and final + issue URL reporting. +- **Evidence:** `skills/guiho-s-0002-buda/SKILL.md:21-59` contains governing + references, explicit-wiki routing, and wiki workflows only. It contains none + of the mandatory evolution text or policy values. +- **Gap:** agents cannot follow the convention's managed feedback or upgrade + lifecycle from the shipped skill. +- **Required correction:** add the exact section with Buda's canonical URLs and + commands after the evolution policies exist. + +#### E2. There is no main install/setup prompt + +- **Convention:** ship at least one user-confirmed main setup prompt explaining + what the CLI is, how to install it, verify it, and upgrade it; additional + prompts use the same confirmed family prefix. +- **Evidence:** the only prompt resource is `prompts/guiho-i-buda.md`, whose + purpose is a bounded repository instruction (`prompts/guiho-i-buda.md:2-18`). + Runtime exposes it as prompt ID `buda` + (`internal/agent/resources.go:16-20,120-140`). It contains no installation, + version verification, or upgrade workflow. +- **Gap:** an instruction template is mislabeled as the prompt catalog's only + prompt, and no setup prompt exists. +- **Required correction:** obtain durable confirmation of the main prompt name, + add the setup prompt (normally `guiho-p-buda`), and keep the instruction as a + separately typed resource. + +#### E3. Agent definitions and confirmation evidence are absent + +- **Convention:** the complete selected release includes all confirmed skills, + prompts, agent definitions, and the one managed instruction; main names are + confirmed with the user rather than inferred. +- **Evidence:** `internal/agent/resources.go:47-58` embeds only a skill and the + instruction/prompt filesystem. No agent-definition family or durable naming + confirmation record exists. +- **Gap:** the artifact inventory cannot demonstrate a complete confirmed agent + family. +- **Required correction:** record confirmations in durable requirements or + decisions, add any selected definitions, and enumerate every resource in the + release and installed manifests. + +#### E4. Bundled agent resource versions lag the current release + +- **Convention:** the installer must source and validate the selected release's + exact versioned resources, and Mirror must govern version-bearing files. +- **Evidence:** current tag `buda/v0.1.1` contains skill and prompt metadata at + `0.1.0` (`skills/guiho-s-0002-buda/SKILL.md:6,18`; + `prompts/guiho-i-buda.md:6`). +- **Gap:** the embedded/released resource version is not equal to the project + release and is not automatically synchronized. +- **Required correction:** make one release version authoritative and verify + binary, manifest, schemas, skills, prompts, definitions, and instruction + metadata against it before publication. + +### F. Installation selection, artifacts, and filesystem architecture + +#### F1. Two of the four mandatory lifecycle scripts are missing + +- **Convention:** root CLI lifecycle requires `devops/install.sh`, + `devops/install.ps1`, `devops/uninstall.sh`, and `devops/uninstall.ps1`. +- **Evidence:** the installers exist; both uninstall scripts are absent. The + devops descriptor inventory confirms only installer/build tooling + (`devops/devops.xdocs.md:6-12`). +- **Gap:** script-based uninstall is impossible and cannot share semantics with + the Cobra command. +- **Required correction:** add both uninstallers around the same manifest, + planning, preservation, confirmation, and rollback contract as the CLI. + +#### F2. Installer selector interfaces do not match the convention + +- **Convention:** Bash accepts `--version ` and + `--channel `; PowerShell accepts `-Version ` and + `-Channel `; the selectors are mutually exclusive; omission selects + the latest stable release. +- **Evidence:** Bash reads one positional token (`devops/install.sh:24`) and + expects a full `buda/v...` tag (`devops/install.sh:50-68`). PowerShell exposes + `-Version` but no `-Channel` (`devops/install.ps1:1-4`) and likewise expects + the canonical tag (`devops/install.ps1:34-49`). +- **Gap:** raw SemVer selection, channels, mutual exclusion, and equivalent + cross-shell behavior do not exist. +- **Required correction:** implement one selection algorithm with shell-native + flag syntax and identical outcomes. + +#### F3. Installers do not inspect the complete paginated release catalog + +- **Convention:** paginate the full public catalog; reject drafts, malformed + tags, missing/malformed manifests and checksums, missing required artifacts, + and ambiguous channel selection; select the highest matching SemVer. +- **Evidence:** both scripts use GitHub's single `releases/latest` endpoint for + default selection (`devops/install.sh:50-59`; + `devops/install.ps1:34-40`). Neither paginates or filters channels. The CLI's + separate catalog does paginate and filter tags + (`internal/selfmanage/catalog.go:129-190`), but `resolveUpgrade` only requires + the current binary and `checksums.txt` (`cmd/upgrade.go:224-257`). +- **Gap:** no install path validates release completeness under the new + convention. +- **Required correction:** share one strict catalog/selection implementation + or equivalent test vectors across Bash, PowerShell, and Cobra. + +#### F4. The release set is the obsolete 11-artifact contract + +- **Convention:** the selected release must include all supported binaries, + launcher artifacts, all skills/prompts/definitions/instruction, schemas, + examples, required operational resources, `artifacts.json`, and checksums. +- **Evidence:** `devops/build-binaries.go:20-24,34-43,84-101` publishes eight + payload binaries, one skill ZIP, `guiho-i-buda.md`, and `checksums.txt`, then + fails unless the total is exactly eleven. There are no launchers, setup + prompt, agent definitions, schemas, examples, or artifact manifest. +- **Gap:** the release cannot install or prove the complete convention-managed + CLI. +- **Required correction:** define the complete inventory first, generate a + deterministic manifest, checksum every declared release file, and derive + workflow expectations from the manifest rather than a fixed count. + +#### F5. `artifacts.json` and the installed manifest are absent + +- **Convention:** `artifacts.json` must declare paths, IDs, versions, checksums, + installed locations, projections, and ownership. The installed manifest must + be stored under `$HOME/.guiho/buda/` and drive reinstall, upgrade, and + uninstall. +- **Evidence:** neither `artifacts.json` nor `installed-artifacts.json` exists. + Agent ownership is inferred from hard-coded IDs and paths + (`internal/agent/skill.go:37-44,91-101`). +- **Gap:** there is no authoritative inventory, retired-artifact detection, + path-ownership proof, or safe uninstall source. +- **Required correction:** introduce versioned release and installed manifests, + strict schema validation, safe relative paths, and projection records. + +#### F6. Checksums validate only an incomplete subset + +- **Convention:** every selected-release artifact must be declared and verified + before activation; malformed, missing, or ambiguous checksum data fails. +- **Evidence:** installers fetch and verify only the platform binary and skill + ZIP (`devops/install.sh:113-137`; `devops/install.ps1:87-103`). They do not + fetch or validate the released instruction, let alone the missing manifest, + launchers, prompts, definitions, schemas, and examples. PowerShell and the Go + checksum reader accept the first matching entry rather than rejecting a + duplicate (`devops/install.ps1:94-101`; + `internal/selfmanage/upgrade.go:99-111`). +- **Gap:** checksum success does not prove release completeness or unambiguous + ownership. +- **Required correction:** validate one strict checksum entry for every + manifest file and reject all undeclared, duplicate, missing, malformed, or + mismatched entries. + +#### F7. Installation uses the wrong staging and shared binary locations + +- **Convention:** stage under + `$HOME/.guiho/.temp/buda-install-/` and install the stable launcher in + `$HOME/.guiho/bin/`. +- **Evidence:** Bash uses system `mktemp -d` and defaults to `~/.local/bin` + (`devops/install.sh:25,73`). PowerShell uses `Path.GetTempPath()` and defaults + to `%LOCALAPPDATA%\GUIHO\bin` + (`devops/install.ps1:1-4,53-58`). Both permit arbitrary install directories. +- **Gap:** shared GUIHO containment, strict-child validation, and cross-platform + layout equivalence are absent. +- **Required correction:** canonicalize and validate GUIHO home paths before any + write or cleanup; make `$HOME/.guiho/bin` the one PATH target. + +#### F8. There is no stable launcher, immutable payload, or activation pointer + +- **Convention:** PATH resolves a stable launcher; versioned payloads live under + `$HOME/.guiho/buda/versions//`; atomic `current.json` records active + and previous relative payloads; the launcher has guarded fallback. +- **Evidence:** installers move the downloaded payload directly to the PATH + command (`devops/install.sh:194-203`; `devops/install.ps1:164-172`). + `main.go:17-29` is the payload entrypoint. There is no launcher source, + `versions/`, `current.json`, pointer protocol, or fallback implementation. +- **Gap:** the core filesystem and process model required for safe upgrades does + not exist. +- **Required correction:** build small platform launchers, define and validate + the pointer protocol, store immutable version directories, and retain the + previous verified payload until a later successful upgrade. + +#### F9. Installation does not validate a complete candidate before activation + +- **Convention:** validate all candidate files, binary format, target, exact raw + version, executable permission, and a hidden installation self-test before + activation; then run the launcher version check, self-test, and `init`. +- **Evidence:** installers replace the executable first and only then run + `--version` (`devops/install.sh:194-211`; + `devops/install.ps1:164-179`). No hidden installation self-test exists. + Neither installer invokes `buda init` as its final lifecycle step. +- **Gap:** an invalid payload can become active before validation, and the + required post-install common reconciliation never occurs. +- **Required correction:** validate inside the staged version directory, + activate atomically only after all checks pass, verify through the launcher, + then run `init` once without rolling back a valid installation if init alone + fails. + +#### F10. Installation is not a complete transaction and cannot safely reinstall + +- **Convention:** installation/reinstallation must replace all versioned + artifacts transactionally, remove retired manifest-owned projections, roll + back every mutated surface, preserve configuration/data/database state, and + support same-version repair. +- **Evidence:** the scripts back up only the binary + (`devops/install.sh:73-89,194-211`; + `devops/install.ps1:54-58,164-179,234-242`). Skill mutation occurs after + binary activation; later failure restores only the executable. Optional + projections are explicitly nonfatal. No manifest classifies persistent or + disposable state. +- **Gap:** resource versions can diverge from the binary, failed installs can + leave partial projections, retired files cannot be identified, and + preservation cannot be proven. +- **Required correction:** snapshot all managed projections and pointer state, + apply the complete selected manifest, validate, and restore every affected + surface on failure while leaving config/data untouched. + +### G. Upgrade lifecycle + +#### G1. The mandatory recovery command is not the first and final visible output + +- **Convention:** after local state validation, every upgrade path must print a + complete reinstall command and preservation guarantee before any risky or + network action, and print it again in the final success/failure block. +- **Evidence:** `cmd/upgrade.go:23-31` resolves a release over the network before + visible output. Already-current, dry-run, success, and failure branches do + not emit the required blocks (`cmd/upgrade.go:32-55,84-111`). The `Recovery` + field is a file-copy description such as restore backup to executable + (`internal/selfmanage/upgrade.go:137-140`), not a runnable remote installer + command. +- **Gap:** users do not receive the required recovery path or preservation + statement when upgrade fails. +- **Required correction:** generate the exact-version installer command from + validated local/release state and route all outcomes through one first/final + recovery renderer, including JSON-equivalent evidence if JSON is supported. + +#### G2. Upgrade installs only one binary, not the selected release + +- **Convention:** upgrade must install the complete release—launcher/payload, + agent resources, schemas, examples, manifests, projections, and metadata. +- **Evidence:** `internal/selfmanage/upgrade.go:184-262` downloads one binary, + rotates an adjacent `.old` backup, and replaces the current executable. + `cmd/upgrade.go:271-284` separately updates one skill and optionally one wiki + instruction. +- **Gap:** upgrade cannot guarantee that every installed resource is from the + same release, cannot remove retired artifacts, and cannot repair missing + resources. +- **Required correction:** make the release manifest—not the executable—the + unit of selection, validation, installation, and rollback. + +#### G3. Windows upgrade returns the explicitly prohibited `scheduled` outcome + +- **Convention:** upgrade must complete synchronously; a detached helper is not + authoritative for success; `scheduled` is not a successful state. +- **Evidence:** Windows copies itself into a helper, starts it detached, and + returns before activation (`internal/selfmanage/replace_windows.go:20-47`). + `cmd/upgrade.go:106-108` prints that the upgrade was scheduled, and + `internal/selfmanage/upgrade.go:49,174` models `Scheduled` as a result. +- **Gap:** the invoking command can finish without knowing whether activation, + validation, reconciliation, or cleanup succeeded. +- **Required correction:** use the stable launcher/current-pointer model so the + command can activate a new immutable payload synchronously without replacing + its running file. + +#### G4. Unix upgrade replaces the executing payload path + +- **Convention:** never replace the currently executing payload in place; + activate an immutable candidate through `current.json`. +- **Evidence:** `internal/selfmanage/replace_unix.go:10-32` renames the current + executable and installs the candidate at that same path. +- **Gap:** Unix follows the old mutable-binary design and has no verified + fallback pointer. +- **Required correction:** install the candidate in a new version directory and + atomically switch only the activation pointer. + +#### G5. Locking, journaling, interrupted recovery, and peer-process handling are absent + +- **Convention:** acquire an exclusive upgrade lock with ownership token, + recover stale/interrupted transactions, journal phases, maintain an instance + registry, verify process ownership/path, and terminate only verified old CLI + instances before activation. +- **Evidence:** there is no lifecycle lock, token, journal, recovery-phase state, + current/previous pointer, or process registry. Windows waits only for the + invoking parent PID (`internal/selfmanage/replace_windows.go:43-47`); Unix has + no peer-instance handling. +- **Gap:** concurrent/interrupted upgrades and other running instances have no + safe, recoverable protocol. +- **Required correction:** implement these as one tested activation state + machine with stale-state and adversarial path/process tests. + +#### G6. Post-activation verification and rollback are incomplete + +- **Convention:** verify launcher `--version`, hidden self-test, CLI home, + complete resources/projections, and current pointer; on failure restore the + old pointer and all projections/config mutations. +- **Evidence:** current verification only checks executable output, and it uses + the wrong prefixed form (`internal/selfmanage/upgrade.go:315-325`). If agent + reconciliation fails, `cmd/upgrade.go:87-96` rolls back only the executable. + Skill or instruction mutations can remain paired with the old binary. There + is no launcher, self-test, pointer, or complete projection validation. +- **Gap:** rollback is not atomic across the actual installed state. +- **Required correction:** snapshot pointer and all manifest-owned projections, + validate the complete new state, and restore every surface on failure. + +#### G7. Upgrade does not rerun the common `init` reconciliation + +- **Convention:** after successful activation and resource replacement, rerun + `init` once to reconcile global/project state; valid installation remains if + init alone fails. +- **Evidence:** `cmd/upgrade.go:271-284` calls agent resource update operations, + not `init`. There is no dual-config/evolution reconciliation to rerun. +- **Gap:** successful upgrades cannot repair or migrate the required common + managed state. +- **Required correction:** implement the complete `init` contract, invoke it + once after activation, and report init failure separately from installation + success. + +#### G8. Upgrade has no channel selector + +- **Convention:** release selection is consistent across managed lifecycle + entry points and supports an exact prerelease channel resolved from the full + paginated catalog. +- **Evidence:** root upgrade exposes only `--version` and `--dry-run` + (`cmd/upgrade.go:114-115`). There is no `--channel`, exact first-prerelease-ID + matching, selector mutual exclusion, or channel-specific result reporting. +- **Gap:** even though the Go catalog can enumerate prereleases, users and + agents cannot request the convention's channel contract during upgrade. +- **Required correction:** share the strict installer selection model with the + upgrade command and test stable, exact-version, channel, malformed, + incomplete, and ambiguous catalogs. + +### H. Uninstall lifecycle + +#### H1. Cobra uninstall exposes the wrong flags + +- **Convention:** CLI and scripts share `--preserve-config`, + `--preserve-data`, `--dry-run`, and `--yes` (with PowerShell-equivalent + names). Replaceable resources are not preservation targets. +- **Evidence:** `cmd/uninstall.go:71-73` exposes only `--dry-run` and + `--keep-agent-resources`. +- **Gap:** all required safety/preservation controls except dry-run are absent, + while the one extra flag preserves content the convention says is + replaceable. +- **Required correction:** adopt the shared flags and remove the incompatible + preservation model. + +#### H2. Uninstall has no complete grouped plan or confirmation gate + +- **Convention:** print exact grouped `REMOVE` and `PRESERVE` targets, request + confirmation unless `--yes`, and fail noninteractively without `--yes`. +- **Evidence:** dry-run output lists only executable, two skill projections, + and optional wiki instructions (`cmd/uninstall.go:24-35`). Normal execution + immediately removes skills (`cmd/uninstall.go:37-52`) and then the executable. + There is no confirmation or terminal check. +- **Gap:** destructive work begins without informed approval and without a + manifest-derived view of its scope. +- **Required correction:** generate the plan from validated installed and + current release manifests, classify every path, and gate all mutations on + confirmation or explicit `--yes`. + +#### H3. Default uninstall scope is incomplete + +- **Convention:** default uninstall removes all CLI-owned files, including + launcher, CLI home, payloads, config, data/databases, caches, metadata, + global skills, instruction blocks, prompts, definitions, schemas, examples, + and project config. Preservation flags retain only documented config/data. +- **Evidence:** `cmd/uninstall.go:19-68` removes only the current executable, + selected global skills, and optional explicit-wiki instructions. It does not + inventory or remove CLI home, configuration, persistent data, databases, + caches, metadata, prompts, definitions, schemas, examples, or all known + project configs. +- **Gap:** the command called full uninstall does not implement the convention's + destructive default or preservation exceptions. +- **Required correction:** derive complete ownership from manifests plus + version-appropriate supplemental discovery, with an explicit persistent-state + inventory and tests. + +#### H4. Uninstall is neither transactional nor ownership-safe + +- **Convention:** remove only proven Buda-owned files, preserve shared + directories/PATH/other CLIs, and use a safe transaction or restore path. +- **Evidence:** skill projections are deleted before executable removal + (`cmd/uninstall.go:37-55`); a later failure does not restore them. Removal is + based on hard-coded locations or `os.Executable()` rather than a validated + installed manifest. Unix deletion removes the supplied executable path + (`internal/selfmanage/replace_unix.go:39-43`). +- **Gap:** partial failure can leave a damaged installation, and canonical + ownership is not proven before deletion. +- **Required correction:** validate every target under an approved ownership + root, stage reversible mutations where possible, preserve shared containers, + and restore prior state if the uninstall transaction fails. + +#### H5. Windows uninstall is asynchronous + +- **Convention:** lifecycle operations must not claim success based on a + detached helper. +- **Evidence:** Windows uses a detached helper for removal + (`internal/selfmanage/replace_windows.go:90-116`) and reports scheduled + removal (`cmd/uninstall.go:63-67`). +- **Gap:** the command cannot confirm that removal completed. +- **Required correction:** coordinate removal through the stable launcher and + a synchronous, verifiable uninstall protocol shared with the PowerShell + script. + +### I. README, workflows, tests, and release gates + +#### I1. README does not end with the mandatory `## Uninstall` section + +- **Convention:** the last README section must be `## Uninstall`, show remote + Bash and PowerShell uninstall commands, state that default uninstall removes + binaries/config/data/databases/agent resources/instructions/project config, + and show dry-run and preservation examples. +- **Evidence:** there is no `## Uninstall`; the final heading is + `## Development` (`README.md:179`). Lines `103-126` show only the Cobra + command and `--keep-agent-resources`, with no remote scripts, destructive + scope, confirmation, or preservation examples. +- **Gap:** the most destructive operation is neither correctly implemented nor + prominently documented. +- **Required correction:** add this only after the shared uninstall contract is + implemented and tested, and keep it as the final section. + +#### I2. README and durable implementation documentation are release-stale + +- **Convention:** version-bearing docs and schemas must be governed by the + project version/release contract. +- **Evidence:** the current tag is `buda/v0.1.1`, while README exact-install and + upgrade examples use `buda/v0.0.2` (`README.md:67-76,97-111`) and + `docs/IMPLEMENTATION.md` still describes the 0.0.2 delivery + (`docs/IMPLEMENTATION.md:4-22,47,84-106`). The docs descriptor repeats 0.0.2. +- **Gap:** public guidance does not describe the current release, and Mirror/XDocs + did not prevent the drift. +- **Required correction:** decide which docs are historical versus current, + label historical records explicitly, make current commands version-derived + or release-neutral, and enforce parity in validation. + +#### I3. CI and publication enforce superseded behavior + +- **Convention:** validation must prove the new launcher, manifest, complete + artifacts, raw version, selector, installation, upgrade, and uninstall + contracts. +- **Evidence:** CI explicitly builds exactly eleven assets and asserts + `buda v0.0.2-ci` (`.github/workflows/ci.yml:39-54`). Publication likewise + requires exactly eleven files and prefixed version output + (`.github/workflows/publish.yml:125-133,181-221`). Adding mandatory launchers, + manifests, schemas, examples, or agent resources would make these gates fail + or cause publication reconciliation to delete them. CI also does not run + Mirror configuration/current checks, RunX checks/listing, strict XDocs + metadata/tree/doctor checks, schema/example parity, launcher tests, installed + manifest tests, or complete uninstall acceptance. +- **Gap:** automation protects the old release model and blocks the new one. +- **Required correction:** derive expected assets from `artifacts.json` and add + native launcher/payload, installer, upgrade, uninstall, checksum, projection, + rollback, and preservation acceptance on supported platforms. + +#### I4. Tests assert behavior the convention forbids + +- **Convention:** the required contract must be covered by automated tests. +- **Evidence:** `cmd/root_test.go:56-58` expects prefixed version output; + `cmd/root_test.go:77-90` allows help depth `1`; + `devops/build-binaries_test.go:32-39` expects exactly eleven artifacts; + `cmd/selfmanage_test.go:122-140` treats same-version upgrade as a no-op; and + `internal/selfmanage/catalog_test.go:134-167` retains scheduled replacement + behavior. There are no tests for channels, manifests, launcher/pointers, + `.guiho/.temp`, hidden self-test, locks, journals, peer-process validation, + complete resource rollback, dual configs, evolution inheritance, + confirmation, preservation, or uninstall scripts. +- **Gap:** a fully green test suite currently proves conformance to the obsolete + design, not to Convention 0001. +- **Required correction:** replace incompatible assertions and build a + traceable requirement-to-test matrix before changing release gates. + +#### I5. Repository-local instructions still mandate the superseded release contract + +- **Convention:** repository instructions, tooling, implementation, and release + gates must agree on the convention-managed complete release unit. +- **Evidence:** `AGENTS.md:54-55` still mandates the standard exact + eleven-artifact release. The convention requires additional launcher, + manifest, agent-resource, schema, example, and operational artifacts, so the + exact old count cannot remain authoritative. The repository instructions also + contain Mirror guidance but no RunX catalog requirement. +- **Gap:** an implementation agent following current repository instructions + faithfully would preserve a release model that Convention 0001 rejects. +- **Required correction:** after the new architecture and artifact inventory + are approved, update the repository instructions and Go CLI skill contract in + one governed decision so local instructions, convention, tooling, and CI are + not contradictory. + +## Compliant foundations to preserve + +The following were reviewed and do obey the relevant convention requirement or +provide a sound base for remediation: + +- Cobra `v1.10.2` is current, and Buda uses one fresh, testable Cobra tree + (`cmd/root.go:172-271`; `cmd/application.go:5-20`). +- Dependencies, streams, time, process, filesystem, and network boundaries are + broadly injected and testable; `main.go` remains thin. +- Public flags are lowercase long names with kebab-case multiword names. No + nonstandard short aliases were found; only command help and root version use + `-h` and `-v`. +- Cobra/pflag supplies both `--name value` and `--name=value` forms. No custom + quote stripping was found. There are currently no list-valued public flags, + so the `StringArray` rule is not applicable yet. +- Standard help is available throughout the command tree and does not perform + repository work. Ordinary output and diagnostics use separate streams. +- JSON output is centralized as one document, and exit mapping is centralized. +- The required root shapes `init`, `agent`, `upgrade`, `upgrade check`, + `upgrade list`, and `uninstall` exist. There is no public Cobra `install` + command, as required. +- Existing project YAML decoding uses `go.yaml.in/yaml/v3`, + `KnownFields(true)`, single-document enforcement, and semantic path + validation (`internal/config/config.go:99-170`). +- Existing repository initialization validates containment, stages writes, + protects immutable wiki identity, and is idempotent for its current narrower + scope (`internal/repository/initialize.go:30-171`). +- Bounded instruction reconciliation preserves unrelated content and + deduplicates its managed block (`internal/agent/instruction.go:242-275`). +- Skill projection replacement is internally staged and rollback-aware for its + current limited destinations (`internal/agent/skill.go:53-82,136-269`). +- The CLI release catalog paginates GitHub releases, rejects drafts/malformed + canonical tags, and sorts SemVer (`internal/selfmanage/catalog.go:129-190`). +- The eight payload targets and architecture tuning are correct and pure Go + with `CGO_ENABLED=0` (`devops/build-binaries.go:34-43,104-114`). Foreign + targets remain build-only unless executed on native runners. +- The current obsolete artifact set is sorted and checksummed deterministically + (`devops/build-binaries.go:94-96,196-227`). +- The repository uses native Go formatting, tidy, test, vet, and build tooling. + The audit's live Go tests passed; that is useful regression evidence even + though it is not convention-compliance evidence. + +## Required remediation sequence + +The gaps are coupled. Fixing them as isolated flags or scripts would create +another partial lifecycle. The dependency-safe order is: + +1. **Durable requirements and ownership:** confirm the main setup prompt and + any agent definitions; define the complete artifact and state inventories; + classify replaceable, persistent, disposable, shared, and project-owned + paths. +2. **Version/config foundation:** update Go; make Mirror authoritative across + all versioned surfaces; implement global/project configs, merge semantics, + evolution policy, schemas, examples, pinned schema URLs, and embedded parity. +3. **Common `init`:** implement the interactive/noninteractive reconciliation, + mandatory `AGENTS.md`, canonical artifact source, policy questions, final + validation, and absolute-path summary. +4. **CLI surface:** switch to raw SemVer, correct developer-help behavior, + rename both agent `update` commands to `upgrade`, and add the required agent + skill evolution guidance and setup prompt. +5. **Release architecture:** add platform launchers, immutable payload layout, + strict `current.json`, `artifacts.json`, installed manifest, complete agent + resources, schemas/examples, checksums, and manifest-derived release gates. +6. **Installers:** rebuild both installers around complete paginated selection, + exact raw SemVer/channels, canonical `.guiho` paths, candidate validation, + hidden self-test, transactional activation, projection rollback, + preservation, PATH idempotency, and final `init`. +7. **Upgrade:** implement recovery-first output, exclusive locking, transaction + journal, stale recovery, peer-instance verification, synchronous pointer + activation, complete resource replacement, full rollback, and post-upgrade + `init`. Remove all scheduled/detached success behavior. +8. **Uninstall:** implement the two missing scripts and rewrite Cobra uninstall + around one manifest-derived grouped plan, confirmation/`--yes`, config/data + preservation, complete default removal, path-ownership validation, and + rollback-safe execution. +9. **Documentation and tests:** update README's final uninstall section, current + installation guidance, XDocs topology/descriptors, and create a + requirement-to-test matrix. Replace every test/workflow assertion that + protects the obsolete design. +10. **Release gate:** run format, tidy, tests, vet, strict XDocs checks, RunX + checks, Mirror config/current/plan, all native lifecycle acceptance, foreign + cross-builds, artifact/checksum verification, and native launcher/payload + smoke before any release. + +## Validation performed for this audit + +| Check | Result | +|---|---| +| `mirror config check` | Passed; this proves the current YAML is valid, not that all version-bearing surfaces are governed. | +| `runx check --format json` | Failed as expected because neither repository nor user scope contains `runx.yaml`; the repository requirement remains unsatisfied. | +| `xdocs meta . --documents --strict --format json` | Passed, including this audit's frontmatter and descriptor registration. | +| `xdocs tree` | Command passed, but its output confirmed the finding: it showed `buda-package` and omitted the disconnected `buda-github` / workflow subtree. | +| `xdocs doctor . --warnings-as-errors --format json` | Passed with zero errors and zero warnings; current doctor does not diagnose the second-root topology. | +| `gofmt -l` over tracked Go files | Passed with no unformatted files. | +| `go mod tidy -diff` | Passed with no module-file drift. | +| `go test -count=1 ./...` | Passed across all packages using an isolated workspace-local Go build cache. | +| `go vet ./...` | Passed across all packages using the same isolated cache. | +| `git diff --check` | Passed for the audit document and descriptor update. | + +The validation result does not change the verdict. It shows that the repository +is internally healthy against its current tests and metadata rules while those +tests and rules still encode an older CLI convention. + +## Final determination + +The answer is **no**: Buda is not compliant with GUIHO CLI Convention 0001 at +the audited commit. Its core Go/Cobra engineering is reusable, but compliance +requires a coordinated redesign of configuration, managed agent resources, +installation layout, release artifacts, upgrade, uninstall, documentation, and +automation. Passing the current suite must not be used as a compliance claim, +because the suite and workflows still codify several behaviors the convention +explicitly rejects. + +## Planning follow-up + +Implementation is decomposed in the +[Convention 0001 implementation plan](GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md). +Every finding in this audit maps to a work unit and exact acceptance evidence +in the [acceptance matrix](GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md), with +repository status tracked in [`TODO.md`](../TODO.md). diff --git a/docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md b/docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..87f442c --- /dev/null +++ b/docs/GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md @@ -0,0 +1,866 @@ +--- +name: buda-guiho-cli-convention-0001-implementation-plan +purpose: Define the complete dependency-ordered implementation program for bringing Buda into compliance with GUIHO CLI Convention 0001. +description: Proposed breaking-change plan covering governance, tooling, Cobra behavior, configuration, agent resources, installation layout, release manifests, launcher and payload architecture, init, upgrade, uninstall, migration, validation, and release preparation. +created: 2026-08-16 +owner: buda-docs +flags: [] +tags: + - implementation + - plan + - cli + - convention +keywords: + - Buda + - GUIHO CLI Convention 0001 + - breaking change + - implementation units + - stable launcher + - artifacts.json + - agent.evolution + - minor release +--- + +# Buda GUIHO CLI Convention 0001 implementation plan + +## Plan status + +**Status: approved and executing.** + +This plan converts the findings in +[`GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md`](GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md) +into independently reviewable, dependency-ordered implementation units. The +trace from every audit finding to its implementation and verification evidence +is recorded in +[`GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md`](GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md). +Repository progress is tracked in [`../TODO.md`](../TODO.md). + +The work is intentionally breaking in behavior and installation layout. The +user has explicitly selected the next minor release target **Buda 0.2.0 from +0.1.1**; Mirror remains the final authority for applying that target. No tag or +GitHub Release is created until implementation, exact-head review, and all +validation gates pass. + +## Governing authority and conflict resolution + +Apply authority in this order for this program: + +1. the user's explicit request to adopt GUIHO CLI Convention 0001, including + acceptance of breaking changes; +2. `C:\GUIHO\guiho\docs\conventions\guiho-convention-0001-cli.md` at + convention commit `bd7dc4583fbc0722d3f05b78f7c70c7a970865e2`; +3. Buda's product contract in `AGENTS.md` and approved GUIHO RFC 0002 at commit + `30698669a2e72f1ded575574b5b8ff7f0b9b5c6e`; +4. current repository architecture and tests where they do not conflict with + the convention; and +5. the older `guiho-s-0035-cli-engineer-go` contract only where it remains + compatible. + +The older CLI skill and Buda instructions currently prescribe `update`, one +mutable executable, and exactly eleven release assets. Convention 0001 is +newer and explicitly replaces those rules with `upgrade`, a stable launcher, +immutable payloads, schemas, examples, manifests, and a complete release unit. +Implementation unit U00 updates repository-local authority and records the +required upstream GUIHO skill amendment. No executor may preserve the old +11-asset count merely to satisfy stale instructions or tests. + +## Product invariants that must not change + +- Buda remains B-U-D-A, repository agnostic and use-case agnostic. +- Every repository-facing action receives one explicit `--wiki ` or the + equivalent full-name lifecycle-script parameter. There is no implicit + current-directory selection, ancestor search, default wiki, or global wiki + registry. +- Buda operates on one selected wiki per invocation and never federates, + publishes, or copies knowledge across repositories. +- Google OKF remains the portable canonical format. Buda-specific health stays + distinct from base OKF conformance. +- qmd remains the sole index and retrieval engine. No search, embedding, + ranking, reranking, or fallback implementation is added. +- Canonical OKF content and raw evidence are user-owned repository data. They + are never installation artifacts and are never deleted by Buda uninstall. +- `main.go` remains thin; Cobra remains the sole public command router; owned + structured input remains strictly decoded and semantically validated. +- Release builds remain pure Go and preserve the eight payload targets, + including Linux ARMv6, ARMv7, and ARM64. A cross-build is never called a + runtime smoke. +- No implementation unit deploys or promotes production state, changes Cloud + Run, DNS, databases, secrets, traffic, or containers. + +## Accepted naming decisions + +The convention-required naming and release decisions were explicitly accepted +by the implementation delegation and are recorded here as durable authority. +No naming decision remains open for implementation or review: + +| Decision | Accepted value | Recorded in | +|---|---|---| +| CLI home directory | `buda` under `$HOME/.guiho/buda/` | U00/U05 and `TODO.md` | +| Main skill | retain `guiho-s-0002-buda` | U00/U04 and the bundled skill | +| Main setup prompt ID | `guiho-p-buda` | U00/U04 and the bundled prompt | +| Agent definitions | none | U00/U04/U07 and the release manifest | +| Canonical GitHub repository | `https://github.com/CGuiho/buda` | U00/U04 and the skill | +| Canonical issue creation URL | `https://github.com/CGuiho/buda/issues/new` | U00/U04 and the skill | +| Release line | next minor `0.2.0` from `0.1.1`, applied only by Mirror after review | U00/U14 and `CHANGELOG.md` | + +The existing product and released skill provide strong evidence for `buda` and +`guiho-s-0002-buda`, but the convention calls for explicit confirmation. The +setup prompt ID has no prior confirmation and is therefore a hard U04 gate. + +## Delivery topology + +Implementation uses a protected integration line so `main` never contains a +half-migrated lifecycle: + +1. After the planning documents are accepted and committed, create + `codex/cli-convention-0001` from the accepted `main` commit. +2. Create one unit branch named + `codex/buda-convention-0001-uNN-` from the current integration head. +3. Open each unit PR against the integration branch. Require exact-head review, + unit validation, and green CI before merge. +4. Rebase the next unit on the new integration head; do not implement multiple + units against stale architecture assumptions. +5. Keep the tag-triggered publication workflow inactive: no tag exists on the + integration branch. +6. After U13 passes, open one aggregate PR from the integration branch to + `main`. Validate the exact aggregate head again. +7. U14 prepares, but does not apply, the Mirror minor plan. Applying the bump, + pushing the release commit/tag, and publishing the GitHub Release require + separate explicit release authorization. + +Every unit owns its source tests, RunX catalog changes, XDocs descriptor +changes, and affected durable documentation. A unit is not mergeable when its +behavior exists only in code or CI and is absent from RunX/XDocs. + +## Target installation and ownership architecture + +### Canonical filesystem layout + +```text +$HOME/.guiho/ +├── bin/ +│ └── buda[.exe] stable launcher +├── .temp/ +│ └── buda--/ one validated operation workspace +└── buda/ + ├── buda.global.yaml persistent global configuration + ├── current.json active and previous relative pointers + ├── installed-artifacts.json installed ownership manifest + ├── cache.json disposable update cache + ├── state/ + │ ├── upgrade.lock ownership-token lock + │ ├── transaction.json recoverable lifecycle journal + │ └── instances/ current-user payload instance records + └── versions/ + └── / + ├── buda[.exe] immutable platform payload + └── artifacts/ + ├── artifacts.json + ├── skills/ + ├── prompts/ + ├── instructions/ + ├── agents/ + ├── schemas/ + ├── examples/ + └── metadata/ +``` + +Only the platform launcher is canonical outside the CLI home. Every projection +to `.agents`, `.claude`, `AGENTS.md`, or another supported agent surface is a +manifest-owned copy whose canonical source remains inside the active version +directory. + +### Ownership and preservation table + +| Path or state | Owner/class | Reinstall/upgrade | Default uninstall | Preservation option | +|---|---|---|---|---| +| `$HOME/.guiho/` | shared GUIHO infrastructure | preserve | preserve | not removable by Buda | +| `$HOME/.guiho/bin/` and shared PATH entry | shared infrastructure | preserve container; repair Buda launcher only | preserve container and PATH; remove Buda launcher only | not removable by Buda | +| Buda stable launcher | Buda replaceable artifact | verify/repair transactionally | remove | none | +| `$HOME/.guiho/buda/versions//` | Buda immutable release artifact | add candidate; retain immediate rollback version; collect older inactive versions | remove | none | +| `current.json` and installed manifest | Buda replaceable state | replace atomically | remove | none | +| `buda.global.yaml` | Buda persistent config | preserve and migrate | remove | `--preserve-config` | +| selected wiki `buda.yaml` | Buda project config | preserve and migrate | remove only from explicit `--wiki` | `--preserve-config` | +| selected wiki canonical OKF bundle and raw evidence | user-owned repository data | never mutate as lifecycle work | **never remove** | always preserved; outside CLI ownership | +| selected wiki `.buda/` | Buda-derived disposable state | may recreate; never treat as canonical knowledge | remove only from explicit `--wiki` when ownership is proven | not preserved by `--preserve-data` because it is disposable | +| selected wiki `.qmd/` | qmd-owned derived state | leave to qmd/Buda domain initialization | preserve unless a separately validated Buda-owned child is recorded | outside Buda installation ownership | +| future Buda databases or user-created application data inside CLI home | Buda persistent data | preserve | remove | `--preserve-data` | +| cache, stale transaction metadata, old operation temp | Buda disposable state | replace/remove safely | remove | none | +| global skill/prompt/definition projections and bounded instruction | Buda manifest-owned projection | complete replacement; remove retired projections | remove | none | + +This table is normative for implementation. No implementation may infer that +all content under a selected wiki is Buda-owned. In particular, uninstall must +not delete the OKF bundle merely because Buda maintains it. + +### Explicit-wiki lifecycle rule + +Convention 0001 refers to the “current project,” but Buda forbids implicit +repository selection. Therefore: + +- `buda init`, agent instruction actions, agent-managed upgrade reconciliation, + and `buda uninstall` project cleanup use explicit `--wiki `; +- Bash lifecycle scripts use full-name `--wiki `; +- PowerShell lifecycle scripts use `-Wiki `; +- project projections/config are never discovered from the process working + directory; and +- no global list of initialized wikis is created. + +Both installers require the explicit wiki parameter and fail before any write +when it is absent. This is an intentional Buda-specific full-name parameter on +top of the convention selectors. It satisfies the convention's project +instruction/config reconciliation without violating Buda's prohibition on +implicit repository selection. The installer runs `buda init --wiki` after the +release is verified and active. + +## Target release unit + +The release builder stops using an exact asset count. It produces a manifest- +derived set containing, at minimum: + +- eight immutable Buda payload binaries with the existing target names; +- eight platform-matched stable launchers, named + `buda-launcher--[.exe]`; +- `guiho-s-0002-buda.zip`; +- the confirmed setup prompt artifact; +- `guiho-i-buda.md`; +- every confirmed agent definition, if any; +- `buda.schema.json` and `buda.global.schema.json`; +- complete `buda.example.yaml` and `buda.global.example.yaml`; +- `artifacts.json`; and +- `checksums.txt` covering every release artifact except itself. + +`artifacts.json` declares release version, target compatibility, artifact ID, +relative release path, canonical installed path, archive members, SHA-256 for +every non-manifest artifact/member, projection destinations, ownership class, +and whether a path is replaceable, persistent, or disposable. The manifest's +own release-file digest is carried by `checksums.txt`, avoiding a self-hash +cycle. Tests derive the expected release set from this manifest. + +## Cross-cutting implementation rules + +- All path constructors live in one injected, platform-native layout package; + tests use synthetic homes and must reject absolute/traversing manifest paths. +- Every lifecycle mutation uses a validated unique child of + `$HOME/.guiho/.temp/`; recursive cleanup first proves strict containment. +- Candidate validation happens before activation and includes checksum, + executable format, target, raw version, embedded-resource read, and hidden + `__self-test`. +- `current.json`, installed manifests, config, journals, and bounded managed + documents use same-filesystem staging, sync where meaningful, and atomic + replacement. +- Upgrade/uninstall process inspection verifies current user and executable + path; a filename alone is never authority to terminate a process. +- The launcher forwards original arguments/streams, waits for the payload, and + returns its exact exit code. It performs no network work. +- The launcher reads only strictly decoded relative pointers under the versions + root and falls back once to the previous verified payload when active launch + fails. +- The CLI does not expose `install`. Agent skill/instruction trees use + `upgrade`, with no hidden or deprecated `update` alias. +- Human output and JSON remain deterministic. Recovery data has text and + structured representations, and raw `--version` bypasses notices. +- Every implementation unit updates its owning XDocs descriptors and RunX + entries. No repeatable acceptance command is CI-only. +- Tests run sequentially on Windows when shared Go caches contend; isolated + workspace caches are acceptable and must be ignored. + +## Expected module and file ownership + +The exact inventory may grow when tests expose a missing boundary, but these +owners are fixed so units do not duplicate lifecycle logic: + +| Owner | Planned files/directories | Responsibility | +|---|---|---| +| U00 | `AGENTS.md`, planning/decision Markdown | Authority, confirmations, historical/current plan routing. | +| U01 | `go.mod`, `go.sum`, `runx.yaml`, `mirror.yaml`, `xdocs.yaml`, `XDOCS.md`, root and GitHub XDocs descriptors, CI tooling steps | Mandatory toolchain and continuous metadata enforcement. | +| U02 | `cmd/root.go`, `cmd/agent.go`, `cmd/doctor.go`, `internal/help/`, focused tests | Public version, flags, help, and command names. | +| U03 | `internal/config/`, `schemas/`, `examples/`, their embeds/descriptors/tests | Dual config, merge, policy, schemas, examples, schema URLs, migration. | +| U04 | `internal/agent/`, `skills/`, `prompts/`, optional `agents/`, resource tests/descriptors | Typed agent artifact family and managed projections. | +| U05 | new `internal/installlayout/`, `internal/artifact/`, and `internal/lifecycle/` packages | Canonical paths, pointers/manifests, ownership, staging, lock, journal, snapshots, rollback. | +| U06 | new `cmd/buda-launcher/`, `internal/launcher/`, and `internal/instance/` packages; hidden payload self-test | Stable dispatch, fallback, instance registration, verified process inspection. | +| U07 | `devops/build-binaries.go`, build tests/fixtures, release-resource assembly | Complete deterministic manifest-derived release unit. | +| U08 | `cmd/init.go`, new `internal/bootstrap/`, repository/agent/config integration tests | Ordered interactive common init plus Buda/qmd initialization. | +| U09 | `devops/install.sh`, `devops/install.ps1`, shared selector/legacy fixtures, installer tests | Fresh/reinstall/downgrade/channel/legacy installation and PATH. | +| U10 | `cmd/upgrade.go`, new `internal/releasecatalog/`, lifecycle/instance integration; retire superseded `internal/selfmanage` replacement code | Complete synchronous upgrade and recovery output. | +| U11 | `cmd/uninstall.go`, `devops/uninstall.sh`, `devops/uninstall.ps1`, new `internal/uninstall/`, parity fixtures | Shared uninstall plan, confirmation, preservation, deletion transaction. | +| U12 | `README.md`, `CHANGELOG.md`, `docs/`, skills/prompts, all affected XDocs descriptors | Public and durable implemented-truth documentation. | +| U13 | `.github/workflows/`, `devops/*_test.go`, cross-platform acceptance fixtures | Exact-head aggregate CI and publication gates. | +| U14 | `TODO.md`, changelog/version surfaces, Mirror plan evidence | Aggregate reconciliation and release preparation only. | + +Each new project-owned directory receives exactly one descriptor in the unit +that creates it, is attached to the XDocs containment tree, and is added to the +release/RunX inventories when applicable. + +## Implementation units + +### U00 — Adopt the convention and seal names + +**Depends on:** accepted planning documents. + +**Goal:** remove governance ambiguity before code changes. + +**Changes:** + +- Record explicit confirmation for CLI home, main skill, setup prompt, agent + definitions, repository URL, issue URL, and breaking release intent. +- Update `AGENTS.md` to replace the exact-11, `update`, mutable-binary guidance + with Convention 0001 authority while preserving Buda product boundaries. +- Mark `docs/IMPLEMENTATION.md` as the historical 0.0.2 delivery record and + make this document the active implementation plan. +- Record the external follow-up required to amend + `guiho-s-0035-cli-engineer-go`; this checkout cannot edit the installed + shared skill package. Buda's `AGENTS.md`, plan, and release handoff make the + newer Convention 0001 precedence explicit, so implementation and review can + proceed without silently preserving the obsolete contract. The shared-skill + amendment remains a separately tracked governance follow-up, not an + unresolved Buda naming decision. + +**Verification:** strict XDocs metadata/tree/doctor, no implementation claims, +and a reviewer confirms all naming/authority decisions are explicit. + +**Exit:** no unresolved naming decision affects file paths, artifact IDs, or +public commands. + +### U01 — Establish Go, Mirror, RunX, XDocs, and CI baseline + +**Depends on:** U00. + +**Goal:** make mandatory project tooling continuously enforceable before the +large implementation begins. + +**Changes:** + +- Move `go.mod` and CI to the approved current Go 1.26 patch line; update module + metadata only through `go mod tidy`. +- Add root `runx.yaml` with a stable `buda` namespace and catalog entries for + format check/fix, tidy check/fix, tests, vet, native build, eight cross-builds, + schemas, XDocs, Mirror, installers, upgrade/uninstall acceptance, artifact + verification, and release preparation. Add new entries in later units before + their commands are considered supported. +- Remove dead `package.json`/`jsr.json` Mirror paths and make Mirror check every + actual version-bearing Buda surface that its schema supports. Add a separate + parity check for version-bearing resources that Mirror cannot rewrite. +- Attach `.github` and `.github/workflows` to the single XDocs root tree; add + missing root tooling files to `buda.xdocs.md`; refresh `XDOCS.md`. +- Add non-mutating CI gates for `mirror config check`, `runx check/list`, strict + XDocs meta/tree/doctor, gofmt, tidy diff, tests, and vet. + +**Verification:** all catalog commands dry-run, mandatory tool checks pass from +the repository root, `xdocs tree` includes `.github`, and no source behavior +changes. + +**Exit:** every current repeatable operation is discoverable and CI rejects +tooling/documentation drift. + +### U02 — Correct the public Cobra surface + +**Depends on:** U01. + +**Goal:** implement the convention's breaking command, version, and developer- +help contract without lifecycle changes. + +**Changes:** + +- Emit raw SemVer only from root `-v`/`--version`; bypass update notices and all + surrounding output. +- Change `--help-docs` to render only the invoked command. +- Implement `--help-tree-depth` as `max | integer > 1`, defaulting visibly to + `max`. +- Add presence-only `--help-tree-global-flags`; show inherited globals once by + default and repeat them only when requested. +- Replace `agent skill update` and `agent instruction update` with `upgrade`. + Do not keep aliases because the convention explicitly prohibits `update`. +- Update doctor guidance, examples, JSON command labels, tests, current scripts, + and workflow assertions in the same unit so no internal caller uses the old + contract. + +**Verification:** exhaustive fresh-tree tests, both space/equals flag forms, +raw version byte equality, tree snapshots at root and nested scopes, rejection +of depth `1`, no forbidden aliases, and stable Markdown output. + +**Exit:** audit findings B1-B5 and their obsolete assertions are closed. + +### U03 — Implement dual configuration, schemas, examples, and evolution policy + +**Depends on:** U01 and confirmed CLI home. + +**Goal:** establish a strict, offline, versioned configuration foundation. + +**Changes:** + +- Split `internal/config` into strict global, project, merge, schema-reference, + and migration responsibilities while retaining `KnownFields(true)` and + semantic path validation. +- Add `buda.global.yaml` under the CLI home and keep `buda.yaml` in the explicit + wiki root. +- Define global baseline fields and project override fields. `wiki_id` remains + project-only and immutable; applicable qmd/default and agent policy fields + merge per field. +- Add typed `agent.evolution` enums and defaults for upgrade, bugs, + improvements, and reviews. Reject unknown values and Booleans. +- Add embedded and released `buda.schema.json`, + `buda.global.schema.json`, and complete examples. +- Generate version-pinned GitHub Release schema comments from linker/release + metadata, never from `main` or `latest`. +- Define a one-time read-only migration from legacy + `$HOME/.guiho/buda/buda.yaml`: validate before mapping, never treat its + `wiki_id` as global, preserve the legacy file until the new config commit + succeeds, and report every migrated field. + +**Verification:** Go type/schema structural parity, strict unknown-field and +multi-document tests, enum/inheritance/default tests, exact schema URL tests, +valid examples, offline validation, migration fixtures, and no secret fields. + +**Exit:** both configs can be resolved, merged, validated, written atomically, +and round-tripped without network access or value loss. + +### U04 — Complete the agent artifact family + +**Depends on:** U00 naming confirmation, U02 command names, and U03 policy +contract. + +**Goal:** provide confirmed, separately typed, versioned agent resources with +managed lifecycle guidance. + +**Changes:** + +- Retain the confirmed main skill and add the exact + `## CLI Evolution and Feedback` section, canonical URLs, Buda-specific bug / + improvement / review guidance, all policy semantics, upgrade-check flow, + post-upgrade init/version checks, and issue URL reporting. +- Add the confirmed setup prompt, explaining Buda, remote installation with an + explicit wiki, raw version verification, init, and upgrade. +- Keep `guiho-i-buda.md` typed only as the managed instruction; stop exposing it + as prompt ID `buda`. +- Add confirmed agent definitions if selected; otherwise explicitly declare an + empty definitions set in the manifest contract. +- Refactor `internal/agent/resources.go` into typed skill, prompt, instruction, + and definition catalogs. IDs and versions come from validated metadata. +- Make `list/show/install/uninstall/upgrade` operate on the complete typed set + and canonical installed sources rather than guessed embedded paths. + +**Verification:** embedding inventory, metadata/version parity, prompt raw +output, exact heading/URL/policy assertions, ID prefix rules, instruction marker +tests, atomic projection replacement, retired projection removal, and no +unmanaged content changes. + +**Exit:** every selected agent artifact has one canonical ID, release source, +installed path, projection set, and independent CLI representation. + +### U05 — Implement layout, ownership manifests, and transaction primitives + +**Depends on:** U00 home confirmation, U03 config ownership, and U04 artifact +inventory. + +**Goal:** create the common safe foundation used by installer, upgrade, +uninstall, and launcher work. + +**Changes:** + +- Add focused internal packages for native path layout, strict artifact + manifests, installed manifests, ownership classification, staging, + same-filesystem atomic files, locks, transaction journals, and rollback + snapshots. +- Strictly validate manifest versions, IDs, unique paths, platform selectors, + checksums, canonical paths, projection paths, ownership classes, and absence + of absolute/traversing/shared-parent targets. +- Implement unique operation directories under `.guiho/.temp` with strict-child + proof before cleanup. +- Implement token-owned locks and stale-owner recovery only after verified + process death. +- Define journal phases: planned, staged, candidate-verified, + projections-snapshotted, artifacts-replaced, activated, verified, + rolling-back, rolled-back, complete. +- Add legacy synthetic-manifest discovery for the two old binary locations, + old skill projections, and Buda-managed instruction markers. Discovery is + allowlisted and read-only until a transaction commits. + +**Verification:** adversarial paths, symlinks/reparse points, duplicate IDs and +checksums, interrupted phases, lock races, rollback snapshots, strict cleanup +containment, shared-directory protection, and legacy/non-Buda discrimination. + +**Exit:** later units can mutate only through manifest-backed, rollback-capable +services; no lifecycle code directly deletes an unvalidated path. + +### U06 — Build the stable launcher, payload self-test, and instance registry + +**Depends on:** U02 and U05. + +**Goal:** establish synchronous immutable activation and exact exit forwarding. + +**Changes:** + +- Add a small launcher entrypoint and internal launcher protocol. It resolves + native home, strictly reads `current.json`, validates the active relative + path, starts the payload with original args/streams/environment, waits, and + returns the exact payload exit code. +- Implement one guarded fallback to the immediately previous verified payload + when active payload resolution/start fails; never fall back after the payload + starts and returns an application error. +- Add hidden payload `__self-test` covering version, build target, embedded + manifest/resources, writable CLI-home diagnostics through an injected test + root, and zero mutation of the real installation. +- Register each payload invocation under CLI-owned instance state with PID, + ownership token, executable path, start identity/time, and cleanup on exit. +- Add platform adapters that verify process existence, current-user ownership, + and executable path before any termination request. qmd and arbitrary child + processes are never registered as Buda payloads. +- Prototype and prove the Windows locked-file behavior needed for canonical + launcher removal/quarantine. U11 may not begin until the proof has a native + Windows test and an agreed synchronous/unlocked cleanup boundary. + +**Verification:** launcher/payload integration on native Windows and Unix, +argument/stdin/stdout/stderr fidelity, exit codes including 130, pointer +traversal rejection, active-missing fallback, no fallback on application error, +instance race/stale record tests, and no foreground network work. + +**Exit:** the launcher protocol is backward-compatible, versioned, documented, +and suitable for installer repair and upgrade pointer activation. + +### U07 — Replace release building with the complete manifest-derived unit + +**Depends on:** U03-U06. + +**Goal:** build a deterministic self-contained release that can drive every +lifecycle operation. + +**Changes:** + +- Refactor `devops/build-binaries.go` into data-driven payload and launcher + matrices while preserving the eight CGO-disabled target tunings. +- Package every confirmed resource, both schemas/examples, instruction, + manifest, metadata, launchers, and payloads from the same source commit and + version. +- Generate `artifacts.json` deterministically, then generate sorted + `checksums.txt` including the manifest and every published artifact except + checksums itself. +- Validate archive members, normalized timestamps, file modes, exact metadata, + and no undeclared files. +- Remove exact-11 assertions from Go tests, CI, release workflow, XDocs, README, + and local instructions; tests compute completeness from the manifest. + +**Verification:** reproducible second build comparison where supported, +manifest/schema validation, all checksums, archive-member checksums, eight +payloads and eight launchers, foreign build-only labels, and native payload / +launcher smoke on available runners. + +**Exit:** one selected release contains every file needed for fresh install, +repair, upgrade, rollback, init, and uninstall ownership. + +### U08 — Rebuild `init` as the common interactive reconciler + +**Depends on:** U03-U07. + +**Goal:** implement the fixed convention sequence while preserving Buda's +explicit-wiki/qmd domain initialization. + +**Changes:** + +- Inject terminal detection, question/answer I/O, filesystem, and policy + decision services; keep Cobra handlers thin. +- Require explicit `--wiki`, resolve/validate it, and never search ancestors. +- Reconcile canonical installed skill sources to all supported global + projections, ensure `AGENTS.md`, and replace only the Buda managed block. +- Remove/migrate retired Buda blocks previously projected to `CLAUDE.md` + according to the installed/legacy manifest while preserving all other bytes. +- Load/migrate/create global and project config; ask only unanswered values; + explain three policy choices; recommend and offer `always-proceed`; keep + skips at `always-ask`; distinguish global/project ownership in every prompt. +- Validate all answers before atomic writes; preserve existing valid values + unless the user explicitly confirms replacement. +- Run existing Buda/qmd repository initialization only after common state is + valid, then revalidate the whole state. +- Print deterministic text/JSON action lists and absolute paths. With no TTY + and missing answers, fail before writes. + +**Verification:** PTY and non-PTY tests, first-run/all-current/partial/migration +cases, each policy branch, invalid answers, write failure rollback, exact +outside-marker preservation, idempotent rerun, qmd failure, and final summary +path/action assertions. + +**Exit:** `init` cannot report success unless every common and Buda-specific +postcondition passes. + +### U09 — Rewrite Bash and PowerShell installers, including legacy migration + +**Depends on:** U07 and U08. + +**Goal:** implement equivalent fresh install, exact/channel install, repair, +downgrade, and legacy migration on Unix/macOS and Windows. + +**Changes:** + +- Implement full-name exact version/channel selectors, mutual exclusion, + explicit wiki, complete GitHub pagination, exact first-prerelease-ID channel + matching, target selection, and incomplete-release rejection. +- Download the complete selected manifest into a unique `.guiho/.temp` + operation; reject missing/duplicate/malformed checksums; validate payload + version and hidden self-test before installation. +- Install immutable candidate payload/resources, install/repair the compatible + launcher, snapshot/replace all managed projections, atomically write pointer + and installed manifest, verify through launcher, and roll back everything on + failure. +- Add the shared bin directory to user PATH idempotently. PowerShell updates the + Windows user environment. POSIX manages one documented bounded entry in the + appropriate user startup file and prints the precise reload requirement. +- Re-running the same version repairs missing artifacts and removes retired + manifest-owned projections instead of short-circuiting. +- Migrate a verified legacy 0.1.x direct-binary installation only after the new + launcher passes. Remove an old binary only from the exact historical Buda + path and only when its identity/version is verified. Preserve both configs, + OKF data, and all non-Buda files. +- With explicit wiki, run `buda init --wiki` after installation; distinguish an + init-only failure from a valid installed release and print recovery details. + +**Verification:** shared selector fixtures in Go/Bash/PowerShell; local fake +release server; multi-page catalogs; stable/canary/alpha/beta/nightly/custom +channels; unsupported targets; checksum attacks; same-version repair; +upgrade/downgrade; rollback at every phase; PATH idempotency; legacy 0.1.1 +migration; native Windows and Linux/macOS acceptance. + +**Exit:** both installers produce byte-for-byte equivalent declared installed +state for the same release and platform class. + +### U10 — Rebuild upgrade as a synchronous manifest transaction + +**Depends on:** U06-U09. + +**Goal:** make `buda upgrade` a complete, recovery-first, synchronous release +activation. + +**Changes:** + +- Add exact `--version` and `--channel` selectors with mutual exclusion and + default latest stable; extend list/check output with channel, compatible + artifact, current/latest markers, and complete pagination evidence. +- After local validation and before network work, print the platform installer + recovery command preserving the requested selector. Route all terminal + outcomes through a final block pinned to the resolved exact version. +- Acquire the token-owned lock, repair interrupted journals, download/verify the + complete manifest, run candidate version/self-test, and install a new + immutable version. +- Verify/terminate only other current-user Buda payload processes whose + registered and OS-observed executable path equals the old payload. Never + terminate the invoking payload or child qmd commands. +- Snapshot all replaceable artifacts/projections, remove old manifest-owned + projections, install the complete new set, atomically activate + `current.json`, verify through launcher and post-activation self-test, then + commit installed manifest. +- On any failure restore pointer, artifacts, projections, and journaled state + before returning. Retain only the immediate previous verified payload for + fallback; defer only garbage collection of locked inactive files and never + report `scheduled`. +- After success, run one explicit-wiki `init` reconciliation and raw version + verification; report init failure separately without rolling back a verified + installation. + +**Verification:** first/final recovery blocks for success/up-to-date/dry-run / +failure; full catalogs; locks; crash injection at every journal phase; process +identity attacks; complete artifact removal/replacement; pointer rollback; +fallback; post-init failure; native Windows and Unix synchronous acceptance. + +**Exit:** the original invocation observes and verifies the new active release; +no detached process is success authority. + +### U11 — Implement manifest-based uninstall across Cobra and both scripts + +**Depends on:** U05, U06 Windows removal proof, U08, and U09. + +**Goal:** deliver one ownership-safe destructive-default contract without +touching canonical wiki knowledge or shared GUIHO infrastructure. + +**Changes:** + +- Build a shared Go planning engine that consumes strict installed/release + manifests plus the explicit wiki and emits ordered `REMOVE`/`PRESERVE` + records with ownership proof. +- Implement `--preserve-config`, `--preserve-data`, `--dry-run`, and `--yes`; + remove `--keep-agent-resources`. +- Require confirmation on an interactive terminal without `--yes`; fail before + mutation on noninteractive input without `--yes`. +- Remove all versions, Buda launcher, manifests, state/cache, projections, + bounded instruction, and explicit wiki project config by default. Preserve + shared directories/PATH, other CLIs, all unmanaged `AGENTS.md` bytes, qmd- + owned state, and canonical OKF/wiki data. +- Preserve global/project config only with `--preserve-config`; preserve future + declared CLI-home databases/data only with `--preserve-data`. +- Implement equivalent Bash/PowerShell script flags and explicit wiki + parameters. Test their plans against the Go engine's golden fixtures. +- On Windows, remove canonical paths synchronously using the U06-proven + quarantine/cleanup boundary. Do not report success while the canonical Buda + launcher or CLI home remains active. Any unavoidable locked inactive garbage + is clearly classified as cleanup, never as pending uninstall authority. + +**Verification:** exact plan snapshots; interactive/noninteractive behavior; +all preservation combinations; partial/corrupt manifests; symlink/reparse path +attacks; other-CLI/shared-path protection; OKF data invariance; rollback/failure +injection; native scripts and Cobra parity; Windows locked-image acceptance. + +**Exit:** all three interfaces implement the same verified plan and leave no +Buda-owned active installation after success. + +### U12 — Rewrite public and durable documentation for the new lifecycle + +**Depends on:** U02-U11 behavior frozen. + +**Goal:** make public instructions exact, current, and safe. + +**Changes:** + +- Rewrite README installation for PowerShell and Linux/macOS with exact remote + commands, explicit wiki, raw version verification, selectors, PATH reload, + legacy 0.1.x migration, upgrade, recovery, and layout. +- Make `## Uninstall` the final operational section. Include remote scripts, + destructive default, dry run, confirmation, and combined config/data + preservation examples; explicitly state that canonical OKF content is never + an uninstall target. +- Update `ARCHITECTURE.md` to implemented launcher/payload/config/manifest / + transaction boundaries and preserve the qmd/OKF product rules. +- Keep `IMPLEMENTATION.md` historical and link forward to this plan and the + acceptance matrix. +- Refresh every affected XDocs descriptor, root index, changelog unreleased + section, agent guidance, and workflow descriptions. Remove current-facing + 0.0.2/11-asset claims while keeping clearly historical evidence. + +**Verification:** every README command exists in RunX or is a documented remote +entry point; URL/version checks; strict XDocs; link checks; help/docs parity; +fresh-user and migration walkthrough reviews. + +**Exit:** a new user and a 0.1.1 user can follow the docs without unstated +commands or unsafe assumptions. + +### U13 — Install the complete CI and release acceptance gate + +**Depends on:** U01-U12. + +**Goal:** prevent regression to the old contract and prove the complete unit on +the exact integration head. + +**Changes:** + +- Make CI invoke RunX-cataloged format, tidy, test, vet, schema, manifest, + XDocs, Mirror, installer, launcher, upgrade, uninstall, and migration checks. +- Build all payload and launcher targets; verify `artifacts.json`, archives, + checksums, raw versions, embedded targets, and undeclared/missing assets. +- Run native launcher/payload/install/upgrade/uninstall smokes on Linux AMD64 / + ARM64, macOS AMD64/ARM64, and Windows AMD64/ARM64 where runners exist. + Clearly label ARMv6/ARMv7 foreign builds as build-only. +- Add end-to-end tests from a synthetic 0.1.1 legacy installation, fresh + installation, same-version repair, upgrade, downgrade, interrupted upgrade, + launcher fallback, all uninstall preservation modes, and reinstall recovery. +- Change publication reconciliation to the manifest-derived release set. Never + delete a declared artifact because of a hard-coded count. +- Add a gate that the released main skill/instruction/prompt/schema/example / + manifest versions agree with the Mirror target version. + +**Verification:** CI green on the exact integration head; artifacts downloaded +from the workflow and independently rechecked; no package/source publication, +tag, or production action. + +**Exit:** every acceptance-matrix row has automated or explicitly native manual +evidence tied to one commit. + +### U14 — Aggregate review and authorized minor-release preparation + +**Depends on:** U13 and upstream shared CLI skill alignment. + +**Goal:** prepare a release-ready aggregate without crossing the release +authorization boundary. + +**Changes:** + +- Rebase/merge the integration line, then perform independent implementation + review and validation on the exact aggregate head. +- Reconcile `TODO.md`, audit finding statuses, plan unit statuses, changelog, + README, XDocs, Mirror config, RunX, schemas, manifests, and workflows. +- Fetch/prune and prove branch ancestry and a clean worktree without discarding + user work. +- Run plain Mirror once if required by the managed instruction, then + `mirror config check`, `mirror version current`, and + `mirror version plan minor`. Inspect every file mutation, release commit, + canonical tag, push, and workflow effect. +- Stop before `mirror version apply`. Report the proposed version (expected + `0.2.0` from `0.1.1`), exact commit, complete validation, native versus + build-only targets, artifact inventory, and remaining authorization. + +**Verification:** final aggregate PR accepted; exact-head CI and independent +validation green; Mirror plan clean and reviewable; no tag/release exists. + +**Exit:** one separately authorized action can apply the Mirror plan and publish +the complete release without source changes. + +## Per-unit review and validation template + +Every implementation unit PR must contain this evidence: + +1. **Scope:** unit ID, owned files/modules, and explicit non-goals. +2. **Authority:** convention sections and audit finding IDs closed. +3. **Compatibility:** intentional public breaks, migration behavior, and data + preservation statement. +4. **Tests:** focused unit/integration tests plus full `go test ./...` and + `go vet ./...` unless a documented external runner is required. +5. **Formatting/modules:** clean gofmt and `go mod tidy -diff`. +6. **Tooling:** `runx check/list`, relevant dry-runs, Mirror config check, + strict XDocs meta/tree/doctor. +7. **Platform evidence:** native execution named separately from foreign + build-only results. +8. **Filesystem safety:** exact paths, containment proof, ownership source, + rollback result, and preserved user data. +9. **Review:** independent review tied to the exact PR head and resolution of + all actionable findings. +10. **Delivery boundary:** no version apply, tag, release, package publication, + or production mutation unless separately authorized. + +## Failure and rollback policy during implementation + +- A unit with a failed source or acceptance test does not merge. +- Never repair a lifecycle test by weakening ownership, checksum, raw-version, + confirmation, or rollback assertions. +- Preserve dirty user work. If in-scope work overlaps unknown changes, stop and + identify the exact files; never reset, force-push, or erase them. +- A failed lifecycle transaction in tests must leave either the old verified + installation active or no new installation; partial mixed-version state is a + test failure. +- A failed init after a verified install/upgrade leaves the verified release + installed and reports reconciliation failure separately. +- A failed uninstall before commit restores the old active installation. Once + canonical removal commits, locked inactive garbage may be cleaned only under + its validated Buda operation directory and must never restore an active + installation unexpectedly. +- No test may read `.env`, `encrypted.env`, keys, or secret directories. Release + tests use public GitHub data or an isolated local fake release server. + +## Risk register and mandatory mitigations + +| Risk | Consequence | Mandatory mitigation/gate | +|---|---|---| +| Windows running launcher/payload cannot be deleted synchronously | Cobra uninstall could falsely claim completion or leave an active command. | U06 native proof before U11; canonical paths must be removed/quarantined during the original operation, and remaining locked inactive garbage cannot be success authority. | +| Shell implementations drift from Go semantics | Version/channel or uninstall behavior differs by platform. | One shared machine-readable fixture corpus; Bash, PowerShell, and Go acceptance consume identical selector, manifest, plan, and preservation vectors. | +| Canonical tag contains `/` | Generated schema/release URLs may be invalid or mutable. | Central URL builder, exact encoded-tag fixtures, and public release URL smoke before U14. | +| Legacy 0.1.1 updater understands only one payload | Direct `buda upgrade` cannot bootstrap the complete new release safely. | Supported migration path is the new exact/channel installer; README/skill recovery guidance directs 0.1.x users to it, and U09 starts from an official-layout legacy fixture. | +| Destructive uninstall confuses maintained wiki with CLI-owned data | Irrecoverable canonical knowledge loss. | Normative ownership table, explicit wiki, manifest/config-derived targets, OKF byte-invariance tests across all uninstall interfaces, and independent destructive-path review. | +| Current project requirement encourages implicit discovery | Violates core Buda contract and may mutate the wrong repository. | Lifecycle scripts require full-name wiki parameter; CLI requires `--wiki`; no cwd/ancestor fallback and no wiki registry. | +| Old global Go CLI skill reintroduces 11 assets or `update` | Future agents regress the repository after migration. | U00 upstream amendment recorded; U14 blocks release until mandatory shared skill and local instructions align. | +| Partial main branch is accidentally released | Users receive an internally inconsistent lifecycle. | Integration branch strategy, no tags, manifest-based publication gate, exact-head aggregate PR, and separate Mirror apply authorization. | + +## Definition of complete + +Buda complies only when all of the following are true on the same exact commit: + +- every row in the acceptance matrix is green with evidence; +- all U00-U13 TODO items are complete and U14 release preparation is ready; +- the effective product still requires one explicit wiki and delegates all + discovery to qmd; +- raw version, help flags, required commands, dual config, evolution policy, + schemas/examples, agent resources, init, launcher, manifest, installers, + synchronous upgrade, and uninstall all pass native acceptance; +- complete release assets are derived from `artifacts.json`, checksummed, and + built for all supported targets; +- RunX, Mirror config, XDocs, gofmt, tidy, tests, vet, cross-builds, and native + smokes pass; +- documentation describes implemented truth and ends with the required + uninstall section; +- an independent reviewer and validator accept the exact aggregate head; and +- no release claim is made until a separately authorized Mirror minor apply, + remote tag, workflow, GitHub Release, assets, checksums, and native downloaded + smoke are all verified. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 31b9948..5a9249e 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,7 +1,7 @@ --- name: buda-implementation-plan -purpose: Record the approved vertical MVP implementation, validation, installation, and GitHub Release plan. -description: File/module ownership, phase dependencies, acceptance criteria, validation commands, and GitHub Release boundaries through Buda 0.0.2. +purpose: Preserve the historical approved vertical MVP implementation, validation, installation, and GitHub Release plan through Buda 0.0.2. +description: Historical file/module ownership, phase dependencies, acceptance criteria, validation commands, and GitHub Release boundaries for Buda 0.0.2, superseded for current Convention 0001 work. created: 2026-07-26 owner: buda-docs flags: [] @@ -16,7 +16,13 @@ keywords: - GitHub Release --- -# Buda vertical MVP and 0.0.2 delivery +# Historical Buda vertical MVP and 0.0.2 delivery + +> **Historical record.** This document describes the completed 0.0.2-era +> implementation and exact eleven-artifact release contract. It is not the +> active plan for GUIHO CLI Convention 0001. Current work is governed by the +> [Convention 0001 implementation plan](GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md) +> and its [acceptance matrix](GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md). This plan implements the approved GUIHO RFC 0002 revision and the authorized 0.0.2 delivery. Buda is distributed only through its GitHub Releases. The diff --git a/docs/docs.xdocs.md b/docs/docs.xdocs.md index 74483b3..9590545 100644 --- a/docs/docs.xdocs.md +++ b/docs/docs.xdocs.md @@ -1,12 +1,17 @@ --- subject: buda-docs -description: Durable Buda architecture, governing-reference, validation, implementation, installation, and GitHub Release documentation. +description: Durable Buda architecture, governing-reference, convention-audit, implementation-planning, validation, installation, and GitHub Release documentation. parent: buda-package -children: [] +children: + - buda-todo + - buda-questions files: {} documents: ARCHITECTURE.md: Component ownership, global-only and explicit-wiki bootstrap boundaries, canonical-versus-derived state, qmd process boundary, and distribution architecture. - IMPLEMENTATION.md: Approved vertical MVP and 0.0.2 delivery phases, bootstrap routing, dependencies, acceptance criteria, validation commands, installers, and GitHub Release-only boundaries. + GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md: Finding-to-unit traceability, product invariants, acceptance evidence, aggregate gates, and release-readiness proof for Convention 0001 adoption. + GUIHO_CLI_CONVENTION_0001_COMPLIANCE_AUDIT.md: Aspect-by-aspect evidence audit of Buda against GUIHO CLI Convention 0001, including mandatory gaps, compliant foundations, and dependency-ordered remediation. + GUIHO_CLI_CONVENTION_0001_IMPLEMENTATION_PLAN.md: Proposed breaking-change implementation units, target lifecycle architecture, migration strategy, ownership rules, dependencies, validation, and release boundary for Convention 0001 adoption. + IMPLEMENTATION.md: Historical approved vertical MVP and 0.0.2 delivery record, superseded for current Convention 0001 work by the active implementation plan. QMD_COMPATIBILITY.md: Supported qmd version range, pinned upstream revision, project-local process mapping, real runtime evidence, fixtures, and external model-download boundary. REFERENCES.md: Normative and informative authorities for LLM Wiki operation, OKF representation, and the approved GUIHO RFC revision. tags: @@ -16,8 +21,10 @@ keywords: - Buda documentation - OKF references - qmd boundary + - GUIHO CLI Convention 0001 - Buda 0.0.2 - GitHub Release + - plan execution flags: [] status: draft --- diff --git a/docs/questions/cli-convention-0001.md b/docs/questions/cli-convention-0001.md new file mode 100644 index 0000000..057910c --- /dev/null +++ b/docs/questions/cli-convention-0001.md @@ -0,0 +1,90 @@ +--- +name: buda-cli-convention-0001-questions +purpose: Record resolved implementation decisions and review-gated validation boundaries for Convention 0001. +description: Resolved answers selected during unattended Convention 0001 implementation. +created: 2026-08-16 +owner: buda-questions +flags: [] +tags: [questions, cli, convention-0001] +keywords: [Buda, GUIHO CLI Convention 0001, stable launcher, release manifest] +status: resolved-review-ready +--- + +# Convention 0001 execution question ledger + +The plan executor continued unattended using the approved audit, plan, and +acceptance matrix. Naming, artifact-model, lifecycle-policy, and validation +boundary decisions are resolved below. Independent review remains required +before merge; none authorizes a tag, release, or production action. + +## Q01 — Complete release asset count versus historical exact-eleven guidance + +- Question: Should the implementation preserve the older exact-11 authored + asset model or publish the complete Convention 0001 manifest-derived set? +- Evidence: Convention 0001 requires every payload, launcher, agent artifact, + instruction, schema, example, manifest, and checksum. The approved plan and + matrix explicitly mark the old exact-eleven assertions as obsolete. +- Candidate answers: retain exact eleven; use the manifest-derived complete + set. +- Chosen answer: use `artifacts.json` as authority and publish 25 files in the + current build (24 manifest artifacts plus `checksums.txt`). +- Rationale: the newer controlling convention and approved plan supersede the + older Go skill/AGENTS exact-eleven wording while preserving all eight CLI + payload targets. +- Confidence: high. Reversibility: high before release publication. +- Action: updated release builder, workflows, tests, docs, and acceptance + matrix to validate counts from the manifest rather than a hard-coded eleven. +- Decision status: resolved; independent implementation review remains an + acceptance gate. + +## Q02 — Stable launcher replacement while the invoking launcher is active + +- Question: How should a Windows upgrade replace the canonical stable launcher + when the launcher process is the parent waiting on the payload? +- Evidence: the transaction never overwrites the active payload, stages and + checksums the selected launcher asset, and verifies the existing stable + launcher after pointer activation. Windows executable replacement can be + locked while the launcher parent remains active. +- Candidate answers: detach a helper; replace the running launcher in place; + retain the generic verified launcher and stage the selected launcher inside + the immutable release until a compatible synchronous replacement mechanism + is approved. +- Chosen answer: fail closed on unsafe in-place replacement; if the canonical + launcher is missing, install the verified selected launcher transactionally; + if it already exists, verify the version-independent launcher and leave it + untouched while the invoking Windows process may hold the file open. The + selected launcher is always stored and checksummed in the immutable release. +- Rationale: detached helpers are forbidden as upgrade authority, and the + existing launcher is version-independent and can dispatch every immutable + payload. This preserves synchronous activation without claiming an unsafe + in-place replacement succeeded. +- Confidence: high. Reversibility: high before merge. +- Action: implemented missing-launcher installation, complete rollback of a + newly created launcher, and post-activation launcher verification. No helper + or deferred `scheduled` replacement was introduced. +- Decision status: resolved; Windows locked-launcher mutation remains a native + acceptance scenario for independent review. + +## Q03 — Native installer and migration execution on this Windows host + +- Question: Can the live host safely exercise remote/fake-release installer, + legacy migration, and locked-file rollback scenarios end to end? +- Evidence: POSIX syntax, PowerShell parser, manifest/checksum checks, native + payload smoke, and isolated stable-launcher smoke pass. Running installer + mutations against a user home or simulating locked native images would alter + user state and is outside the current safe validation fixture. +- Candidate answers: mutate the real user home; execute a fully isolated fake + release fixture; record the native behavior as review-gated. +- Chosen answer: record native installer/migration acceptance as review-gated + and leave production/user installation state untouched. The repository + scripts remain syntax-validated and their fake-release path is represented + by `BUDA_RELEASE_ASSET_DIR`; only an isolated fixture or native CI runner + should exercise mutation and lock/recovery behavior. +- Rationale: the user authorized repository delivery, not destructive mutation + of an existing personal installation; the scripts already have syntax and + static contract coverage. +- Confidence: high. Reversibility: high. +- Action: added explicit remaining-gap evidence to `docs/todo/` and the PR + handoff. +- Decision status: resolved validation boundary; native acceptance remains an + explicit independent-review gate. diff --git a/docs/questions/questions.xdocs.md b/docs/questions/questions.xdocs.md new file mode 100644 index 0000000..4869c76 --- /dev/null +++ b/docs/questions/questions.xdocs.md @@ -0,0 +1,17 @@ +--- +subject: buda-questions +description: Resolved execution decisions and explicitly review-gated validation boundaries for Convention 0001 delivery. +parent: buda-docs +children: [] +files: {} +documents: + cli-convention-0001.md: Accepted implementation decisions, validation boundaries, and independent-review handoff risks for Convention 0001. +tags: [questions, plan-execution] +keywords: [Convention 0001, decision record, review] +flags: [] +status: review-ready +--- + +Question records preserve unattended implementation decisions and distinguish +resolved choices from acceptance evidence that still requires independent +review. diff --git a/docs/todo/cli-convention-0001.md b/docs/todo/cli-convention-0001.md new file mode 100644 index 0000000..b5a8cbd --- /dev/null +++ b/docs/todo/cli-convention-0001.md @@ -0,0 +1,90 @@ +--- +name: buda-cli-convention-0001-execution +purpose: Durable execution record for the approved GUIHO CLI Convention 0001 implementation. +description: Tracks the unattended implementation branch, plan units, provisional decisions, validation state, and release deferral for Buda Convention 0001. +created: 2026-08-16 +updated: 2026-08-16T00:00:00Z +owner: buda-todo +status: review-ready +plan_unit: U00-U14 aggregate execution +skipped_phases: + - brainstorm: approved convention audit and implementation plan already provide requirements. + - requirements: acceptance matrix and explicit human confirmations are durable. + - architecture: target lifecycle architecture is recorded in the implementation plan. + - plan-review: the user explicitly delegated execution of the approved plan. +flags: [] +tags: + - todo + - cli + - convention + - execution +keywords: + - GUIHO CLI Convention 0001 + - Buda + - 0.2.0 +--- + +# GUIHO CLI Convention 0001 execution + +## Scope + +Execute the approved Buda Convention 0001 implementation plan on the dedicated +`codex/cli-convention-0001` branch. The accepted target is the next minor +release, `0.2.0` from `0.1.1`; Mirror apply, tagging, and GitHub Release +publication remain deferred to the integration owner after independent review. + +## Ownership and handoff + +- Branch: `codex/cli-convention-0001` +- Base: `09cba7a` / `origin/main` at delegation +- Worktree: `C:\GUIHO\buda` +- Executor: `guiho-a-0048-plan-executor` / `guiho-s-0023-plan-executor` +- Next handoff: `guiho-a-0049-implementation-reviewer`, then validation and PR integration. +- Question ledger: `docs/questions/cli-convention-0001.md` records the + manifest-count, stable-launcher, and native-installer provisional answers. +- Production boundary: no deployment, traffic, DNS, database, secret, or live-service mutation. + +## Provisional answers + +| Question | Chosen answer | Evidence and rationale | Human review | +|---|---|---|---| +| Which release target follows the breaking convention implementation? | `0.2.0` minor from `0.1.1` | Explicit latest user direction overrides the plan's provisional major wording; Mirror remains authoritative for application. | pending | +| Should legacy direct self-replacement retain a successful deferred/scheduled state? | No; stable-launcher transactions are synchronous and legacy direct replacement must fail closed when it cannot replace in-process. | Convention requires a synchronous next invocation and rejects scheduled success. | pending | +| Which project instruction file is canonical? | Always reconcile `AGENTS.md`; reconcile existing `CLAUDE.md` as a projection. | Convention requires a managed AGENTS block and Buda preserves unrelated bytes. | pending | + +## Milestones + +- [x] U00-U04 authority, public surface, config, and agent resource implementation complete. +- [x] U05-U08 lifecycle primitives, launcher, release builder, and init integration complete. +- [x] U09-U11 installer, upgrade, and uninstall implementations complete; native mutation/recovery acceptance remains review-gated. +- [x] U12-U13 documentation, tooling, CI, and release-preparation implementation complete. +- [ ] U14 aggregate review and release preparation complete. +- [ ] Independent implementation review and validation accepted on exact head. + +## Testing state + +Current state is `review-ready`. The final local validation pass recorded: + +- `gofmt` and `git diff --check`: clean. +- `go mod tidy -diff`, `go test -count=1 ./...`, and `go vet ./...`: passed. +- Pure-Go builds: Windows amd64/arm64 and Linux ARMv6/ARMv7 passed; foreign + ARM targets are build-only on this Windows host. +- `runx check --format json`, `runx list --format json`, and dry-runs for all + 28 catalog entries: passed without executing catalog commands. +- `xdocs meta . --documents --strict`, `xdocs tree`, and + `xdocs doctor . --warnings-as-errors`: 35 descriptors, zero errors/warnings. +- `mirror`, `mirror config check`, `mirror version current`, and + `mirror version plan minor`: passed; current `0.1.1`, planned `0.2.0`, tag + `buda/v0.2.0`. Mirror apply was not run. +- Local release build: 25 generated files and 24 manifest artifacts (eight + payloads, eight launchers, seven managed resources, manifest, and checksums); + SHA-256 verification, native Windows payload smoke, and stable-launcher smoke + passed. Generated `dist`, `.gocache`, and `.gomodcache` were removed. +- POSIX `install.sh`/`uninstall.sh` syntax and PowerShell installer/uninstaller + parsing: passed. + +Remaining gates are the independent exact-head review, full native installer +mutation/reinstall and crash-recovery suites, legacy 0.1.1 migration evidence, +and the separately authorized Mirror apply/tag/release boundary. Do not mark +the record complete until the PR head is reviewed and pushed and the deferred +release boundary is explicitly recorded. diff --git a/docs/todo/todo.xdocs.md b/docs/todo/todo.xdocs.md new file mode 100644 index 0000000..8eb059c --- /dev/null +++ b/docs/todo/todo.xdocs.md @@ -0,0 +1,23 @@ +--- +subject: buda-todo +description: Durable execution task records for Buda implementation plans and lifecycle handoffs. +parent: buda-docs +children: [] +files: {} +documents: + cli-convention-0001.md: Durable execution record for Convention 0001 implementation, validation, resolved decisions, and release deferral. +tags: + - documentation + - todo + - execution +keywords: + - Buda TODO + - plan executor + - Convention 0001 +flags: [] +status: draft +--- + +This directory contains durable task specifications used while executing +approved Buda plans. Each record states its scope, ownership, skipped phases, +resolved decisions, validation state, and handoff boundary. From 3b75e67a09f7081a57f197aae2db8610b3b8e5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:17:58 +0200 Subject: [PATCH 04/11] ci: install pinned GUIHO validation tools --- .github/workflows/ci.yml | 20 ++++++++++++++------ .github/workflows/publish.yml | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4dae22..e2fd622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,17 +16,25 @@ jobs: with: { fetch-depth: 0 } - uses: actions/setup-go@v6 with: { go-version-file: go.mod, cache: true } + - name: Install repository tooling + env: + GOBIN: ${{ github.workspace }}/.bin + run: | + mkdir -p "$GOBIN" + go install github.com/CGuiho/mirror@v4.0.1 + go install github.com/CGuiho/runx@v0.12.1 + go install github.com/CGuiho/xdocs@v0.10.0 - name: Mirror configuration - run: mirror config check + run: ./.bin/mirror config check - name: RunX catalog run: | - runx check --format json - runx list --format json + ./.bin/runx check --format json + ./.bin/runx list --format json - name: Strict XDocs run: | - xdocs meta . --documents --strict - xdocs tree - xdocs doctor . --warnings-as-errors + ./.bin/xdocs meta . --documents --strict + ./.bin/xdocs tree + ./.bin/xdocs doctor . --warnings-as-errors - name: Formatting and module graph run: | test -z "$(gofmt -l .)" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af3e520..8bf010c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,6 +16,14 @@ jobs: with: { fetch-depth: 0 } - uses: actions/setup-go@v6 with: { go-version-file: go.mod, cache: true } + - name: Install repository tooling + env: + GOBIN: ${{ github.workspace }}/.bin + run: | + mkdir -p "$GOBIN" + go install github.com/CGuiho/mirror@v4.0.1 + go install github.com/CGuiho/runx@v0.12.1 + go install github.com/CGuiho/xdocs@v0.10.0 - name: Resolve and validate canonical version id: release shell: bash @@ -27,10 +35,10 @@ jobs: echo "date=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" - name: Required quality and tooling gates run: | - mirror config check - runx check --format json - xdocs meta . --documents --strict - xdocs doctor . --warnings-as-errors + ./.bin/mirror config check + ./.bin/runx check --format json + ./.bin/xdocs meta . --documents --strict + ./.bin/xdocs doctor . --warnings-as-errors test -z "$(gofmt -l .)" go mod tidy git diff --exit-code -- go.mod go.sum From 7216e33f7ac2e888aed98ce838082381d0702be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:24:58 +0200 Subject: [PATCH 05/11] ci: pin GUIHO tooling release commits --- .github/workflows/ci.yml | 9 ++++++--- .github/workflows/publish.yml | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2fd622..566f365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,12 @@ jobs: GOBIN: ${{ github.workspace }}/.bin run: | mkdir -p "$GOBIN" - go install github.com/CGuiho/mirror@v4.0.1 - go install github.com/CGuiho/runx@v0.12.1 - go install github.com/CGuiho/xdocs@v0.10.0 + # These projects use repository-prefixed release tags (for example + # mirror/v4.0.1), which Go does not interpret as module versions. + # Pin the corresponding release commits with immutable pseudo-versions. + go install github.com/CGuiho/mirror@v0.0.0-20260802221718-766730ccf4b6 + go install github.com/CGuiho/runx@v0.0.0-20260811091523-2c96e8aec105 + go install github.com/CGuiho/xdocs@v0.0.0-20260802225907-1d353b79b73e - name: Mirror configuration run: ./.bin/mirror config check - name: RunX catalog diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8bf010c..eeab651 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,9 +21,12 @@ jobs: GOBIN: ${{ github.workspace }}/.bin run: | mkdir -p "$GOBIN" - go install github.com/CGuiho/mirror@v4.0.1 - go install github.com/CGuiho/runx@v0.12.1 - go install github.com/CGuiho/xdocs@v0.10.0 + # These projects use repository-prefixed release tags (for example + # mirror/v4.0.1), which Go does not interpret as module versions. + # Pin the corresponding release commits with immutable pseudo-versions. + go install github.com/CGuiho/mirror@v0.0.0-20260802221718-766730ccf4b6 + go install github.com/CGuiho/runx@v0.0.0-20260811091523-2c96e8aec105 + go install github.com/CGuiho/xdocs@v0.0.0-20260802225907-1d353b79b73e - name: Resolve and validate canonical version id: release shell: bash From f0455412e32925bebee3ea2e652b0cef67ef4808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:27:16 +0200 Subject: [PATCH 06/11] build: pin current Go patch toolchain --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 1643349..f10fbea 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/CGuiho/buda -go 1.26.0 +go 1.26.5 require ( github.com/spf13/cobra v1.10.2 From 8d9b35685c324b96a58aa2b8125e279a44b9f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:34:06 +0200 Subject: [PATCH 07/11] test: isolate lifecycle and policy contracts --- cmd/domain_commands_test.go | 4 +++- cmd/selfmanage_test.go | 19 +++++++++++++++++-- internal/launcher/launcher_test.go | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/domain_commands_test.go b/cmd/domain_commands_test.go index d877fd0..1137c29 100644 --- a/cmd/domain_commands_test.go +++ b/cmd/domain_commands_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/CGuiho/buda/internal/agent" @@ -118,7 +119,8 @@ func TestStatusKeepsCanonicalAndQMDResultsSeparate(t *testing.T) { func domainDeps(output *bytes.Buffer, home string) Dependencies { return Dependencies{ - In: bytes.NewReader(nil), Out: output, Err: &bytes.Buffer{}, Options: &Options{}, + In: strings.NewReader("yes\n"), Out: output, Err: &bytes.Buffer{}, Options: &Options{}, + Interactive: func() bool { return true }, Agents: agent.NewService(agent.DefaultResources(), agent.WithHomeDir(func() (string, error) { return home, nil })), Executable: func() (string, error) { return "buda", nil }, ScheduleMaintenance: func(string, string) error { return nil }, diff --git a/cmd/selfmanage_test.go b/cmd/selfmanage_test.go index 2286197..8a8c5de 100644 --- a/cmd/selfmanage_test.go +++ b/cmd/selfmanage_test.go @@ -55,6 +55,15 @@ func testWiki(t *testing.T) string { return root } +func testInstallLayout(t *testing.T) installlayout.Layout { + t.Helper() + layout, err := installlayout.ForHome(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return layout +} + func upgradeClient(binary []byte) *http.Client { digest := sha256.Sum256(binary) return &http.Client{Transport: releaseTransport(func(request *http.Request) (*http.Response, error) { @@ -171,9 +180,11 @@ func TestUpgradeAndUninstallNeverScheduleResourceMaintenance(t *testing.T) { func TestUpgradeEqualVersionIsNoOp(t *testing.T) { var output bytes.Buffer wiki := testWiki(t) + layout := testInstallLayout(t) called := false deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), + InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, UpgradeBinary: func(context.Context, selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { called = true return selfmanage.UpgradeResult{}, nil @@ -192,9 +203,11 @@ func TestUpgradeEqualVersionIsNoOp(t *testing.T) { func TestUpgradeTextReportsOperationalProgress(t *testing.T) { var output bytes.Buffer wiki := testWiki(t) + layout := testInstallLayout(t) deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), - Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, + InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, + Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, UpgradeBinary: func(_ context.Context, options selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { options.Progress(selfmanage.DownloadProgress{Bytes: 6, Total: 6, Percent: 100}) return selfmanage.UpgradeResult{Executable: options.Executable, Backup: options.Executable + ".old", TargetVersion: options.TargetVersion}, nil @@ -216,9 +229,11 @@ func TestUpgradeTextReportsOperationalProgress(t *testing.T) { func TestReconciliationFailureAutomaticallyRollsBack(t *testing.T) { rolledBack := false wiki := testWiki(t) + layout := testInstallLayout(t) deps := Dependencies{ Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), - Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, + InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, + Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, UpgradeBinary: func(_ context.Context, options selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { return selfmanage.UpgradeResult{Executable: options.Executable, Backup: options.Executable + ".old"}, nil }, diff --git a/internal/launcher/launcher_test.go b/internal/launcher/launcher_test.go index 73da6f6..2a224e7 100644 --- a/internal/launcher/launcher_test.go +++ b/internal/launcher/launcher_test.go @@ -99,7 +99,7 @@ func TestRunRejectsSymlinkedPayload(t *testing.T) { t.Fatal(err) } var out, errOut bytes.Buffer - if code := Run(nil, strings.NewReader(""), &out, &errOut, layout); code == 0 || !strings.Contains(errOut.String(), "symlink") { + if code := Run(nil, strings.NewReader(""), &out, &errOut, layout); code == 0 || !strings.Contains(errOut.String(), "outside the versions directory") { t.Fatalf("symlinked payload result code=%d stderr=%q", code, errOut.String()) } } From de6ed1dbc605c7c87a72985bf34b02e4963cd6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Sun, 16 Aug 2026 21:38:15 +0200 Subject: [PATCH 08/11] test: isolate init policy configuration --- cmd/domain_commands_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/domain_commands_test.go b/cmd/domain_commands_test.go index 1137c29..384b988 100644 --- a/cmd/domain_commands_test.go +++ b/cmd/domain_commands_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/CGuiho/buda/internal/agent" + "github.com/CGuiho/buda/internal/config" "github.com/CGuiho/buda/internal/qmd" "github.com/CGuiho/buda/internal/repository" ) @@ -46,6 +47,17 @@ func TestInitCommandPreflightsQMDAndInstallsAgentResources(t *testing.T) { home := t.TempDir() client := &domainFakeQMD{} output := &bytes.Buffer{} + globalPath := config.GlobalPath(home) + if err := os.MkdirAll(filepath.Dir(globalPath), 0o755); err != nil { + t.Fatal(err) + } + globalData, err := config.MarshalGlobal(config.DefaultGlobal(), "test") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(globalPath, globalData, 0o644); err != nil { + t.Fatal(err) + } deps := domainDeps(output, home) root := NewRootCommand(deps, BuildInfo{Version: "test"}, NewInitCommand(deps, func(repository.Repository) (QMDClient, error) { return client, nil })) root.SetArgs([]string{"--wiki", wiki, "--json", "init", "--wiki-id", "wiki-test"}) @@ -121,6 +133,7 @@ func domainDeps(output *bytes.Buffer, home string) Dependencies { return Dependencies{ In: strings.NewReader("yes\n"), Out: output, Err: &bytes.Buffer{}, Options: &Options{}, Interactive: func() bool { return true }, + HomeDir: func() (string, error) { return home, nil }, Agents: agent.NewService(agent.DefaultResources(), agent.WithHomeDir(func() (string, error) { return home, nil })), Executable: func() (string, error) { return "buda", nil }, ScheduleMaintenance: func(string, string) error { return nil }, From 3ea96688c01dbb26d63590e5a27cbf9b2983f148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Mon, 17 Aug 2026 00:09:17 +0200 Subject: [PATCH 09/11] fix: close Convention 0001 review blockers Close the release blockers from the review round with evidence: - chmod staged POSIX candidates before execution (install.sh and the upgrade transaction); - capture replacement edits known fields only and preserves unknown OKF metadata; - manifest projection snapshot/apply/retire/rollback plus journal recovery states are tested; - symlink/reparse-safe quarantined transactional uninstall with same-filesystem staging, instruction snapshots, and data-loss-free rollback; Windows reparse points detected through file attributes; - init discovers every unanswered policy before any write and migrates the legacy global config strictly; - complete 24-binary release matrix with duplicate rejection and typed launcher metadata in Go, jq, and PowerShell selectors; - meaningful zero-mutation self-test and precise --version/__self-test instance bypass; - legacy 0.1.x direct-binary migration only after verified launcher activation from the exact historical paths; - explicit wiki and grouped REMOVE/PRESERVE plans before uninstall confirmation; the plan removes the payload so the legacy-executable step no longer fails after success; - embedded schema/example runtime parity tests; - pointer EOF and canonical payload-name validation, all-zero digest rejection except artifacts.json, and strict checksum parsing; - BUDA_DISABLE_MAINTENANCE is now honored by the maintenance scheduler. --- cmd/domain_commands_test.go | 28 +++ cmd/init.go | 94 ++++----- cmd/root.go | 60 ++++-- cmd/root_test.go | 35 ++++ cmd/selfmanage_test.go | 121 ++++++----- cmd/uninstall.go | 58 +++++- cmd/upgrade.go | 123 +++++------- devops/build-binaries.go | 54 ++++- devops/install.ps1 | 29 ++- devops/install.sh | 12 +- devops/installers_test.go | 21 ++ devops/uninstall.ps1 | 59 ++++-- devops/uninstall.sh | 56 +++--- examples/embed.go | 8 + examples/examples.xdocs.md | 1 + internal/artifact/manifest.go | 3 + internal/artifact/manifest_test.go | 22 ++ internal/capture/capture.go | 102 ++++++++-- internal/capture/capture_test.go | 92 +++++++++ internal/config/config.go | 13 +- internal/config/dual.go | 6 - internal/launcher/launcher.go | 15 ++ internal/lifecycle/linkcheck_other.go | 10 + internal/lifecycle/linkcheck_windows.go | 23 +++ internal/lifecycle/transaction.go | 40 +++- internal/maintenance/maintenance.go | 3 + internal/releasecatalog/catalog.go | 31 ++- internal/repository/repository.go | 23 ++- internal/uninstall/plan.go | 219 +++++++++++++++++++- internal/uninstall/plan_test.go | 43 +++- internal/upgrade/transaction.go | 257 ++++++++++++++++++++++++ internal/upgrade/transaction_test.go | 190 ++++++++++++++++++ schemas/embed.go | 8 + schemas/embed_test.go | 82 ++++++++ schemas/schemas.xdocs.md | 2 + 35 files changed, 1613 insertions(+), 330 deletions(-) create mode 100644 examples/embed.go create mode 100644 internal/lifecycle/linkcheck_other.go create mode 100644 internal/lifecycle/linkcheck_windows.go create mode 100644 schemas/embed.go create mode 100644 schemas/embed_test.go diff --git a/cmd/domain_commands_test.go b/cmd/domain_commands_test.go index 384b988..be065aa 100644 --- a/cmd/domain_commands_test.go +++ b/cmd/domain_commands_test.go @@ -111,6 +111,34 @@ func TestCaptureCommandRefreshesQMDAndEmitsOneJSONDocument(t *testing.T) { } } +func TestInitFailsBeforeAnyWriteWhenPoliciesAreUnansweredWithoutTerminal(t *testing.T) { + wiki := filepath.Join(t.TempDir(), "wiki") + home := t.TempDir() + client := &domainFakeQMD{} + deps := domainDeps(&bytes.Buffer{}, home) + deps.Interactive = func() bool { return false } + root := NewRootCommand(deps, BuildInfo{Version: "test"}, NewInitCommand(deps, func(repository.Repository) (QMDClient, error) { return client, nil })) + root.SetArgs([]string{"--wiki", wiki, "init", "--wiki-id", "wiki-test"}) + err := root.Execute() + if ExitCode(err) != 2 || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("error = %v, code = %d", err, ExitCode(err)) + } + if client.ensure != 0 { + t.Fatalf("qmd project was initialized before policy answers: %d", client.ensure) + } + for _, path := range []string{ + config.GlobalPath(home), + filepath.Join(wiki, "buda.yaml"), + filepath.Join(wiki, "AGENTS.md"), + filepath.Join(wiki, "knowledge"), + filepath.Join(home, ".agents", "skills", agent.SkillID), + } { + if _, statErr := os.Stat(path); statErr == nil { + t.Fatalf("init wrote %s before every policy answer existed", path) + } + } +} + func TestStatusKeepsCanonicalAndQMDResultsSeparate(t *testing.T) { wiki := initializedWiki(t) output := &bytes.Buffer{} diff --git a/cmd/init.go b/cmd/init.go index 49bbe92..68cb0f5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -41,7 +41,7 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { return UsageError("--wiki-id is required for a new wiki; an existing valid project configuration was not found") } } - globalPath, globalCreated, globalErr := ensureGlobalConfiguration(deps.HomeDir, version) + globalPath, global, globalPending, migrated, globalErr := resolveGlobalConfig(deps.HomeDir, version) if globalErr != nil { return MutationError("prepare global Buda configuration", globalErr) } @@ -54,19 +54,10 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { } else if !os.IsNotExist(statErr) { return RepositoryError("inspect existing project configuration", statErr) } - global, err := config.LoadGlobal(globalPath) - if err != nil { - return MutationError("load global Buda configuration", err) - } - policyPrompted, err := reconcileAgentPolicies(command.OutOrStdout(), input, deps.Interactive() && !JSONRequested(deps), &global, globalCreated) + policyPrompted, err := reconcileAgentPolicies(command.OutOrStdout(), input, deps.Interactive() && !JSONRequested(deps), &global, globalPending && !migrated) if err != nil { return err } - if policyPrompted { - if err := persistGlobalConfiguration(globalPath, global, version); err != nil { - return MutationError("persist agent-evolution policy", err) - } - } configuration, err := config.Merge(global, project) if err != nil { return RepositoryError("merge Buda configuration", err) @@ -81,6 +72,11 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { if _, err := client.CheckCompatibility(command.Context()); err != nil { return externalError("validate qmd compatibility before initialization", err) } + if globalPending || policyPrompted { + if err := persistGlobalConfiguration(globalPath, global, version); err != nil { + return MutationError("persist global Buda configuration", err) + } + } result, err := repository.Initialize(root, repository.InitOptions{WikiID: wikiID, Configuration: configuration, Version: version, Now: dependencyNow(deps), BeforeCommit: func(candidate repository.Repository) error { if err := client.EnsureProject(command.Context()); err != nil { @@ -112,7 +108,7 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { output := map[string]any{"command": "init", "wiki": result.Repository.Root, "bundle": result.Repository.Bundle, "qmd_project_directory": result.Repository.QMDProject, "collection": result.Repository.Collection, "wiki_id": result.Repository.Config.WikiID, "created": result.Created, "unchanged": result.Unchanged, "skills": skills, "instructions": instructions, - "project_config": projectConfigPath, "global_config": globalPath, "global_config_created": globalCreated, + "project_config": projectConfigPath, "global_config": globalPath, "global_config_created": globalPending, "policy_prompted": policyPrompted, "effective_agent_evolution": result.Repository.Config.EffectiveAgent()} if JSONRequested(deps) { @@ -134,56 +130,54 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { return command } -func ensureGlobalConfiguration(homeDir func() (string, error), version string) (string, bool, error) { +func resolveGlobalConfig(homeDir func() (string, error), version string) (string, config.GlobalConfig, bool, bool, error) { if homeDir == nil { homeDir = os.UserHomeDir } home, err := homeDir() if err != nil { - return "", false, err + return "", config.GlobalConfig{}, false, false, err } path := config.GlobalPath(home) if _, err := os.Stat(path); err == nil { - if _, err := config.LoadGlobal(path); err != nil { - return path, false, err + global, err := config.LoadGlobal(path) + if err != nil { + return path, config.GlobalConfig{}, false, false, err } - return path, false, nil + return path, global, false, false, nil } else if !os.IsNotExist(err) { - return path, false, err - } - if _, migrated, err := config.MigrateLegacyGlobal(home, version); err != nil { - return path, false, err - } else if migrated { - return path, true, nil - } - data, err := config.MarshalGlobal(config.DefaultGlobal(), version) - if err != nil { - return path, false, err + return path, config.GlobalConfig{}, false, false, err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return path, false, err - } - temp, err := os.CreateTemp(filepath.Dir(path), ".buda-init-*") - if err != nil { - return path, false, err - } - tempPath := temp.Name() - defer os.Remove(tempPath) - if _, err := temp.Write(data); err != nil { - temp.Close() - return path, false, err - } - if err := temp.Sync(); err != nil { - temp.Close() - return path, false, err - } - if err := temp.Close(); err != nil { - return path, false, err - } - if err := os.Rename(tempPath, path); err != nil { - return path, false, err + // One-time read-only discovery of the 0.1.x global buda.yaml. Strictly + // validated before mapping; the legacy file is left untouched and the new + // global file is only persisted after every interactive answer exists. + legacyPath := filepath.Join(home, ".guiho", "buda", "buda.yaml") + if _, err := os.Stat(legacyPath); err == nil { + legacyConfig, loadErr := config.Load(legacyPath) + if loadErr != nil { + return path, config.GlobalConfig{}, false, false, fmt.Errorf("validate legacy global configuration: %w", loadErr) + } + global := config.GlobalConfig{ + Schema: config.CurrentSchema, + Bundle: &legacyConfig.Bundle, + Derived: &legacyConfig.Derived, + QMD: &config.QMDConfig{ + Executable: legacyConfig.QMD.Executable, + Collection: legacyConfig.QMD.Collection, + ProjectDirectory: legacyConfig.QMD.ProjectDirectory, + }, + Agent: &config.AgentConfig{ + Evolution: legacyConfig.EffectiveAgent().Evolution, + }, + } + if err := global.Validate(); err != nil { + return path, config.GlobalConfig{}, false, false, err + } + return path, global, true, true, nil + } else if !os.IsNotExist(err) { + return path, config.GlobalConfig{}, false, false, err } - return path, true, nil + return path, config.DefaultGlobal(), true, false, nil } func readPromptLine(reader *bufio.Reader) (string, error) { diff --git a/cmd/root.go b/cmd/root.go index c89bcb7..9577365 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "os" "path/filepath" "strings" "time" + "github.com/CGuiho/buda/examples" "github.com/CGuiho/buda/internal/agent" clihelp "github.com/CGuiho/buda/internal/help" "github.com/CGuiho/buda/internal/installlayout" @@ -22,6 +24,9 @@ import ( "github.com/CGuiho/buda/internal/selfmanage" "github.com/CGuiho/buda/internal/source" "github.com/CGuiho/buda/internal/upgrade" + "github.com/CGuiho/buda/prompts" + "github.com/CGuiho/buda/schemas" + "github.com/CGuiho/buda/skills" "github.com/spf13/cobra" ) @@ -62,7 +67,6 @@ type Dependencies struct { HTTPClient source.Doer RemoveExecutable func(string) (bool, error) RollbackExecutable func(string) (bool, error) - UpgradeBinary func(context.Context, selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) UpgradeRelease func(context.Context, upgrade.Options) (upgrade.Result, error) ReconcileInstalled func(string, string) error } @@ -133,22 +137,33 @@ func DefaultDependencies() Dependencies { HTTPClient: &http.Client{Timeout: 30 * time.Second}, RemoveExecutable: selfmanage.RemoveExecutable, RollbackExecutable: selfmanage.Rollback, - UpgradeBinary: selfmanage.Upgrade, UpgradeRelease: upgrade.Execute, ReconcileInstalled: reconcileInstalledResources, } } +func isProbeInvocation(args []string) bool { + if len(args) == 0 { + return false + } + if len(args) == 1 && (args[0] == "--version" || args[0] == "-v") { + return true + } + return args[0] == "__self-test" +} + func Execute(info BuildInfo) error { - cleanup, err := registerCurrentInstance(info) - if err != nil { - return MutationError("register Buda payload instance", err) + if !isProbeInvocation(os.Args[1:]) { + cleanup, err := registerCurrentInstance(info) + if err != nil { + return MutationError("register Buda payload instance", err) + } + defer cleanup() } - defer cleanup() deps := DefaultDependencies() deps.Version = info.Version root := NewRootCommand(deps, info, NewApplicationCommands(deps)...) - err = root.Execute() + err := root.Execute() if errors.Is(err, errHelpRendered) { return nil } @@ -299,7 +314,7 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman root.AddCommand(newUpgradeCommand(deps, info)) root.AddCommand(newUninstallCommand(deps)) root.AddCommand(newMaintenanceCommand(deps)) - root.AddCommand(newSelfTestCommand()) + root.AddCommand(newSelfTestCommand(info)) wrapDeveloperHelpArgs(root) return root } @@ -307,12 +322,36 @@ func NewRootCommand(deps Dependencies, info BuildInfo, commands ...*cobra.Comman // newSelfTestCommand is intentionally hidden from the public command tree. // Installers and lifecycle transactions use it to prove the candidate can // construct its command surface and read embedded resources before activation. -func newSelfTestCommand() *cobra.Command { +func newSelfTestCommand(info BuildInfo) *cobra.Command { return &cobra.Command{ Use: "__self-test", Hidden: true, Args: NoArgs, RunE: func(command *cobra.Command, _ []string) error { + if info.Version != "dev" && !releasecatalog.IsSemver(info.Version) { + return fmt.Errorf("invalid build version %q", info.Version) + } + if _, err := fs.ReadFile(skills.FS, "guiho-s-0002-buda/SKILL.md"); err != nil { + return fmt.Errorf("read embedded skill: %w", err) + } + if _, err := fs.ReadFile(prompts.FS, "guiho-i-buda.md"); err != nil { + return fmt.Errorf("read embedded instruction: %w", err) + } + if _, err := fs.ReadFile(prompts.FS, "guiho-p-buda.md"); err != nil { + return fmt.Errorf("read embedded prompt: %w", err) + } + if _, err := fs.ReadFile(schemas.FS, "buda.schema.json"); err != nil { + return fmt.Errorf("read embedded project schema: %w", err) + } + if _, err := fs.ReadFile(schemas.FS, "buda.global.schema.json"); err != nil { + return fmt.Errorf("read embedded global schema: %w", err) + } + if _, err := fs.ReadFile(examples.FS, "buda.example.yaml"); err != nil { + return fmt.Errorf("read embedded project example: %w", err) + } + if _, err := fs.ReadFile(examples.FS, "buda.global.example.yaml"); err != nil { + return fmt.Errorf("read embedded global example: %w", err) + } fmt.Fprintln(command.OutOrStdout(), "ok") return nil }, @@ -390,9 +429,6 @@ func normalizeDependencies(deps Dependencies) Dependencies { if deps.RollbackExecutable == nil { deps.RollbackExecutable = selfmanage.Rollback } - if deps.UpgradeBinary == nil { - deps.UpgradeBinary = selfmanage.Upgrade - } if deps.UpgradeRelease == nil { deps.UpgradeRelease = upgrade.Execute } diff --git a/cmd/root_test.go b/cmd/root_test.go index e6b6083..102176f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -281,3 +281,38 @@ func TestAgentHumanOutputIsDistinctFromJSON(t *testing.T) { t.Fatalf("JSON output = %q", jsonOutput) } } + +func TestVersionAndSelfTestDoNotMutateFileSystem(t *testing.T) { + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + + for _, args := range [][]string{{"--version"}, {"__self-test"}} { + var output bytes.Buffer + deps := Dependencies{ + In: strings.NewReader(""), + Out: &output, + Err: &bytes.Buffer{}, + HomeDir: func() (string, error) { + return home, nil + }, + Options: &Options{}, + } + root := NewRootCommand(deps, BuildInfo{Version: "0.2.0", Target: "buda-linux-amd64"}) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(%v) = %v", args, err) + } + if args[0] == "__self-test" && strings.TrimSpace(output.String()) != "ok" { + t.Fatalf("__self-test output = %q, want ok", output.String()) + } + // Assert zero files were created under home directory + entries, err := filepath.Glob(filepath.Join(home, "*")) + if err != nil { + t.Fatal(err) + } + if len(entries) > 0 { + t.Fatalf("invocation %v mutated filesystem in disposable home: %v", args, entries) + } + } +} diff --git a/cmd/selfmanage_test.go b/cmd/selfmanage_test.go index 8a8c5de..81f2a64 100644 --- a/cmd/selfmanage_test.go +++ b/cmd/selfmanage_test.go @@ -5,7 +5,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" "io" "net/http" "path/filepath" @@ -14,7 +13,7 @@ import ( "github.com/CGuiho/buda/internal/installlayout" "github.com/CGuiho/buda/internal/repository" - "github.com/CGuiho/buda/internal/selfmanage" + "github.com/CGuiho/buda/internal/upgrade" ) type releaseTransport func(*http.Request) (*http.Response, error) @@ -23,24 +22,43 @@ func (transport releaseTransport) RoundTrip(request *http.Request) (*http.Respon return transport(request) } +func completeReleaseAssetsJSON() string { + assets := []string{ + `{"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda-linux-amd64"}`, + `{"name":"buda-linux-arm64","browser_download_url":"https://downloads.example/buda-linux-arm64"}`, + `{"name":"buda-linux-armv7","browser_download_url":"https://downloads.example/buda-linux-armv7"}`, + `{"name":"buda-linux-armv6","browser_download_url":"https://downloads.example/buda-linux-armv6"}`, + `{"name":"buda-darwin-amd64","browser_download_url":"https://downloads.example/buda-darwin-amd64"}`, + `{"name":"buda-darwin-arm64","browser_download_url":"https://downloads.example/buda-darwin-arm64"}`, + `{"name":"buda-windows-amd64.exe","browser_download_url":"https://downloads.example/buda-windows-amd64.exe"}`, + `{"name":"buda-windows-arm64.exe","browser_download_url":"https://downloads.example/buda-windows-arm64.exe"}`, + `{"name":"buda-launcher-linux-amd64","browser_download_url":"https://downloads.example/buda-launcher-linux-amd64"}`, + `{"name":"buda-launcher-linux-arm64","browser_download_url":"https://downloads.example/buda-launcher-linux-arm64"}`, + `{"name":"buda-launcher-linux-armv7","browser_download_url":"https://downloads.example/buda-launcher-linux-armv7"}`, + `{"name":"buda-launcher-linux-armv6","browser_download_url":"https://downloads.example/buda-launcher-linux-armv6"}`, + `{"name":"buda-launcher-darwin-amd64","browser_download_url":"https://downloads.example/buda-launcher-darwin-amd64"}`, + `{"name":"buda-launcher-darwin-arm64","browser_download_url":"https://downloads.example/buda-launcher-darwin-arm64"}`, + `{"name":"buda-launcher-windows-amd64.exe","browser_download_url":"https://downloads.example/buda-launcher-windows-amd64.exe"}`, + `{"name":"buda-launcher-windows-arm64.exe","browser_download_url":"https://downloads.example/buda-launcher-windows-arm64.exe"}`, + `{"name":"buda.schema.json","browser_download_url":"https://downloads.example/buda.schema.json"}`, + `{"name":"buda.global.schema.json","browser_download_url":"https://downloads.example/buda.global.schema.json"}`, + `{"name":"buda.example.yaml","browser_download_url":"https://downloads.example/buda.example.yaml"}`, + `{"name":"buda.global.example.yaml","browser_download_url":"https://downloads.example/buda.global.example.yaml"}`, + `{"name":"guiho-i-buda.md","browser_download_url":"https://downloads.example/guiho-i-buda.md"}`, + `{"name":"guiho-p-buda.md","browser_download_url":"https://downloads.example/guiho-p-buda.md"}`, + `{"name":"guiho-s-0002-buda.zip","browser_download_url":"https://downloads.example/guiho-s-0002-buda.zip"}`, + `{"name":"artifacts.json","browser_download_url":"https://downloads.example/artifacts.json"}`, + `{"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}`, + } + return strings.Join(assets, ",\n") +} + func catalogClient() *http.Client { return &http.Client{Transport: releaseTransport(func(request *http.Request) (*http.Response, error) { body := `[{ "tag_name":"buda/v0.0.2","name":"Buda 0.0.2","published_at":"2026-07-27T20:00:00Z", "html_url":"https://github.com/CGuiho/buda/releases/tag/buda/v0.0.2", - "assets":[ - {"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda-linux-amd64"}, - {"name":"buda-launcher-linux-amd64","browser_download_url":"https://downloads.example/buda-launcher-linux-amd64"}, - {"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}, - {"name":"artifacts.json","browser_download_url":"https://downloads.example/artifacts.json"}, - {"name":"buda.schema.json","browser_download_url":"https://downloads.example/buda.schema.json"}, - {"name":"buda.global.schema.json","browser_download_url":"https://downloads.example/buda.global.schema.json"}, - {"name":"buda.example.yaml","browser_download_url":"https://downloads.example/buda.example.yaml"}, - {"name":"buda.global.example.yaml","browser_download_url":"https://downloads.example/buda.global.example.yaml"}, - {"name":"guiho-i-buda.md","browser_download_url":"https://downloads.example/guiho-i-buda.md"}, - {"name":"guiho-p-buda.md","browser_download_url":"https://downloads.example/guiho-p-buda.md"}, - {"name":"guiho-s-0002-buda.zip","browser_download_url":"https://downloads.example/guiho-s-0002-buda.zip"} - ] + "assets":[` + completeReleaseAssetsJSON() + `] }]` return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil })} @@ -70,19 +88,7 @@ func upgradeClient(binary []byte) *http.Client { body := "" switch request.URL.Host { case "api.github.com": - body = `[{"tag_name":"buda/v0.0.2","html_url":"https://github.com/CGuiho/buda/releases/tag/buda/v0.0.2","assets":[ -{"name":"buda-linux-amd64","browser_download_url":"https://downloads.example/buda"}, -{"name":"buda-launcher-linux-amd64","browser_download_url":"https://downloads.example/buda-launcher-linux-amd64"}, -{"name":"checksums.txt","browser_download_url":"https://downloads.example/checksums.txt"}, -{"name":"artifacts.json","browser_download_url":"https://downloads.example/artifacts.json"}, -{"name":"buda.schema.json","browser_download_url":"https://downloads.example/buda.schema.json"}, -{"name":"buda.global.schema.json","browser_download_url":"https://downloads.example/buda.global.schema.json"}, -{"name":"buda.example.yaml","browser_download_url":"https://downloads.example/buda.example.yaml"}, -{"name":"buda.global.example.yaml","browser_download_url":"https://downloads.example/buda.global.example.yaml"}, -{"name":"guiho-i-buda.md","browser_download_url":"https://downloads.example/guiho-i-buda.md"}, -{"name":"guiho-p-buda.md","browser_download_url":"https://downloads.example/guiho-p-buda.md"}, -{"name":"guiho-s-0002-buda.zip","browser_download_url":"https://downloads.example/guiho-s-0002-buda.zip"} -]}]` + body = `[{"tag_name":"buda/v0.0.2","html_url":"https://github.com/CGuiho/buda/releases/tag/buda/v0.0.2","assets":[` + completeReleaseAssetsJSON() + `]}]` case "downloads.example": if strings.HasSuffix(request.URL.Path, "checksums.txt") { body = hex.EncodeToString(digest[:]) + " buda-linux-amd64\n" @@ -125,8 +131,9 @@ func TestUpgradeCheckAndListEmitOneJSONDocument(t *testing.T) { } } -func TestUninstallDryRunNeedsNoWikiAndDoesNotDelete(t *testing.T) { +func TestUninstallDryRunNeedsExplicitWikiAndDoesNotDelete(t *testing.T) { var output bytes.Buffer + wiki := testWiki(t) removed := false layout, err := installlayout.ForHome(t.TempDir()) if err != nil { @@ -139,20 +146,18 @@ func TestUninstallDryRunNeedsNoWikiAndDoesNotDelete(t *testing.T) { InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.1"}) - root.SetArgs([]string{"uninstall", "--dry-run"}) + root.SetArgs([]string{"uninstall", "--dry-run", "--wiki", wiki}) if err := root.Execute(); err != nil { t.Fatal(err) } - if removed || !strings.Contains(output.String(), "REMOVE ") || !strings.Contains(output.String(), "buda CLI home") { + if removed || !strings.Contains(output.String(), "REMOVE:") || !strings.Contains(output.String(), "PRESERVE:") || !strings.Contains(output.String(), "buda CLI home") { t.Fatalf("uninstall preview = %q, removed=%t", output.String(), removed) } } func TestUpgradeAndUninstallNeverScheduleResourceMaintenance(t *testing.T) { for _, arguments := range [][]string{{"upgrade", "--dry-run"}, {"uninstall", "--dry-run"}} { - if arguments[0] == "upgrade" { - arguments = append(arguments, "--wiki", testWiki(t)) - } + arguments = append(arguments, "--wiki", testWiki(t)) scheduled := 0 deps := Dependencies{ Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), @@ -185,9 +190,9 @@ func TestUpgradeEqualVersionIsNoOp(t *testing.T) { deps := Dependencies{ Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, - UpgradeBinary: func(context.Context, selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { + UpgradeRelease: func(context.Context, upgrade.Options) (upgrade.Result, error) { called = true - return selfmanage.UpgradeResult{}, nil + return upgrade.Result{}, nil }, } root := NewRootCommand(deps, BuildInfo{Version: "0.0.2", Target: "buda-linux-amd64"}) @@ -200,17 +205,22 @@ func TestUpgradeEqualVersionIsNoOp(t *testing.T) { } } -func TestUpgradeTextReportsOperationalProgress(t *testing.T) { +func TestUpgradeExecutesManifestTransaction(t *testing.T) { var output bytes.Buffer wiki := testWiki(t) layout := testInstallLayout(t) + called := false deps := Dependencies{ - Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), + Out: &output, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: catalogClient(), InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, - Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, - UpgradeBinary: func(_ context.Context, options selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { - options.Progress(selfmanage.DownloadProgress{Bytes: 6, Total: 6, Percent: 100}) - return selfmanage.UpgradeResult{Executable: options.Executable, Backup: options.Executable + ".old", TargetVersion: options.TargetVersion}, nil + UpgradeRelease: func(_ context.Context, options upgrade.Options) (upgrade.Result, error) { + called = true + return upgrade.Result{ + PreviousVersion: "0.0.1", + InstalledVersion: "0.0.2", + LauncherPath: layout.Launcher, + PayloadPath: filepath.Join(layout.Versions, "0.0.2", "buda"), + }, nil }, ReconcileInstalled: func(string, string) error { return nil }, } @@ -219,35 +229,16 @@ func TestUpgradeTextReportsOperationalProgress(t *testing.T) { if err := root.Execute(); err != nil { t.Fatal(err) } - for _, expected := range []string{"Target: 0.0.2", "URL: https://downloads.example/buda", "Checksum verified: sha256:", "Installation path: C:/tools/buda.exe", "Download progress: 100.0%", "Agent resources: reconciled", "Final version verified: 0.0.2"} { + if !called { + t.Fatal("UpgradeRelease was not called") + } + for _, expected := range []string{"Buda upgraded from 0.0.1 to 0.0.2", "Launcher:", "Payload:", "Post-upgrade explicit-wiki init: verified"} { if !strings.Contains(output.String(), expected) { t.Fatalf("upgrade text missing %q:\n%s", expected, output.String()) } } } -func TestReconciliationFailureAutomaticallyRollsBack(t *testing.T) { - rolledBack := false - wiki := testWiki(t) - layout := testInstallLayout(t) - deps := Dependencies{ - Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, Options: &Options{}, HTTPClient: upgradeClient([]byte("binary")), - InstallLayout: func() (installlayout.Layout, error) { return layout, nil }, - Executable: func() (string, error) { return "C:/tools/buda.exe", nil }, - UpgradeBinary: func(_ context.Context, options selfmanage.UpgradeOptions) (selfmanage.UpgradeResult, error) { - return selfmanage.UpgradeResult{Executable: options.Executable, Backup: options.Executable + ".old"}, nil - }, - ReconcileInstalled: func(string, string) error { return errors.New("skill write failed") }, - RollbackExecutable: func(string) (bool, error) { rolledBack = true; return false, nil }, - } - root := NewRootCommand(deps, BuildInfo{Version: "0.0.1", Target: "buda-linux-amd64"}) - root.SetArgs([]string{"upgrade", "--wiki", wiki}) - err := root.Execute() - if !rolledBack || err == nil || !strings.Contains(err.Error(), "automatically rolled back") { - t.Fatalf("reconcile failure=%v rolledBack=%t", err, rolledBack) - } -} - func TestUpgradeRollbackRouteUsesInjectedBoundary(t *testing.T) { called := false var output bytes.Buffer diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 4fda887..bebc54e 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -1,9 +1,13 @@ package cmd import ( + "errors" "fmt" + "io" "os" + "strings" + "github.com/CGuiho/buda/internal/repository" "github.com/CGuiho/buda/internal/uninstall" "github.com/spf13/cobra" ) @@ -12,9 +16,12 @@ func newUninstallCommand(deps Dependencies) *cobra.Command { var dryRun, preserveConfig, preserveData, yes bool command := &cobra.Command{Use: "uninstall", Short: "Remove Buda-owned installation artifacts safely.", Args: NoArgs, RunE: func(command *cobra.Command, _ []string) error { - wiki, err := optionalSelectedWiki(deps.Options.Wiki) + if strings.TrimSpace(deps.Options.Wiki) == "" { + return RepositoryError("--wiki is required for uninstall; Buda never selects a wiki implicitly", nil) + } + wiki, err := repository.ResolveTarget(deps.Options.Wiki) if err != nil { - return err + return RepositoryError("resolve selected wiki", err) } layout, err := deps.InstallLayout() if err != nil { @@ -24,12 +31,12 @@ func newUninstallCommand(deps Dependencies) *cobra.Command { if err != nil { return MutationError("build uninstall plan", err) } - if JSONRequested(deps) && dryRun { - return WriteJSON(command, map[string]any{"command": "buda uninstall", "dry_run": true, "plan": plan}) + if !JSONRequested(deps) { + printGroupedPlan(command.OutOrStdout(), plan) } if dryRun { - for _, item := range plan.Items { - fmt.Fprintf(command.OutOrStdout(), "%s %s (%s)\n", item.Action, item.Path, item.Owner) + if JSONRequested(deps) { + return WriteJSON(command, map[string]any{"command": "buda uninstall", "dry_run": true, "plan": plan}) } return nil } @@ -43,12 +50,19 @@ func newUninstallCommand(deps Dependencies) *cobra.Command { return UsageError("uninstall cancelled") } } - if err := uninstall.Apply(plan); err != nil { + if err := uninstall.Apply(plan, layout.Temp); err != nil { return MutationError("apply Buda uninstall plan", err) } + // The manifest-driven plan already removed the immutable payload. + // This secondary removal covers only a verified legacy direct-binary + // executable that still exists outside the managed layout. executable, err := absoluteExecutable(deps) if err == nil && executable != layout.Launcher { - if deferred, removeErr := deps.RemoveExecutable(executable); removeErr != nil { + if _, statErr := os.Stat(executable); errors.Is(statErr, os.ErrNotExist) { + // Already removed by the plan; nothing legacy remains. + } else if statErr != nil { + return MutationError("inspect legacy Buda executable", statErr) + } else if deferred, removeErr := deps.RemoveExecutable(executable); removeErr != nil { return MutationError("remove legacy Buda executable", removeErr) } else if deferred { return MutationError("remove Buda executable", fmt.Errorf("removal was not synchronous")) @@ -68,6 +82,34 @@ func newUninstallCommand(deps Dependencies) *cobra.Command { return command } +func printGroupedPlan(out io.Writer, plan uninstall.Plan) { + fmt.Fprintln(out, "Buda Uninstall Plan:") + var removeItems, preserveItems []uninstall.Item + for _, item := range plan.Items { + if item.Action == "REMOVE" { + removeItems = append(removeItems, item) + } else { + preserveItems = append(preserveItems, item) + } + } + fmt.Fprintln(out, "REMOVE:") + if len(removeItems) == 0 { + fmt.Fprintln(out, " (none)") + } else { + for _, item := range removeItems { + fmt.Fprintf(out, " - %s (%s)\n", item.Path, item.Owner) + } + } + fmt.Fprintln(out, "PRESERVE:") + if len(preserveItems) == 0 { + fmt.Fprintln(out, " (none)") + } else { + for _, item := range preserveItems { + fmt.Fprintf(out, " - %s (%s)\n", item.Path, item.Owner) + } + } +} + func interactiveReader(reader any) bool { file, ok := reader.(*os.File) if !ok || file == nil { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index fbbb933..43ca002 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "os/exec" @@ -74,100 +75,66 @@ func newUpgradeCommand(deps Dependencies, info BuildInfo) *cobra.Command { } return nil } - if layout, layoutErr := deps.InstallLayout(); layoutErr == nil && fileExists(layout.Current) && deps.UpgradeRelease != nil { - transaction, txErr := deps.UpgradeRelease(command.Context(), upgrade.Options{Layout: layout, Selection: selection, Client: deps.HTTPClient, CurrentVersion: info.Version, Target: info.Target, OS: runtime.GOOS, Arch: runtime.GOARCH, CurrentPID: os.Getpid()}) - if txErr != nil { - printRecovery(command, release.Version, "", wiki) - return MutationError("upgrade Buda synchronously", txErr) - } - initErr := error(nil) - if deps.ReconcileInstalled != nil { - initErr = deps.ReconcileInstalled(transaction.PayloadPath, wiki) - } - if JSONRequested(deps) { - outcome := "succeeded" - if initErr != nil { - outcome = "succeeded-init-failed" - } - if writeErr := WriteJSON(command, map[string]any{"command": "buda upgrade", "outcome": outcome, "result": transaction, "init_error": errorMessage(initErr), "recovery": recoveryCommand(transaction.InstalledVersion, "", wiki)}); writeErr != nil { - return writeErr - } - if initErr != nil { - return MutationError("Buda upgraded but explicit-wiki init failed", initErr) - } - return nil - } - fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nLauncher: %s\nPayload: %s\n", info.Version, transaction.InstalledVersion, transaction.LauncherPath, transaction.PayloadPath) - if initErr != nil { - fmt.Fprintf(command.OutOrStdout(), "Post-upgrade explicit-wiki init failed; the verified release remains active: %v\n", initErr) - printRecovery(command, transaction.InstalledVersion, "", wiki) - return MutationError("Buda upgraded but explicit-wiki init failed", initErr) - } - fmt.Fprintln(command.OutOrStdout(), "Post-upgrade explicit-wiki init: verified") - printRecovery(command, transaction.InstalledVersion, "", wiki) - return nil - } - if !JSONRequested(deps) { - fmt.Fprintf(command.OutOrStdout(), "Target: %s\nAsset: %s\nURL: %s\nChecksums: %s\n", release.Version, asset.Name, asset.DownloadURL, manifest.DownloadURL) - } - checksum, err := selfmanage.FetchChecksum(command.Context(), deps.HTTPClient, manifest.DownloadURL, asset.Name) - if err != nil { + layout, layoutErr := deps.InstallLayout() + if layoutErr != nil { if !JSONRequested(deps) { printRecovery(command, release.Version, "", wiki) } - return externalError("verify Buda release manifest", err) + return MutationError("resolve Buda installation layout", layoutErr) } - executable, err := deps.Executable() - if err != nil { + if deps.UpgradeRelease == nil { if !JSONRequested(deps) { printRecovery(command, release.Version, "", wiki) } - return MutationError("determine Buda executable", err) - } - if !JSONRequested(deps) { - fmt.Fprintf(command.OutOrStdout(), "Checksum verified: sha256:%s\nInstallation path: %s\n", checksum, executable) - } - var progress func(selfmanage.DownloadProgress) - if !JSONRequested(deps) { - progress = func(event selfmanage.DownloadProgress) { - if event.Total > 0 { - fmt.Fprintf(command.OutOrStdout(), "Download progress: %.1f%% (%d/%d bytes)\n", event.Percent, event.Bytes, event.Total) - } else { - fmt.Fprintf(command.OutOrStdout(), "Download progress: %d bytes\n", event.Bytes) - } - } - } - result, err := deps.UpgradeBinary(command.Context(), selfmanage.UpgradeOptions{ - Executable: executable, TargetVersion: release.Version, DownloadURL: asset.DownloadURL, - ExpectedChecksum: checksum, Client: deps.HTTPClient, Wiki: wiki, Progress: progress, + return MutationError("upgrade Buda synchronously", errors.New("no upgrade release transaction handler configured")) + } + transaction, txErr := deps.UpgradeRelease(command.Context(), upgrade.Options{ + Layout: layout, + Selection: selection, + Client: deps.HTTPClient, + CurrentVersion: info.Version, + Target: info.Target, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + CurrentPID: os.Getpid(), }) - result.CurrentVersion = info.Version - result.Asset = asset.Name - if err != nil { + if txErr != nil { if !JSONRequested(deps) { printRecovery(command, release.Version, "", wiki) } - return MutationError("upgrade Buda", err) + return MutationError("upgrade Buda synchronously", txErr) } - if err := deps.ReconcileInstalled(result.Executable, wiki); err != nil { - rollbackComplete, rollbackErr := deps.RollbackExecutable(result.Executable) - if !JSONRequested(deps) { - printRecovery(command, release.Version, "", wiki) + initErr := error(nil) + if deps.ReconcileInstalled != nil { + initErr = deps.ReconcileInstalled(transaction.PayloadPath, wiki) + } + if JSONRequested(deps) { + outcome := "succeeded" + if initErr != nil { + outcome = "succeeded-init-failed" } - if rollbackErr != nil || rollbackComplete { - return MutationError("agent-resource reconciliation failed and automatic binary rollback did not complete synchronously", fmt.Errorf("reconcile: %w; rollback: %v", err, rollbackErr)) + if writeErr := WriteJSON(command, map[string]any{ + "command": "buda upgrade", + "outcome": outcome, + "result": transaction, + "init_error": errorMessage(initErr), + "recovery": recoveryCommand(transaction.InstalledVersion, "", wiki), + }); writeErr != nil { + return writeErr } - return MutationError("agent-resource reconciliation failed; binary automatically rolled back", err) - } - if !JSONRequested(deps) { - fmt.Fprintln(command.OutOrStdout(), "Agent resources: reconciled") - fmt.Fprintf(command.OutOrStdout(), "Final version verified: %s\n", release.Version) + if initErr != nil { + return MutationError("Buda upgraded but explicit-wiki init failed", initErr) + } + return nil } - if JSONRequested(deps) { - return WriteJSON(command, map[string]any{"command": "buda upgrade", "outcome": "succeeded", "result": result, "recovery": recoveryCommand(release.Version, "", wiki)}) + fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nLauncher: %s\nPayload: %s\n", info.Version, transaction.InstalledVersion, transaction.LauncherPath, transaction.PayloadPath) + if initErr != nil { + fmt.Fprintf(command.OutOrStdout(), "Post-upgrade explicit-wiki init failed; the verified release remains active: %v\n", initErr) + printRecovery(command, transaction.InstalledVersion, "", wiki) + return MutationError("Buda upgraded but explicit-wiki init failed", initErr) } - fmt.Fprintf(command.OutOrStdout(), "Buda upgraded from %s to %s.\nBackup: %s\n", info.Version, release.Version, result.Backup) - printRecovery(command, release.Version, "", wiki) + fmt.Fprintln(command.OutOrStdout(), "Post-upgrade explicit-wiki init: verified") + printRecovery(command, transaction.InstalledVersion, "", wiki) return nil }, } diff --git a/devops/build-binaries.go b/devops/build-binaries.go index 24b83f0..d0acf6c 100644 --- a/devops/build-binaries.go +++ b/devops/build-binaries.go @@ -46,10 +46,35 @@ func main() { version := flag.String("version", "", "Semantic version embedded in every binary") commit := flag.String("commit", "unknown", "Source commit embedded in every binary") buildDate := flag.String("build-date", "", "Stable RFC3339 build timestamp") + targetFilter := flag.String("target", "", "Build only a specific target binary") flag.Parse() if *version == "" { fatalf("--version is required") } + if *targetFilter != "" { + var selected *target + for i, t := range targets { + if t.name == *targetFilter || strings.TrimSuffix(t.name, ".exe") == strings.TrimSuffix(*targetFilter, ".exe") { + selected = &targets[i] + break + } + } + if selected == nil { + fatalf("unknown target %q", *targetFilter) + } + if *buildDate == "" { + *buildDate = time.Now().UTC().Format(time.RFC3339) + } + if err := os.MkdirAll(outputDirectory, 0o755); err != nil { + fatalf("create %s: %v", outputDirectory, err) + } + path := filepath.Join(outputDirectory, selected.name) + if err := build(path, ".", *selected, *version, *commit, *buildDate); err != nil { + fatalf("build %s: %v", selected.name, err) + } + fmt.Printf("built target %s\n", selected.name) + return + } if *buildDate == "" { fatalf("--build-date is required for a reproducible release archive") } @@ -144,8 +169,16 @@ func build(path, packagePath string, buildTarget target, version, commit, date s func buildManifest(version string, assets []string) artifact.Manifest { manifest := artifact.Manifest{Schema: artifact.CurrentSchema, CLI: cliName, Version: version, Artifacts: []artifact.Artifact{}} + seen := map[string]bool{} for _, path := range assets { name := filepath.Base(path) + if name == manifestAssetName { + continue + } + if seen[name] { + fatalf("duplicate release asset %q; the release unit must not publish two assets with one name", name) + } + seen[name] = true digest, err := digestFile(path) if err != nil { fatalf("digest %s: %v", name, err) @@ -153,9 +186,6 @@ func buildManifest(version string, assets []string) artifact.Manifest { installed := filepath.ToSlash(filepath.Join("versions", version, "artifacts", name)) id := strings.TrimSuffix(name, filepath.Ext(name)) id = strings.ReplaceAll(id, ".", "-") - if name == "artifacts.json" { - digest = strings.Repeat("0", 64) - } entry := artifact.Artifact{ID: id, Version: version, Path: name, InstalledPath: installed, SHA256: digest, Ownership: artifact.OwnershipReplaceable, Replaceable: true} setTargetMetadata(&entry, name) if name == skillAssetName { @@ -174,10 +204,20 @@ func buildManifest(version string, assets []string) artifact.Manifest { func setTargetMetadata(entry *artifact.Artifact, name string) { target := strings.TrimSuffix(name, ".exe") - parts := strings.Split(target, "-") - if len(parts) >= 3 && (strings.HasPrefix(target, "buda-") || strings.HasPrefix(target, "buda-launcher-")) { - entry.OS = parts[1] - entry.Arch = strings.Join(parts[2:], "-") + if strings.HasPrefix(target, "buda-launcher-") { + trimmed := strings.TrimPrefix(target, "buda-launcher-") + parts := strings.Split(trimmed, "-") + if len(parts) >= 2 { + entry.OS = parts[0] + entry.Arch = strings.Join(parts[1:], "-") + } + } else if strings.HasPrefix(target, "buda-") { + trimmed := strings.TrimPrefix(target, "buda-") + parts := strings.Split(trimmed, "-") + if len(parts) >= 2 { + entry.OS = parts[0] + entry.Arch = strings.Join(parts[1:], "-") + } } } diff --git a/devops/install.ps1 b/devops/install.ps1 index a528867..6c39687 100644 --- a/devops/install.ps1 +++ b/devops/install.ps1 @@ -24,8 +24,12 @@ if (-not $Version -and -not $env:BUDA_RELEASE_ASSET_DIR) { } while ($batch.Count -eq 100) $matches = @($all | Where-Object { -not $_.draft -and $_.tag_name -match '^buda/v' } | ForEach-Object { $candidate = $_.tag_name.Substring(6); $candidateChannel = if ($candidate.Contains('-')) { $candidate.Split('-')[1].Split('.')[0] } else { 'stable' } - $requiredNames = @($binary, $launcher, 'checksums.txt', 'artifacts.json', 'guiho-s-0002-buda.zip', 'guiho-i-buda.md', 'guiho-p-buda.md', 'buda.schema.json', 'buda.global.schema.json', 'buda.example.yaml', 'buda.global.example.yaml') + $requiredNames = @($binary, $launcher, 'checksums.txt', 'artifacts.json', 'guiho-s-0002-buda.zip', 'guiho-i-buda.md', 'guiho-p-buda.md', 'buda.schema.json', 'buda.global.schema.json', 'buda.example.yaml', 'buda.global.example.yaml', + 'buda-linux-amd64', 'buda-linux-arm64', 'buda-linux-armv7', 'buda-linux-armv6', 'buda-darwin-amd64', 'buda-darwin-arm64', 'buda-windows-amd64.exe', 'buda-windows-arm64.exe', + 'buda-launcher-linux-amd64', 'buda-launcher-linux-arm64', 'buda-launcher-linux-armv7', 'buda-launcher-linux-armv6', 'buda-launcher-darwin-amd64', 'buda-launcher-darwin-arm64', 'buda-launcher-windows-amd64.exe', 'buda-launcher-windows-arm64.exe') $assetNames = @($_.assets | ForEach-Object { [string]$_.name }) + $uniqueAssetNames = @($assetNames | Select-Object -Unique) + if ($assetNames.Count -ne $uniqueAssetNames.Count) { return } if ($candidateChannel -eq $wanted -and $candidate -match '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' -and @($requiredNames | Where-Object { $assetNames -notcontains $_ }).Count -eq 0) { [pscustomobject]@{ Release=$_; Version=$candidate } } } | Sort-Object Version -Descending) if ($matches.Count -eq 0) { throw "No release found for channel $wanted." } @@ -52,7 +56,7 @@ if (-not $Version -and -not $env:BUDA_RELEASE_ASSET_DIR) { if (-not $Version) { throw 'Version selection did not resolve a stable release.' } $tag = "buda/v$Version" $assetDir = $env:BUDA_RELEASE_ASSET_DIR -$userHome = [Environment]::GetFolderPath('UserProfile') +$userHome = if ($env:USERPROFILE) { $env:USERPROFILE } elseif ($env:HOME) { $env:HOME } else { [Environment]::GetFolderPath('UserProfile') } $guihoHome = Join-Path $userHome '.guiho' $cliHome = Join-Path $guihoHome 'buda' $globalConfigName = 'buda.global.yaml' @@ -67,7 +71,11 @@ $hadCurrent = $false; $hadManifest = $false; $hadLauncher = $false; $hadVersion $hadLegacy = $false $versionDir = Join-Path $cliHome (Join-Path 'versions' $Version) $backupDir = Join-Path $stage 'backup' -$legacyPath = Join-Path $cliHome 'buda' +# The historical 0.1.x direct-binary installation wrote the payload to +# %LOCALAPPDATA%\GUIHO\bin\buda.exe. Migration removes it only from that exact +# historical path, only after the new launcher transaction has been verified, +# and never in place. +$legacyPath = if ($env:LOCALAPPDATA) { Join-Path (Join-Path $env:LOCALAPPDATA 'GUIHO\bin') 'buda.exe' } else { '' } function Get-Asset([string]$name) { $destination = Join-Path $stage $name @@ -77,14 +85,23 @@ function Get-Asset([string]$name) { } function Write-Atomic([string]$path, [string]$content) { $temporary = "$path.new" - Set-Content -NoNewline -Encoding UTF8 -LiteralPath $temporary -Value $content + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($temporary, $content, $encoding) Move-Item -Force -LiteralPath $temporary -Destination $path } +function Get-Sha256([string]$path) { + $stream = [System.IO.File]::OpenRead($path) + try { + $hash = [System.Security.Cryptography.SHA256]::Create() + try { return ([System.BitConverter]::ToString($hash.ComputeHash($stream)) -replace '-', '').ToUpperInvariant() } + finally { $hash.Dispose() } + } finally { $stream.Dispose() } +} function Verify-Checksum([string]$name, $checksumLines) { $matches = @($checksumLines | Where-Object { $_ -match "^\s*([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($name))\s*$" }) if ($matches.Count -ne 1) { throw "Missing or duplicate checksum for $name." } $expected = $matches[0].Substring(0,64).ToUpperInvariant() - $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $stage $name)).Hash.ToUpperInvariant() + $actual = Get-Sha256 (Join-Path $stage $name) if ($expected -ne $actual) { throw "Checksum mismatch for $name." } } function Restore-Previous { @@ -128,7 +145,7 @@ try { if (Test-Path -LiteralPath $currentPath) { Copy-Item -Force -LiteralPath $currentPath -Destination (Join-Path $backupDir 'current.json'); $hadCurrent = $true } if (Test-Path -LiteralPath $manifestPath) { Copy-Item -Force -LiteralPath $manifestPath -Destination (Join-Path $backupDir 'installed-artifacts.json'); $hadManifest = $true } if (Test-Path -LiteralPath $launcherPath) { Copy-Item -Force -LiteralPath $launcherPath -Destination (Join-Path $backupDir 'buda.exe'); $hadLauncher = $true } - if (Test-Path -LiteralPath $legacyPath -PathType Leaf) { + if ($legacyPath -and (Test-Path -LiteralPath $legacyPath -PathType Leaf)) { $legacyObserved = (& $legacyPath --version | Out-String).Trim() if ($legacyObserved -match '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$') { Copy-Item -Force -LiteralPath $legacyPath -Destination (Join-Path $backupDir 'legacy-buda.exe'); $hadLegacy = $true diff --git a/devops/install.sh b/devops/install.sh index b05a21a..bc0f227 100644 --- a/devops/install.sh +++ b/devops/install.sh @@ -58,6 +58,8 @@ case "$VERSION" in v*) VERSION=${VERSION#v} ;; esac case "$(uname -s):$(uname -m)" in Linux:x86_64|Linux:amd64) OS=linux; ARCH=amd64; BINARY=buda-linux-amd64; LAUNCHER=buda-launcher-linux-amd64 ;; Linux:aarch64|Linux:arm64) OS=linux; ARCH=arm64; BINARY=buda-linux-arm64; LAUNCHER=buda-launcher-linux-arm64 ;; + Linux:armv7l|Linux:armv7|Linux:armhf) OS=linux; ARCH=armv7; BINARY=buda-linux-armv7; LAUNCHER=buda-launcher-linux-armv7 ;; + Linux:armv6l|Linux:armv6) OS=linux; ARCH=armv6; BINARY=buda-linux-armv6; LAUNCHER=buda-launcher-linux-armv6 ;; Darwin:x86_64|Darwin:amd64) OS=darwin; ARCH=amd64; BINARY=buda-darwin-amd64; LAUNCHER=buda-launcher-darwin-amd64 ;; Darwin:arm64) OS=darwin; ARCH=arm64; BINARY=buda-darwin-arm64; LAUNCHER=buda-launcher-darwin-arm64 ;; *) printf 'unsupported platform: %s:%s\n' "$(uname -s)" "$(uname -m)" >&2; exit 2 ;; @@ -84,7 +86,7 @@ else while :; do releases=$(curl -fsSL "https://api.github.com/repos/$OWNER/$REPOSITORY/releases?per_page=100&page=$page") count=$(printf '%s' "$releases" | jq 'length') - candidates=$(printf '%s' "$releases" | jq -r --arg wanted "$wanted" --arg binary "$BINARY" --arg launcher "$LAUNCHER" '.[] | select(.draft|not) | select(.tag_name|test("^buda/v")) | ((.assets // [] | map(.name) | unique) as $assets | select(([$binary,$launcher,"checksums.txt","artifacts.json","guiho-s-0002-buda.zip","guiho-i-buda.md","guiho-p-buda.md","buda.schema.json","buda.global.schema.json","buda.example.yaml","buda.global.example.yaml"] - $assets | length) == 0)) | (.tag_name|sub("^buda/v";"")) as $v | select($v|test("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$")) | select((if ($v|contains("-")) then ($v|split("-")[1]|split(".")[0]) else "stable" end)==$wanted) | $v') + candidates=$(printf '%s' "$releases" | jq -r --arg wanted "$wanted" --arg binary "$BINARY" --arg launcher "$LAUNCHER" '.[] | select(.draft|not) | select(.tag_name|test("^buda/v")) | ((.assets // []) as $raw | select(((([$binary,$launcher,"checksums.txt","artifacts.json","guiho-s-0002-buda.zip","guiho-i-buda.md","guiho-p-buda.md","buda.schema.json","buda.global.schema.json","buda.example.yaml","buda.global.example.yaml","buda-linux-amd64","buda-linux-arm64","buda-linux-armv7","buda-linux-armv6","buda-darwin-amd64","buda-darwin-arm64","buda-windows-amd64.exe","buda-windows-arm64.exe","buda-launcher-linux-amd64","buda-launcher-linux-arm64","buda-launcher-linux-armv7","buda-launcher-linux-armv6","buda-launcher-darwin-amd64","buda-launcher-darwin-arm64","buda-launcher-windows-amd64.exe","buda-launcher-windows-arm64.exe"] - ($raw | map(.name) | unique)) | length) == 0) and (($raw | map(.name) | length) == ($raw | map(.name) | unique | length)))) | (.tag_name|sub("^buda/v";"")) as $v | select($v|test("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$")) | select((if ($v|contains("-")) then ($v|split("-")[1]|split(".")[0]) else "stable" end)==$wanted) | $v') for candidate in $candidates; do if [ -z "$selected_version" ] || [ "$(semver_gt "$candidate" "$selected_version")" = 1 ]; then selected_version=$candidate; fi done @@ -143,6 +145,7 @@ for name in "$BINARY" "$LAUNCHER" guiho-s-0002-buda.zip guiho-i-buda.md guiho-p- manifest_has "$name" || { printf 'manifest does not declare required asset: %s\n' "$name" >&2; exit 1; } done for name in $(manifest_paths); do verify_checksum "$name"; done +chmod 755 "$STAGE/$BINARY" "$STAGE/$LAUNCHER" while read -r digest name extra; do [ -n "${name:-}" ] || continue [ -z "${extra:-}" ] || { printf 'malformed checksum entry: %s\n' "$name" >&2; exit 1; } @@ -157,7 +160,12 @@ self_test=$($STAGE/$BINARY __self-test 2>/dev/null || true) VERSION_DIR=$CLI_HOME/versions/$VERSION BACKUP_DIR=$STAGE/backup mkdir -p "$BACKUP_DIR" "$CLI_HOME/versions" "$BIN_DIR" -LEGACY_PATH=$CLI_HOME/buda +# The historical 0.1.x direct-binary installation wrote the payload to +# ~/.local/bin/buda by default (or $GUIHO_INSTALL_DIR/buda when overridden). +# Migration removes it only from that exact historical path, only after the +# new launcher transaction has been verified, and never in place. +LEGACY_PATH=$HOME_DIR/.local/bin/buda +if [ -n "${GUIHO_INSTALL_DIR:-}" ]; then LEGACY_PATH=$GUIHO_INSTALL_DIR/buda; fi had_legacy=0 if [ -f "$LEGACY_PATH" ]; then legacy_version=$($LEGACY_PATH --version 2>/dev/null || true) diff --git a/devops/installers_test.go b/devops/installers_test.go index 30cde3a..856a82e 100644 --- a/devops/installers_test.go +++ b/devops/installers_test.go @@ -59,4 +59,25 @@ func TestLifecycleScriptsRejectObsoleteMutableReleaseAssumptions(t *testing.T) { } } } + installSh, _ := os.ReadFile("install.sh") + if !strings.Contains(string(installSh), "armv6") || !strings.Contains(string(installSh), "armv7") { + t.Fatal("install.sh lacks ARMv6 or ARMv7 platform support") + } + if !strings.Contains(string(installSh), `chmod 755 "$STAGE/$BINARY"`) { + t.Fatal("install.sh does not chmod candidate binary before invocation") + } + uninstallSh, _ := os.ReadFile("uninstall.sh") + if !strings.Contains(string(uninstallSh), "mv -f") { + t.Fatal("uninstall.sh does not use atomic move for instruction edits") + } + if !strings.Contains(string(uninstallSh), "Buda Uninstall Plan:") || !strings.Contains(string(uninstallSh), "REMOVE:") || !strings.Contains(string(uninstallSh), "PRESERVE:") { + t.Fatal("uninstall.sh lacks grouped plan output") + } + uninstallPs1, _ := os.ReadFile("uninstall.ps1") + if !strings.Contains(string(uninstallPs1), "Move-Item") { + t.Fatal("uninstall.ps1 does not use atomic move for instruction edits") + } + if !strings.Contains(string(uninstallPs1), "Buda Uninstall Plan:") || !strings.Contains(string(uninstallPs1), "REMOVE:") || !strings.Contains(string(uninstallPs1), "PRESERVE:") { + t.Fatal("uninstall.ps1 lacks grouped plan output") + } } diff --git a/devops/uninstall.ps1 b/devops/uninstall.ps1 index 43a36cb..562ab75 100644 --- a/devops/uninstall.ps1 +++ b/devops/uninstall.ps1 @@ -1,20 +1,20 @@ [CmdletBinding()] param( - [string]$Wiki, + [Parameter(Mandatory=$true)][string]$Wiki, [switch]$PreserveConfig, [switch]$PreserveData, [switch]$DryRun, [switch]$Yes ) $ErrorActionPreference='Stop' -$homeDir=[Environment]::GetFolderPath('UserProfile'); $guiho=Join-Path $homeDir '.guiho'; $cliHome=Join-Path $guiho 'buda'; $bin=Join-Path $guiho 'bin'; $launcher=Join-Path $bin 'buda.exe' +$homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } elseif ($env:HOME) { $env:HOME } else { [Environment]::GetFolderPath('UserProfile') } +$guiho=Join-Path $homeDir '.guiho'; $cliHome=Join-Path $guiho 'buda'; $bin=Join-Path $guiho 'bin'; $launcher=Join-Path $bin 'buda.exe' $tempRoot=Join-Path $guiho '.temp' if ((Test-Path -LiteralPath $launcher) -and $env:BUDA_UNINSTALL_FALLBACK -ne '1') { - $cliArgs=@('uninstall','--yes') + $cliArgs=@('uninstall','--yes','--wiki',$Wiki) if ($PreserveConfig) { $cliArgs += '--preserve-config' } if ($PreserveData) { $cliArgs += '--preserve-data' } if ($DryRun) { $cliArgs += '--dry-run' } - if ($Wiki) { $cliArgs += @('--wiki',$Wiki) } & $launcher @cliArgs exit $LASTEXITCODE } @@ -25,11 +25,10 @@ if (-not (Test-Path -LiteralPath $launcher) -and $env:BUDA_UNINSTALL_FALLBACK -n if ($active -match '^[^\\/]+/buda(?:\.exe)?$') { $payload = Join-Path (Join-Path $cliHome 'versions') ($active -replace '/', [IO.Path]::DirectorySeparatorChar) if (Test-Path -LiteralPath $payload -PathType Leaf) { - $cliArgs=@('uninstall','--yes') + $cliArgs=@('uninstall','--yes','--wiki',$Wiki) if ($PreserveConfig) { $cliArgs += '--preserve-config' } if ($PreserveData) { $cliArgs += '--preserve-data' } if ($DryRun) { $cliArgs += '--dry-run' } - if ($Wiki) { $cliArgs += @('--wiki',$Wiki) } & $payload @cliArgs exit $LASTEXITCODE } @@ -43,26 +42,46 @@ if (Test-Path -LiteralPath $cliHome) { if ($manifest.schema -ne 1 -or $manifest.cli -ne 'buda') { throw 'Refusing fallback uninstall for an invalid or foreign manifest.' } throw 'Refusing fallback uninstall: the installed Buda payload is unavailable; no ownership-safe executor remains.' } -Write-Host "REMOVE $launcher" -Write-Host "REMOVE $(Join-Path $homeDir '.agents\skills\guiho-s-0002-buda')" -Write-Host "REMOVE $(Join-Path $homeDir '.claude\skills\guiho-s-0002-buda')" -Write-Host "PRESERVE $tempRoot" -if ($PreserveData) { Write-Host "PRESERVE $cliHome" } else { Write-Host "REMOVE $cliHome" } -if ($Wiki) { if ($PreserveConfig) { Write-Host "PRESERVE $(Join-Path $Wiki 'buda.yaml')" } else { Write-Host "REMOVE $(Join-Path $Wiki 'buda.yaml')" }; Write-Host "REMOVE managed block $(Join-Path $Wiki 'AGENTS.md')" } +Write-Host "Buda Uninstall Plan:" +Write-Host "REMOVE:" +Write-Host " - $launcher (buda stable launcher)" +Write-Host " - $(Join-Path $homeDir '.agents\skills\guiho-s-0002-buda') (buda global agent skill)" +Write-Host " - $(Join-Path $homeDir '.claude\skills\guiho-s-0002-buda') (buda global agent skill)" +if (-not $PreserveData) { Write-Host " - $cliHome (buda persistent data)" } +if (-not $PreserveConfig) { + Write-Host " - $(Join-Path $cliHome 'buda.global.yaml') (global configuration)" + Write-Host " - $(Join-Path $Wiki 'buda.yaml') (selected wiki configuration)" +} +Write-Host " - $(Join-Path $Wiki 'AGENTS.md') (managed AGENTS instruction block)" +Write-Host "PRESERVE:" +Write-Host " - $tempRoot (shared temp directory)" +if ($PreserveData) { Write-Host " - $cliHome (buda persistent data)" } +if ($PreserveConfig) { + Write-Host " - $(Join-Path $cliHome 'buda.global.yaml') (global configuration)" + Write-Host " - $(Join-Path $Wiki 'buda.yaml') (selected wiki configuration)" +} if ($DryRun) { exit 0 } if (-not $Yes) { if (-not [Environment]::UserInteractive) { throw '--Yes is required for non-interactive uninstall.' }; $answer=Read-Host 'Remove Buda-owned files? [y/N]'; if ($answer -notin @('y','Y','yes','YES')) { throw 'Uninstall cancelled.' } } if (Test-Path -LiteralPath $launcher) { Remove-Item -Force -LiteralPath $launcher } Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $homeDir '.agents\skills\guiho-s-0002-buda\SKILL.md'),(Join-Path $homeDir '.claude\skills\guiho-s-0002-buda\SKILL.md') -Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'versions'),(Join-Path $cliHome 'state'),(Join-Path $cliHome 'current.json'),(Join-Path $cliHome 'installed-artifacts.json'),(Join-Path $cliHome 'cache.json') -if (-not $PreserveData) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'data'),(Join-Path $cliHome 'database') } +if ($PreserveData) { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'versions'),(Join-Path $cliHome 'state'),(Join-Path $cliHome 'current.json'),(Join-Path $cliHome 'installed-artifacts.json'),(Join-Path $cliHome 'cache.json') +} else { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'versions'),(Join-Path $cliHome 'state'),(Join-Path $cliHome 'data'),(Join-Path $cliHome 'database'),(Join-Path $cliHome 'current.json'),(Join-Path $cliHome 'installed-artifacts.json'),(Join-Path $cliHome 'cache.json') +} if (-not $PreserveConfig) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $cliHome 'buda.global.yaml') } if (-not $PreserveConfig -and -not $PreserveData) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $cliHome } -if ($Wiki) { - if (-not $PreserveConfig) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki 'buda.yaml') } - foreach ($name in @('AGENTS.md','CLAUDE.md')) { - $instruction=Join-Path $Wiki $name - if (Test-Path -LiteralPath $instruction) { $content=Get-Content -Raw -LiteralPath $instruction; $content=[regex]::Replace($content,'(?s).*?\r?\n?',''); Set-Content -NoNewline -LiteralPath $instruction -Value $content } +if (-not $PreserveConfig) { Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki 'buda.yaml') } +foreach ($name in @('AGENTS.md','CLAUDE.md')) { + $instruction=Join-Path $Wiki $name + if (Test-Path -LiteralPath $instruction) { + $content=Get-Content -Raw -LiteralPath $instruction + $content=[regex]::Replace($content,'(?s).*?\r?\n?','') + $tempFile=[IO.Path]::GetTempFileName() + $encoding = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($tempFile, $content, $encoding) + Move-Item -Force -LiteralPath $tempFile -Destination $instruction } - Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki '.agents\skills\guiho-s-0002-buda\SKILL.md'),(Join-Path $Wiki '.claude\skills\guiho-s-0002-buda\SKILL.md') } +Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $Wiki '.agents\skills\guiho-s-0002-buda\SKILL.md'),(Join-Path $Wiki '.claude\skills\guiho-s-0002-buda\SKILL.md') Write-Host 'Buda uninstall completed synchronously.' diff --git a/devops/uninstall.sh b/devops/uninstall.sh index e9c26db..d52e2c4 100644 --- a/devops/uninstall.sh +++ b/devops/uninstall.sh @@ -16,6 +16,7 @@ while [ "$#" -gt 0 ]; do *) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; esac done +[ -n "$WIKI" ] || { printf '%s\n' '--wiki is required for uninstall' >&2; exit 2; } HOME_DIR=${HOME:?HOME is required} GUIHO_HOME=$HOME_DIR/.guiho CLI_HOME=$GUIHO_HOME/buda @@ -25,11 +26,10 @@ TEMP_ROOT=$GUIHO_HOME/.temp # The installed Cobra command is the canonical manifest-driven planner. The # script remains usable for recovery when the launcher is missing or corrupt. if [ -x "$BIN_DIR/buda" ] && [ "${BUDA_UNINSTALL_FALLBACK:-0}" != "1" ]; then - set -- uninstall --yes + set -- uninstall --yes --wiki "$WIKI" [ "$PRESERVE_CONFIG" -eq 1 ] && set -- "$@" --preserve-config [ "$PRESERVE_DATA" -eq 1 ] && set -- "$@" --preserve-data [ "$DRY_RUN" -eq 1 ] && set -- "$@" --dry-run - [ -n "$WIKI" ] && set -- "$@" --wiki "$WIKI" exec "$BIN_DIR/buda" "$@" fi @@ -44,11 +44,10 @@ if [ "${BUDA_UNINSTALL_FALLBACK:-0}" != "1" ] && [ ! -x "$BIN_DIR/buda" ] && [ - *) payload="$CLI_HOME/versions/$active" if [ -x "$payload" ]; then - set -- uninstall --yes + set -- uninstall --yes --wiki "$WIKI" [ "$PRESERVE_CONFIG" -eq 1 ] && set -- "$@" --preserve-config [ "$PRESERVE_DATA" -eq 1 ] && set -- "$@" --preserve-data [ "$DRY_RUN" -eq 1 ] && set -- "$@" --dry-run - [ -n "$WIKI" ] && set -- "$@" --wiki "$WIKI" exec "$payload" "$@" fi ;; @@ -63,22 +62,32 @@ if [ -e "$CLI_HOME" ]; then exit 1 fi -printf 'REMOVE %s\n' "$BIN_DIR/buda" -printf 'REMOVE %s\n' "$HOME_DIR/.agents/skills/guiho-s-0002-buda" -printf 'REMOVE %s\n' "$HOME_DIR/.claude/skills/guiho-s-0002-buda" -printf 'PRESERVE %s\n' "$TEMP_ROOT" -if [ "$PRESERVE_DATA" -eq 1 ]; then printf 'PRESERVE %s\n' "$CLI_HOME"; else printf 'REMOVE %s\n' "$CLI_HOME"; fi -if [ -n "$WIKI" ]; then - if [ "$PRESERVE_CONFIG" -eq 1 ]; then printf 'PRESERVE %s\n' "$WIKI/buda.yaml"; else printf 'REMOVE %s\n' "$WIKI/buda.yaml"; fi - printf 'REMOVE managed block %s/AGENTS.md\n' "$WIKI" +printf '%s\n' 'Buda Uninstall Plan:' +printf '%s\n' 'REMOVE:' +printf ' - %s (buda stable launcher)\n' "$BIN_DIR/buda" +printf ' - %s (buda global agent skill)\n' "$HOME_DIR/.agents/skills/guiho-s-0002-buda" +printf ' - %s (buda global agent skill)\n' "$HOME_DIR/.claude/skills/guiho-s-0002-buda" +if [ "$PRESERVE_DATA" -ne 1 ]; then printf ' - %s (buda persistent data)\n' "$CLI_HOME"; fi +if [ "$PRESERVE_CONFIG" -ne 1 ]; then + printf ' - %s (global configuration)\n' "$CLI_HOME/buda.global.yaml" + printf ' - %s (selected wiki configuration)\n' "$WIKI/buda.yaml" fi +printf ' - %s (managed AGENTS instruction block)\n' "$WIKI/AGENTS.md" +printf '%s\n' 'PRESERVE:' +printf ' - %s (shared temp directory)\n' "$TEMP_ROOT" +if [ "$PRESERVE_DATA" -eq 1 ]; then printf ' - %s (buda persistent data)\n' "$CLI_HOME"; fi +if [ "$PRESERVE_CONFIG" -eq 1 ]; then + printf ' - %s (global configuration)\n' "$CLI_HOME/buda.global.yaml" + printf ' - %s (selected wiki configuration)\n' "$WIKI/buda.yaml" +fi + [ "$DRY_RUN" -eq 1 ] && exit 0 if [ "$YES" -ne 1 ]; then [ -t 0 ] || { printf '%s\n' '--yes is required for non-interactive uninstall' >&2; exit 2; } printf 'Remove Buda-owned files? [y/N] '; read answer; case "$answer" in y|Y|yes|YES) ;; *) exit 2 ;; esac fi rm -f "$BIN_DIR/buda" -rm -f "$BIN_DIR/buda" "$HOME_DIR/.agents/skills/guiho-s-0002-buda/SKILL.md" "$HOME_DIR/.claude/skills/guiho-s-0002-buda/SKILL.md" +rm -f "$HOME_DIR/.agents/skills/guiho-s-0002-buda/SKILL.md" "$HOME_DIR/.claude/skills/guiho-s-0002-buda/SKILL.md" if [ "$PRESERVE_DATA" -ne 1 ]; then rm -rf "$CLI_HOME/versions" "$CLI_HOME/state" "$CLI_HOME/data" "$CLI_HOME/database" "$CLI_HOME/current.json" "$CLI_HOME/installed-artifacts.json" "$CLI_HOME/cache.json" else @@ -86,16 +95,13 @@ else fi [ "$PRESERVE_CONFIG" -eq 1 ] || rm -f "$CLI_HOME/buda.global.yaml" if [ "$PRESERVE_CONFIG" -eq 0 ] && [ "$PRESERVE_DATA" -eq 0 ]; then rmdir "$CLI_HOME" 2>/dev/null || true; fi -if [ -n "$WIKI" ]; then - [ "$PRESERVE_CONFIG" -eq 1 ] || rm -f "$WIKI/buda.yaml" - for instruction in "$WIKI/AGENTS.md" "$WIKI/CLAUDE.md"; do - if [ -f "$instruction" ]; then - temporary=$(mktemp "$TEMP_ROOT/buda-uninstall-XXXXXX") - sed '//,//d' "$instruction" > "$temporary" - cat "$temporary" > "$instruction" - rm -f "$temporary" - fi - done - rm -f "$WIKI/.agents/skills/guiho-s-0002-buda/SKILL.md" "$WIKI/.claude/skills/guiho-s-0002-buda/SKILL.md" -fi +[ "$PRESERVE_CONFIG" -eq 1 ] || rm -f "$WIKI/buda.yaml" +for instruction in "$WIKI/AGENTS.md" "$WIKI/CLAUDE.md"; do + if [ -f "$instruction" ]; then + temporary=$(mktemp "$TEMP_ROOT/buda-uninstall-XXXXXX") + sed '//,//d' "$instruction" > "$temporary" + mv -f "$temporary" "$instruction" + fi +done +rm -f "$WIKI/.agents/skills/guiho-s-0002-buda/SKILL.md" "$WIKI/.claude/skills/guiho-s-0002-buda/SKILL.md" printf '%s\n' 'Buda uninstall completed synchronously.' diff --git a/examples/embed.go b/examples/embed.go new file mode 100644 index 0000000..2284b05 --- /dev/null +++ b/examples/embed.go @@ -0,0 +1,8 @@ +package examples + +import "embed" + +// FS contains Buda's configuration example files. +// +//go:embed *.yaml +var FS embed.FS diff --git a/examples/examples.xdocs.md b/examples/examples.xdocs.md index fe31865..42a3086 100644 --- a/examples/examples.xdocs.md +++ b/examples/examples.xdocs.md @@ -4,6 +4,7 @@ description: Complete valid project and global configuration examples. parent: buda-package children: [] files: + embed.go: Embedded filesystem exporting the configuration example files. buda.example.yaml: Complete valid explicit-wiki project configuration example. buda.global.example.yaml: Complete valid global configuration example. documents: {} diff --git a/internal/artifact/manifest.go b/internal/artifact/manifest.go index 84c8c49..a0dbde0 100644 --- a/internal/artifact/manifest.go +++ b/internal/artifact/manifest.go @@ -93,6 +93,9 @@ func (m Manifest) Validate() error { if _, err := hex.DecodeString(strings.ToLower(a.SHA256)); err != nil { return fmt.Errorf("artifact %s SHA-256 is invalid: %w", a.ID, err) } + if strings.Trim(a.SHA256, "0") == "" && a.Path != "artifacts.json" { + return fmt.Errorf("artifact %s has an all-zero SHA-256 digest; all-zero is permitted only for artifacts.json", a.ID) + } if a.Ownership != OwnershipReplaceable && a.Ownership != OwnershipPersistent && a.Ownership != OwnershipDisposable { return fmt.Errorf("artifact %s ownership is invalid", a.ID) } diff --git a/internal/artifact/manifest_test.go b/internal/artifact/manifest_test.go index 9c66b61..e910b41 100644 --- a/internal/artifact/manifest_test.go +++ b/internal/artifact/manifest_test.go @@ -28,6 +28,7 @@ func TestManifestRejectsUnsafeAndInconsistentOwnership(t *testing.T) { {"version drift", func(value *Manifest) { value.Artifacts[0].Version = "0.1.1" }}, {"ownership flags", func(value *Manifest) { value.Artifacts[0].Persistent = true }}, {"duplicate release path", func(value *Manifest) { value.Artifacts = append(value.Artifacts, validManifest().Artifacts[0]) }}, + {"all-zero digest on regular artifact", func(value *Manifest) { value.Artifacts[0].SHA256 = strings.Repeat("0", 64) }}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { @@ -57,3 +58,24 @@ func TestManifestRoundTripUsesStrictJSON(t *testing.T) { t.Fatal("unknown manifest field was accepted") } } + +func TestManifestAllowsAllZeroForArtifactsJSON(t *testing.T) { + manifest := Manifest{ + Schema: 1, CLI: "buda", Version: "0.2.0", + Artifacts: []Artifact{ + { + ID: "payload", Version: "0.2.0", Path: "buda-linux-amd64", + InstalledPath: "versions/0.2.0/buda", SHA256: strings.Repeat("a", 64), + Ownership: OwnershipReplaceable, Replaceable: true, + }, + { + ID: "artifacts-json", Version: "0.2.0", Path: "artifacts.json", + InstalledPath: "versions/0.2.0/artifacts/artifacts.json", SHA256: strings.Repeat("0", 64), + Ownership: OwnershipReplaceable, Replaceable: true, + }, + }, + } + if err := manifest.Validate(); err != nil { + t.Fatalf("Validate() failed for manifest with all-zero artifacts.json: %v", err) + } +} diff --git a/internal/capture/capture.go b/internal/capture/capture.go index f5a6915..c7ab14d 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -120,28 +120,7 @@ func Run(selected repository.Repository, request Request) (Result, error) { body = append(append([]byte(nil), body...), append(marker, '\n')...) body = append(body, definition...) } - metadata := frontmatter{ - Type: request.Type, - Title: request.Title, - Description: request.Description, - Status: "draft", - Generated: generated{By: request.Actor, At: request.Now.UTC().Format(time.RFC3339)}, - Sources: []okf.Source{{ - ID: sourceID, Resource: "buda:capture", Title: "Explicit user-directed capture input", Author: request.Actor, - }}, - Buda: okf.BudaMetadata{ - SchemaVersion: "1", UID: uid, WikiID: selected.Config.WikiID, - SourceDigests: map[string]string{sourceID: digest}, - }, - } - front, err := yaml.Marshal(metadata) - if err != nil { - return Result{}, fmt.Errorf("encode captured concept frontmatter: %w", err) - } - data := append([]byte("---\n"), front...) - data = append(data, []byte("---\n\n")...) - data = append(data, body...) - + var data []byte created, updated, unchanged := false, false, false if existing, err := os.ReadFile(absolute); err == nil { document, parseErr := okf.ParseConcept(relative, existing) @@ -161,9 +140,88 @@ func Run(selected repository.Repository, request Request) (Result, error) { return Result{}, fmt.Errorf("concept %q already exists with different content; pass explicit replacement approval", relative) } else { updated = true + if err := document.Set("type", request.Type); err != nil { + return Result{}, err + } + if err := document.Set("title", request.Title); err != nil { + return Result{}, err + } + if err := document.Set("description", request.Description); err != nil { + return Result{}, err + } + if document.String("status") == "" { + if err := document.Set("status", "draft"); err != nil { + return Result{}, err + } + } + if err := document.Set("generated", generated{By: request.Actor, At: request.Now.UTC().Format(time.RFC3339)}); err != nil { + return Result{}, err + } + sources, _ := document.Sources() + sourceUpdated := false + newSource := okf.Source{ + ID: sourceID, Resource: "buda:capture", Title: "Explicit user-directed capture input", Author: request.Actor, + } + for i, s := range sources { + if s.ID == sourceID { + sources[i] = newSource + sourceUpdated = true + break + } + } + if !sourceUpdated { + sources = append(sources, newSource) + } + if err := document.Set("sources", sources); err != nil { + return Result{}, err + } + budaMeta, _, _ := document.Buda() + if budaMeta.SchemaVersion == "" { + budaMeta.SchemaVersion = "1" + } + if budaMeta.UID == "" { + budaMeta.UID = uid + } + if budaMeta.WikiID == "" { + budaMeta.WikiID = selected.Config.WikiID + } + if budaMeta.SourceDigests == nil { + budaMeta.SourceDigests = make(map[string]string) + } + budaMeta.SourceDigests[sourceID] = digest + if err := document.Set("buda", budaMeta); err != nil { + return Result{}, err + } + document.SetBody(body) + marshaled, err := document.Marshal() + if err != nil { + return Result{}, fmt.Errorf("marshal updated concept %q: %w", relative, err) + } + data = marshaled } } else if errors.Is(err, os.ErrNotExist) { created = true + metadata := frontmatter{ + Type: request.Type, + Title: request.Title, + Description: request.Description, + Status: "draft", + Generated: generated{By: request.Actor, At: request.Now.UTC().Format(time.RFC3339)}, + Sources: []okf.Source{{ + ID: sourceID, Resource: "buda:capture", Title: "Explicit user-directed capture input", Author: request.Actor, + }}, + Buda: okf.BudaMetadata{ + SchemaVersion: "1", UID: uid, WikiID: selected.Config.WikiID, + SourceDigests: map[string]string{sourceID: digest}, + }, + } + front, err := yaml.Marshal(metadata) + if err != nil { + return Result{}, fmt.Errorf("encode captured concept frontmatter: %w", err) + } + data = append([]byte("---\n"), front...) + data = append(data, []byte("---\n\n")...) + data = append(data, body...) } else { return Result{}, fmt.Errorf("inspect capture target %q: %w", relative, err) } diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index c5f241c..b5c9727 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -320,3 +320,95 @@ func TestCaptureAlreadyMarkedDoesNotDuplicateMarker(t *testing.T) { t.Fatalf("captured concept not healthy: conformant=%v healthy=%v findings=%+v", report.Conformant, report.Healthy, report.Findings) } } + +func TestCaptureReplacePreservesUnknownOKFMetadata(t *testing.T) { + wiki := filepath.Join(t.TempDir(), "wiki") + if _, err := repository.Initialize(wiki, repository.InitOptions{WikiID: "wiki"}); err != nil { + t.Fatal(err) + } + selected, err := repository.Open(wiki) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) + initial := []byte(`--- +type: Note +title: Custom Knowledge +description: Has custom producer metadata. +status: draft +custom_owner: human:alice +tags: + - domain:research + - priority:high +extra_field: + nested_key: 42 +generated: + by: human:alice + at: 2026-08-16T00:00:00Z +sources: + - id: capture-input + resource: buda:capture + title: Explicit user-directed capture input + author: human:alice +--- + +Initial text.[^capture-input] + +[^capture-input]: Explicit user-directed capture input. +`) + targetPath := filepath.Join(selected.Bundle, "concepts", "custom.md") + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetPath, initial, 0o644); err != nil { + t.Fatal(err) + } + + // Now run capture with --replace and updated text + request := Request{ + Target: "concepts/custom.md", + Title: "Updated Custom Knowledge", + Description: "Updated description.", + Type: "Decision", + Text: []byte("Updated text content with new insights."), + Actor: "human:bob", + Now: now, + Replace: true, + } + result, err := Run(selected, request) + if err != nil || !result.Updated { + t.Fatalf("Run = %+v, %v", result, err) + } + + updatedBytes, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + document, err := okf.ParseConcept("concepts/custom.md", updatedBytes) + if err != nil { + t.Fatalf("parse updated concept: %v", err) + } + + if document.String("type") != "Decision" { + t.Fatalf("type = %q, want Decision", document.String("type")) + } + if document.String("title") != "Updated Custom Knowledge" { + t.Fatalf("title = %q, want Updated Custom Knowledge", document.String("title")) + } + // Verify unknown OKF metadata keys were preserved + if document.String("custom_owner") != "human:alice" { + t.Fatalf("custom_owner = %q, want human:alice", document.String("custom_owner")) + } + tags := document.Strings("tags") + if len(tags) != 2 || tags[0] != "domain:research" || tags[1] != "priority:high" { + t.Fatalf("tags = %+v, want [domain:research priority:high]", tags) + } + metaCopy, err := document.MetadataCopy() + if err != nil { + t.Fatalf("MetadataCopy() err = %v", err) + } + extra, ok := metaCopy["extra_field"].(map[string]any) + if !ok || extra["nested_key"] != 42 { + t.Fatalf("extra_field = %+v, want map with nested_key: 42", metaCopy["extra_field"]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 99f9c01..287488b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -249,10 +249,19 @@ func validateRepositoryRelative(name, value string) error { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s must not be empty", name) } - if strings.ContainsRune(value, '\x00') || strings.ContainsRune(value, ':') || filepath.IsAbs(value) || filepath.VolumeName(value) != "" { + if strings.ContainsAny(value, "\r\n\x00") { + return fmt.Errorf("%s contains an invalid control character", name) + } + normalized := strings.ReplaceAll(value, "\\", "/") + if strings.ContainsRune(value, ':') || filepath.IsAbs(value) || filepath.VolumeName(value) != "" || strings.HasPrefix(normalized, "/") { return fmt.Errorf("%s must be a repository-relative path", name) } - clean := filepath.Clean(value) + for _, component := range strings.Split(normalized, "/") { + if component == ".." { + return fmt.Errorf("%s must resolve within the selected wiki", name) + } + } + clean := filepath.Clean(filepath.FromSlash(normalized)) if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return fmt.Errorf("%s must resolve within the selected wiki", name) } diff --git a/internal/config/dual.go b/internal/config/dual.go index 8147f5c..4c3c4b8 100644 --- a/internal/config/dual.go +++ b/internal/config/dual.go @@ -102,9 +102,6 @@ func DecodeGlobal(reader io.Reader) (GlobalConfig, error) { if err := decodeStrict(reader, &value); err != nil { return GlobalConfig{}, err } - if value.Schema == 0 { - value.Schema = CurrentSchema - } if value.Schema != CurrentSchema { return GlobalConfig{}, fmt.Errorf("global schema must be %d", CurrentSchema) } @@ -119,9 +116,6 @@ func DecodeProject(reader io.Reader) (ProjectConfig, error) { if err := decodeStrict(reader, &value); err != nil { return ProjectConfig{}, err } - if value.Schema == 0 { - value.Schema = CurrentSchema - } if value.Schema != CurrentSchema { return ProjectConfig{}, fmt.Errorf("project schema must be %d", CurrentSchema) } diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index 0dea78e..387f7a3 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -37,12 +37,23 @@ func ReadPointer(path string) (Pointer, error) { if err := decoder.Decode(&pointer); err != nil { return Pointer{}, fmt.Errorf("decode active Buda pointer: %w", err) } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return Pointer{}, errors.New("pointer must contain exactly one JSON document") + } + return Pointer{}, fmt.Errorf("decode active Buda pointer: expected exactly one JSON document: %w", err) + } if pointer.Schema != 1 { return Pointer{}, errors.New("unsupported active Buda pointer schema") } if err := validateRelative(pointer.Active); err != nil { return Pointer{}, fmt.Errorf("active payload: %w", err) } + baseActive := filepath.Base(filepath.FromSlash(pointer.Active)) + if baseActive != "buda" && baseActive != "buda.exe" { + return Pointer{}, fmt.Errorf("active payload filename %q is not a canonical Buda payload name", baseActive) + } if !validVersion(pointer.ActiveVersion) || pointerVersion(pointer.Active) != pointer.ActiveVersion { return Pointer{}, errors.New("active payload version does not match active_version") } @@ -53,6 +64,10 @@ func ReadPointer(path string) (Pointer, error) { if err := validateRelative(pointer.Previous); err != nil { return Pointer{}, fmt.Errorf("previous payload: %w", err) } + basePrev := filepath.Base(filepath.FromSlash(pointer.Previous)) + if basePrev != "buda" && basePrev != "buda.exe" { + return Pointer{}, fmt.Errorf("previous payload filename %q is not a canonical Buda payload name", basePrev) + } if !validVersion(pointer.PreviousVersion) || pointerVersion(pointer.Previous) != pointer.PreviousVersion { return Pointer{}, errors.New("previous payload version does not match previous_version") } diff --git a/internal/lifecycle/linkcheck_other.go b/internal/lifecycle/linkcheck_other.go new file mode 100644 index 0000000..5fb63bc --- /dev/null +++ b/internal/lifecycle/linkcheck_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package lifecycle + +import "os" + +// isLinked reports whether the entry is a symbolic link. +func isLinked(info os.FileInfo) bool { + return info.Mode()&os.ModeSymlink != 0 +} diff --git a/internal/lifecycle/linkcheck_windows.go b/internal/lifecycle/linkcheck_windows.go new file mode 100644 index 0000000..ddc69bb --- /dev/null +++ b/internal/lifecycle/linkcheck_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package lifecycle + +import ( + "os" + "syscall" +) + +// isLinked reports whether the entry is a symlink or any other reparse point, +// such as a junction or mount point. Go's Lstat only reports IO_REPARSE_TAG_SYMLINK +// entries as ModeSymlink; junction points are reported as directories and must +// be detected through the raw Win32 file attributes. +func isLinked(info os.FileInfo) bool { + if info.Mode()&os.ModeSymlink != 0 { + return true + } + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return false + } + return data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 +} diff --git a/internal/lifecycle/transaction.go b/internal/lifecycle/transaction.go index d3ccd3f..fa66cc6 100644 --- a/internal/lifecycle/transaction.go +++ b/internal/lifecycle/transaction.go @@ -199,9 +199,41 @@ func AtomicWrite(path string, content []byte, mode os.FileMode) error { return os.Rename(tmpPath, path) } -func SafeRemove(path, root string) error { +// IsLinked reports whether the entry is a symbolic link or, on Windows, any +// reparse point such as a junction. +func IsLinked(info os.FileInfo) bool { return isLinked(info) } + +func VerifyNoLinkedAncestors(path, root string) error { if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" { - return errors.New("remove path and root are required") + return errors.New("path and root are required") + } + cleanPath := filepath.Clean(path) + cleanRoot := filepath.Clean(root) + rel, err := filepath.Rel(cleanRoot, cleanPath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path %s is outside root %s", cleanPath, cleanRoot) + } + curr := cleanPath + for { + info, err := os.Lstat(curr) + if err == nil { + if isLinked(info) { + return fmt.Errorf("path or ancestor is a symlink or reparse point: %s", curr) + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if curr == cleanRoot || curr == filepath.Dir(curr) { + break + } + curr = filepath.Dir(curr) + } + return nil +} + +func SafeRemove(path, root string) error { + if err := VerifyNoLinkedAncestors(path, root); err != nil { + return err } rel, err := filepath.Rel(root, path) if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { @@ -214,8 +246,8 @@ func SafeRemove(path, root string) error { if err != nil { return err } - if info.Mode()&os.ModeSymlink != 0 { - return errors.New("refuse removal of symlink") + if isLinked(info) { + return errors.New("refuse removal of symlink or reparse point") } return os.RemoveAll(path) } diff --git a/internal/maintenance/maintenance.go b/internal/maintenance/maintenance.go index b2e2683..a3f4ec6 100644 --- a/internal/maintenance/maintenance.go +++ b/internal/maintenance/maintenance.go @@ -23,6 +23,9 @@ func ShouldSchedule(command *cobra.Command) bool { if command == nil || command.Hidden { return false } + if os.Getenv("BUDA_DISABLE_MAINTENANCE") == "1" { + return false + } for current := command; current != nil; current = current.Parent() { if current.Hidden || current.Name() == "uninstall" || current.Name() == "upgrade" { return false diff --git a/internal/releasecatalog/catalog.go b/internal/releasecatalog/catalog.go index 1f9b917..ba80c15 100644 --- a/internal/releasecatalog/catalog.go +++ b/internal/releasecatalog/catalog.go @@ -70,11 +70,15 @@ type Result struct { Channel string } -var required = map[string]bool{ - "checksums.txt": true, "artifacts.json": true, "buda.schema.json": true, - "buda.global.schema.json": true, "guiho-i-buda.md": true, "guiho-p-buda.md": true, - "buda.example.yaml": true, "buda.global.example.yaml": true, - "guiho-s-0002-buda.zip": true, +var requiredReleaseAssets = []string{ + "buda-linux-amd64", "buda-linux-arm64", "buda-linux-armv7", "buda-linux-armv6", + "buda-darwin-amd64", "buda-darwin-arm64", "buda-windows-amd64.exe", "buda-windows-arm64.exe", + "buda-launcher-linux-amd64", "buda-launcher-linux-arm64", "buda-launcher-linux-armv7", "buda-launcher-linux-armv6", + "buda-launcher-darwin-amd64", "buda-launcher-darwin-arm64", "buda-launcher-windows-amd64.exe", "buda-launcher-windows-arm64.exe", + "buda.schema.json", "buda.global.schema.json", + "buda.example.yaml", "buda.global.example.yaml", + "guiho-s-0002-buda.zip", "guiho-i-buda.md", "guiho-p-buda.md", + "artifacts.json", "checksums.txt", } func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, buildTarget string) (Result, error) { @@ -110,10 +114,15 @@ func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, } var binary, launcher, manifest selfmanage.Asset present := map[string]bool{} + hasDuplicates := false for _, asset := range release.Assets { - if strings.TrimSpace(asset.Name) == "" || strings.TrimSpace(asset.DownloadURL) == "" || present[asset.Name] { + if strings.TrimSpace(asset.Name) == "" || strings.TrimSpace(asset.DownloadURL) == "" { continue } + if present[asset.Name] { + hasDuplicates = true + break + } present[asset.Name] = true if asset.Name == assetName { binary = asset @@ -125,6 +134,12 @@ func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, manifest = asset } } + if hasDuplicates { + if requested != "" { + return Result{}, fmt.Errorf("release %s contains duplicate asset names", release.Version) + } + continue + } if binary.Name == "" || launcher.Name == "" || manifest.Name == "" { if requested != "" { return Result{}, fmt.Errorf("release %s lacks compatible payload, launcher, or checksums.txt", release.Version) @@ -138,7 +153,7 @@ func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, continue } complete := true - for name := range required { + for _, name := range requiredReleaseAssets { if !present[name] { complete = false break @@ -146,7 +161,7 @@ func Select(ctx context.Context, catalog selfmanage.Catalog, selector Selector, } if !complete { if requested != "" { - return Result{}, fmt.Errorf("release %s is incomplete: required manifest and agent artifacts are missing", release.Version) + return Result{}, fmt.Errorf("release %s is incomplete: required manifest, payload, launcher, schema, example, or agent artifacts are missing", release.Version) } continue } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 33d87a1..5a9167f 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -52,9 +52,28 @@ func ResolveTarget(wiki string) (string, error) { return filepath.Join(parent, filepath.Base(absolute)), nil } +func effectiveHome() (string, error) { + if h := os.Getenv("USERPROFILE"); h != "" { + return h, nil + } + if h := os.Getenv("HOME"); h != "" { + return h, nil + } + return os.UserHomeDir() +} + // Open resolves exactly the selected root and its root buda.yaml. It does not // search upward if the selected path is a child of a wiki. func Open(wiki string) (Repository, error) { + home, err := effectiveHome() + if err != nil { + return Repository{}, fmt.Errorf("resolve user home for global Buda configuration: %w", err) + } + return OpenWithHome(wiki, home) +} + +// OpenWithHome resolves exactly the selected root using the provided user home directory. +func OpenWithHome(wiki, home string) (Repository, error) { root, err := ResolveTarget(wiki) if err != nil { return Repository{}, err @@ -67,10 +86,6 @@ func Open(wiki string) (Repository, error) { return Repository{}, fmt.Errorf("wiki path %q is not a directory", root) } configPath := filepath.Join(root, config.FileName) - home, err := os.UserHomeDir() - if err != nil { - return Repository{}, fmt.Errorf("resolve user home for global Buda configuration: %w", err) - } configuration, err := config.LoadEffective(configPath, home) if err != nil { return Repository{}, fmt.Errorf("selected wiki must contain exactly one root %s: %w", config.FileName, err) diff --git a/internal/uninstall/plan.go b/internal/uninstall/plan.go index 57431e3..dc815ab 100644 --- a/internal/uninstall/plan.go +++ b/internal/uninstall/plan.go @@ -4,6 +4,7 @@ package uninstall import ( "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -105,27 +106,225 @@ func BuildPlan(layout installlayout.Layout, wiki string, preserveConfig, preserv return plan, nil } -func Apply(plan Plan) error { +type movedItem struct { + source string + quarantine string +} + +type fileSnapshot struct { + path string + content []byte + existed bool +} + +// Apply executes the plan transactionally. Every removable path is first +// proven safe against symlink/reparse ancestors, then moved or copied into a +// unique Buda-owned quarantine directory under stagingRoot (normally the +// shared .guiho/.temp directory, so moves stay on one filesystem). Instruction +// blocks are edited only after snapshots allow byte-exact restoration. +// +// On any failure the previous state is restored before returning. If a +// restoration step itself fails, the quarantine directory is intentionally +// retained and reported so no user data can be lost. +func Apply(plan Plan, stagingRoot string) error { for _, item := range plan.Items { - if item.Preserved { - continue + if strings.TrimSpace(item.Path) == "" || strings.TrimSpace(item.Root) == "" { + return errors.New("uninstall plan contains an empty path or ownership root") + } + if err := lifecycle.VerifyNoLinkedAncestors(item.Path, item.Root); err != nil { + return err + } + } + if strings.TrimSpace(stagingRoot) == "" { + return errors.New("uninstall staging root is required") + } + if err := os.MkdirAll(stagingRoot, 0o755); err != nil { + return fmt.Errorf("create uninstall staging root: %w", err) + } + quarantineDir, err := os.MkdirTemp(stagingRoot, "buda-uninstall-") + if err != nil { + return fmt.Errorf("create uninstall quarantine directory: %w", err) + } + quarantineRetained := false + cleanupQuarantine := func() { + if !quarantineRetained { + _ = os.RemoveAll(quarantineDir) } - if strings.Contains(item.Owner, "instruction block") { - if err := removeManagedInstruction(item.Path); err != nil { - return err + } + defer cleanupQuarantine() + + instructionSnapshots, err := snapshotInstructions(plan) + if err != nil { + return err + } + + var moved []movedItem + restore := func() error { + var failures []error + for i := len(moved) - 1; i >= 0; i-- { + item := moved[i] + if restoreErr := restoreItem(item); restoreErr != nil { + failures = append(failures, restoreErr) + } + } + for _, snapshot := range instructionSnapshots { + if restoreErr := restoreFileSnapshot(snapshot); restoreErr != nil { + failures = append(failures, restoreErr) } + } + if len(failures) > 0 { + quarantineRetained = true + return fmt.Errorf("uninstall rollback incomplete (quarantine retained at %s): %v", quarantineDir, failures) + } + return nil + } + + // Managed instruction blocks are edited first because that step still + // reads the selected wiki configuration, which is quarantined afterwards. + for _, item := range plan.Items { + if item.Preserved || !strings.Contains(item.Owner, "instruction block") { continue } - if strings.TrimSpace(item.Path) == "" || strings.TrimSpace(item.Root) == "" { - return errors.New("uninstall plan contains an empty path or ownership root") + if err := removeManagedInstruction(item.Path); err != nil { + _ = restore() + return err } - if err := lifecycle.SafeRemove(item.Path, item.Root); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", item.Path, err) + } + + for i, item := range plan.Items { + if item.Preserved || strings.Contains(item.Owner, "instruction block") { + continue + } + info, statErr := os.Lstat(item.Path) + if errors.Is(statErr, os.ErrNotExist) { + continue } + if statErr != nil { + _ = restore() + return fmt.Errorf("inspect %s: %w", item.Path, statErr) + } + if lifecycle.IsLinked(info) { + _ = restore() + return fmt.Errorf("refuse removal of symlink or reparse point: %s", item.Path) + } + quarantineTarget := filepath.Join(quarantineDir, fmt.Sprintf("item-%d-%s", i, filepath.Base(item.Path))) + entry := movedItem{source: item.Path, quarantine: quarantineTarget} + if err := moveToQuarantine(item.Path, quarantineTarget); err != nil { + _ = restore() + return fmt.Errorf("quarantine %s: %w", item.Path, err) + } + moved = append(moved, entry) } return nil } +// moveToQuarantine prefers a same-filesystem rename. When source and +// quarantine live on different volumes it falls back to copy plus verified +// removal so cross-device targets stay transactional. +func moveToQuarantine(source, target string) error { + if err := os.Rename(source, target); err == nil { + return nil + } + info, statErr := os.Stat(source) + if statErr != nil { + return statErr + } + if info.IsDir() { + if err := copyTree(source, target); err != nil { + return err + } + } else { + if err := copyFileAtomic(source, target); err != nil { + return err + } + } + if err := os.RemoveAll(source); err != nil { + _ = os.RemoveAll(target) + return err + } + return nil +} + +func restoreItem(item movedItem) error { + if err := os.MkdirAll(filepath.Dir(item.source), 0o755); err != nil { + return err + } + if err := os.Rename(item.quarantine, item.source); err == nil { + return nil + } + info, statErr := os.Stat(item.quarantine) + if statErr != nil { + return statErr + } + if info.IsDir() { + return copyTree(item.quarantine, item.source) + } + return copyFileAtomic(item.quarantine, item.source) +} + +func snapshotInstructions(plan Plan) ([]fileSnapshot, error) { + var snapshots []fileSnapshot + for _, item := range plan.Items { + if item.Preserved || !strings.Contains(item.Owner, "instruction block") { + continue + } + data, err := os.ReadFile(item.Path) + if errors.Is(err, os.ErrNotExist) { + snapshots = append(snapshots, fileSnapshot{path: item.Path, existed: false}) + continue + } + if err != nil { + return nil, fmt.Errorf("snapshot managed instruction %s: %w", item.Path, err) + } + snapshots = append(snapshots, fileSnapshot{path: item.Path, content: data, existed: true}) + } + return snapshots, nil +} + +func restoreFileSnapshot(snapshot fileSnapshot) error { + if !snapshot.existed { + return nil + } + return lifecycle.AtomicWrite(snapshot.path, snapshot.content, 0o644) +} + +func copyTree(source, destination string) error { + return filepath.Walk(source, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFileAtomic(path, target) + }) +} + +func copyFileAtomic(source, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.Create(destination) + if err != nil { + return err + } + if _, err := io.Copy(output, input); err != nil { + output.Close() + return err + } + return output.Close() +} + func removeManagedInstruction(path string) error { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return nil diff --git a/internal/uninstall/plan_test.go b/internal/uninstall/plan_test.go index ba84949..933b138 100644 --- a/internal/uninstall/plan_test.go +++ b/internal/uninstall/plan_test.go @@ -112,10 +112,51 @@ func TestBuildPlanNeverRecursivelyRemovesCLIHomeContainer(t *testing.T) { t.Fatalf("CLI home container is destructive: %#v", item) } } - if err := Apply(plan); err != nil { + if err := Apply(plan, layout.Temp); err != nil { t.Fatal(err) } if _, err := os.Stat(sentinel); err != nil { t.Fatalf("unmanaged CLI-home content was removed: %v", err) } } + +func TestApplyQuarantineRollbackOnFailure(t *testing.T) { + home := t.TempDir() + layout, err := installlayout.ForHome(home) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.CLIHome, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"schema":1,"cli":"buda","version":"0.2.0","artifacts":[{"id":"example","version":"0.2.0","path":"buda.example.yaml","installed_path":"versions/0.2.0/artifacts/buda.example.yaml","sha256":"` + strings.Repeat("a", 64) + `","ownership":"replaceable","replaceable":true,"persistent":false,"disposable":false}]}` + if err := os.WriteFile(layout.InstalledManifest, []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + target1 := filepath.Join(layout.CLIHome, "versions", "0.2.0", "artifacts", "buda.example.yaml") + if err := os.MkdirAll(filepath.Dir(target1), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target1, []byte("content1"), 0o644); err != nil { + t.Fatal(err) + } + // Add an unremovable/failing item to simulate failure + plan, err := BuildPlan(layout, "", false, false) + if err != nil { + t.Fatal(err) + } + // Inject an item with an invalid path that cannot be accessed or moved + plan.Items = append(plan.Items, Item{ + Path: filepath.Join(layout.CLIHome, "nonexistent-dir", "invalid\x00file"), + Root: layout.CLIHome, + Action: "REMOVE", + Owner: "test-fail", + }) + if err := Apply(plan, layout.Temp); err == nil { + t.Fatal("Apply unexpectedly succeeded with invalid item") + } + // Verify target1 was restored by rollback + if content, err := os.ReadFile(target1); err != nil || string(content) != "content1" { + t.Fatalf("target1 was not restored after rollback: %q, %v", content, err) + } +} diff --git a/internal/upgrade/transaction.go b/internal/upgrade/transaction.go index 3f5e5b9..adc2c0a 100644 --- a/internal/upgrade/transaction.go +++ b/internal/upgrade/transaction.go @@ -4,6 +4,7 @@ package upgrade import ( "archive/zip" + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -115,6 +116,7 @@ func Execute(ctx context.Context, options Options) (result Result, err error) { var versionDir string var versionBackup string + var projectionSnapshots []projectionSnapshot versionChanged := false launcherChanged := false mutated := false @@ -124,6 +126,11 @@ func Execute(ctx context.Context, options Options) (result Result, err error) { } _ = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseRollingBack, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}) var failures []error + if len(projectionSnapshots) > 0 { + if projErr := rollbackProjections(projectionSnapshots); projErr != nil { + failures = append(failures, projErr) + } + } if (versionChanged || versionBackup != "") && versionDir != "" { if versionChanged { if removeErr := lifecycle.SafeRemove(versionDir, options.Layout.Versions); removeErr != nil && !os.IsNotExist(removeErr) { @@ -179,6 +186,9 @@ func Execute(ctx context.Context, options Options) (result Result, err error) { } payload := filepath.Join(op, selection.Binary.Name) + if err = ensureExecutable(payload); err != nil { + return Result{}, err + } if observed, versionErr := versionOf(payload); versionErr != nil { return Result{}, versionErr } else if observed != selection.Release.Version { @@ -219,9 +229,23 @@ func Execute(ctx context.Context, options Options) (result Result, err error) { } versionChanged = true mutated = true + + var previousManifest artifact.Manifest + if installedExists && len(installedBytes) > 0 { + previousManifest, _ = artifact.Decode(bytes.NewReader(installedBytes)) + } + + projectionSnapshots, err = snapshotProjections(op, options.Layout.Home, manifest, previousManifest) + if err != nil { + return Result{}, fmt.Errorf("snapshot projections: %w", err) + } if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseProjectionsSnapshotted, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { return Result{}, err } + + if err = applyProjections(op, options.Layout.Home, versionDir, manifest, previousManifest); err != nil { + return Result{}, fmt.Errorf("apply manifest projections: %w", err) + } if err = lifecycle.SaveJournal(journalPath, lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseArtifactsReplaced, Token: lock.Token, Version: selection.Release.Version, PreviousVersion: previous.ActiveVersion}); err != nil { return Result{}, err } @@ -573,6 +597,18 @@ func versionOf(path string) (string, error) { return strings.TrimSpace(string(output)), nil } +// ensureExecutable makes a staged candidate runnable on POSIX before the +// transaction executes it. Windows relies on the executable extension instead. +func ensureExecutable(path string) error { + if runtime.GOOS == "windows" { + return nil + } + if err := os.Chmod(path, 0o755); err != nil { + return fmt.Errorf("make candidate executable: %w", err) + } + return nil +} + func selfTest(path string) error { command := exec.Command(path, "__self-test") output, err := command.Output() @@ -657,3 +693,224 @@ func mustEncodeManifest(manifest artifact.Manifest) []byte { } return append(data, '\n') } + +func copyDirectory(source, destination string) error { + return filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + destPath := filepath.Join(destination, rel) + if info.IsDir() { + return os.MkdirAll(destPath, 0o755) + } + return copyFile(path, destPath, info.Mode().Perm()) + }) +} + +func unzipArchive(archivePath, targetDir string) error { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer reader.Close() + for _, file := range reader.File { + cleanName := filepath.Clean(filepath.FromSlash(file.Name)) + if cleanName == "." || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) { + return errors.New("unsafe archive member path") + } + destPath := filepath.Join(targetDir, cleanName) + if file.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + rc, err := file.Open() + if err != nil { + return err + } + out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) + closeErr := out.Close() + rc.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +type projectionSnapshot struct { + relPath string + targetPath string + backupPath string + existed bool + isDir bool +} + +func snapshotProjections(op, home string, manifest, previousManifest artifact.Manifest) ([]projectionSnapshot, error) { + seen := make(map[string]bool) + var relPaths []string + for _, a := range manifest.Artifacts { + for _, p := range a.ProjectionPaths { + if !seen[p] { + seen[p] = true + relPaths = append(relPaths, p) + } + } + } + for _, a := range previousManifest.Artifacts { + for _, p := range a.ProjectionPaths { + if !seen[p] { + seen[p] = true + relPaths = append(relPaths, p) + } + } + } + + backupBase := filepath.Join(op, "projection-backups") + var snapshots []projectionSnapshot + for i, rel := range relPaths { + targetPath := filepath.Join(home, filepath.FromSlash(rel)) + if err := lifecycle.VerifyNoLinkedAncestors(targetPath, home); err != nil { + return nil, fmt.Errorf("verify projection target %s: %w", targetPath, err) + } + info, err := os.Lstat(targetPath) + if errors.Is(err, os.ErrNotExist) { + snapshots = append(snapshots, projectionSnapshot{ + relPath: rel, + targetPath: targetPath, + existed: false, + }) + continue + } + if err != nil { + return nil, fmt.Errorf("stat projection target %s: %w", targetPath, err) + } + backupPath := filepath.Join(backupBase, fmt.Sprintf("proj-%d", i)) + if info.IsDir() { + if err := copyDirectory(targetPath, backupPath); err != nil { + return nil, fmt.Errorf("snapshot projection dir %s: %w", targetPath, err) + } + snapshots = append(snapshots, projectionSnapshot{ + relPath: rel, + targetPath: targetPath, + backupPath: backupPath, + existed: true, + isDir: true, + }) + } else { + if err := copyFile(targetPath, backupPath, info.Mode().Perm()); err != nil { + return nil, fmt.Errorf("snapshot projection file %s: %w", targetPath, err) + } + snapshots = append(snapshots, projectionSnapshot{ + relPath: rel, + targetPath: targetPath, + backupPath: backupPath, + existed: true, + isDir: false, + }) + } + } + return snapshots, nil +} + +func applyProjections(op, home, versionDir string, manifest, previousManifest artifact.Manifest) error { + for _, a := range manifest.Artifacts { + if len(a.ProjectionPaths) == 0 { + continue + } + if strings.HasSuffix(a.Path, ".zip") { + zipPath := filepath.Join(versionDir, "artifacts", filepath.Base(a.Path)) + unpackedDir := filepath.Join(op, "unpacked-"+a.ID) + if err := unzipArchive(zipPath, unpackedDir); err != nil { + return fmt.Errorf("unpack projection archive %s: %w", a.Path, err) + } + sourceDir := unpackedDir + entries, _ := os.ReadDir(unpackedDir) + if len(entries) == 1 && entries[0].IsDir() { + sourceDir = filepath.Join(unpackedDir, entries[0].Name()) + } + for _, proj := range a.ProjectionPaths { + target := filepath.Join(home, filepath.FromSlash(proj)) + _ = os.RemoveAll(target) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := copyDirectory(sourceDir, target); err != nil { + return fmt.Errorf("apply projection to %s: %w", target, err) + } + } + } else { + filePath := filepath.Join(versionDir, "artifacts", filepath.Base(a.Path)) + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read artifact %s: %w", a.Path, err) + } + for _, proj := range a.ProjectionPaths { + target := filepath.Join(home, filepath.FromSlash(proj)) + if err := lifecycle.AtomicWrite(target, content, 0o644); err != nil { + return fmt.Errorf("apply projection to %s: %w", target, err) + } + } + } + } + + newProjections := make(map[string]bool) + for _, a := range manifest.Artifacts { + for _, p := range a.ProjectionPaths { + newProjections[p] = true + } + } + for _, a := range previousManifest.Artifacts { + for _, p := range a.ProjectionPaths { + if !newProjections[p] { + target := filepath.Join(home, filepath.FromSlash(p)) + _ = os.RemoveAll(target) + } + } + } + return nil +} + +func rollbackProjections(snapshots []projectionSnapshot) error { + var failures []error + for _, s := range snapshots { + if !s.existed { + if err := os.RemoveAll(s.targetPath); err != nil && !os.IsNotExist(err) { + failures = append(failures, err) + } + } else { + _ = os.RemoveAll(s.targetPath) + if s.isDir { + if err := copyDirectory(s.backupPath, s.targetPath); err != nil { + failures = append(failures, err) + } + } else { + if info, statErr := os.Stat(s.backupPath); statErr == nil { + if err := copyFile(s.backupPath, s.targetPath, info.Mode().Perm()); err != nil { + failures = append(failures, err) + } + } + } + } + } + if len(failures) > 0 { + return fmt.Errorf("rollback projections: %v", failures) + } + return nil +} diff --git a/internal/upgrade/transaction_test.go b/internal/upgrade/transaction_test.go index 5de0cb3..03a439a 100644 --- a/internal/upgrade/transaction_test.go +++ b/internal/upgrade/transaction_test.go @@ -3,12 +3,15 @@ package upgrade import ( "crypto/sha256" "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/CGuiho/buda/internal/artifact" + "github.com/CGuiho/buda/internal/installlayout" + "github.com/CGuiho/buda/internal/lifecycle" ) func TestVerifyChecksumsRejectsMissingDuplicateAndExtraEntries(t *testing.T) { @@ -60,3 +63,190 @@ func TestValidateManifestAssetsRequiresEveryPublishedArtifact(t *testing.T) { t.Fatal("undeclared published artifact was accepted") } } + +func TestManifestDrivenProjectionsApplyAndRollback(t *testing.T) { + home := t.TempDir() + op := t.TempDir() + versionDir := filepath.Join(op, "version") + artifactsDir := filepath.Join(versionDir, "artifacts") + if err := os.MkdirAll(artifactsDir, 0o755); err != nil { + t.Fatal(err) + } + + // Create a dummy text projection artifact + artifactFile := filepath.Join(artifactsDir, "instruction.md") + if err := os.WriteFile(artifactFile, []byte("v2 instruction"), 0o644); err != nil { + t.Fatal(err) + } + + // Set up prior projection in home + priorTarget := filepath.Join(home, ".agents", "instructions", "test.md") + if err := os.MkdirAll(filepath.Dir(priorTarget), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(priorTarget, []byte("v1 instruction"), 0o644); err != nil { + t.Fatal(err) + } + + manifest := artifact.Manifest{ + Schema: 1, + CLI: "buda", + Version: "0.2.0", + Artifacts: []artifact.Artifact{ + { + ID: "instruction", + Version: "0.2.0", + Path: "instruction.md", + InstalledPath: "versions/0.2.0/artifacts/instruction.md", + Ownership: artifact.OwnershipReplaceable, + Replaceable: true, + ProjectionPaths: []string{".agents/instructions/test.md"}, + }, + }, + } + prevManifest := artifact.Manifest{ + Schema: 1, + CLI: "buda", + Version: "0.1.0", + Artifacts: []artifact.Artifact{ + { + ID: "instruction", + Version: "0.1.0", + Path: "instruction.md", + InstalledPath: "versions/0.1.0/artifacts/instruction.md", + Ownership: artifact.OwnershipReplaceable, + Replaceable: true, + ProjectionPaths: []string{".agents/instructions/test.md"}, + }, + }, + } + + // 1. Snapshot projections + snapshots, err := snapshotProjections(op, home, manifest, prevManifest) + if err != nil { + t.Fatalf("snapshotProjections error: %v", err) + } + if len(snapshots) != 1 || !snapshots[0].existed { + t.Fatalf("expected 1 existing snapshot, got: %+v", snapshots) + } + + // 2. Apply projections + if err := applyProjections(op, home, versionDir, manifest, prevManifest); err != nil { + t.Fatalf("applyProjections error: %v", err) + } + content, err := os.ReadFile(priorTarget) + if err != nil || string(content) != "v2 instruction" { + t.Fatalf("applied projection content = %q, want 'v2 instruction'", string(content)) + } + + // 3. Rollback projections + if err := rollbackProjections(snapshots); err != nil { + t.Fatalf("rollbackProjections error: %v", err) + } + rolledBackContent, err := os.ReadFile(priorTarget) + if err != nil || string(rolledBackContent) != "v1 instruction" { + t.Fatalf("rolled back content = %q, want 'v1 instruction'", string(rolledBackContent)) + } +} + +func TestRecoverInterruptedJournalStates(t *testing.T) { + setup := func(t *testing.T, activeVersion string, journal lifecycle.Journal) (installlayout.Layout, string) { + t.Helper() + layout, err := installlayout.ForHome(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.Versions, 0o755); err != nil { + t.Fatal(err) + } + payloadName := "buda" + if strings.Contains(activeVersion, ".exe") { + payloadName = "buda.exe" + } + pointer := map[string]any{ + "schema": 1, "active": activeVersion + "/" + payloadName, "previous": "", + "active_version": activeVersion, "previous_version": "", + } + data, _ := json.Marshal(pointer) + if err := os.WriteFile(layout.Current, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } + payload := filepath.Join(layout.Versions, activeVersion, payloadName) + if err := os.MkdirAll(filepath.Dir(payload), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(payload, []byte("payload"), 0o755); err != nil { + t.Fatal(err) + } + journalPath := filepath.Join(layout.State, "transaction.json") + if err := os.MkdirAll(layout.State, 0o755); err != nil { + t.Fatal(err) + } + if err := lifecycle.SaveJournal(journalPath, journal); err != nil { + t.Fatal(err) + } + return layout, journalPath + } + + t.Run("complete journal is removed", func(t *testing.T) { + layout, journalPath := setup(t, "0.2.0", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseComplete, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + if err := recoverInterrupted(layout, journalPath); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(journalPath); !os.IsNotExist(err) { + t.Fatalf("complete journal still exists: %v", err) + } + }) + t.Run("rolled-back journal is removed", func(t *testing.T) { + layout, journalPath := setup(t, "0.1.1", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseRolledBack, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + if err := recoverInterrupted(layout, journalPath); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(journalPath); !os.IsNotExist(err) { + t.Fatalf("rolled-back journal still exists: %v", err) + } + }) + t.Run("activated target wins and journal is removed", func(t *testing.T) { + layout, journalPath := setup(t, "0.2.0", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseActivated, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + if err := recoverInterrupted(layout, journalPath); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(journalPath); !os.IsNotExist(err) { + t.Fatalf("activated journal still exists: %v", err) + } + }) + t.Run("activated target with missing payload fails", func(t *testing.T) { + layout, journalPath := setup(t, "0.2.0", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseActivated, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + payload := filepath.Join(layout.Versions, "0.2.0", "buda") + if err := os.Remove(payload); err != nil { + t.Fatal(err) + } + if err := recoverInterrupted(layout, journalPath); err == nil { + t.Fatal("interrupted upgrade with missing activated payload was accepted") + } + }) + t.Run("previous active pointer is treated as aborted", func(t *testing.T) { + layout, journalPath := setup(t, "0.1.1", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseArtifactsReplaced, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + if err := recoverInterrupted(layout, journalPath); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(journalPath); !os.IsNotExist(err) { + t.Fatalf("aborted journal still exists: %v", err) + } + }) + t.Run("pointer journal disagreement fails closed", func(t *testing.T) { + layout, journalPath := setup(t, "0.1.1", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseActivated, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.0"}) + if err := recoverInterrupted(layout, journalPath); err == nil { + t.Fatal("journal/pointer disagreement was accepted") + } + }) + t.Run("missing pointer fails closed", func(t *testing.T) { + layout, journalPath := setup(t, "0.1.1", lifecycle.Journal{Operation: "upgrade", Phase: lifecycle.PhaseStaged, Token: "t", Version: "0.2.0", PreviousVersion: "0.1.1"}) + if err := os.Remove(layout.Current); err != nil { + t.Fatal(err) + } + if err := recoverInterrupted(layout, journalPath); err == nil { + t.Fatal("interrupted upgrade without a readable pointer was accepted") + } + }) +} diff --git a/schemas/embed.go b/schemas/embed.go new file mode 100644 index 0000000..2f0a2f4 --- /dev/null +++ b/schemas/embed.go @@ -0,0 +1,8 @@ +package schemas + +import "embed" + +// FS contains Buda's version-pinned configuration JSON schemas. +// +//go:embed *.json +var FS embed.FS diff --git a/schemas/embed_test.go b/schemas/embed_test.go new file mode 100644 index 0000000..4cdfb51 --- /dev/null +++ b/schemas/embed_test.go @@ -0,0 +1,82 @@ +package schemas_test + +import ( + "encoding/json" + "io/fs" + "strings" + "testing" + + "github.com/CGuiho/buda/examples" + "github.com/CGuiho/buda/internal/config" + "github.com/CGuiho/buda/schemas" +) + +func TestEmbeddedSchemasPresentAndValid(t *testing.T) { + for _, name := range []string{"buda.schema.json", "buda.global.schema.json"} { + data, err := fs.ReadFile(schemas.FS, name) + if err != nil { + t.Fatalf("ReadFile(%q) = %v", name, err) + } + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Unmarshal(%q) = %v", name, err) + } + if parsed["$schema"] != "https://json-schema.org/draft/2020-12/schema" { + t.Fatalf("%s $schema = %v", name, parsed["$schema"]) + } + id, ok := parsed["$id"].(string) + if !ok || !strings.Contains(id, "v0.2.0") { + t.Fatalf("%s $id = %v", name, id) + } + } +} + +func TestEmbeddedExamplesValidateAgainstRuntimeConfig(t *testing.T) { + projectData, err := fs.ReadFile(examples.FS, "buda.example.yaml") + if err != nil { + t.Fatalf("read buda.example.yaml: %v", err) + } + projectConfig, err := config.DecodeProject(strings.NewReader(string(projectData))) + if err != nil { + t.Fatalf("DecodeProject(buda.example.yaml): %v", err) + } + if projectConfig.WikiID != "example-wiki" || projectConfig.Schema != config.CurrentSchema { + t.Fatalf("projectConfig = %+v", projectConfig) + } + + globalData, err := fs.ReadFile(examples.FS, "buda.global.example.yaml") + if err != nil { + t.Fatalf("read buda.global.example.yaml: %v", err) + } + globalConfig, err := config.DecodeGlobal(strings.NewReader(string(globalData))) + if err != nil { + t.Fatalf("DecodeGlobal(buda.global.example.yaml): %v", err) + } + if globalConfig.Schema != config.CurrentSchema { + t.Fatalf("globalConfig = %+v", globalConfig) + } + + merged, err := config.Merge(globalConfig, projectConfig) + if err != nil { + t.Fatalf("Merge examples: %v", err) + } + if err := merged.Validate(); err != nil { + t.Fatalf("Validate merged examples: %v", err) + } +} + +func TestDecodingRejectsMissingSchema(t *testing.T) { + noSchemaProject := `wiki_id: test +bundle: knowledge +` + if _, err := config.DecodeProject(strings.NewReader(noSchemaProject)); err == nil { + t.Fatal("DecodeProject unexpectedly accepted missing schema") + } + + noSchemaGlobal := `bundle: knowledge +derived: .buda +` + if _, err := config.DecodeGlobal(strings.NewReader(noSchemaGlobal)); err == nil { + t.Fatal("DecodeGlobal unexpectedly accepted missing schema") + } +} diff --git a/schemas/schemas.xdocs.md b/schemas/schemas.xdocs.md index f75208a..d01e0d6 100644 --- a/schemas/schemas.xdocs.md +++ b/schemas/schemas.xdocs.md @@ -4,6 +4,8 @@ description: Version-pinned JSON Schemas for project and global Buda configurati parent: buda-package children: [] files: + embed.go: Embedded filesystem exporting the version-pinned JSON Schemas. + embed_test.go: Validation tests for embedded JSON schemas and runtime configuration parity. buda.schema.json: Offline and editor-discoverable schema for explicit-wiki project configuration. buda.global.schema.json: Offline and editor-discoverable schema for user-wide Buda configuration. documents: {} From 46776662ab05db3c6605d6295747d08cf36bb57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Mon, 17 Aug 2026 00:09:24 +0200 Subject: [PATCH 10/11] ci/docs: native lifecycle acceptance and truthful delivery records - CI gains POSIX and Windows native lifecycle jobs: install, repair, synthetic 0.1.1 migration, and synchronous uninstall in disposable homes with qmd compatibility stubs and sentinel preservation; - native launcher smokes run through a disposable pointer fixture on every hosted runner matrix; - actionlint-clean workflows; lifecycle transaction and interruption acceptance is an explicit CI step and a RunX catalog entry; - RunX cross targets build through the target-aware release builder; - TODO records the blocker-to-evidence table for the review fix pass; - README documents legacy 0.1.x migration truth; XDocs workflow descriptors match the implemented jobs. --- .github/workflows/ci.yml | 206 +++++++++++++++++- .github/workflows/workflows.xdocs.md | 25 ++- README.md | 15 +- TODO.md | 33 +++ ...O_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md | 6 + runx.yaml | 37 ++-- 6 files changed, 292 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 566f365..e566acd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: run: | go test -count=1 ./... go vet ./... + - name: Lifecycle transaction and interruption acceptance + run: | + go test -count=1 -run 'Lock|Rollback|Recover|Interrupt|Quarantine|Snapshot|Projection|Pointer|Checksum' ./internal/lifecycle/... ./internal/upgrade/... ./internal/uninstall/... ./internal/launcher/... - name: Build manifest-derived release unit run: | go run ./devops/build-binaries.go --version 0.2.0-ci --commit "$GITHUB_SHA" --build-date "$(git show -s --format=%cI HEAD)" @@ -88,15 +91,208 @@ jobs: with: { name: buda-release-unit, path: dist } - name: Unix native launcher/payload smoke if: runner.os != 'Windows' + env: + ASSET: ${{ matrix.asset }} run: | - chmod +x "dist/${{ matrix.asset }}" - test "$("dist/${{ matrix.asset }}" --version)" = '0.2.0-ci' - - name: Windows native payload smoke + chmod +x "dist/$ASSET" + test "$("dist/$ASSET" --version)" = '0.2.0-ci' + export HOME="$RUNNER_TEMP/launcher-home" + mkdir -p "$HOME/.guiho/buda/versions/0.2.0-ci" + payload="buda" + case "$ASSET" in *.exe) payload="buda.exe" ;; esac + cp "dist/$ASSET" "$HOME/.guiho/buda/versions/0.2.0-ci/$payload" + chmod +x "$HOME/.guiho/buda/versions/0.2.0-ci/$payload" + printf '{"schema":1,"active":"0.2.0-ci/%s","previous":"","active_version":"0.2.0-ci","previous_version":""}\n' "$payload" > "$HOME/.guiho/buda/current.json" + launcher="dist/buda-launcher-${ASSET#buda-}" + chmod +x "$launcher" + test "$("$launcher" --version)" = '0.2.0-ci' + - name: Windows native launcher/payload smoke if: runner.os == 'Windows' shell: pwsh + env: + ASSET: ${{ matrix.asset }} + run: | + $value = (& (Join-Path $PWD "dist\$env:ASSET") --version | Out-String).Trim() + if ($value -ne '0.2.0-ci') { throw "Unexpected raw payload version: $value" } + $home = Join-Path $env:RUNNER_TEMP 'launcher-home' + $env:USERPROFILE = $home + New-Item -ItemType Directory -Force -Path (Join-Path $home '.guiho\buda\versions\0.2.0-ci') | Out-Null + Copy-Item -LiteralPath "dist\$env:ASSET" -Destination (Join-Path $home '.guiho\buda\versions\0.2.0-ci\buda.exe') + Set-Content -NoNewline -Path (Join-Path $home '.guiho\buda\current.json') -Value '{"schema":1,"active":"0.2.0-ci/buda.exe","previous":"","active_version":"0.2.0-ci","previous_version":""}' + $launcher = 'dist\buda-launcher-' + $env:ASSET.Substring(5) + $launcherValue = (& (Join-Path $PWD $launcher) --version | Out-String).Trim() + if ($launcherValue -ne '0.2.0-ci') { throw "Unexpected raw launcher version: $launcherValue" } + + posix-lifecycle: + needs: quality + runs-on: ubuntu-latest + env: + BUDA_DISABLE_MAINTENANCE: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: buda-release-unit, path: dist } + - name: Install disposable qmd compatibility stub + run: | + mkdir -p "$RUNNER_TEMP/stub" + cat > "$RUNNER_TEMP/stub/qmd" <<'STUB' + #!/bin/sh + # CI-only process stub satisfying Buda's qmd compatibility contract. + case "$1" in + --version|version) printf 'qmd 2.6.0\n' ;; + --help|capabilities) + printf '%s\n' 'qmd search qmd vsearch qmd query qmd get qmd multi-get qmd init qmd status qmd update qmd embed qmd collection --format --collection' ;; + init) + mkdir -p .qmd + printf 'collections: {}\n' > .qmd/index.yml ;; + collection) + case "$2" in + add) + printf 'collections:\n %s:\n path: %s\n' "$5" "../$3" > .qmd/index.yml ;; + show) + path=$(sed -n 's/^ path: //p' .qmd/index.yml) + printf 'Path: %s\n' "${path#../}" ;; + esac ;; + esac + STUB + chmod +x "$RUNNER_TEMP/stub/qmd" + - name: Native POSIX install, repair, and uninstall lifecycle + env: + BUDA_RELEASE_ASSET_DIR: ${{ github.workspace }}/dist + BUDA_RELEASE_VERSION: 0.2.0-ci + run: | + set -eu + export HOME="$RUNNER_TEMP/buda-lifecycle-home" + export PATH="$RUNNER_TEMP/stub:$PATH" + mkdir -p "$HOME/.guiho/buda" + cp examples/buda.global.example.yaml "$HOME/.guiho/buda/buda.global.yaml" + wiki="$RUNNER_TEMP/wiki" + mkdir -p "$wiki" + sh devops/install.sh --wiki "$wiki" --wiki-id lifecycle-wiki --version 0.2.0-ci + test "$("$HOME/.guiho/bin/buda" --version)" = '0.2.0-ci' + test -f "$wiki/buda.yaml" + test -f "$wiki/AGENTS.md" + test -d "$wiki/knowledge" + test -f "$HOME/.guiho/buda/current.json" + # Same-version reinstall is an idempotent repair. + sh devops/install.sh --wiki "$wiki" --version 0.2.0-ci + test "$("$HOME/.guiho/bin/buda" --version)" = '0.2.0-ci' + # Uninstall is destructive by default but never touches shared or + # user-owned content. + printf 'keep' > "$HOME/.guiho/sentinel.txt" + printf 'keep' > "$wiki/knowledge/index.md" + sh devops/uninstall.sh --wiki "$wiki" --yes + test ! -e "$HOME/.guiho/bin/buda" + test ! -f "$wiki/buda.yaml" + test -f "$wiki/knowledge/index.md" + test -f "$HOME/.guiho/sentinel.txt" + test -d "$HOME/.guiho/bin" + - name: Legacy 0.1.1 direct-binary migration through the launcher transaction + env: + BUDA_RELEASE_ASSET_DIR: ${{ github.workspace }}/dist + BUDA_RELEASE_VERSION: 0.2.0-ci + run: | + set -eu + export HOME="$RUNNER_TEMP/buda-migration-home" + export PATH="$RUNNER_TEMP/stub:$PATH" + mkdir -p "$HOME/.local/bin" "$HOME/.guiho/buda" + # Synthetic official-layout 0.1.1 installation: direct binary on the + # historical path plus the legacy global buda.yaml. + cp dist/buda-linux-amd64 "$HOME/.local/bin/buda" + chmod +x "$HOME/.local/bin/buda" + cat > "$HOME/.guiho/buda/buda.yaml" <<'LEGACY' + schema: 1 + wiki_id: legacy-wiki + bundle: knowledge + qmd: + executable: qmd + collection: buda-wiki + project_directory: .qmd + derived: .buda + LEGACY + wiki="$RUNNER_TEMP/migration-wiki" + mkdir -p "$wiki" + sh devops/install.sh --wiki "$wiki" --version 0.2.0-ci + test "$("$HOME/.guiho/bin/buda" --version)" = '0.2.0-ci' + test ! -e "$HOME/.local/bin/buda" + test -f "$HOME/.guiho/buda/buda.global.yaml" + test -f "$HOME/.guiho/buda/buda.yaml" + grep -F 'legacy-wiki' "$HOME/.guiho/buda/buda.yaml" + + windows-lifecycle: + needs: quality + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: buda-release-unit, path: dist } + - name: Install disposable qmd compatibility stub + shell: pwsh + run: | + $stub = Join-Path $env:RUNNER_TEMP 'stub' + New-Item -ItemType Directory -Force -Path $stub | Out-Null + @' + @echo off + %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0qmd-stub.ps1" %* + '@ | Set-Content -NoNewline -Path (Join-Path $stub 'qmd.cmd') -Encoding ASCII + @' + param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ArgsList) + switch ($ArgsList[0]) { + '--version' { Write-Output 'qmd 2.6.0' } + 'version' { Write-Output 'qmd 2.6.0' } + '--help' { Write-Output 'qmd search qmd vsearch qmd query qmd get qmd multi-get qmd init qmd status qmd update qmd embed qmd collection --format --collection' } + 'capabilities' { Write-Output 'qmd search qmd vsearch qmd query qmd get qmd multi-get qmd init qmd status qmd update qmd embed qmd collection --format --collection' } + 'init' { + New-Item -ItemType Directory -Force -Path .qmd | Out-Null + Set-Content -NoNewline -Path .qmd\index.yml -Value "collections: {}`n" + } + 'collection' { + if ($ArgsList[1] -eq 'add') { + Set-Content -NoNewline -Path .qmd\index.yml -Value "collections:`n $($ArgsList[4]):`n path: ../$($ArgsList[2])`n" + } elseif ($ArgsList[1] -eq 'show') { + $line = (Select-String -Path .qmd\index.yml -Pattern '^\s+path: (.+)$').Matches[0].Groups[1].Value + Write-Output ('Path: ' + $line.Substring(3)) + } + } + } + exit 0 + '@ | Set-Content -NoNewline -Path (Join-Path $stub 'qmd-stub.ps1') -Encoding UTF8 + - name: Native Windows install, repair, migration, and uninstall lifecycle + shell: pwsh + env: + BUDA_RELEASE_ASSET_DIR: ${{ github.workspace }}\dist + BUDA_RELEASE_VERSION: 0.2.0-ci + BUDA_DISABLE_MAINTENANCE: '1' run: | - $value = (& (Join-Path $PWD 'dist\${{ matrix.asset }}') --version | Out-String).Trim() - if ($value -ne '0.2.0-ci') { throw "Unexpected raw version: $value" } + $ErrorActionPreference = 'Stop' + $lifecycleHome = Join-Path $env:RUNNER_TEMP 'buda-win-home' + $env:USERPROFILE = $lifecycleHome + New-Item -ItemType Directory -Force -Path (Join-Path $lifecycleHome '.guiho\buda') | Out-Null + Copy-Item -LiteralPath 'examples\buda.global.example.yaml' -Destination (Join-Path $lifecycleHome '.guiho\buda\buda.global.yaml') + $env:PATH = (Join-Path $env:RUNNER_TEMP 'stub') + ';' + $env:PATH + # Synthetic official-layout 0.1.1 direct binary on the historical path. + $legacyDir = Join-Path $env:LOCALAPPDATA 'GUIHO\bin' + New-Item -ItemType Directory -Force -Path $legacyDir | Out-Null + Copy-Item -LiteralPath 'dist\buda-windows-amd64.exe' -Destination (Join-Path $legacyDir 'buda.exe') + $wiki = Join-Path $env:RUNNER_TEMP 'win-wiki' + New-Item -ItemType Directory -Path $wiki | Out-Null + & '.\devops\install.ps1' -Version '0.2.0-ci' -Wiki $wiki -WikiId 'win-lifecycle' + $launcher = Join-Path $lifecycleHome '.guiho\bin\buda.exe' + $version = (& $launcher --version | Out-String).Trim() + if ($version -ne '0.2.0-ci') { throw "Unexpected installed version: $version" } + if (Test-Path -LiteralPath (Join-Path $legacyDir 'buda.exe')) { throw 'Legacy direct binary survived verified launcher activation.' } + if (-not (Test-Path -LiteralPath (Join-Path $wiki 'buda.yaml'))) { throw 'Selected wiki configuration missing.' } + # Same-version reinstall is an idempotent repair. + & '.\devops\install.ps1' -Version '0.2.0-ci' -Wiki $wiki + # Uninstall is synchronous and destructive by default; user-owned + # sentinels survive. + Set-Content -NoNewline -Path (Join-Path $lifecycleHome '.guiho\sentinel.txt') -Value 'keep' + Set-Content -NoNewline -Path (Join-Path $wiki 'knowledge\index.md') -Value 'keep' + & '.\devops\uninstall.ps1' -Wiki $wiki -Yes + if (Test-Path -LiteralPath $launcher) { throw 'Stable launcher survived synchronous uninstall.' } + if (Test-Path -LiteralPath (Join-Path $wiki 'buda.yaml')) { throw 'Selected wiki configuration survived uninstall.' } + if (-not (Test-Path -LiteralPath (Join-Path $wiki 'knowledge\index.md'))) { throw 'Canonical OKF content was removed.' } + if (-not (Test-Path -LiteralPath (Join-Path $lifecycleHome '.guiho\sentinel.txt'))) { throw 'Shared GUIHO sentinel was removed.' } windows-script-syntax: runs-on: windows-latest diff --git a/.github/workflows/workflows.xdocs.md b/.github/workflows/workflows.xdocs.md index e4f1d59..7e62f9b 100644 --- a/.github/workflows/workflows.xdocs.md +++ b/.github/workflows/workflows.xdocs.md @@ -1,10 +1,10 @@ --- subject: buda-github-workflows -description: Go quality, native smoke coverage, complete manifest-derived GitHub Release publication, and public lifecycle acceptance. +description: Go quality, native launcher and payload smoke, complete manifest-derived GitHub Release publication, and public lifecycle acceptance. parent: buda-github children: [] files: - ci.yml: Go formatting, module, test, vet, RunX, Mirror, strict XDocs, manifest build, checksums, installer syntax, and hosted native-platform smoke. + ci.yml: Go formatting, module, test, vet, RunX, Mirror, strict XDocs, manifest build, checksums, installer syntax, hosted native launcher/payload smoke, POSIX and Windows lifecycle acceptance, legacy 0.1.1 migration, and foreign build-only targets. publish.yml: Canonical buda/v* GitHub Release publication with manifest-derived assets and public checksum reconciliation. documents: {} tags: @@ -21,13 +21,22 @@ flags: [] status: stable --- -CI builds the manifest-derived complete release unit, validates checksums, and -executes compatible native payload smoke checks on hosted Linux, macOS, and -Windows AMD64 and ARM64 runners. ARMv6 and ARMv7 remain cross-build-only -because GitHub does not provide matching hosted runners. RunX, Mirror, strict -XDocs, Go quality, and both installer-language syntax checks are required. +CI builds the manifest-derived complete release unit and validates checksums. +Hosted Linux, macOS, and Windows AMD64 and ARM64 runners execute native +payload smokes and launcher smokes through a disposable pointer fixture. +ARMv6 and ARMv7 remain cross-build-only because GitHub does not provide +matching hosted runners. RunX, Mirror, strict XDocs, Go quality, lifecycle +transaction and interruption acceptance, and both installer-language syntax +checks are required. + +Native lifecycle jobs on Linux and Windows install the release from a local +asset directory into a disposable home with a qmd process stub, verify the +stable launcher, prove same-version reinstall repair, migrate a synthetic +0.1.1 direct-binary layout through the launcher transaction, and run a +synchronous default uninstall while asserting that canonical OKF content and +shared GUIHO sentinels survive. Publication runs for canonical `buda/v*` tags or a manual recovery dispatch. -It reconciles one GitHub Release to every manifest-declared artifact, verifies +It reconciles one GitHub Release to every manifest-declared asset, verifies the public asset names against the generated release directory, and never publishes to a package manager or registry. diff --git a/README.md b/README.md index 35fd642..9fb255d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ and immutable payloads and manifest-owned resources live under `$HOME/.guiho/buda/versions//`. Foreign ARMv6/ARMv7 targets are cross-build-only unless run on their native platform. +### Migrating a 0.1.x direct-binary installation + +A verified 0.1.x direct binary is migrated only through the new launcher +transaction: the installer activates and verifies the new launcher and +immutable payload first, then removes the old binary from its exact historical +path (`~/.local/bin/buda` on Unix, `%LOCALAPPDATA%\GUIHO\bin\buda.exe` on +Windows). The legacy global `buda.yaml` is strictly validated, mapped into +`buda.global.yaml`, and left in place. Canonical OKF knowledge and +configuration are never deleted by installation. + ## Upgrade ```text @@ -108,7 +118,10 @@ go vet ./... `devops/build-binaries.go` produces the eight pure-Go payloads, eight stable launchers, typed resources, schemas, examples, `artifacts.json`, and `checksums.txt`. Release completeness is derived from the manifest rather than -from a fixed asset count. +from a fixed asset count. CI additionally runs native POSIX and Windows +lifecycle jobs that install, repair, migrate a synthetic 0.1.1 layout, and +uninstall in disposable homes, plus interruption, rollback, and locked-file +acceptance in the Go suite. ## Uninstall diff --git a/TODO.md b/TODO.md index 8ce0d25..5be500a 100644 --- a/TODO.md +++ b/TODO.md @@ -83,6 +83,39 @@ the exact PR head receives independent review. Native installer/migration and crash-recovery execution remain review-gated because this Windows checkout was not allowed to mutate an existing personal installation. +## Kimi review fix pass + +A review fix pass on `codex/cli-convention-0001` closed the release blockers +that remained after the first review round. Every fix below is evidenced by +source, tests, workflows, or the disposable-home native Windows smoke. + +| Blocker | Closure | +|---|---| +| POSIX chmod before candidate execution | `devops/install.sh` chmods staged payload/launcher before `--version`; `internal/upgrade/transaction.go` runs `ensureExecutable` before the staged candidate executes. | +| Capture replacement preserves unknown OKF metadata | `internal/capture/capture.go` edits known fields through `okf.Document` setters instead of rebuilding frontmatter; `TestCaptureReplacePreservesUnknownOKFMetadata` byte-proves custom keys, tags, and nested maps survive. | +| Manifest projection apply/snapshot/retire/rollback and crash recovery | `internal/upgrade/transaction.go` snapshots, applies, retires, and rolls back manifest projections; `recoverInterrupted` states are tested exhaustively in `TestRecoverInterruptedJournalStates`. | +| Symlink/reparse-safe quarantined transactional uninstall | `internal/lifecycle` detects Windows reparse points via `FILE_ATTRIBUTE_REPARSE_POINT` (`linkcheck_windows.go`); `internal/uninstall/plan.go` quarantines under `.guiho/.temp`, restores on any failure, retains the quarantine instead of losing data, and edits instruction blocks before the wiki config is quarantined. | +| Init discovers all unanswered policies before any write | `cmd/init.go` resolves/migrates the global config without writing, prompts every missing policy, and persists only afterwards; `TestInitFailsBeforeAnyWriteWhenPoliciesAreUnansweredWithoutTerminal` proves zero writes. | +| Full release matrix, duplicate rejection, typed launcher metadata, ARMv6/v7 | Go catalog, `install.sh` jq filter, and `install.ps1` all require the complete 24-binary matrix and reject duplicate asset names; `setTargetMetadata` types payload and launcher entries; ARMv6/ARMv7 appear in every selector and the release manifest. | +| Meaningful zero-mutation self-test and probe instance bypass | `__self-test` verifies the build version and reads every embedded skill/prompt/schema/example; only exact `--version`/`-v`/`__self-test` invocations bypass instance registration; `TestVersionAndSelfTestDoNotMutateFileSystem` proves zero mutation. | +| Historical install migration exclusively through the launcher transaction | Installers remove the verified legacy binary only after launcher activation, from the exact historical paths (`~/.local/bin/buda`, `%LOCALAPPDATA%\GUIHO\bin\buda.exe`); no in-place mutable route remains. | +| Explicit wiki and grouped plan before uninstall confirmation | All three uninstall interfaces require the explicit wiki and print grouped `REMOVE`/`PRESERVE` plans before any confirmation or mutation. | +| Injected disposable home and real-profile sentinels | Lifecycle tests and the native smoke inject `USERPROFILE`/`HOME` into disposable directories and assert sentinel survival; the real user profile is never touched. | +| Embedded strict schema/runtime/example parity | `schemas/embed.go` plus `TestEmbeddedSchemasPresentAndValid`, `TestEmbeddedExamplesValidateAgainstRuntimeConfig`, and `TestDecodingRejectsMissingSchema` pin offline parity. | +| CI/native lifecycle/migration/interruption, RunX cross targets, XDocs, README | `ci.yml` adds POSIX and Windows native lifecycle jobs (install, repair, migration, synchronous uninstall) with disposable homes and qmd stubs; cross targets use the target-aware release builder; XDocs descriptors and the README describe the implemented truth. | +| Pointer EOF, canonical payload names, strict digest rules | `ReadPointer` rejects trailing documents and noncanonical payload filenames; manifests reject all-zero digests except `artifacts.json`; checksum readers reject duplicates, extras, and malformed entries. | + +Evidence recorded during the fix pass: + +- disposable-home native Windows lifecycle smoke: install, same-version + repair, legacy 0.1.1 direct-binary migration, and synchronous uninstall all + passed with sentinel preservation (real profile never inspected); +- `TestRecoverInterruptedJournalStates`, `TestManifestDrivenProjectionsApplyAndRollback`, + `TestApplyQuarantineRollbackOnFailure`, and the lifecycle acceptance test run + are cataloged in `runx.yaml` and CI; +- `actionlint` passes for both workflows; POSIX and PowerShell installer + syntax checks pass. + ## Release boundary - [ ] Apply the separately authorized Mirror minor plan, diff --git a/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md b/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md index a3e722d..5cd7f79 100644 --- a/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md +++ b/docs/GUIHO_CLI_CONVENTION_0001_ACCEPTANCE_MATRIX.md @@ -184,5 +184,11 @@ For each unit, retain in the PR or validation report: - explicit confirmation that no production action or unauthorized release action occurred. +The review fix pass on the integration branch closed every release blocker +that survived the first review round. Its blocker-to-evidence record, the +disposable-home native Windows lifecycle smoke, interruption/rollback +acceptance, workflow coverage, and the Mirror 0.2.0 plan preview are recorded +in [`TODO.md`](../TODO.md) under “Kimi review fix pass”. + This matrix is complete only when all audit rows, product invariants, and aggregate gates are satisfied on the same aggregate commit. diff --git a/runx.yaml b/runx.yaml index d72c9ab..99d1eee 100644 --- a/runx.yaml +++ b/runx.yaml @@ -35,6 +35,11 @@ commands: summary: Run Go vet. command: go vet ./... description: Run Go vet across every package. + - uid: budalifecycleacceptance + id: lifecycleacceptance + summary: Run lifecycle transaction and interruption acceptance tests. + command: go test -count=1 -run 'Lock|Rollback|Recover|Interrupt|Quarantine|Snapshot|Projection|Pointer|Checksum' ./internal/lifecycle/... ./internal/upgrade/... ./internal/uninstall/... ./internal/launcher/... + description: Exercise lock ownership, journal recovery, rollback, quarantine, projection snapshots, pointer security, and strict checksum acceptance. - uid: budabuild id: build summary: Build native Go packages. @@ -108,40 +113,40 @@ commands: - uid: budacrosslinuxamd64 id: crosslinuxamd64 summary: Build Linux AMD64 payload. - command: go build -trimpath -o dist/buda-linux-amd64 . - description: Build the Linux AMD64 payload (foreign build-only when not native). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-linux-amd64 + description: Build the Linux AMD64 payload with target-specific environment. - uid: budacrosslinuxarm64 id: crosslinuxarm64 summary: Build Linux ARM64 payload. - command: go build -trimpath -o dist/buda-linux-arm64 . - description: Build the Linux ARM64 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-linux-arm64 + description: Build the Linux ARM64 payload with target-specific environment. - uid: budacrosslinuxarmv7 id: crosslinuxarmv7 summary: Build Linux ARMv7 payload. - command: go build -trimpath -o dist/buda-linux-armv7 . - description: Build the Linux ARMv7 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-linux-armv7 + description: Build the Linux ARMv7 payload with target-specific environment. - uid: budacrosslinuxarmv6 id: crosslinuxarmv6 summary: Build Linux ARMv6 payload. - command: go build -trimpath -o dist/buda-linux-armv6 . - description: Build the Linux ARMv6 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-linux-armv6 + description: Build the Linux ARMv6 payload with target-specific environment. - uid: budacrossdarwinamd64 id: crossdarwinamd64 summary: Build Darwin AMD64 payload. - command: go build -trimpath -o dist/buda-darwin-amd64 . - description: Build the Darwin AMD64 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-darwin-amd64 + description: Build the Darwin AMD64 payload with target-specific environment. - uid: budacrossdarwinarm64 id: crossdarwinarm64 summary: Build Darwin ARM64 payload. - command: go build -trimpath -o dist/buda-darwin-arm64 . - description: Build the Darwin ARM64 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-darwin-arm64 + description: Build the Darwin ARM64 payload with target-specific environment. - uid: budacrosswindowsamd64 id: crosswindowsamd64 summary: Build Windows AMD64 payload. - command: go build -trimpath -o dist/buda-windows-amd64.exe . - description: Build the Windows AMD64 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-windows-amd64.exe + description: Build the Windows AMD64 payload with target-specific environment. - uid: budacrosswindowsarm64 id: crosswindowsarm64 summary: Build Windows ARM64 payload. - command: go build -trimpath -o dist/buda-windows-arm64.exe . - description: Build the Windows ARM64 payload (foreign build-only). + command: go run ./devops/build-binaries.go --version 0.2.0 --commit local --target buda-windows-arm64.exe + description: Build the Windows ARM64 payload with target-specific environment. From 484848be792d705f8e236466415b174aa96aafd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crist=C3=B3v=C3=A3o=20GUIHO?= Date: Mon, 17 Aug 2026 01:04:33 +0200 Subject: [PATCH 11/11] fix: carry legacy wiki_id through non-interactive migration and repair Windows native smoke --- .github/workflows/ci.yml | 10 +++---- .github/workflows/workflows.xdocs.md | 7 +++-- README.md | 6 ++-- TODO.md | 8 ++++- cmd/cmd.xdocs.md | 2 +- cmd/domain_commands_test.go | 38 ++++++++++++++++++++++++ cmd/init.go | 44 +++++++++++++++++++--------- 7 files changed, 89 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e566acd..b82e873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,11 +114,11 @@ jobs: run: | $value = (& (Join-Path $PWD "dist\$env:ASSET") --version | Out-String).Trim() if ($value -ne '0.2.0-ci') { throw "Unexpected raw payload version: $value" } - $home = Join-Path $env:RUNNER_TEMP 'launcher-home' - $env:USERPROFILE = $home - New-Item -ItemType Directory -Force -Path (Join-Path $home '.guiho\buda\versions\0.2.0-ci') | Out-Null - Copy-Item -LiteralPath "dist\$env:ASSET" -Destination (Join-Path $home '.guiho\buda\versions\0.2.0-ci\buda.exe') - Set-Content -NoNewline -Path (Join-Path $home '.guiho\buda\current.json') -Value '{"schema":1,"active":"0.2.0-ci/buda.exe","previous":"","active_version":"0.2.0-ci","previous_version":""}' + $launcherHome = Join-Path $env:RUNNER_TEMP 'launcher-home' + $env:USERPROFILE = $launcherHome + New-Item -ItemType Directory -Force -Path (Join-Path $launcherHome '.guiho\buda\versions\0.2.0-ci') | Out-Null + Copy-Item -LiteralPath "dist\$env:ASSET" -Destination (Join-Path $launcherHome '.guiho\buda\versions\0.2.0-ci\buda.exe') + Set-Content -NoNewline -Path (Join-Path $launcherHome '.guiho\buda\current.json') -Value '{"schema":1,"active":"0.2.0-ci/buda.exe","previous":"","active_version":"0.2.0-ci","previous_version":""}' $launcher = 'dist\buda-launcher-' + $env:ASSET.Substring(5) $launcherValue = (& (Join-Path $PWD $launcher) --version | Out-String).Trim() if ($launcherValue -ne '0.2.0-ci') { throw "Unexpected raw launcher version: $launcherValue" } diff --git a/.github/workflows/workflows.xdocs.md b/.github/workflows/workflows.xdocs.md index 7e62f9b..b8728a7 100644 --- a/.github/workflows/workflows.xdocs.md +++ b/.github/workflows/workflows.xdocs.md @@ -32,9 +32,10 @@ checks are required. Native lifecycle jobs on Linux and Windows install the release from a local asset directory into a disposable home with a qmd process stub, verify the stable launcher, prove same-version reinstall repair, migrate a synthetic -0.1.1 direct-binary layout through the launcher transaction, and run a -synchronous default uninstall while asserting that canonical OKF content and -shared GUIHO sentinels survive. +0.1.1 direct-binary layout through the launcher transaction (carrying the +legacy wiki_id into the newly selected wiki), and run a synchronous default +uninstall while asserting that canonical OKF content and shared GUIHO +sentinels survive. Publication runs for canonical `buda/v*` tags or a manual recovery dispatch. It reconciles one GitHub Release to every manifest-declared asset, verifies diff --git a/README.md b/README.md index 9fb255d..fb71d91 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ transaction: the installer activates and verifies the new launcher and immutable payload first, then removes the old binary from its exact historical path (`~/.local/bin/buda` on Unix, `%LOCALAPPDATA%\GUIHO\bin\buda.exe` on Windows). The legacy global `buda.yaml` is strictly validated, mapped into -`buda.global.yaml`, and left in place. Canonical OKF knowledge and -configuration are never deleted by installation. +`buda.global.yaml`, and left in place; its `wiki_id` is carried into the +explicitly selected wiki so migration works without a terminal and keeps the +wiki's identity. Canonical OKF knowledge and configuration are never deleted +by installation. ## Upgrade diff --git a/TODO.md b/TODO.md index 5be500a..cb8dedc 100644 --- a/TODO.md +++ b/TODO.md @@ -98,7 +98,7 @@ source, tests, workflows, or the disposable-home native Windows smoke. | Init discovers all unanswered policies before any write | `cmd/init.go` resolves/migrates the global config without writing, prompts every missing policy, and persists only afterwards; `TestInitFailsBeforeAnyWriteWhenPoliciesAreUnansweredWithoutTerminal` proves zero writes. | | Full release matrix, duplicate rejection, typed launcher metadata, ARMv6/v7 | Go catalog, `install.sh` jq filter, and `install.ps1` all require the complete 24-binary matrix and reject duplicate asset names; `setTargetMetadata` types payload and launcher entries; ARMv6/ARMv7 appear in every selector and the release manifest. | | Meaningful zero-mutation self-test and probe instance bypass | `__self-test` verifies the build version and reads every embedded skill/prompt/schema/example; only exact `--version`/`-v`/`__self-test` invocations bypass instance registration; `TestVersionAndSelfTestDoNotMutateFileSystem` proves zero mutation. | -| Historical install migration exclusively through the launcher transaction | Installers remove the verified legacy binary only after launcher activation, from the exact historical paths (`~/.local/bin/buda`, `%LOCALAPPDATA%\GUIHO\bin\buda.exe`); no in-place mutable route remains. | +| Historical install migration exclusively through the launcher transaction | Installers remove the verified legacy binary only after launcher activation, from the exact historical paths (`~/.local/bin/buda`, `%LOCALAPPDATA%\GUIHO\bin\buda.exe`); no in-place mutable route remains. The legacy global `wiki_id` is carried into the newly selected wiki by `init` so migration completes without an interactive terminal and preserves identity. | | Explicit wiki and grouped plan before uninstall confirmation | All three uninstall interfaces require the explicit wiki and print grouped `REMOVE`/`PRESERVE` plans before any confirmation or mutation. | | Injected disposable home and real-profile sentinels | Lifecycle tests and the native smoke inject `USERPROFILE`/`HOME` into disposable directories and assert sentinel survival; the real user profile is never touched. | | Embedded strict schema/runtime/example parity | `schemas/embed.go` plus `TestEmbeddedSchemasPresentAndValid`, `TestEmbeddedExamplesValidateAgainstRuntimeConfig`, and `TestDecodingRejectsMissingSchema` pin offline parity. | @@ -110,6 +110,12 @@ Evidence recorded during the fix pass: - disposable-home native Windows lifecycle smoke: install, same-version repair, legacy 0.1.1 direct-binary migration, and synchronous uninstall all passed with sentinel preservation (real profile never inspected); +- hosted CI hardening: the POSIX legacy-migration job exposed that init + required an interactive terminal for a migrated wiki with no project + configuration; init now carries the legacy global `wiki_id` into the + selected wiki (covered by `TestInitMigrationCarriesLegacyWikiIDWithoutTerminal`), + and the Windows native smoke renamed its `$home` script variable to avoid + PowerShell's read-only `$HOME` automatic variable; - `TestRecoverInterruptedJournalStates`, `TestManifestDrivenProjectionsApplyAndRollback`, `TestApplyQuarantineRollbackOnFailure`, and the lifecycle acceptance test run are cataloged in `runx.yaml` and CI; diff --git a/cmd/cmd.xdocs.md b/cmd/cmd.xdocs.md index 7918309..bb03816 100644 --- a/cmd/cmd.xdocs.md +++ b/cmd/cmd.xdocs.md @@ -14,7 +14,7 @@ files: index.go: qmd project initialization and indexing command. ingest.go: Source registration and qmd-backed ingest work-item command. instance.go: Installed-payload instance registration for lifecycle process safety. - init.go: OKF-aware wiki initialization command. + init.go: OKF-aware wiki initialization command with global-config discovery, legacy 0.1.x migration, wiki-id identity carryover, and atomic persistence. init_policy_test.go: Interactive agent-evolution policy setup and fail-closed tests. lint.go: Base OKF conformance and Buda health command. pack.go: Deterministic portable-bundle packaging command. diff --git a/cmd/domain_commands_test.go b/cmd/domain_commands_test.go index be065aa..0886953 100644 --- a/cmd/domain_commands_test.go +++ b/cmd/domain_commands_test.go @@ -139,6 +139,44 @@ func TestInitFailsBeforeAnyWriteWhenPoliciesAreUnansweredWithoutTerminal(t *test } } +func TestInitMigrationCarriesLegacyWikiIDWithoutTerminal(t *testing.T) { + wiki := filepath.Join(t.TempDir(), "wiki") + home := t.TempDir() + legacyPath := filepath.Join(home, ".guiho", "buda", "buda.yaml") + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatal(err) + } + legacy := "schema: 1\nwiki_id: legacy-wiki\nbundle: knowledge\nqmd:\n executable: qmd\n collection: buda-wiki\n project_directory: .qmd\nderived: .buda\n" + if err := os.WriteFile(legacyPath, []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + client := &domainFakeQMD{} + deps := domainDeps(&bytes.Buffer{}, home) + deps.Interactive = func() bool { return false } + root := NewRootCommand(deps, BuildInfo{Version: "test"}, NewInitCommand(deps, func(repository.Repository) (QMDClient, error) { return client, nil })) + root.SetArgs([]string{"--wiki", wiki, "init"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + project, err := config.LoadProject(filepath.Join(wiki, "buda.yaml")) + if err != nil { + t.Fatal(err) + } + if project.WikiID != "legacy-wiki" { + t.Fatalf("project wiki_id = %q, want legacy-wiki", project.WikiID) + } + global, err := config.LoadGlobal(config.GlobalPath(home)) + if err != nil { + t.Fatal(err) + } + if global.Agent == nil || global.Agent.Evolution.Upgrade != config.PolicyAlwaysAsk { + t.Fatalf("migrated global agent = %#v", global.Agent) + } + if client.ensure != 1 { + t.Fatalf("qmd project was not established: %d", client.ensure) + } +} + func TestStatusKeepsCanonicalAndQMDResultsSeparate(t *testing.T) { wiki := initializedWiki(t) output := &bytes.Buffer{} diff --git a/cmd/init.go b/cmd/init.go index 68cb0f5..cf30ba1 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -27,9 +27,19 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { return RepositoryError("resolve initialization target", err) } projectConfigPath := filepath.Join(root, config.FileName) + resolved, err := resolveGlobalConfig(deps.HomeDir, version) + if err != nil { + return MutationError("prepare global Buda configuration", err) + } + globalPath, global, globalPending, migrated := resolved.path, resolved.global, resolved.pending, resolved.migrated if wikiID == "" { if existing, loadErr := config.LoadProject(projectConfigPath); loadErr == nil { wikiID = existing.WikiID + } else if resolved.legacyWikiID != "" { + // One-time identity carryover from the 0.1.x global + // buda.yaml. A global file can never select a repository, + // but the migrating wiki keeps the id it already had. + wikiID = resolved.legacyWikiID } else if deps.Interactive() && !JSONRequested(deps) { fmt.Fprint(command.OutOrStdout(), "Immutable wiki id for this selected repository: ") answer, readErr := readPromptLine(input) @@ -41,10 +51,6 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { return UsageError("--wiki-id is required for a new wiki; an existing valid project configuration was not found") } } - globalPath, global, globalPending, migrated, globalErr := resolveGlobalConfig(deps.HomeDir, version) - if globalErr != nil { - return MutationError("prepare global Buda configuration", globalErr) - } project := config.ProjectConfig{Schema: config.CurrentSchema, WikiID: wikiID} if _, statErr := os.Stat(projectConfigPath); statErr == nil { project, err = config.LoadProject(projectConfigPath) @@ -130,32 +136,42 @@ func NewInitCommand(deps Dependencies, factories ...QMDFactory) *cobra.Command { return command } -func resolveGlobalConfig(homeDir func() (string, error), version string) (string, config.GlobalConfig, bool, bool, error) { +type resolvedGlobalConfig struct { + path string + global config.GlobalConfig + pending bool + migrated bool + legacyWikiID string +} + +func resolveGlobalConfig(homeDir func() (string, error), version string) (resolvedGlobalConfig, error) { if homeDir == nil { homeDir = os.UserHomeDir } home, err := homeDir() if err != nil { - return "", config.GlobalConfig{}, false, false, err + return resolvedGlobalConfig{}, err } path := config.GlobalPath(home) if _, err := os.Stat(path); err == nil { global, err := config.LoadGlobal(path) if err != nil { - return path, config.GlobalConfig{}, false, false, err + return resolvedGlobalConfig{}, err } - return path, global, false, false, nil + return resolvedGlobalConfig{path: path, global: global}, nil } else if !os.IsNotExist(err) { - return path, config.GlobalConfig{}, false, false, err + return resolvedGlobalConfig{}, err } // One-time read-only discovery of the 0.1.x global buda.yaml. Strictly // validated before mapping; the legacy file is left untouched and the new // global file is only persisted after every interactive answer exists. + // The legacy wiki_id never enters the global file, but it is carried to + // the explicitly selected wiki so migration preserves its identity. legacyPath := filepath.Join(home, ".guiho", "buda", "buda.yaml") if _, err := os.Stat(legacyPath); err == nil { legacyConfig, loadErr := config.Load(legacyPath) if loadErr != nil { - return path, config.GlobalConfig{}, false, false, fmt.Errorf("validate legacy global configuration: %w", loadErr) + return resolvedGlobalConfig{}, fmt.Errorf("validate legacy global configuration: %w", loadErr) } global := config.GlobalConfig{ Schema: config.CurrentSchema, @@ -171,13 +187,13 @@ func resolveGlobalConfig(homeDir func() (string, error), version string) (string }, } if err := global.Validate(); err != nil { - return path, config.GlobalConfig{}, false, false, err + return resolvedGlobalConfig{}, err } - return path, global, true, true, nil + return resolvedGlobalConfig{path: path, global: global, pending: true, migrated: true, legacyWikiID: legacyConfig.WikiID}, nil } else if !os.IsNotExist(err) { - return path, config.GlobalConfig{}, false, false, err + return resolvedGlobalConfig{}, err } - return path, config.DefaultGlobal(), true, false, nil + return resolvedGlobalConfig{path: path, global: config.DefaultGlobal(), pending: true}, nil } func readPromptLine(reader *bufio.Reader) (string, error) {