From fcf4bc6c695bec54285786fda2f4ec3194e9620d Mon Sep 17 00:00:00 2001 From: Roman Komarov Date: Fri, 21 Aug 2026 12:14:41 +0300 Subject: [PATCH 1/2] feat: repo auth --- .claude/settings.json | 17 +++++++ README.md | 29 ++++++++++-- cmd/repo.go | 102 +++++++++++++++++++++++++++++++++++++++++- skill/SKILL.md | 28 +++++++++++- 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9dd500c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch *)", + "Bash(gh pr *)", + "Bash(git ls-tree *)", + "Read(//tmp/**)", + "Bash(git show pr-1-review:cmd/job.go > /tmp/pr1-job.go *)", + "Bash(go vet *)", + "Bash(echo \"vet exit: $?\")", + "Bash(git branch *)" + ], + "additionalDirectories": [ + "/tmp" + ] + } +} diff --git a/README.md b/README.md index b4284a8..d1e3478 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ for secrets. - Reads the API key from `EDS_API_KEY` or from `~/.config/eds/config.json`. - Outputs JSON when piped, pretty tables on a TTY (`--json` to force). - Repo uses the local `git` CLI for clone. Both products authenticate with - `X-API-KEY`. + `X-API-KEY`. `eds repo clone` embeds the API key as HTTP Basic Auth + credentials directly into the smart-HTTP URL it passes to `git clone`, so + clone/push work standalone — no git credential helper, OS keychain, or + `~/.netrc` needs to be pre-configured. This matters for CI and AI agent + sandboxes, which typically have none of those. ## Installation @@ -112,6 +116,7 @@ eds repo create create a repository eds repo show show repository details eds repo delete [--force] delete a repository eds repo clone [dir] [--ssh] clone via local git CLI +eds repo remote-add [--name N] [--ssh] wire an existing local checkout to it (git remote add) eds wf app create --repository R|--repository-url URL --branch B create and auto-deploy a Workflow Studio application eds wf app list list applications @@ -223,8 +228,26 @@ cd demo git add . && git commit -m "init" && git push origin main ``` -`git push` works out of the box because the API key is used as -basic-auth credentials on the smart-HTTP endpoint exposed by the server. +`git push` works out of the box because `eds repo clone` already embedded the +API key as basic-auth credentials in `origin`'s URL (see `.git/config`) — no +git credential helper or OS keychain is involved. Note this means the API +key sits in plaintext in that repo's `.git/config`; treat the clone +directory with the same care as the key itself. + +If the code already exists locally (no `eds repo clone` involved) and you +just created the remote repository, use `eds repo remote-add` instead of a +plain `git remote add` to get the same embedded authentication: + +```bash +eds repo create demo +cd path/to/existing/local/repo +eds repo remote-add demo +git push -u origin main +``` + +`--ssh` is the one exception: it depends on the host's SSH public key being +registered separately (not handled by this CLI), so it still needs whatever +ambient SSH setup the environment provides. ## Distribution / publishing diff --git a/cmd/repo.go b/cmd/repo.go index dfe94c9..a5c161c 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "net/url" "os" "os/exec" "strings" @@ -26,6 +27,7 @@ func newRepoCmd() *cobra.Command { cmd.AddCommand(newRepoShowCmd()) cmd.AddCommand(newRepoDeleteCmd()) cmd.AddCommand(newRepoCloneCmd()) + cmd.AddCommand(newRepoRemoteAddCmd()) return cmd } @@ -279,26 +281,39 @@ the clone, as you would with any other git server.`, return fmt.Errorf("api get repository: %w", err) } + useSSH := ssh && info.Clone.SSH != "" cloneURL := info.Clone.HTTPS - if ssh && info.Clone.SSH != "" { + if useSSH { cloneURL = info.Clone.SSH } if cloneURL == "" { return fmt.Errorf("repository has no clone url") } + // The smart-HTTP endpoint authenticates via HTTP Basic Auth using + // the API key as the password (any username works). Embed it in + // the URL so clone/push work standalone, without relying on a + // git credential helper or ~/.netrc being pre-configured in the + // environment (agents/CI have neither). + displayCloneURL := cloneURL + if !useSSH { + cloneURL = withBasicAuth(cloneURL, ctx.Cfg.APIKey) + } + dst := target if dst == "" && len(args) == 2 { dst = args[1] } gitArgs := []string{"clone", cloneURL} + displayArgs := []string{"clone", displayCloneURL} if dst != "" { gitArgs = append(gitArgs, dst) + displayArgs = append(displayArgs, dst) } if !ctx.Quiet { - fmt.Fprintf(cmd.OutOrStdout(), "Running: git %s\n", strings.Join(gitArgs, " ")) + fmt.Fprintf(cmd.OutOrStdout(), "Running: git %s\n", strings.Join(displayArgs, " ")) } c := exec.CommandContext(cmd.Context(), "git", gitArgs...) @@ -314,6 +329,89 @@ the clone, as you would with any other git server.`, return cmd } +func newRepoRemoteAddCmd() *cobra.Command { + var ( + remoteName string + ssh bool + ) + + cmd := &cobra.Command{ + Use: "remote-add ", + Short: "Wire an existing local git checkout to a Repo-product remote", + Long: `remote-add looks up the repository via the API and runs +"git remote add" in the current directory against its smart-HTTP (or SSH) +URL, with the API key embedded as HTTP Basic Auth credentials - same +authentication "eds repo clone" sets up, just for a local checkout that +already exists (e.g. code was scaffolded locally, then "eds repo create" +made the remote). If you don't have a local checkout yet, use +"eds repo clone" instead.`, + Args: cobra.ExactArgs(1), + Example: ` eds repo remote-add my-repo + eds repo remote-add my-repo --name upstream + eds repo remote-add my-repo --ssh`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := resolveContext(cmd) + if err != nil { + return err + } + if err := ctx.requireAPIKey(); err != nil { + return err + } + + // Try to resolve the argument as either an id or a name. + id, err := resolveRepoID(cmd.Context(), ctx, args[0]) + if err != nil { + return err + } + + info, err := ctx.API.GetRepository(cmd.Context(), id) + if err != nil { + return fmt.Errorf("api get repository: %w", err) + } + + useSSH := ssh && info.Clone.SSH != "" + remoteURL := info.Clone.HTTPS + if useSSH { + remoteURL = info.Clone.SSH + } + if remoteURL == "" { + return fmt.Errorf("repository has no clone url") + } + + displayURL := remoteURL + if !useSSH { + remoteURL = withBasicAuth(remoteURL, ctx.Cfg.APIKey) + } + + if !ctx.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "Running: git remote add %s %s\n", remoteName, displayURL) + } + + c := exec.CommandContext(cmd.Context(), "git", "remote", "add", remoteName, remoteURL) + c.Stdout = cmd.OutOrStdout() + c.Stderr = cmd.ErrOrStderr() + c.Stdin = os.Stdin + return c.Run() + }, + } + + cmd.Flags().StringVar(&remoteName, "name", "origin", "git remote name to create") + cmd.Flags().BoolVar(&ssh, "ssh", false, "use the SSH remote instead of HTTPS") + return cmd +} + +// withBasicAuth embeds apiKey as the password of an HTTP Basic Auth userinfo +// component in rawURL (username is arbitrary; the server only checks the +// password). Returns rawURL unchanged if it doesn't parse as a URL. +func withBasicAuth(rawURL, apiKey string) string { + u, err := url.Parse(rawURL) + if err != nil || apiKey == "" { + return rawURL + } + u.User = url.UserPassword("eds", apiKey) + return u.String() +} + // resolveRepoID accepts either a raw repository id (UUID) or a repository // name and returns the id. Names are resolved against the configured // project's listing. diff --git a/skill/SKILL.md b/skill/SKILL.md index b3d4d96..8a6aa03 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -59,6 +59,7 @@ Errors go to stderr and the process exits non-zero. | `eds repo show [--json]` | Show details: id, default_branch, size, clone URLs | | `eds repo delete [--force] [--json]` | Delete (irreversible; requires confirmation unless `--force`) | | `eds repo clone [dir] [--ssh] [--target DIR]` | Clone via local `git` CLI | +| `eds repo remote-add [--name N] [--ssh]` | Wire an existing local checkout to it (`git remote add`, authenticated) | | `eds wf app create --repository R\|--repository-url URL --branch B [--json]` | Create a Workflow Studio application from a repo + branch (auto-triggers first deploy) | | `eds wf app list [--search S] [--sort created_at_asc\|created_at_desc] [--json]` | List applications | | `eds wf app show [--json]` | Show application details (status, run_id, pipeline_id, ...) | @@ -161,15 +162,40 @@ cd ./work/my-new-repo git status ``` +`eds repo clone` (HTTPS mode, the default) embeds `EDS_API_KEY` as HTTP +Basic Auth credentials directly in the clone URL it hands to `git clone`. +It does **not** need or use a git credential helper, an OS keychain, or +`~/.netrc` — none of which exist in a typical agent sandbox. If a plain +`git clone ` (bypassing this CLI) ever fails with something like +`could not read Username ... terminal prompts disabled` or a keychain/ +credential-helper error, that's this exact gap — use `eds repo clone` +instead of shelling out to `git clone` directly. + ### Push code (after clone) -The CLI does not implement a custom upload path — use git directly: +The CLI does not implement a custom upload path — use git directly. Because +`eds repo clone` already wrote the API key into `origin`'s URL, `git push` +authenticates the same way, no extra setup needed: ```bash cd ./work/my-new-repo git add . && git commit -m "init" && git push origin main ``` +### Wire up code that already exists locally (no clone involved) + +If the code was scaffolded locally first and the repository was created +after the fact, use `eds repo remote-add` instead of a plain +`git remote add` — it embeds the same authenticated URL `eds repo clone` +would have: + +```bash +eds repo create my-new-repo +cd path/to/existing/local/repo +eds repo remote-add my-new-repo +git push -u origin main +``` + ### Delete a repository (with confirmation) ```bash From 729f7edb4e2572f12a00f03f477fd29e526b17f2 Mon Sep 17 00:00:00 2001 From: Roman Komarov Date: Fri, 21 Aug 2026 12:17:06 +0300 Subject: [PATCH 2/2] chore: remove local Claude settings from branch .claude/settings.json is local editor config, not part of the CLI. --- .claude/settings.json | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 9dd500c..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git fetch *)", - "Bash(gh pr *)", - "Bash(git ls-tree *)", - "Read(//tmp/**)", - "Bash(git show pr-1-review:cmd/job.go > /tmp/pr1-job.go *)", - "Bash(go vet *)", - "Bash(echo \"vet exit: $?\")", - "Bash(git branch *)" - ], - "additionalDirectories": [ - "/tmp" - ] - } -}