diff --git a/README.md b/README.md index 051dee6..0af1e62 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ reports `No changes` for each synchronized target. | Command | Behavior | |---|---| -| `shenron install ` | Install a local directory or a public HTTPS Git package. | +| `shenron install ` | Install a local directory or a remote Git package (public HTTPS, or SSH via `git@host:path` / `ssh://`). | | `shenron list` | List installed packages, ordered by name. | | `shenron update ` | Validate and replace an installed snapshot from a new source or ref. | | `shenron diff ` | Show a package's native diff plus its permission grants and missing skills. | @@ -96,7 +96,10 @@ Common flags: - `--store ` (root, persistent) selects a custom package cache directory (default `~/.shenron/packages`). -- `install --ref ` pins the Git revision for HTTPS sources. +- `install --ref ` pins the Git revision for HTTPS and SSH sources. + SSH sources (`git@host:path` or `ssh://…`) authenticate through your + ssh-agent and verify host keys against `~/.ssh/known_hosts`; credentials are + never read from the URL. - `update --source ` and `update --ref ` replace the installed package's source and revision. - `diff --target ` and `push --target ` select `claude-code`, @@ -277,8 +280,12 @@ matching `^[a-z][a-z0-9-]*$`, a strict semver `version`, a non-empty # Local directory ./shenron install ./my-package -# Public Git repository (HTTPS only, immutable tag or full commit SHA) +# Public Git repository over HTTPS (immutable tag or full commit SHA) ./shenron install https://github.com/acme/reviewers.git --ref 1.2.0 + +# Private/public repository over SSH (uses your ssh-agent and known_hosts) +./shenron install git@github.com:acme/reviewers.git --ref 1.2.0 +./shenron install ssh://git@github.com/acme/reviewers.git --ref 1.2.0 ``` The first install copies the source into a content-addressed snapshot under @@ -373,9 +380,11 @@ use `push --force` deliberately. - Skill-name validation checks kebab-case syntax, not local filesystem availability. - OpenCode JSON is structurally preserved, not guaranteed byte-identical. -- Package installs accept only local directories or public HTTPS Git - repositories, and HTTPS sources require an immutable tag or full commit - SHA. Branches, `HEAD`, SSH, and archive URLs are refused. +- Package installs accept local directories, public HTTPS Git repositories, or + SSH Git repositories (`git@host:path` and `ssh://`). Remote sources require an + immutable tag or full commit SHA; branches, `HEAD`, embedded credentials, and + archive URLs are refused. SSH auth is delegated to the caller's ssh-agent and + host keys are verified against `known_hosts`. ## Architecture for contributors diff --git a/internal/cli/package.go b/internal/cli/package.go index 6b48eb2..aa3847d 100644 --- a/internal/cli/package.go +++ b/internal/cli/package.go @@ -34,7 +34,7 @@ type PackageUpdateOptions struct { Output io.Writer } -// RunPackageInstall installs a local directory or a public HTTPS Git package. +// RunPackageInstall installs a local directory or a remote (HTTPS or SSH) Git package. func RunPackageInstall(opts PackageInstallOptions) error { store := packageStore(opts.Store) installed, err := store.Install(opts.Source, opts.Ref) @@ -165,14 +165,14 @@ func NewInstallCmd(store func() *shenronpackage.Store) *cobra.Command { var ref string cmd := &cobra.Command{ Use: "install ", - Short: "Install a local or public HTTPS Git package", + Short: "Install a local, public HTTPS, or SSH Git package", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return RunPackageInstall(PackageInstallOptions{Store: store(), Source: args[0], Ref: ref, Output: cmd.OutOrStdout()}) }, } - cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA (required for HTTPS sources)") + cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA (required for HTTPS and SSH sources)") return cmd } @@ -201,7 +201,7 @@ func NewUpdateCmd(store func() *shenronpackage.Store) *cobra.Command { return RunPackageUpdate(PackageUpdateOptions{Store: store(), Name: args[0], Source: source, Ref: ref, Output: cmd.OutOrStdout()}) }, } - cmd.Flags().StringVar(&source, "source", "", "replacement local directory or public HTTPS Git source") + cmd.Flags().StringVar(&source, "source", "", "replacement local directory or remote (HTTPS or SSH) Git source") cmd.Flags().StringVar(&ref, "ref", "", "immutable Git tag or full commit SHA") return cmd } diff --git a/internal/package/package.go b/internal/package/package.go index 2e76cfe..a612cf1 100644 --- a/internal/package/package.go +++ b/internal/package/package.go @@ -287,17 +287,18 @@ func (s *Store) InstallLocal(source string) (*InstalledPackage, error) { return installed, nil } -// Install chooses a local-directory or public HTTPS Git installation based on -// source. A ref is accepted only for Git sources. +// Install chooses a local-directory or remote Git installation based on +// source. Remote sources are public HTTPS URLs or SSH URLs (scp-like +// git@host:path or ssh://). A ref is accepted only for Git sources. func (s *Store) Install(source, ref string) (*InstalledPackage, error) { - if isHTTPSURL(source) { + if isRemoteGitSource(source) { return s.InstallGit(source, ref) } if isNonHTTPSRemote(source) { - return nil, fmt.Errorf("package Git source must be a public HTTPS URL") + return nil, fmt.Errorf("package Git source must be a public HTTPS or SSH URL") } if ref != "" { - return nil, fmt.Errorf("--ref is only supported for public HTTPS Git sources") + return nil, fmt.Errorf("--ref is only supported for remote Git sources") } return s.InstallLocal(source) } @@ -411,24 +412,27 @@ func (s *Store) UpdateLocal(name, source string) (*InstalledPackage, error) { }) } -// Update chooses a local-directory or public HTTPS Git update based on source. +// Update chooses a local-directory or remote Git update based on source. +// Remote sources are public HTTPS URLs or SSH URLs. func (s *Store) Update(name, source, ref string) (*InstalledPackage, error) { - if isHTTPSURL(source) { + if isRemoteGitSource(source) { return s.UpdateGit(name, source, ref) } if isNonHTTPSRemote(source) { - return nil, fmt.Errorf("package Git source must be a public HTTPS URL") + return nil, fmt.Errorf("package Git source must be a public HTTPS or SSH URL") } if ref != "" { - return nil, fmt.Errorf("--ref is only supported for public HTTPS Git sources") + return nil, fmt.Errorf("--ref is only supported for remote Git sources") } return s.UpdateLocal(name, source) } -// InstallGit installs a package from a public HTTPS Git repository. ref must -// name a tag or a full commit SHA; branches and HEAD are never selected. +// InstallGit installs a package from a public HTTPS or SSH Git repository. ref +// must name a tag or a full commit SHA; branches and HEAD are never selected. +// SSH sources authenticate through the caller's ssh-agent and verify host keys +// against the local known_hosts file; no credentials are read from the URL. func (s *Store) InstallGit(source, ref string) (*InstalledPackage, error) { - if _, err := validateGitSource(source, ref); err != nil { + if err := validateGitSource(source, ref); err != nil { return nil, err } unlock, err := s.lockIndex() @@ -464,9 +468,9 @@ func (s *Store) InstallGit(source, ref string) (*InstalledPackage, error) { } // UpdateGit replaces an installed package with a newly fetched, validated -// snapshot from a public HTTPS Git source. +// snapshot from a public HTTPS or SSH Git source. func (s *Store) UpdateGit(name, source, ref string) (*InstalledPackage, error) { - if _, err := validateGitSource(source, ref); err != nil { + if err := validateGitSource(source, ref); err != nil { return nil, err } return s.update(name, func() (*stagedSnapshot, error) { @@ -581,29 +585,86 @@ func (s *Store) stageGitSnapshot(source, ref string) (*stagedSnapshot, error) { return &stagedSnapshot{source: source, ref: ref, revision: revision.String(), root: root, tmp: tmp, pkg: pkg, digest: digest}, nil } -var commitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) +var ( + commitSHA = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + // scpLikeURL matches the SSH scp-like syntax [user@]host:path where the + // host segment carries no slash, e.g. git@github.com:acme/pkg.git. + scpLikeURL = regexp.MustCompile(`^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+:.+$`) +) + +func validateGitSource(source, ref string) error { + switch { + case isHTTPSURL(source): + return validateHTTPSSource(source, ref) + case isSSHURL(source): + return validateSSHSource(source, ref) + default: + return fmt.Errorf("package Git source must be a public HTTPS or SSH URL") + } +} -func validateGitSource(source, ref string) (*url.URL, error) { +func validateHTTPSSource(source, ref string) error { parsed, err := url.Parse(source) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { - return nil, fmt.Errorf("package Git source must be a public HTTPS URL") + return fmt.Errorf("package Git source must be a public HTTPS or SSH URL") } if parsed.User != nil { - return nil, fmt.Errorf("package Git source must not include credentials") + return fmt.Errorf("package Git source must not include credentials") } if parsed.RawQuery != "" || parsed.Fragment != "" { - return nil, fmt.Errorf("package Git source must not include a query or fragment") + return fmt.Errorf("package Git source must not include a query or fragment") + } + if err := validateNotArchive(parsed.Path); err != nil { + return err + } + return validateImmutableRef(ref) +} + +// validateSSHSource accepts scp-like (git@host:path) and ssh:// sources. The +// login user in the source is not a credential; an embedded password is. Auth +// is delegated to the caller's ssh-agent at clone time. +func validateSSHSource(source, ref string) error { + if strings.HasPrefix(source, "ssh://") { + parsed, err := url.Parse(source) + if err != nil || parsed.Host == "" { + return fmt.Errorf("package Git source must be a public HTTPS or SSH URL") + } + if parsed.User != nil { + if _, hasPassword := parsed.User.Password(); hasPassword { + return fmt.Errorf("package Git source must not include credentials") + } + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("package Git source must not include a query or fragment") + } + if err := validateNotArchive(parsed.Path); err != nil { + return err + } + return validateImmutableRef(ref) } - lowerPath := strings.ToLower(parsed.Path) + // scp-like: [user@]host:path — no password can be embedded in this form. + _, path, _ := strings.Cut(source, ":") + if err := validateNotArchive(path); err != nil { + return err + } + return validateImmutableRef(ref) +} + +func validateNotArchive(path string) error { + lowerPath := strings.ToLower(path) for _, suffix := range []string{".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz"} { if strings.HasSuffix(lowerPath, suffix) { - return nil, fmt.Errorf("package source must be a Git repository, not an archive") + return fmt.Errorf("package source must be a Git repository, not an archive") } } + return nil +} + +func validateImmutableRef(ref string) error { if ref == "" || strings.EqualFold(ref, "head") || strings.HasPrefix(ref, "refs/heads/") { - return nil, fmt.Errorf("package Git ref must be an immutable tag or full commit SHA") + return fmt.Errorf("package Git ref must be an immutable tag or full commit SHA") } - return parsed, nil + return nil } func isHTTPSURL(source string) bool { @@ -611,6 +672,24 @@ func isHTTPSURL(source string) bool { return err == nil && parsed.Scheme == "https" } +// isSSHURL reports whether source is an SSH Git source: either an ssh:// URL +// or the scp-like [user@]host:path form. +func isSSHURL(source string) bool { + if parsed, err := url.Parse(source); err == nil && parsed.Scheme == "ssh" { + return true + } + if strings.Contains(source, "://") { + return false + } + return scpLikeURL.MatchString(source) +} + +// isRemoteGitSource reports whether source should be fetched over the network +// as a Git repository rather than copied from a local directory. +func isRemoteGitSource(source string) bool { + return isHTTPSURL(source) || isSSHURL(source) +} + func isNonHTTPSRemote(source string) bool { if strings.HasPrefix(source, "git@") { return true diff --git a/internal/package/package_test.go b/internal/package/package_test.go index 749be06..f11a24f 100644 --- a/internal/package/package_test.go +++ b/internal/package/package_test.go @@ -573,11 +573,13 @@ func TestStoreInstallGitRejectsUnsafeSourcesAndMutableRefs(t *testing.T) { tests := []struct { name, source, ref, want string }{ - {"ssh", "git@github.com:acme/reviewers.git", "v1.2.3", "HTTPS"}, {"credentials", "https://token@example.com/acme/reviewers.git", "v1.2.3", "credentials"}, + {"ssh-password", "ssh://git:secret@github.com/acme/reviewers.git", "v1.2.3", "credentials"}, {"archive", "https://example.com/reviewers.tar.gz", "v1.2.3", "Git repository"}, + {"ssh-archive", "git@github.com:acme/reviewers.tar.gz", "v1.2.3", "Git repository"}, {"branch", "https://example.com/acme/reviewers.git", "refs/heads/main", "immutable"}, {"head", "https://example.com/acme/reviewers.git", "HEAD", "immutable"}, + {"ssh-head", "git@github.com:acme/reviewers.git", "HEAD", "immutable"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -591,9 +593,53 @@ func TestStoreInstallGitRejectsUnsafeSourcesAndMutableRefs(t *testing.T) { func TestStoreInstallRejectsNonHTTPSRemoteSources(t *testing.T) { store := NewStore(filepath.Join(t.TempDir(), "cache")) - for _, source := range []string{"git@github.com:acme/reviewers.git", "ssh://github.com/acme/reviewers.git", "http://example.com/acme/reviewers.git"} { - if _, err := store.Install(source, ""); err == nil || !strings.Contains(err.Error(), "public HTTPS") { - t.Errorf("Install(%q) error = %v, want HTTPS source rejection", source, err) + for _, source := range []string{"http://example.com/acme/reviewers.git", "git://example.com/acme/reviewers.git", "ftp://example.com/acme/reviewers.git"} { + if _, err := store.Install(source, ""); err == nil || !strings.Contains(err.Error(), "public HTTPS or SSH") { + t.Errorf("Install(%q) error = %v, want HTTPS/SSH source rejection", source, err) + } + } +} + +func TestGitSourceClassification(t *testing.T) { + tests := []struct { + source string + https, ssh, nonHTTPS bool + }{ + {"https://github.com/acme/pkg.git", true, false, false}, + {"git@github.com:acme/pkg.git", false, true, true}, + {"ssh://git@github.com/acme/pkg.git", false, true, true}, + {"http://example.com/acme/pkg.git", false, false, true}, + {"git://example.com/acme/pkg.git", false, false, true}, + {"testdata/local-package", false, false, false}, + {"/abs/path/to/package", false, false, false}, + {"./relative/package", false, false, false}, + } + for _, tt := range tests { + if got := isHTTPSURL(tt.source); got != tt.https { + t.Errorf("isHTTPSURL(%q) = %v, want %v", tt.source, got, tt.https) + } + if got := isSSHURL(tt.source); got != tt.ssh { + t.Errorf("isSSHURL(%q) = %v, want %v", tt.source, got, tt.ssh) + } + if got := isRemoteGitSource(tt.source); got != (tt.https || tt.ssh) { + t.Errorf("isRemoteGitSource(%q) = %v, want %v", tt.source, got, tt.https || tt.ssh) + } + } +} + +func TestValidateGitSourceAcceptsSSH(t *testing.T) { + sources := []string{ + "git@github.com:acme/reviewers.git", + "ssh://git@github.com/acme/reviewers.git", + "ssh://git@github.com:2222/acme/reviewers.git", + } + for _, source := range sources { + if err := validateGitSource(source, "v1.2.3"); err != nil { + t.Errorf("validateGitSource(%q, tag) = %v, want nil", source, err) + } + fullSHA := "0123456789abcdef0123456789abcdef01234567" + if err := validateGitSource(source, fullSHA); err != nil { + t.Errorf("validateGitSource(%q, sha) = %v, want nil", source, err) } } }