From ad34c63cea6da8646530fa0a11f8ee606f61ce59 Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Wed, 15 Jul 2026 22:52:39 +0800 Subject: [PATCH 1/3] feat: manage selected bundle lifecycles --- internal/bundle/discover.go | 53 ++ internal/bundle/discover_test.go | 33 + internal/bundle/recipe.go | 8 + internal/bundle/recipes/agent-capsule.toml | 15 +- internal/bundle/recipes/anysearch.toml | 9 +- internal/bundle/recipes/codex-conductor.toml | 9 +- internal/bundle/recipes/known-skills.toml | 9 +- internal/bundle/recipes/mainline.toml | 20 +- internal/bundle/recipes/sherlog.toml | 15 +- internal/bundle/recipes/shuorenhua.toml | 9 +- internal/bundle/recipes/xsearch.toml | 22 +- internal/bundle/service.go | 124 ++- internal/bundle/service_test.go | 44 + internal/bundledriver/driver.go | 913 +++++++++++++++++++ internal/bundledriver/driver_test.go | 145 +++ internal/cli/app.go | 4 + internal/cli/bundle_commands.go | 4 +- internal/cli/bundle_driver_commands.go | 24 + internal/cli/worker_commands.go | 3 + internal/store/bundles.go | 4 + internal/store/bundles_test.go | 35 + 21 files changed, 1475 insertions(+), 27 deletions(-) create mode 100644 internal/bundledriver/driver.go create mode 100644 internal/bundledriver/driver_test.go create mode 100644 internal/cli/bundle_driver_commands.go diff --git a/internal/bundle/discover.go b/internal/bundle/discover.go index a8ba491..c35bd73 100644 --- a/internal/bundle/discover.go +++ b/internal/bundle/discover.go @@ -97,6 +97,7 @@ func Discover(ctx context.Context, database *store.Store, options DiscoverOption match := matchedFromObserved(*item, artifact, options.HomeDir) enrichSkillMatch(&match, *item, skillEvidence) enrichWorkspaceMatch(&match, *item) + enrichRecipeSource(&match, artifact) matches[artifact.Key] = append(matches[artifact.Key], match) matchedBindings[item.binding.ID] = struct{}{} } @@ -104,9 +105,17 @@ func Discover(ctx context.Context, database *store.Store, options DiscoverOption for _, probe := range artifact.Probes { match, ok := resolveProbe(ctx, probe, recipe, artifact, options, lookup) if ok { + enrichRecipeSource(&match, artifact) matches[artifact.Key] = append(matches[artifact.Key], match) } } + if artifact.Driver == "mainline-hooks" { + hookMatches, hookErr := discoverMainlineHooks(ctx, database) + if hookErr != nil { + return DiscoverResult{}, hookErr + } + matches[artifact.Key] = append(matches[artifact.Key], hookMatches...) + } matches[artifact.Key] = dedupeMatches(matches[artifact.Key]) } if countMatches(matches) == 0 { @@ -588,6 +597,50 @@ func enrichWorkspaceMatch(match *matchedInstallation, observed observedInstallat } } +func enrichRecipeSource(match *matchedInstallation, artifact ArtifactRecipe) { + if strings.TrimSpace(artifact.Source) == "" { + return + } + match.sourceIdentity = strings.TrimSpace(artifact.Source) + if artifact.Subdir != "" { + match.sourceIdentity += "#" + filepath.ToSlash(filepath.Clean(artifact.Subdir)) + } + match.metadata["recipe_source_url"] = strings.TrimSpace(artifact.Source) + if artifact.Subdir != "" { + match.metadata["recipe_source_subdir"] = filepath.ToSlash(filepath.Clean(artifact.Subdir)) + } +} + +func discoverMainlineHooks(ctx context.Context, database *store.Store) ([]matchedInstallation, error) { + projects, err := database.ListProjects(ctx) + if err != nil { + return nil, err + } + var result []matchedInstallation + for _, project := range projects { + if !project.Selected { + continue + } + root := filepath.Clean(project.RootPath) + if info, statErr := os.Stat(filepath.Join(root, ".mainline", "config.toml")); statErr != nil || !info.Mode().IsRegular() { + continue + } + digest := sha256.New() + for _, relative := range []string{".claude/settings.json", ".codex/config.toml", ".codex/hooks.json", ".cursor/hooks.json"} { + data, readErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(relative))) + if readErr == nil { + _, _ = digest.Write([]byte(relative + "\x00")) + _, _ = digest.Write(data) + } + } + result = append(result, matchedInstallation{ + path: root, packageIdentity: "mainline-hooks", sourceIdentity: "mainline-hooks:" + root, + hash: hex.EncodeToString(digest.Sum(nil)), metadata: map[string]any{"project_id": project.ID, "derived": true}, + }) + } + return result, nil +} + func inspectSignedSkillManifest(skillPath string) (string, bool, bool) { manifestPath := filepath.Join(skillPath, "skill.manifest") data, err := os.ReadFile(manifestPath) diff --git a/internal/bundle/discover_test.go b/internal/bundle/discover_test.go index 93b6f56..bc9fd84 100644 --- a/internal/bundle/discover_test.go +++ b/internal/bundle/discover_test.go @@ -88,6 +88,39 @@ func TestDiscoverDeduplicatesPhysicalInstallAndReadsPackageMetadata(t *testing.T } } +func TestDiscoverMainlineHooksUsesSelectedRepositories(t *testing.T) { + database, err := store.OpenRW(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + selected := t.TempDir() + ignored := t.TempDir() + for _, root := range []string{selected, ignored} { + if err := os.MkdirAll(filepath.Join(root, ".mainline"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".mainline", "config.toml"), []byte("[hooks]\nenabled=true\n"), 0o644); err != nil { + t.Fatal(err) + } + } + now := time.Now().UTC() + if err := database.UpsertProject(ctx, model.Project{ID: "selected", RootPath: selected, RootFingerprint: "selected", Selected: true, DiscoveredVia: "test", LastSeenAt: now}); err != nil { + t.Fatal(err) + } + if err := database.UpsertProject(ctx, model.Project{ID: "ignored", RootPath: ignored, RootFingerprint: "ignored", Selected: false, DiscoveredVia: "test", LastSeenAt: now}); err != nil { + t.Fatal(err) + } + matches, err := discoverMainlineHooks(ctx, database) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].path != selected || matches[0].packageIdentity != "mainline-hooks" { + t.Fatalf("matches = %#v", matches) + } +} + func TestDiscoverPrunesStaleProbeWhenBindingProvidesRicherEvidence(t *testing.T) { database, err := store.OpenRW(filepath.Join(t.TempDir(), "state.db")) if err != nil { diff --git a/internal/bundle/recipe.go b/internal/bundle/recipe.go index 84b8916..58d6f37 100644 --- a/internal/bundle/recipe.go +++ b/internal/bundle/recipe.go @@ -45,6 +45,8 @@ type ArtifactRecipe struct { Name string `toml:"name" json:"name"` Kind model.ArtifactKind `toml:"kind" json:"kind"` Driver string `toml:"driver" json:"driver"` + Source string `toml:"source" json:"source,omitempty"` + Subdir string `toml:"subdir" json:"subdir,omitempty"` Required bool `toml:"required" json:"required"` Selectors []Selector `toml:"selectors" json:"selectors"` Probes []string `toml:"probes" json:"probes,omitempty"` @@ -184,6 +186,12 @@ func (r Recipe) Validate() error { if err := artifact.Kind.Validate(); err != nil { return err } + if strings.ContainsAny(artifact.Source+artifact.Subdir, "\x00\r\n") { + return fmt.Errorf("artifact %s source contains invalid characters", artifact.Key) + } + if artifact.Subdir != "" && (filepath.IsAbs(artifact.Subdir) || strings.HasPrefix(filepath.Clean(artifact.Subdir), "..")) { + return fmt.Errorf("artifact %s source subdirectory must be relative", artifact.Key) + } for _, selector := range artifact.Selectors { if err := selector.Validate(); err != nil { return fmt.Errorf("artifact %s: %w", artifact.Key, err) diff --git a/internal/bundle/recipes/agent-capsule.toml b/internal/bundle/recipes/agent-capsule.toml index 3eb6bae..cc8c42a 100644 --- a/internal/bundle/recipes/agent-capsule.toml +++ b/internal/bundle/recipes/agent-capsule.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "agent-capsule" -version = "1" +version = "2" name = "Agent Capsule" owner = "delegated" confidence = "high" @@ -13,7 +13,11 @@ kind = "cli" driver = "github-release" required = true probes = ["command:capsule"] -health_argv = ["capsule", "version"] +resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "z2z23n0/agent-capsule"] +stage_argv = ["tooltend", "__bundle-driver", "github-stage", "z2z23n0/agent-capsule", "capsule", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "github-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "github-rollback", "z2z23n0/agent-capsule", "capsule", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "binary-health", "${path}", "help"] [[artifacts.selectors]] field = "name" equals = "capsule" @@ -23,7 +27,14 @@ key = "skill" name = "Agent Capsule Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/z2z23n0/agent-capsule.git" +subdir = "skills/agent-capsule" required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-release-resolve", "https://github.com/z2z23n0/agent-capsule.git", "z2z23n0/agent-capsule"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/z2z23n0/agent-capsule.git", "skills/agent-capsule", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/z2z23n0/agent-capsule.git", "skills/agent-capsule", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "agent-capsule" diff --git a/internal/bundle/recipes/anysearch.toml b/internal/bundle/recipes/anysearch.toml index f58fe34..1bea9a9 100644 --- a/internal/bundle/recipes/anysearch.toml +++ b/internal/bundle/recipes/anysearch.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "anysearch" -version = "1" +version = "2" name = "AnySearch" owner = "delegated" confidence = "high" @@ -9,7 +9,14 @@ key = "skill" name = "AnySearch Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/catoncat/anysearch-skill.git" +subdir = "anysearch" required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/anysearch-skill.git"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/anysearch-skill.git", "anysearch", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/anysearch-skill.git", "anysearch", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "anysearch" diff --git a/internal/bundle/recipes/codex-conductor.toml b/internal/bundle/recipes/codex-conductor.toml index 71222ae..17e44bc 100644 --- a/internal/bundle/recipes/codex-conductor.toml +++ b/internal/bundle/recipes/codex-conductor.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "codex-conductor" -version = "1" +version = "2" name = "Codex Conductor" owner = "delegated" confidence = "high" @@ -9,7 +9,14 @@ key = "skill" name = "Codex Conductor Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/catoncat/codex-conductor.git" +subdir = "." required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/codex-conductor.git"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/codex-conductor.git", ".", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/codex-conductor.git", ".", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "codex-conductor" diff --git a/internal/bundle/recipes/known-skills.toml b/internal/bundle/recipes/known-skills.toml index 3037dba..7864d59 100644 --- a/internal/bundle/recipes/known-skills.toml +++ b/internal/bundle/recipes/known-skills.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "0g-hk" -version = "1" +version = "2" name = "0g-hk" owner = "delegated" confidence = "high" @@ -10,7 +10,14 @@ key = "skill" name = "0g-hk Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/catoncat/0g-hk.git" +subdir = "skill-packages/0g-hk" required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/0g-hk.git"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/0g-hk.git", "skill-packages/0g-hk", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/0g-hk.git", "skill-packages/0g-hk", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "0g-hk" diff --git a/internal/bundle/recipes/mainline.toml b/internal/bundle/recipes/mainline.toml index d6542dd..d01c1d0 100644 --- a/internal/bundle/recipes/mainline.toml +++ b/internal/bundle/recipes/mainline.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "mainline" -version = "1" +version = "2" name = "Mainline" owner = "delegated" confidence = "high" @@ -13,7 +13,11 @@ kind = "cli" driver = "github-release" required = true probes = ["command:mainline"] -health_argv = ["mainline", "version"] +resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "mainline-org/mainline"] +stage_argv = ["tooltend", "__bundle-driver", "github-stage", "mainline-org/mainline", "mainline", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "github-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "github-rollback", "mainline-org/mainline", "mainline", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "binary-health", "${path}", "version"] [[artifacts.selectors]] field = "name" equals = "mainline" @@ -26,7 +30,14 @@ key = "skill" name = "Mainline Skill" kind = "skill" driver = "npx-skills" +source = "https://github.com/mainline-org/mainline.git" +subdir = "skills/mainline" required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-release-resolve", "https://github.com/mainline-org/mainline.git", "mainline-org/mainline"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/mainline-org/mainline.git", "skills/mainline", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/mainline-org/mainline.git", "skills/mainline", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "mainline" @@ -40,3 +51,8 @@ name = "Mainline generated hooks" kind = "hook" driver = "mainline-hooks" required = false +resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "mainline-org/mainline"] +stage_argv = ["tooltend", "__bundle-driver", "mainline-hooks-stage", "${path}", "${stage}"] +activate_argv = ["tooltend", "__bundle-driver", "mainline-hooks-activate", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "mainline-hooks-rollback", "${path}", "${stage}"] +health_argv = ["tooltend", "__bundle-driver", "mainline-hooks-health", "${path}"] diff --git a/internal/bundle/recipes/sherlog.toml b/internal/bundle/recipes/sherlog.toml index 525938a..eae0b01 100644 --- a/internal/bundle/recipes/sherlog.toml +++ b/internal/bundle/recipes/sherlog.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "sherlog" -version = "1" +version = "2" name = "Sherlog" owner = "delegated" confidence = "high" @@ -13,7 +13,11 @@ kind = "cli" driver = "npm" required = true probes = ["command:sherlog", "command:shlog"] -health_argv = ["sherlog", "--version"] +resolve_argv = ["tooltend", "__bundle-driver", "npm-resolve", "@act0r/sherlog"] +stage_argv = ["tooltend", "__bundle-driver", "npm-stage", "@act0r/sherlog", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "npm-activate", "${stage}"] +rollback_argv = ["tooltend", "__bundle-driver", "npm-rollback", "@act0r/sherlog", "${rollback_version}", "${stage}"] +health_argv = ["shlog", "--version"] [[artifacts.selectors]] field = "name" equals = "sherlog|shlog" @@ -26,7 +30,14 @@ key = "skill" name = "Sherlog Skill" kind = "skill" driver = "npx-skills" +source = "https://github.com/catoncat/sherlog.git" +subdir = "skill-packages/sherlog" required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-npm-release-resolve", "https://github.com/catoncat/sherlog.git", "@act0r/sherlog"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/sherlog.git", "skill-packages/sherlog", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/sherlog.git", "skill-packages/sherlog", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "sherlog" diff --git a/internal/bundle/recipes/shuorenhua.toml b/internal/bundle/recipes/shuorenhua.toml index 5628b63..4998c40 100644 --- a/internal/bundle/recipes/shuorenhua.toml +++ b/internal/bundle/recipes/shuorenhua.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "shuorenhua" -version = "1" +version = "2" name = "shuorenhua" owner = "delegated" confidence = "high" @@ -9,7 +9,14 @@ key = "skill" name = "shuorenhua Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/MrGeDiao/shuorenhua.git" +subdir = "." required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/MrGeDiao/shuorenhua.git"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/MrGeDiao/shuorenhua.git", ".", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/MrGeDiao/shuorenhua.git", ".", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "shuorenhua" diff --git a/internal/bundle/recipes/xsearch.toml b/internal/bundle/recipes/xsearch.toml index ddf05a6..4b4a378 100644 --- a/internal/bundle/recipes/xsearch.toml +++ b/internal/bundle/recipes/xsearch.toml @@ -1,6 +1,6 @@ schema = "bundle-recipe-v1" id = "xsearch" -version = "1" +version = "2" name = "xsearch" owner = "delegated" confidence = "high" @@ -9,7 +9,27 @@ key = "skill" name = "xsearch Skill" kind = "skill" driver = "git-skill" +source = "https://github.com/catoncat/xsearch.git" +subdir = "." required = true +resolve_argv = ["tooltend", "__bundle-driver", "git-resolve", "https://github.com/catoncat/xsearch.git"] +stage_argv = ["tooltend", "__bundle-driver", "git-stage", "https://github.com/catoncat/xsearch.git", ".", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "git-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "git-rollback", "https://github.com/catoncat/xsearch.git", ".", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "skill-health", "${path}"] [[artifacts.selectors]] field = "name" equals = "xsearch" + +[[artifacts]] +key = "binary" +name = "xsearch embedded binary" +kind = "embedded_binary" +driver = "github-release" +required = true +probes = ["path:~/.agents/skills/xsearch/bin/xsearch"] +resolve_argv = ["tooltend", "__bundle-driver", "github-resolve", "catoncat/xsearch"] +stage_argv = ["tooltend", "__bundle-driver", "github-stage", "catoncat/xsearch", "xsearch", "${version}", "${previous_version}", "${stage}", "${path}"] +activate_argv = ["tooltend", "__bundle-driver", "github-activate", "${stage}", "${path}"] +rollback_argv = ["tooltend", "__bundle-driver", "github-rollback", "catoncat/xsearch", "xsearch", "${rollback_version}", "${stage}", "${path}"] +health_argv = ["tooltend", "__bundle-driver", "binary-health", "${path}", "--version"] diff --git a/internal/bundle/service.go b/internal/bundle/service.go index 02bef97..4784ff9 100644 --- a/internal/bundle/service.go +++ b/internal/bundle/service.go @@ -12,6 +12,8 @@ import ( "strings" "time" + semver "github.com/Masterminds/semver/v3" + "github.com/z2z23n0/tooltend/internal/config" "github.com/z2z23n0/tooltend/internal/execx" "github.com/z2z23n0/tooltend/internal/model" @@ -26,19 +28,21 @@ type Service struct { } type UpdatePreview struct { - Bundle model.Bundle `json:"bundle"` - Policy model.BundlePolicy `json:"policy"` - Current *model.BundleRelease `json:"current_release,omitempty"` - Target model.BundleRelease `json:"target_release"` - Artifacts []UpdateArtifactPreview `json:"artifacts"` - StageOnly bool `json:"stage_only"` - AutoEligible bool `json:"auto_eligible"` + Bundle model.Bundle `json:"bundle"` + Policy model.BundlePolicy `json:"policy"` + Current *model.BundleRelease `json:"current_release,omitempty"` + Target model.BundleRelease `json:"target_release"` + Artifacts []UpdateArtifactPreview `json:"artifacts"` + StageOnly bool `json:"stage_only"` + AutoEligible bool `json:"auto_eligible"` + UpdateAvailable bool `json:"update_available"` } type UpdateArtifactPreview struct { Artifact model.BundleArtifact `json:"artifact"` Installations int `json:"installations"` ResolvedVersion string `json:"resolved_version,omitempty"` + Changed bool `json:"changed"` CanStage bool `json:"can_stage"` CanActivate bool `json:"can_activate"` CanRollback bool `json:"can_rollback"` @@ -108,6 +112,13 @@ func (s Service) PrepareUpdate(ctx context.Context, bundleID string, stageOnly b preview.Current = ¤t } } + currentVersions := map[string]string{} + if preview.Current != nil { + currentVersions, err = parseReleaseVersions(preview.Current.ManifestJSON) + if err != nil { + return UpdatePreview{}, fmt.Errorf("current bundle release manifest: %w", err) + } + } versions := map[string]string{} for _, artifact := range artifacts { recipe, err := decodeArtifactMetadata(artifact) @@ -133,7 +144,12 @@ func (s Service) PrepareUpdate(ctx context.Context, bundleID string, stageOnly b if err != nil { return UpdatePreview{}, fmt.Errorf("resolve artifact %s: %w", artifact.Name, err) } + currentVersion := currentVersions[artifact.RecipeKey] + if compareArtifactVersions(resolved, currentVersion) < 0 { + resolved = currentVersion + } item.ResolvedVersion = resolved + item.Changed = resolved != currentVersion versions[artifact.RecipeKey] = resolved if !item.CanStage || !item.CanActivate || !item.CanRollback || !item.CanHealthCheck { preview.AutoEligible = false @@ -158,6 +174,7 @@ func (s Service) PrepareUpdate(ctx context.Context, bundleID string, stageOnly b ID: stableID("rel", bundleValue.ID+"\x00"+string(manifest)), BundleID: bundleValue.ID, Version: releaseVersion, ResolvedRef: releaseVersion, ManifestJSON: string(manifest), Status: "resolved", CreatedAt: s.now(), } + preview.UpdateAvailable = preview.Current == nil || !artifactVersionMapsEqual(currentVersions, versions) return preview, nil } @@ -165,6 +182,9 @@ func (s Service) ExecuteUpdate(ctx context.Context, preview UpdatePreview) (resu if err := s.validate(); err != nil { return result, err } + if !preview.UpdateAvailable { + return result, errors.New("bundle is already at the resolved release") + } currentBundle, err := s.Database.GetBundle(ctx, preview.Bundle.ID) if err != nil { return result, err @@ -216,6 +236,10 @@ func (s Service) ExecuteUpdate(ctx context.Context, preview UpdatePreview) (resu byArtifact[installation.ArtifactID] = append(byArtifact[installation.ArtifactID], installation) } versions := releaseVersions(preview.Target.ManifestJSON) + currentVersions := map[string]string{} + if preview.Current != nil { + currentVersions = releaseVersions(preview.Current.ManifestJSON) + } steps := []executionStep{} ordinal := 0 for _, artifact := range artifacts { @@ -227,6 +251,9 @@ func (s Service) ExecuteUpdate(ctx context.Context, preview UpdatePreview) (resu if len(recipe.StageArgv) == 0 && len(recipe.ActivateArgv) == 0 { continue } + if versions[artifact.RecipeKey] == currentVersions[artifact.RecipeKey] { + continue + } stepID := stableID("bst", transactionID+fmt.Sprintf("\x00%d", ordinal)) step := executionStep{ record: model.BundleTransactionStep{ID: stepID, TransactionID: transactionID, Ordinal: ordinal, ArtifactID: artifact.ID, @@ -410,7 +437,7 @@ func (s Service) PrepareRollback(ctx context.Context, bundleID, targetReleaseID continue } version := targetVersions[artifact.RecipeKey] - if count > 0 && !exactVersion(version) { + if count > 0 && !exactArtifactVersion(version) { return RollbackPreview{}, fmt.Errorf("rollback target has no exact version for artifact %s", artifact.Name) } recipe, err := decodeArtifactMetadata(artifact) @@ -501,7 +528,7 @@ func (s Service) ExecuteRollback(ctx context.Context, preview RollbackPreview) ( } transaction.Status = model.BundleTransactionRollingBack completedSteps := make([]executionStep, 0, len(steps)) - for index := len(steps) - 1; index >= 0; index-- { + for _, index := range explicitRollbackOrder(steps) { step := steps[index] if err := s.Database.UpdateBundleTransactionStep(ctx, step.record.ID, model.BundleStepCompensating, "", "", "{}", nil); err != nil { return result, s.failTransaction(ctx, transaction, "journal_failed", err) @@ -551,14 +578,15 @@ func (s Service) ExecuteRollback(ctx context.Context, preview RollbackPreview) ( func (s Service) restoreAfterRollbackFailure(ctx context.Context, completed []executionStep, versions map[string]string) error { var failures []error - for index := len(completed) - 1; index >= 0; index-- { + for _, index := range explicitRollbackOrder(completed) { step := completed[index] - step.version = versions[step.artifact.RecipeKey] - if !exactVersion(step.version) { + step.rollbackVersion = versions[step.artifact.RecipeKey] + step.version = step.rollbackVersion + if !exactArtifactVersion(step.rollbackVersion) { failures = append(failures, fmt.Errorf("artifact %s has no exact restore version", step.artifact.Name)) continue } - if err := s.runCommand(context.WithoutCancel(ctx), step.recipe.ActivateArgv, step, DefaultInstallTimeout); err != nil { + if err := s.runCommand(context.WithoutCancel(ctx), step.recipe.RollbackArgv, step, DefaultInstallTimeout); err != nil { failures = append(failures, err) } } @@ -585,8 +613,11 @@ func (s Service) runResolver(ctx context.Context, argv []string, installation mo if index := strings.IndexByte(version, '\n'); index >= 0 { version = strings.TrimSpace(version[:index]) } - if !exactVersion(version) { - return "", errors.New("resolver did not return an exact semantic version") + if !exactArtifactVersion(version) { + return "", errors.New("resolver did not return an exact semantic version or git commit") + } + if strings.HasPrefix(version, "git:") { + return strings.ToLower(version), nil } return strings.TrimPrefix(version, "v"), nil } @@ -783,3 +814,66 @@ func bundleReleaseVersion(versions map[string]string, manifest []byte) string { } return "bundle-" + strings.TrimPrefix(stableID("", string(manifest)), "_")[:12] } + +func exactArtifactVersion(value string) bool { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "git:") { + commit := strings.TrimPrefix(value, "git:") + if len(commit) != 40 { + return false + } + for _, character := range commit { + if !strings.ContainsRune("0123456789abcdefABCDEF", character) { + return false + } + } + return true + } + return exactVersion(value) +} + +func compareArtifactVersions(candidate, current string) int { + candidate, current = strings.TrimSpace(candidate), strings.TrimSpace(current) + if current == "" { + return 1 + } + if candidate == current { + return 0 + } + if strings.HasPrefix(candidate, "git:") || strings.HasPrefix(current, "git:") { + return 1 + } + candidateVersion, candidateErr := semver.NewVersion(strings.TrimPrefix(candidate, "v")) + currentVersion, currentErr := semver.NewVersion(strings.TrimPrefix(current, "v")) + if candidateErr != nil || currentErr != nil { + return 1 + } + return candidateVersion.Compare(currentVersion) +} + +func artifactVersionMapsEqual(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +func explicitRollbackOrder(steps []executionStep) []int { + order := make([]int, 0, len(steps)) + for index := len(steps) - 1; index >= 0; index-- { + if steps[index].artifact.Kind != model.ArtifactHook { + order = append(order, index) + } + } + for index := len(steps) - 1; index >= 0; index-- { + if steps[index].artifact.Kind == model.ArtifactHook { + order = append(order, index) + } + } + return order +} diff --git a/internal/bundle/service_test.go b/internal/bundle/service_test.go index c454150..de59adf 100644 --- a/internal/bundle/service_test.go +++ b/internal/bundle/service_test.go @@ -28,6 +28,8 @@ func (r *transactionRunner) Run(_ context.Context, name string, args ...string) switch name { case "resolver": return execx.Result{Stdout: []byte("2.0.0\n")}, nil + case "git-resolver": + return execx.Result{Stdout: []byte("git:0123456789abcdef0123456789abcdef01234567\n")}, nil case "activate-two": return execx.Result{}, errors.New("activation failed") default: @@ -35,6 +37,48 @@ func (r *transactionRunner) Run(_ context.Context, name string, args ...string) } } +func TestArtifactVersionComparisonAndRollbackOrder(t *testing.T) { + if !exactArtifactVersion("git:0123456789abcdef0123456789abcdef01234567") { + t.Fatal("exact git commit was rejected") + } + if exactArtifactVersion("git:main") { + t.Fatal("symbolic git ref was accepted") + } + if compareArtifactVersions("1.2.3", "1.2.4") >= 0 { + t.Fatal("semantic downgrade was not detected") + } + if !artifactVersionMapsEqual(map[string]string{"cli": "1.2.3"}, map[string]string{"cli": "1.2.3"}) { + t.Fatal("equivalent observed and resolved manifests were treated as an update") + } + steps := []executionStep{ + {artifact: model.BundleArtifact{Kind: model.ArtifactCLI}}, + {artifact: model.BundleArtifact{Kind: model.ArtifactSkill}}, + {artifact: model.BundleArtifact{Kind: model.ArtifactHook}}, + } + order := explicitRollbackOrder(steps) + if len(order) != 3 || order[0] != 1 || order[1] != 0 || order[2] != 2 { + t.Fatalf("rollback order = %v", order) + } +} + +func TestSelectedBuiltinRecipesAreAutoCapable(t *testing.T) { + catalog, err := LoadCatalog("") + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"sherlog", "mainline", "agent-capsule", "0g-hk", "anysearch", "codex-conductor", "shuorenhua", "xsearch"} { + recipe, ok := catalog.Get(id) + if !ok { + t.Fatalf("recipe %s is missing", id) + } + for _, artifact := range recipe.Artifacts { + if len(artifact.ResolveArgv) == 0 || len(artifact.StageArgv) == 0 || len(artifact.ActivateArgv) == 0 || len(artifact.RollbackArgv) == 0 || len(artifact.HealthArgv) == 0 { + t.Fatalf("recipe %s artifact %s is not auto capable", id, artifact.Key) + } + } + } +} + func TestBundleTransactionStagesAllArtifactsBeforeActivationAndCompensates(t *testing.T) { root := t.TempDir() paths := config.ResolveWith(root, func(key string) string { diff --git a/internal/bundledriver/driver.go b/internal/bundledriver/driver.go new file mode 100644 index 0000000..54a63a1 --- /dev/null +++ b/internal/bundledriver/driver.go @@ -0,0 +1,913 @@ +package bundledriver + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + semver "github.com/Masterminds/semver/v3" + + "github.com/z2z23n0/tooltend/internal/execx" + "github.com/z2z23n0/tooltend/internal/safeio" +) + +const maxDownloadBytes = 256 << 20 + +type Driver struct { + Runner execx.Runner + Client *http.Client + Out io.Writer + GOOS string + GOARCH string +} + +func (d Driver) Execute(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("bundle driver action is required") + } + switch args[0] { + case "npm-resolve": + if len(args) != 2 { + return errors.New("npm-resolve requires package") + } + return d.npmResolve(ctx, args[1]) + case "npm-stage": + if len(args) != 6 { + return errors.New("npm-stage requires package, version, previous version, stage, and path") + } + return d.npmStage(ctx, args[1], args[2], args[3], args[4], args[5]) + case "npm-activate": + if len(args) != 2 { + return errors.New("npm-activate requires stage") + } + return d.npmInstallArchive(ctx, filepath.Join(args[1], "target.tgz")) + case "npm-rollback": + if len(args) != 4 { + return errors.New("npm-rollback requires package, version, and stage") + } + return d.npmRollback(ctx, args[1], args[2], args[3]) + case "github-resolve": + if len(args) != 2 { + return errors.New("github-resolve requires repository") + } + return d.githubResolve(ctx, args[1]) + case "github-stage": + if len(args) != 7 { + return errors.New("github-stage requires repository, binary, version, previous version, stage, and path") + } + return d.githubStage(ctx, args[1], args[2], args[3], args[4], args[5], args[6]) + case "github-activate": + if len(args) != 3 { + return errors.New("github-activate requires stage and path") + } + return replacePath(filepath.Join(args[1], "next"), args[2]) + case "github-rollback": + if len(args) != 6 { + return errors.New("github-rollback requires repository, binary, version, stage, and path") + } + return d.githubRollback(ctx, args[1], args[2], args[3], args[4], args[5]) + case "git-resolve": + if len(args) != 2 { + return errors.New("git-resolve requires repository URL") + } + return d.gitResolve(ctx, args[1]) + case "git-release-resolve": + if len(args) != 3 { + return errors.New("git-release-resolve requires repository URL and GitHub repository") + } + return d.gitReleaseResolve(ctx, args[1], args[2]) + case "git-npm-release-resolve": + if len(args) != 3 { + return errors.New("git-npm-release-resolve requires repository URL and npm package") + } + return d.gitNPMReleaseResolve(ctx, args[1], args[2]) + case "git-stage": + if len(args) != 7 { + return errors.New("git-stage requires repository, subdirectory, ref, previous ref, stage, and path") + } + return d.gitStage(ctx, args[1], args[2], args[3], args[4], args[5], args[6]) + case "git-activate": + if len(args) != 3 { + return errors.New("git-activate requires stage and path") + } + if err := replacePath(filepath.Join(args[1], "next"), args[2]); err != nil { + return err + } + return detachSkillLock(args[2]) + case "git-rollback": + if len(args) != 6 { + return errors.New("git-rollback requires repository, subdirectory, ref, stage, and path") + } + return d.gitRollback(ctx, args[1], args[2], args[3], args[4], args[5]) + case "skill-health": + if len(args) != 2 { + return errors.New("skill-health requires path") + } + return skillHealth(args[1]) + case "binary-health": + if len(args) < 2 { + return errors.New("binary-health requires path") + } + _, err := d.runner().Run(ctx, args[1], args[2:]...) + if err != nil { + return errors.New("managed binary health check failed") + } + return nil + case "mainline-hooks-stage": + if len(args) != 3 { + return errors.New("mainline-hooks-stage requires project and stage") + } + return stageMainlineHooks(args[1], args[2]) + case "mainline-hooks-activate": + if len(args) != 2 { + return errors.New("mainline-hooks-activate requires project") + } + return d.runMainlineHooks(ctx, args[1], "install") + case "mainline-hooks-rollback": + if len(args) != 3 { + return errors.New("mainline-hooks-rollback requires project and stage") + } + if restored, err := restoreMainlineHooks(args[1], args[2]); err != nil || restored { + return err + } + return d.runMainlineHooks(ctx, args[1], "install") + case "mainline-hooks-health": + if len(args) != 2 { + return errors.New("mainline-hooks-health requires project") + } + return d.runMainlineHooks(ctx, args[1], "status") + default: + return fmt.Errorf("unsupported bundle driver action %q", args[0]) + } +} + +func (d Driver) runner() execx.Runner { + if d.Runner != nil { + return d.Runner + } + return execx.ExecRunner{} +} + +func (d Driver) output(value string) error { + w := d.Out + if w == nil { + w = os.Stdout + } + _, err := fmt.Fprintln(w, value) + return err +} + +func (d Driver) npmResolve(ctx context.Context, packageName string) error { + version, err := d.npmVersion(ctx, packageName) + if err != nil { + return err + } + return d.output(version) +} + +func (d Driver) npmVersion(ctx context.Context, packageName string) (string, error) { + result, err := d.runner().Run(ctx, "npm", "view", packageName, "version", "--json") + if err != nil { + return "", errors.New("npm version lookup failed") + } + var version string + if json.Unmarshal(result.Stdout, &version) != nil { + var versions []string + if json.Unmarshal(result.Stdout, &versions) != nil || len(versions) == 0 { + return "", errors.New("npm returned an invalid version") + } + version = versions[len(versions)-1] + } + if _, err := semver.StrictNewVersion(version); err != nil { + return "", errors.New("npm returned an invalid semantic version") + } + return version, nil +} + +func (d Driver) npmStage(ctx context.Context, packageName, version, previous, stage, path string) error { + if _, err := semver.StrictNewVersion(version); err != nil { + return errors.New("npm target version is invalid") + } + if err := resetStage(stage); err != nil { + return err + } + if err := d.npmPack(ctx, packageName, version, stage, "target.tgz"); err != nil { + return err + } + if _, err := semver.StrictNewVersion(strings.TrimPrefix(previous, "v")); err == nil { + if err := d.npmPack(ctx, packageName, strings.TrimPrefix(previous, "v"), stage, "previous.tgz"); err != nil { + return err + } + } + return backupPath(path, filepath.Join(stage, "previous-installation")) +} + +func (d Driver) npmPack(ctx context.Context, packageName, version, stage, target string) error { + result, err := d.runner().Run(ctx, "npm", "pack", packageName+"@"+version, "--json", "--pack-destination", stage) + if err != nil { + return errors.New("npm package staging failed") + } + var records []struct { + Filename string `json:"filename"` + } + if json.Unmarshal(result.Stdout, &records) != nil || len(records) != 1 || filepath.Base(records[0].Filename) != records[0].Filename { + return errors.New("npm package staging returned invalid metadata") + } + return os.Rename(filepath.Join(stage, records[0].Filename), filepath.Join(stage, target)) +} + +func (d Driver) npmInstallArchive(ctx context.Context, archive string) error { + if info, err := os.Stat(archive); err != nil || !info.Mode().IsRegular() { + return errors.New("staged npm package is missing") + } + if _, err := d.runner().Run(ctx, "npm", "install", "--global", "--no-audit", "--no-fund", archive); err != nil { + return errors.New("npm package activation failed") + } + return nil +} + +func (d Driver) npmRollback(ctx context.Context, packageName, version, stage string) error { + archive := filepath.Join(stage, "previous.tgz") + if info, err := os.Stat(archive); err == nil && info.Mode().IsRegular() { + return d.npmInstallArchive(ctx, archive) + } + version = strings.TrimPrefix(version, "v") + if _, err := semver.StrictNewVersion(version); err != nil { + return errors.New("npm rollback version is unavailable") + } + if _, err := d.runner().Run(ctx, "npm", "install", "--global", "--no-audit", "--no-fund", packageName+"@"+version); err != nil { + return errors.New("npm package rollback failed") + } + return nil +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +func (d Driver) githubResolve(ctx context.Context, repository string) error { + release, err := d.getRelease(ctx, repository, "latest") + if err != nil { + return err + } + version := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v") + if _, err := semver.StrictNewVersion(version); err != nil { + return errors.New("GitHub latest release is not a stable semantic version") + } + return d.output(version) +} + +func (d Driver) githubStage(ctx context.Context, repository, binary, version, _ string, stage, path string) error { + version = strings.TrimPrefix(version, "v") + if _, err := semver.StrictNewVersion(version); err != nil { + return errors.New("GitHub release version is invalid") + } + if err := resetStage(stage); err != nil { + return err + } + release, err := d.getRelease(ctx, repository, "tags/v"+version) + if err != nil { + release, err = d.getRelease(ctx, repository, "tags/"+version) + } + if err != nil { + return err + } + asset, checksums, err := selectReleaseAssets(release.Assets, d.goos(), d.goarch()) + if err != nil { + return err + } + archive, err := d.download(ctx, asset) + if err != nil { + return err + } + checksumData, err := d.download(ctx, checksums) + if err != nil { + return err + } + if err := verifyChecksum(asset.Name, archive, checksumData); err != nil { + return err + } + if err := extractTarBinary(archive, binary, filepath.Join(stage, "next")); err != nil { + return err + } + return backupPath(path, filepath.Join(stage, "previous")) +} + +func (d Driver) githubRollback(ctx context.Context, repository, binary, version, stage, path string) error { + previous := filepath.Join(stage, "previous") + if info, err := os.Stat(previous); err == nil && info.Mode().IsRegular() { + return replacePath(previous, path) + } + if _, err := semver.StrictNewVersion(strings.TrimPrefix(version, "v")); err != nil { + return errors.New("GitHub rollback version is unavailable") + } + temporary, err := os.MkdirTemp(filepath.Dir(path), ".tooltend-github-rollback-*") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + if err := d.githubStage(ctx, repository, binary, version, "", temporary, path); err != nil { + return err + } + return replacePath(filepath.Join(temporary, "next"), path) +} + +func (d Driver) getRelease(ctx context.Context, repository, endpoint string) (githubRelease, error) { + if strings.Count(repository, "/") != 1 || strings.ContainsAny(repository, "\x00\r\n?#") { + return githubRelease{}, errors.New("GitHub repository identity is invalid") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/"+repository+"/releases/"+endpoint, nil) + if err != nil { + return githubRelease{}, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "tooltend-bundle-driver") + if token := d.githubToken(ctx); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := d.client().Do(request) + if err != nil { + return githubRelease{}, errors.New("GitHub release lookup failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return githubRelease{}, fmt.Errorf("GitHub release lookup failed with status %d", response.StatusCode) + } + var release githubRelease + decoder := json.NewDecoder(io.LimitReader(response.Body, 4<<20)) + if decoder.Decode(&release) != nil || release.TagName == "" { + return githubRelease{}, errors.New("GitHub release metadata is invalid") + } + return release, nil +} + +func (d Driver) githubToken(ctx context.Context) string { + for _, name := range []string{"GITHUB_TOKEN", "GH_TOKEN"} { + if token := strings.TrimSpace(os.Getenv(name)); validToken(token) { + return token + } + } + result, err := d.runner().Run(ctx, "gh", "auth", "token") + if err != nil { + return "" + } + token := strings.TrimSpace(string(result.Stdout)) + if !validToken(token) { + return "" + } + return token +} + +func validToken(value string) bool { + return value != "" && len(value) <= 4096 && !strings.ContainsAny(value, "\x00\r\n \t") +} + +func (d Driver) download(ctx context.Context, asset githubAsset) ([]byte, error) { + if asset.Size <= 0 || asset.Size > maxDownloadBytes || !strings.HasPrefix(asset.URL, "https://github.com/") { + return nil, errors.New("GitHub release asset metadata is invalid") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "tooltend-bundle-driver") + response, err := d.client().Do(request) + if err != nil { + return nil, errors.New("GitHub release asset download failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub release asset download failed with status %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxDownloadBytes+1)) + if err != nil || int64(len(data)) > maxDownloadBytes { + return nil, errors.New("GitHub release asset exceeded the download limit") + } + if int64(len(data)) != asset.Size { + return nil, errors.New("GitHub release asset size mismatch") + } + return data, nil +} + +func (d Driver) client() *http.Client { + if d.Client != nil { + return d.Client + } + return &http.Client{Timeout: 5 * time.Minute} +} + +func (d Driver) goos() string { + if d.GOOS != "" { + return d.GOOS + } + return runtime.GOOS +} + +func (d Driver) goarch() string { + if d.GOARCH != "" { + return d.GOARCH + } + return runtime.GOARCH +} + +func selectReleaseAssets(assets []githubAsset, goos, goarch string) (githubAsset, githubAsset, error) { + osTokens := map[string][]string{"darwin": {"darwin", "apple-darwin"}, "linux": {"linux", "unknown-linux"}}[goos] + archTokens := map[string][]string{"arm64": {"arm64", "aarch64"}, "amd64": {"amd64", "x86_64"}}[goarch] + if len(osTokens) == 0 || len(archTokens) == 0 { + return githubAsset{}, githubAsset{}, errors.New("platform is not supported by the bundle release driver") + } + var candidates []githubAsset + var checksums githubAsset + for _, asset := range assets { + name := strings.ToLower(asset.Name) + if name == "checksums.txt" { + checksums = asset + continue + } + if strings.HasSuffix(name, ".tar.gz") && containsAny(name, osTokens) && containsAny(name, archTokens) { + candidates = append(candidates, asset) + } + } + if len(candidates) != 1 || checksums.Name == "" { + return githubAsset{}, githubAsset{}, errors.New("release does not contain one matching archive and checksums.txt") + } + return candidates[0], checksums, nil +} + +func containsAny(value string, candidates []string) bool { + for _, candidate := range candidates { + if strings.Contains(value, candidate) { + return true + } + } + return false +} + +func verifyChecksum(name string, data, checksums []byte) error { + expected := "" + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && strings.TrimPrefix(fields[len(fields)-1], "*") == name { + expected = strings.ToLower(fields[0]) + break + } + } + if len(expected) != sha256.Size*2 { + return errors.New("release checksum entry is missing") + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != expected { + return errors.New("release checksum verification failed") + } + return nil +} + +func extractTarBinary(archive []byte, binary, target string) error { + reader, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return errors.New("release archive is not valid gzip") + } + defer reader.Close() + tr := tar.NewReader(reader) + for { + header, nextErr := tr.Next() + if errors.Is(nextErr, io.EOF) { + break + } + if nextErr != nil { + return errors.New("release archive is invalid") + } + if header.Typeflag != tar.TypeReg || filepath.Base(filepath.Clean(header.Name)) != binary { + continue + } + if header.Size <= 0 || header.Size > 128<<20 { + return errors.New("release binary size is invalid") + } + data, readErr := io.ReadAll(io.LimitReader(tr, header.Size+1)) + if readErr != nil || int64(len(data)) != header.Size { + return errors.New("release binary is truncated") + } + return safeio.AtomicWriteFile(target, data, 0o755) + } + return errors.New("release archive does not contain the expected binary") +} + +func (d Driver) gitResolve(ctx context.Context, repository string) error { + result, err := d.runner().Run(ctx, "git", "ls-remote", repository, "HEAD") + if err != nil { + return errors.New("git source lookup failed") + } + fields := strings.Fields(string(result.Stdout)) + if len(fields) < 2 || !commitHash(fields[0]) { + return errors.New("git source did not resolve to an exact commit") + } + return d.output("git:" + strings.ToLower(fields[0])) +} + +func (d Driver) gitReleaseResolve(ctx context.Context, repository, githubRepository string) error { + release, err := d.getRelease(ctx, githubRepository, "latest") + if err != nil { + return err + } + tag := strings.TrimSpace(release.TagName) + if tag == "" || strings.ContainsAny(tag, "\x00\r\n") { + return errors.New("GitHub release tag is invalid") + } + for _, ref := range []string{"refs/tags/" + tag + "^{}", "refs/tags/" + tag} { + result, resolveErr := d.runner().Run(ctx, "git", "ls-remote", repository, ref) + if resolveErr != nil { + continue + } + fields := strings.Fields(string(result.Stdout)) + if len(fields) >= 2 && commitHash(fields[0]) { + return d.output("git:" + strings.ToLower(fields[0])) + } + } + return errors.New("GitHub release tag did not resolve to an exact git commit") +} + +func (d Driver) gitNPMReleaseResolve(ctx context.Context, repository, packageName string) error { + version, err := d.npmVersion(ctx, packageName) + if err != nil { + return err + } + for _, tag := range []string{"v" + version, version} { + for _, ref := range []string{"refs/tags/" + tag + "^{}", "refs/tags/" + tag} { + result, resolveErr := d.runner().Run(ctx, "git", "ls-remote", repository, ref) + if resolveErr != nil { + continue + } + fields := strings.Fields(string(result.Stdout)) + if len(fields) >= 2 && commitHash(fields[0]) { + return d.output("git:" + strings.ToLower(fields[0])) + } + } + } + return errors.New("npm version did not resolve to an exact git release tag") +} + +func (d Driver) gitStage(ctx context.Context, repository, subdir, ref, _ string, stage, path string) error { + commit := strings.TrimPrefix(ref, "git:") + if !commitHash(commit) { + return errors.New("git target ref is invalid") + } + cleanSubdir, err := sourceSubdir(subdir) + if err != nil { + return err + } + if err := resetStage(stage); err != nil { + return err + } + clone := filepath.Join(stage, "repository") + commands := [][]string{ + {"init", "--quiet", clone}, + {"-C", clone, "remote", "add", "origin", repository}, + {"-C", clone, "fetch", "--quiet", "--depth", "1", "origin", commit}, + {"-C", clone, "checkout", "--quiet", "--detach", "FETCH_HEAD"}, + } + for _, command := range commands { + if _, err := d.runner().Run(ctx, "git", command...); err != nil { + return errors.New("git skill staging failed") + } + } + root := clone + if cleanSubdir != "." { + root = filepath.Join(clone, filepath.FromSlash(cleanSubdir)) + } + if err := copySkillTree(root, filepath.Join(stage, "next")); err != nil { + return err + } + if err := skillHealth(filepath.Join(stage, "next")); err != nil { + return err + } + if err := backupPath(path, filepath.Join(stage, "previous")); err != nil { + return err + } + return backupSkillLock(path, stage) +} + +func (d Driver) gitRollback(ctx context.Context, repository, subdir, ref, stage, path string) error { + previous := filepath.Join(stage, "previous") + if info, err := os.Stat(previous); err == nil && info.IsDir() { + if err := replacePath(previous, path); err != nil { + return err + } + return restoreSkillLock(path, stage) + } + if !commitHash(strings.TrimPrefix(ref, "git:")) { + return errors.New("git rollback ref is unavailable") + } + temporary, err := os.MkdirTemp(filepath.Dir(path), ".tooltend-git-rollback-*") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + if err := d.gitStage(ctx, repository, subdir, ref, "", temporary, path); err != nil { + return err + } + if err := replacePath(filepath.Join(temporary, "next"), path); err != nil { + return err + } + return detachSkillLock(path) +} + +func commitHash(value string) bool { + if len(value) != 40 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func sourceSubdir(value string) (string, error) { + value = filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))) + if value == "" { + value = "." + } + if filepath.IsAbs(value) || value == ".." || strings.HasPrefix(value, "../") { + return "", errors.New("git skill subdirectory is invalid") + } + return value, nil +} + +func resetStage(stage string) error { + if !filepath.IsAbs(stage) || filepath.Clean(stage) == string(filepath.Separator) { + return errors.New("bundle stage path must be an absolute non-root path") + } + if err := os.RemoveAll(stage); err != nil { + return err + } + return os.MkdirAll(stage, 0o700) +} + +func backupPath(path, destination string) error { + if strings.TrimSpace(path) == "" { + return nil + } + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + return copyPath(path, destination, false) +} + +func replacePath(source, destination string) error { + if !filepath.IsAbs(destination) || filepath.Clean(destination) == string(filepath.Separator) { + return errors.New("managed installation path must be an absolute non-root path") + } + if _, err := os.Lstat(source); err != nil { + return errors.New("staged installation is missing") + } + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + temporary, err := os.MkdirTemp(parent, ".tooltend-next-*") + if err != nil { + return err + } + _ = os.Remove(temporary) + defer os.RemoveAll(temporary) + if err := copyPath(source, temporary, false); err != nil { + return err + } + backup := temporary + ".old" + hadDestination := false + if _, err := os.Lstat(destination); err == nil { + hadDestination = true + if err := os.Rename(destination, backup); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Rename(temporary, destination); err != nil { + if hadDestination { + _ = os.Rename(backup, destination) + } + return err + } + return os.RemoveAll(backup) +} + +func copySkillTree(source, destination string) error { + if info, err := os.Stat(source); err != nil || !info.IsDir() { + return errors.New("git skill source directory is missing") + } + return copyPath(source, destination, true) +} + +func copyPath(source, destination string, skipGit bool) error { + info, err := os.Lstat(source) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(source) + if err != nil { + return err + } + if filepath.IsAbs(target) || strings.HasPrefix(filepath.Clean(target), "..") { + return errors.New("source contains an unsafe symbolic link") + } + return os.Symlink(target, destination) + } + if info.IsDir() { + if err := os.MkdirAll(destination, info.Mode().Perm()); err != nil { + return err + } + entries, err := os.ReadDir(source) + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if skipGit && entry.Name() == ".git" { + continue + } + if err := copyPath(filepath.Join(source, entry.Name()), filepath.Join(destination, entry.Name()), skipGit); err != nil { + return err + } + } + return nil + } + if !info.Mode().IsRegular() { + return errors.New("source contains an unsupported filesystem entry") + } + data, err := os.ReadFile(source) + if err != nil { + return err + } + return safeio.AtomicWriteFile(destination, data, info.Mode().Perm()) +} + +func skillHealth(path string) error { + info, err := os.Stat(filepath.Join(path, "SKILL.md")) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > 4<<20 { + return errors.New("managed skill is missing a valid SKILL.md") + } + return nil +} + +func skillLockPath(path string) string { + parent := filepath.Dir(path) + if filepath.Base(parent) != "skills" || filepath.Base(filepath.Dir(parent)) != ".agents" { + return "" + } + return filepath.Join(filepath.Dir(parent), ".skill-lock.json") +} + +func backupSkillLock(path, stage string) error { + lock := skillLockPath(path) + if lock == "" { + return nil + } + manifest := map[string]bool{"existed": false} + if info, err := os.Stat(lock); err == nil && info.Mode().IsRegular() { + manifest["existed"] = true + if err := copyPath(lock, filepath.Join(stage, "skill-lock.previous"), false); err != nil { + return err + } + } + data, _ := json.Marshal(manifest) + return safeio.AtomicWriteFile(filepath.Join(stage, "skill-lock.json"), data, 0o600) +} + +func detachSkillLock(path string) error { + lock := skillLockPath(path) + if lock == "" { + return nil + } + data, err := os.ReadFile(lock) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var document struct { + Version int `json:"version"` + Skills map[string]json.RawMessage `json:"skills"` + Dismissed map[string]json.RawMessage `json:"dismissed"` + } + if json.Unmarshal(data, &document) != nil || document.Skills == nil { + return errors.New("npx skills lock file is invalid") + } + delete(document.Skills, filepath.Base(path)) + updated, err := json.MarshalIndent(document, "", " ") + if err != nil { + return err + } + updated = append(updated, '\n') + return safeio.AtomicWriteFile(lock, updated, 0o600) +} + +func restoreSkillLock(path, stage string) error { + manifestData, err := os.ReadFile(filepath.Join(stage, "skill-lock.json")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var manifest map[string]bool + if json.Unmarshal(manifestData, &manifest) != nil { + return errors.New("skill lock rollback metadata is invalid") + } + lock := skillLockPath(path) + if lock == "" { + return nil + } + if !manifest["existed"] { + if err := os.Remove(lock); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + return copyPath(filepath.Join(stage, "skill-lock.previous"), lock, false) +} + +var mainlineHookFiles = []string{".claude/settings.json", ".codex/config.toml", ".codex/hooks.json", ".cursor/hooks.json"} + +func stageMainlineHooks(project, stage string) error { + if !filepath.IsAbs(project) { + return errors.New("mainline hook project path must be absolute") + } + if err := resetStage(stage); err != nil { + return err + } + existed := map[string]bool{} + for _, relative := range mainlineHookFiles { + source := filepath.Join(project, filepath.FromSlash(relative)) + if info, err := os.Stat(source); err == nil && info.Mode().IsRegular() { + existed[relative] = true + if err := copyPath(source, filepath.Join(stage, "previous", filepath.FromSlash(relative)), false); err != nil { + return err + } + } + } + data, _ := json.Marshal(existed) + return safeio.AtomicWriteFile(filepath.Join(stage, "manifest.json"), data, 0o600) +} + +func restoreMainlineHooks(project, stage string) (bool, error) { + data, err := os.ReadFile(filepath.Join(stage, "manifest.json")) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + var existed map[string]bool + if json.Unmarshal(data, &existed) != nil { + return false, errors.New("mainline hook rollback metadata is invalid") + } + for _, relative := range mainlineHookFiles { + target := filepath.Join(project, filepath.FromSlash(relative)) + if existed[relative] { + if err := copyPath(filepath.Join(stage, "previous", filepath.FromSlash(relative)), target, false); err != nil { + return false, err + } + } else if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + return true, nil +} + +func (d Driver) runMainlineHooks(ctx context.Context, project, action string) error { + if !filepath.IsAbs(project) { + return errors.New("mainline hook project path must be absolute") + } + runner := d.runner() + if value, ok := runner.(execx.ExecRunner); ok { + value.Dir = project + runner = value + } + if _, err := runner.Run(ctx, "mainline", "hooks", action); err != nil { + return errors.New("mainline hook command failed") + } + return nil +} diff --git a/internal/bundledriver/driver_test.go b/internal/bundledriver/driver_test.go new file mode 100644 index 0000000..30ce9c5 --- /dev/null +++ b/internal/bundledriver/driver_test.go @@ -0,0 +1,145 @@ +package bundledriver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/z2z23n0/tooltend/internal/safeio" +) + +func TestSelectReleaseAssetsSupportsGoAndRustNames(t *testing.T) { + tests := []struct { + name string + asset string + goos string + goarch string + }{ + {name: "go", asset: "mainline_0.5.0_darwin_arm64.tar.gz", goos: "darwin", goarch: "arm64"}, + {name: "rust", asset: "xsearch-x86_64-unknown-linux-gnu.tar.gz", goos: "linux", goarch: "amd64"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + selected, checksums, err := selectReleaseAssets([]githubAsset{ + {Name: test.asset, URL: "https://github.com/example/repo/releases/download/v1/" + test.asset, Size: 10}, + {Name: "checksums.txt", URL: "https://github.com/example/repo/releases/download/v1/checksums.txt", Size: 10}, + }, test.goos, test.goarch) + if err != nil { + t.Fatal(err) + } + if selected.Name != test.asset || checksums.Name != "checksums.txt" { + t.Fatalf("selected = %#v, checksums = %#v", selected, checksums) + } + }) + } +} + +func TestVerifyChecksumRejectsTampering(t *testing.T) { + data := []byte("release") + digest := sha256.Sum256(data) + checksums := []byte(hex.EncodeToString(digest[:]) + " tool.tar.gz\n") + if err := verifyChecksum("tool.tar.gz", data, checksums); err != nil { + t.Fatal(err) + } + if err := verifyChecksum("tool.tar.gz", []byte("tampered"), checksums); err == nil { + t.Fatal("expected tampered asset rejection") + } +} + +func TestGitSkillActivationDetachesAndCompensationRestoresSkillLock(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".agents", "skills", "mainline") + stage := filepath.Join(home, "stage") + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + if err := safeio.AtomicWriteFile(filepath.Join(path, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(home, ".agents", ".skill-lock.json") + lock := map[string]any{"version": 3, "skills": map[string]any{"mainline": map[string]any{"source": "mainline-org/mainline"}}, "dismissed": map[string]any{}} + lockData, _ := json.Marshal(lock) + if err := safeio.AtomicWriteFile(lockPath, lockData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(stage, "next"), 0o755); err != nil { + t.Fatal(err) + } + if err := safeio.AtomicWriteFile(filepath.Join(stage, "next", "SKILL.md"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + if err := backupPath(path, filepath.Join(stage, "previous")); err != nil { + t.Fatal(err) + } + if err := backupSkillLock(path, stage); err != nil { + t.Fatal(err) + } + driver := Driver{} + if err := driver.Execute(context.Background(), []string{"git-activate", stage, path}); err != nil { + t.Fatal(err) + } + assertFileContent(t, filepath.Join(path, "SKILL.md"), "new") + updatedLock, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + if string(updatedLock) == string(lockData) { + t.Fatal("npx skills lock entry was not detached") + } + if err := driver.Execute(context.Background(), []string{"git-rollback", "unused", ".", "", stage, path}); err != nil { + t.Fatal(err) + } + assertFileContent(t, filepath.Join(path, "SKILL.md"), "old") + restoredLock, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + var restored struct { + Skills map[string]json.RawMessage `json:"skills"` + } + if json.Unmarshal(restoredLock, &restored) != nil || restored.Skills["mainline"] == nil { + t.Fatal("npx skills lock was not restored during compensation") + } +} + +func TestMainlineHookBackupRestoresRemovedAndExistingFiles(t *testing.T) { + project := t.TempDir() + stage := filepath.Join(t.TempDir(), "stage") + existing := filepath.Join(project, ".codex", "hooks.json") + if err := safeio.AtomicWriteFile(existing, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := stageMainlineHooks(project, stage); err != nil { + t.Fatal(err) + } + if err := safeio.AtomicWriteFile(existing, []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + created := filepath.Join(project, ".cursor", "hooks.json") + if err := safeio.AtomicWriteFile(created, []byte("created"), 0o644); err != nil { + t.Fatal(err) + } + restored, err := restoreMainlineHooks(project, stage) + if err != nil || !restored { + t.Fatalf("restored = %t, err = %v", restored, err) + } + assertFileContent(t, existing, "old") + if _, err := os.Stat(created); !os.IsNotExist(err) { + t.Fatalf("new hook file still exists: %v", err) + } +} + +func assertFileContent(t *testing.T, path, expected string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != expected { + t.Fatalf("%s = %q, want %q", path, data, expected) + } +} diff --git a/internal/cli/app.go b/internal/cli/app.go index 88f1567..65dd551 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -92,6 +92,9 @@ func New(options Options) *cobra.Command { flags.BoolVar(&a.global.NoColor, "no-color", false, "disable colored human output") root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { a.warnings = nil + if cmd.Annotations[internalDriverAnnotation] == "true" { + return nil + } if legacyCommand(commandName(cmd)) { a.warnings = append(a.warnings, v1.Warning{Code: "deprecated_component_api", Message: "this component-level command is deprecated; use tooltend bundles instead"}) } @@ -138,6 +141,7 @@ func New(options Options) *cobra.Command { a.newKickCommand(), a.newReconcileCommand(), a.newVersionCommand(), + a.newBundleDriverCommand(), ) root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return a.writeFailure(commandName(cmd), cliError("invalid_argument", err.Error(), err)) diff --git a/internal/cli/bundle_commands.go b/internal/cli/bundle_commands.go index 2f40971..2788289 100644 --- a/internal/cli/bundle_commands.go +++ b/internal/cli/bundle_commands.go @@ -285,7 +285,9 @@ func (a *App) newBundlesUpdateCommand() *cobra.Command { if prepareErr != nil { return nil, prepareErr } - previews = append(previews, preview) + if preview.UpdateAvailable { + previews = append(previews, preview) + } } var results []bundle.UpdateResult value := plan.Plan{ID: "bundle-update-v1", Title: "Update complete ToolTend bundles"} diff --git a/internal/cli/bundle_driver_commands.go b/internal/cli/bundle_driver_commands.go new file mode 100644 index 0000000..ada351a --- /dev/null +++ b/internal/cli/bundle_driver_commands.go @@ -0,0 +1,24 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/z2z23n0/tooltend/internal/bundledriver" +) + +const internalDriverAnnotation = "tooltend.io/internal-bundle-driver" + +func (a *App) newBundleDriverCommand() *cobra.Command { + command := &cobra.Command{ + Use: "__bundle-driver [arguments...]", + Hidden: true, + DisableFlagParsing: true, + Args: cobra.MinimumNArgs(1), + Annotations: map[string]string{internalDriverAnnotation: "true"}, + } + command.RunE = func(cmd *cobra.Command, args []string) error { + driver := bundledriver.Driver{Runner: a.runner, Out: a.out} + return driver.Execute(cmd.Context(), args) + } + return command +} diff --git a/internal/cli/worker_commands.go b/internal/cli/worker_commands.go index 6520fe8..722a0db 100644 --- a/internal/cli/worker_commands.go +++ b/internal/cli/worker_commands.go @@ -284,6 +284,9 @@ func (a *App) reconcileOnce(ctx context.Context, paths config.Paths, reason stri if prepareErr != nil { return prepareErr } + if !preview.UpdateAvailable { + return nil + } if !activate { return database.UpsertBundleRelease(bundleCtx, preview.Target) } diff --git a/internal/store/bundles.go b/internal/store/bundles.go index 04b490d..41e3976 100644 --- a/internal/store/bundles.go +++ b/internal/store/bundles.go @@ -428,6 +428,10 @@ func (s *Store) ConfigureBundle(ctx context.Context, value model.BundlePolicy) e _, err = tx.ExecContext(ctx, `INSERT INTO bundle_policies(bundle_id,mode,recipe_trusted,updated_at) VALUES(?,?,?,?) ON CONFLICT(bundle_id) DO UPDATE SET mode=excluded.mode,recipe_trusted=excluded.recipe_trusted,updated_at=excluded.updated_at`, value.BundleID, value.Mode, boolInt(value.RecipeTrusted), timeText(value.UpdatedAt)) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE installations SET managed=? WHERE bundle_id=?`, boolInt(value.Mode == model.BundlePolicyAuto || value.Mode == model.BundlePolicyManual), value.BundleID) return err }) } diff --git a/internal/store/bundles_test.go b/internal/store/bundles_test.go index 6f53f53..aa82243 100644 --- a/internal/store/bundles_test.go +++ b/internal/store/bundles_test.go @@ -7,6 +7,9 @@ import ( "sort" "strconv" "testing" + "time" + + "github.com/z2z23n0/tooltend/internal/model" ) func TestSchemaV5MigratesV4WithBackup(t *testing.T) { @@ -60,3 +63,35 @@ func TestSchemaV5MigratesV4WithBackup(t *testing.T) { t.Fatalf("migration backups = %v err=%v", backups, err) } } + +func TestConfigureBundleMarksPhysicalInstallationsManaged(t *testing.T) { + database, err := OpenRW(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + now := time.Now().UTC() + bundle := model.Bundle{ID: "bundle", Slug: "bundle", Name: "Bundle", RecipeID: "bundle", RecipeVersion: "1", RecipeSource: "builtin", Owner: model.LifecycleDelegated, ConfigState: model.BundleUnconfigured, Confidence: model.BundleConfidenceHigh, DiscoveredAt: now, LastSeenAt: now} + if err := database.UpsertBundle(ctx, bundle); err != nil { + t.Fatal(err) + } + installation := model.Installation{ID: "installation", BundleID: bundle.ID, Driver: "git-skill", Path: "/tmp/skill", Owner: model.LifecycleDelegated, LastSeenAt: now} + if err := database.UpsertInstallation(ctx, installation); err != nil { + t.Fatal(err) + } + if err := database.ConfigureBundle(ctx, model.BundlePolicy{BundleID: bundle.ID, Mode: model.BundlePolicyAuto, RecipeTrusted: true, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + installations, err := database.ListInstallations(ctx, bundle.ID) + if err != nil || len(installations) != 1 || !installations[0].Managed { + t.Fatalf("installations = %#v, err = %v", installations, err) + } + if err := database.ConfigureBundle(ctx, model.BundlePolicy{BundleID: bundle.ID, Mode: model.BundlePolicyObserve, RecipeTrusted: true, UpdatedAt: now.Add(time.Second)}); err != nil { + t.Fatal(err) + } + installations, err = database.ListInstallations(ctx, bundle.ID) + if err != nil || installations[0].Managed { + t.Fatalf("observed installations = %#v, err = %v", installations, err) + } +} From 9f6c0cc1150acb78d37141e4a9e508a3f032134b Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Wed, 15 Jul 2026 23:03:17 +0800 Subject: [PATCH 2/3] fix: include Pi hook in Mainline rollback --- internal/bundledriver/driver.go | 8 +++++++- internal/bundledriver/driver_test.go | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/bundledriver/driver.go b/internal/bundledriver/driver.go index 54a63a1..f3b5aed 100644 --- a/internal/bundledriver/driver.go +++ b/internal/bundledriver/driver.go @@ -849,7 +849,13 @@ func restoreSkillLock(path, stage string) error { return copyPath(filepath.Join(stage, "skill-lock.previous"), lock, false) } -var mainlineHookFiles = []string{".claude/settings.json", ".codex/config.toml", ".codex/hooks.json", ".cursor/hooks.json"} +var mainlineHookFiles = []string{ + ".claude/settings.json", + ".codex/config.toml", + ".codex/hooks.json", + ".cursor/hooks.json", + ".pi/extensions/mainline.ts", +} func stageMainlineHooks(project, stage string) error { if !filepath.IsAbs(project) { diff --git a/internal/bundledriver/driver_test.go b/internal/bundledriver/driver_test.go index 30ce9c5..713249f 100644 --- a/internal/bundledriver/driver_test.go +++ b/internal/bundledriver/driver_test.go @@ -123,6 +123,10 @@ func TestMainlineHookBackupRestoresRemovedAndExistingFiles(t *testing.T) { if err := safeio.AtomicWriteFile(created, []byte("created"), 0o644); err != nil { t.Fatal(err) } + createdPi := filepath.Join(project, ".pi", "extensions", "mainline.ts") + if err := safeio.AtomicWriteFile(createdPi, []byte("created"), 0o644); err != nil { + t.Fatal(err) + } restored, err := restoreMainlineHooks(project, stage) if err != nil || !restored { t.Fatalf("restored = %t, err = %v", restored, err) @@ -131,6 +135,9 @@ func TestMainlineHookBackupRestoresRemovedAndExistingFiles(t *testing.T) { if _, err := os.Stat(created); !os.IsNotExist(err) { t.Fatalf("new hook file still exists: %v", err) } + if _, err := os.Stat(createdPi); !os.IsNotExist(err) { + t.Fatalf("new Pi hook file still exists: %v", err) + } } func assertFileContent(t *testing.T, path, expected string) { From 48adf083e4048bef34e167c30c02af78f88e35b1 Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Thu, 16 Jul 2026 15:54:04 +0800 Subject: [PATCH 3/3] fix: preserve PATH in scheduled workers --- internal/doctor/doctor.go | 6 +++-- internal/doctor/doctor_test.go | 11 ++++++++++ internal/scheduler/scheduler.go | 33 ++++++++++++++++++++++++++++ internal/scheduler/scheduler_test.go | 23 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 50d6080..c71094c 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -270,8 +270,10 @@ func schedulerFileContentMatches(name, content, executable, stateDir string) boo return true } switch name { - case "io.tooltend.reconcile.plist", "tooltend-reconcile.service": - return containsAll("reconcile", "--once", "--state-dir", filepath.Base(executable), filepath.Base(stateDir)) + case "io.tooltend.reconcile.plist": + return containsAll("reconcile", "--once", "--state-dir", filepath.Base(executable), filepath.Base(stateDir), "PATH") + case "tooltend-reconcile.service": + return containsAll("reconcile", "--once", "--state-dir", filepath.Base(executable), filepath.Base(stateDir), `Environment="PATH=`) case "tooltend-reconcile.timer": return containsAll("[Timer]", "OnCalendar=*-*-* ", "RandomizedDelaySec=1h", "Persistent=true", "[Install]", "WantedBy=timers.target") default: diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 6ef27b7..66258d8 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -83,8 +83,16 @@ Description=ToolTend one-shot reconciliation [Service] Type=oneshot +Environment="PATH=/opt/tooltend/bin:/usr/bin:/bin" ExecStart="/opt/tooltend/bin/tooltend" reconcile --once --state-dir "/var/lib/tooltend-state" --json ` + plist := ` +ProgramArguments +/opt/tooltend/bin/tooltendreconcile--once +--state-dir/var/lib/tooltend-state + +EnvironmentVariablesPATH/opt/tooltend/bin:/usr/bin:/bin +` timer := `[Unit] Description=Run ToolTend reconciliation daily @@ -104,11 +112,14 @@ WantedBy=timers.target stateDir string want bool }{ + {name: "plist", file: "io.tooltend.reconcile.plist", content: plist, exe: executable, stateDir: stateDir, want: true}, + {name: "plist missing path", file: "io.tooltend.reconcile.plist", content: strings.Replace(plist, "PATH", "OLD_PATH", 1), exe: executable, stateDir: stateDir}, {name: "service", file: "tooltend-reconcile.service", content: service, exe: executable, stateDir: stateDir, want: true}, {name: "timer", file: "tooltend-reconcile.timer", content: timer, exe: executable, stateDir: stateDir, want: true}, {name: "timer missing calendar", file: "tooltend-reconcile.timer", content: strings.Replace(timer, "OnCalendar=", "Calendar=", 1), exe: executable, stateDir: stateDir}, {name: "timer missing persistence", file: "tooltend-reconcile.timer", content: strings.Replace(timer, "Persistent=true", "Persistent=false", 1), exe: executable, stateDir: stateDir}, {name: "service missing once", file: "tooltend-reconcile.service", content: strings.Replace(service, "--once", "--continuous", 1), exe: executable, stateDir: stateDir}, + {name: "service missing path", file: "tooltend-reconcile.service", content: strings.Replace(service, "Environment=", "EnvironmentFile=", 1), exe: executable, stateDir: stateDir}, {name: "service wrong executable", file: "tooltend-reconcile.service", content: service, exe: "/opt/tooltend/bin/other", stateDir: stateDir}, {name: "service wrong state", file: "tooltend-reconcile.service", content: service, exe: executable, stateDir: "/var/lib/other-state"}, {name: "unknown file", file: "schedule.txt", content: service, exe: executable, stateDir: stateDir}, diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index dfcbaaf..f4619f9 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -31,6 +31,7 @@ type Options struct { Executable string Home string StateDir string + PathEnv string Hour int Minute int } @@ -136,6 +137,7 @@ func randomDailyTime() (int, int) { func renderLaunchd(options Options) string { args := []string{options.Executable, "reconcile", "--once", "--state-dir", options.StateDir, "--json"} + pathEnv := workerPATH(options.Executable, options.PathEnv) var program strings.Builder for _, arg := range args { program.WriteString(" ") @@ -150,6 +152,10 @@ func renderLaunchd(options Options) string { ProgramArguments ` + program.String() + ` + EnvironmentVariables + + PATH` + xmlEscape(pathEnv) + ` + StartCalendarInterval Hour` + strconv.Itoa(options.Hour) + ` @@ -170,10 +176,37 @@ Description=ToolTend one-shot reconciliation [Service] Type=oneshot +Environment=` + systemdQuote("PATH="+workerPATH(options.Executable, options.PathEnv)) + ` ExecStart=` + systemdQuote(options.Executable) + ` reconcile --once --state-dir ` + systemdQuote(options.StateDir) + ` --json ` } +func workerPATH(executable, current string) string { + if strings.TrimSpace(current) == "" { + current = os.Getenv("PATH") + } + candidates := []string{filepath.Dir(executable)} + candidates = append(candidates, filepath.SplitList(current)...) + if runtime.GOOS == "darwin" { + candidates = append(candidates, "/opt/homebrew/bin", "/opt/homebrew/sbin") + } + candidates = append(candidates, "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin") + seen := map[string]struct{}{} + entries := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + candidate = filepath.Clean(strings.TrimSpace(candidate)) + if !filepath.IsAbs(candidate) { + continue + } + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + entries = append(entries, candidate) + } + return strings.Join(entries, string(os.PathListSeparator)) +} + func renderSystemdTimer(options Options) string { return `[Unit] Description=Run ToolTend reconciliation daily diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 58e1f20..fe84fce 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -37,6 +37,29 @@ func TestXMLAndSystemdEscaping(t *testing.T) { } } +func TestRenderedSchedulesIncludeWorkerPATH(t *testing.T) { + options := Options{ + Executable: "/Users/example/.local/bin/tooltend", + StateDir: "/tmp/state", + PathEnv: "relative:/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin", + Hour: 1, + Minute: 2, + } + for name, content := range map[string]string{ + "launchd": renderLaunchd(options), + "systemd": renderSystemdService(options), + } { + t.Run(name, func(t *testing.T) { + if !strings.Contains(content, "PATH") || !strings.Contains(content, "/Users/example/.local/bin:/opt/homebrew/bin:/usr/bin") { + t.Fatalf("schedule does not preserve the ToolTend executable path: %s", content) + } + if strings.Contains(content, "relative") || strings.Count(content, "/opt/homebrew/bin") != 1 { + t.Fatalf("schedule contains an unsafe or duplicate PATH entry: %s", content) + } + }) + } +} + type recordingRunner struct { calls []string fail string