diff --git a/CHANGELOG.md b/CHANGELOG.md index f45a5d5..cc40f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,64 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi ## [Unreleased] +### Added + +- **`dhq skills show [reference]`** prints DeployHQ's skill for AI agents (or + one of its references) without installing anything. An agent can read it in + the session it is working in: a skill installed with `dhq skills install` is + only loaded by the agent's next session. Plain Markdown, also when piped; + `--json` gives `{name, content, references}`. + +### Fixed + +- **Getting AI agents to the skill**: agents act on the CLI's errors but rarely + run `dhq --help`, and a skill they install mid-session isn't loaded until + their next one. The first messages of a new setup (the "not signed in" + guidance, `next_step` in `dhq auth status --json`, `dhq doctor`, and the + "No project specified" errors, including `dhq deploy`'s for an account with + no projects yet) now ask the agent + to read the setup steps with `dhq skills show` before going on. In `--json` + output their recovery actions add `read_setup_skill` (`dhq skills show`). + `dhq --help` says the same. Installing for future sessions is offered for the + running agent only (for example `dhq skills install --agent claude-code`), + not for every AI tool on the machine. +- **The skill says to exclude files before the first deploy.** Every file in + the repository is uploaded unless excluded; the setup steps now say to check + the files on the branch DeployHQ deploys (the remote's, not the local + checkout) and exclude deploy scripts or local config first, and, since + `dhq launch` deploys straight away, to create the project, add the + exclusions, then run `dhq launch --project `. `dhq deploy`'s hint + for an account with no projects, which suggests `dhq launch`, says the same. +- **The skill says DeployHQ hosting is free during the beta** and may be billed + once it ends. It said "billed once the beta ends", which agents passed on as + "may be a paid resource". +- **Installed copies of the skill are now reported as outdated**, so + `dhq skills list` shows them and `dhq hello` refreshes the running agent's + copy (the skill's version is now 2; it hadn't changed since the skill was + added, so older installs were never flagged). `dhq skills install` always + rewrites it. +- **`dhq deployments logs`** prints each log entry's detail under its message, + as the DeployHQ web app does (command output, the list of excluded files, an + error's cause). Without it, "Excluded files for server ..." showed no files. + Without `--step`, `--json` now returns every step's logs as JSON (an empty + array before a deployment has steps, and an `error` for a step whose logs + couldn't be read); without `--json` it prints text as before, also when piped. +- **Signing in or signing up "in a separate terminal window"** now says + "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" + as well. Agents read "not through the AI agent" as allowing it, and suggested + `! dhq auth login` / `! dhq signup`, which always refuse there. The hint for + adding a password-only server with `--password-stdin` says the same. +- **`dhq repos create` / `dhq repos update` with an HTTPS git URL**: DeployHQ + saves the branch of such a repository without checking it, so a branch that + isn't in the repository was accepted and the first deployment failed. dhq now + checks the branch against the repository's branch list and fails (exit code 1) + with the branches and the command to pick one. When the list can't be read, + it carries on as before. Branch names in these messages are now quoted when + they contain shell syntax, as they may be pasted into a command. +- **`dhq repos create` with an HTTPS URL** no longer suggests adding the + project's deploy key: such a repository is read with a username and password + (or nothing, when public), never with the key. + ## [0.24.0] - 2026-11-04 ### Fixed diff --git a/internal/auth/guidance.go b/internal/auth/guidance.go index 313a79c..e603ba8 100644 --- a/internal/auth/guidance.go +++ b/internal/auth/guidance.go @@ -1,6 +1,13 @@ package auth -import "fmt" +import ( + "fmt" + "strings" + + "github.com/deployhq/deployhq-cli/internal/harness" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/internal/skillinstaller" +) // APIKeyLocation tells the person where to find their API key in the DeployHQ // web app. account and host are optional; when account is empty a placeholder @@ -15,16 +22,51 @@ func APIKeyLocation(account, host string) string { return fmt.Sprintf("Settings → Security → API Credentials in DeployHQ (https://%s.%s/security)", account, host) } +// SeparateTerminal says where the person runs a sign-in or sign-up command: +// in a terminal the agent does not drive. Agents read "not through the AI +// agent" as allowing their own '!' shell shortcut (Claude Code even suggests +// it for interactive logins), but a command run that way gets no terminal on +// stdin or stdout, so dhq can't prompt and refuses. Hence named explicitly, +// with the reason. +const SeparateTerminal = "in a separate terminal window (not through the AI agent, and not with its '!' shortcut, " + + "which gives dhq no terminal to prompt in)" + +// SkillInstallCommand is the command that installs DeployHQ's skill for the +// agent running dhq only (see skillinstaller.InstallCommandFor), never for +// every AI tool on the machine. +func SkillInstallCommand() string { + return skillinstaller.InstallCommandFor(harness.Detect().Name) +} + +// SkillReadCommand prints DeployHQ's skill (see output.SkillReadCommand). +const SkillReadCommand = output.SkillReadCommand + +// SkillPointer sends an agent to DeployHQ's skill. Agents act on the CLI's +// errors but rarely run 'dhq --help', so the messages they meet first (not +// signed in, no project) carry it. It asks the agent to read the skill now, +// not to install it: an installed skill only loads in a new session. +func SkillPointer() string { + cmd := SkillInstallCommand() + names := "" + if strings.Contains(cmd, "<") { + names = ", 'dhq skills list' names the agents" + } + return "AI agents: before going on, read DeployHQ's setup steps with '" + SkillReadCommand + "' and follow them: " + + "they say what to set up before the first deploy and when to ask the person. " + + "(To have them in future sessions too: '" + cmd + "'" + names + ".)" +} + // SignInHint is the guidance shown whenever dhq needs credentials it cannot // prompt for (for example when run by an AI agent). The API key is a secret: // the person enters it themselves, in a terminal the agent does not drive, so // it never ends up in a chat transcript. A person without an account signs up // the same way, since sign-up asks for a password. func SignInHint(account, host string) string { - return "Ask the person to sign in themselves: run 'dhq auth login' in a separate terminal window " + - "(not through the AI agent). It prompts for the account, email and API key, and stores them securely.\n" + - "If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, also in a separate terminal window.\n" + + return "Ask the person to sign in themselves: run 'dhq auth login' " + SeparateTerminal + + ". It prompts for the account, email and API key, and stores them securely.\n" + + "If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, the same way: " + SeparateTerminal + ".\n" + "The API key is under " + APIKeyLocation(account, host) + ".\n" + "Never paste an API key into an AI chat.\n" + - "For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead." + "For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead.\n" + + SkillPointer() } diff --git a/internal/cli/context.go b/internal/cli/context.go index 9be64b1..f378c46 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -226,7 +226,8 @@ func (c *Context) RequireProject() (string, error) { Hint: "Find your project identifier with 'dhq projects list', then set it:\n" + " dhq config set project (persist for this directory)\n" + " --project (pass per command)\n" + - " export DEPLOYHQ_PROJECT= (set for the shell session)", + " export DEPLOYHQ_PROJECT= (set for the shell session)\n" + + auth.SkillPointer(), } } diff --git a/internal/cli/context_require_project_test.go b/internal/cli/context_require_project_test.go new file mode 100644 index 0000000..d077732 --- /dev/null +++ b/internal/cli/context_require_project_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "testing" + + "github.com/deployhq/deployhq-cli/internal/config" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The "No project specified" error is one of the first an agent meets in a new +// setup: its hint and JSON recovery point at DeployHQ's skill. +func TestRequireProject_PointsAgentsAtSkill(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") + c := &Context{Config: &config.Config{}} + _, err := c.RequireProject() + require.Error(t, err) + + var uerr *output.UserError + require.ErrorAs(t, err, &uerr) + assert.Contains(t, uerr.Hint, "read DeployHQ's setup steps with 'dhq skills show'") + assert.Contains(t, uerr.Hint, "dhq skills install --agent claude-code", "install is scoped to the running agent") + assert.Contains(t, output.ErrorResponseFromErr(err).Data.(output.ErrorData).Recovery, + output.Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}) +} diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index cd87ec9..a7f5e8e 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -54,6 +54,12 @@ var commandMetadataTable = map[string]AgentMetadata{ ResourceTypes: []string{"project", "server", "deployment"}, }, + // Skills + "dhq skills show": { + // Prints the embedded skill; changes nothing. + Idempotent: true, SupportsJSON: true, SafeForAutomation: true, + }, + // Projects "dhq projects list": { Idempotent: true, SupportsJSON: true, SafeForAutomation: true, @@ -143,7 +149,7 @@ var commandMetadataTable = map[string]AgentMetadata{ ResourceTypes: []string{"deployment"}, }, "dhq deployments logs": { - Idempotent: true, SupportsJSON: false, SafeForAutomation: true, + Idempotent: true, SupportsJSON: true, SafeForAutomation: true, ResourceTypes: []string{"deployment"}, }, "dhq deployments watch": { diff --git a/internal/commands/auth.go b/internal/commands/auth.go index fac0169..2ad7481 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -58,8 +58,9 @@ The API key is under Settings → Security → API Credentials in DeployHQ (https://.deployhq.com/security). AI agents: do not ask the person for their API key. Ask them to run -'dhq auth login' themselves in a separate terminal window, never to paste the -key into the chat. For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and +'dhq auth login' themselves in a separate terminal window, not with the agent's '!' +shortcut (it gives dhq no terminal to prompt in), and never to paste the key +into the chat. For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead of logging in.`, Example: ` # Interactive login (prompts for any missing values) dhq auth login @@ -439,15 +440,18 @@ func invalidCredentialsNextStep(creds *cli.ResolvedCredentials, host string) str case "file": return "Check the account and api_key in the dhq config file ('dhq config show'). " + keyAt } - return "Ask the person to sign in again by running 'dhq auth login' in a separate terminal window. " + keyAt + return "Ask the person to sign in again by running 'dhq auth login' " + auth.SeparateTerminal + ". " + keyAt } // signInNextStep asks the person to sign in themselves. -const signInNextStep = "Ask the person to sign in by running 'dhq auth login' in a separate terminal window." +const signInNextStep = "Ask the person to sign in by running 'dhq auth login' " + auth.SeparateTerminal + "." // notLoggedInNextStep is the next step when dhq has no credentials to use. The // person may not have an account yet, so it names sign-up too. -const notLoggedInNextStep = signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead." +func notLoggedInNextStep() string { + return signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead. " + + auth.SkillPointer() +} // notLoggedInStatus is the not_logged_in result. It names the account dhq is // configured to use, if any, and where that setting came from, since a wrong @@ -457,7 +461,7 @@ func notLoggedInStatus(cfg *config.Config) authStatusResult { Status: authStatusNotLoggedIn, Message: "No credentials found: dhq has no stored login to use, and flags, " + "DEPLOYHQ_* environment variables and config files do not set all of account, email and API key", - NextStep: notLoggedInNextStep, + NextStep: notLoggedInNextStep(), } if cfg.Account == "" { return res diff --git a/internal/commands/auth_signup_honesty_test.go b/internal/commands/auth_signup_honesty_test.go index 6e361ce..0d9c096 100644 --- a/internal/commands/auth_signup_honesty_test.go +++ b/internal/commands/auth_signup_honesty_test.go @@ -62,6 +62,9 @@ func TestAuthLogin_NonInteractive_PointsToOwnTerminalNotKeyFlag(t *testing.T) { var authErr *output.AuthError require.True(t, errors.As(err, &authErr), "got %T", err) assert.Contains(t, authErr.Hint, "separate terminal window") + assert.Contains(t, authErr.Hint, "not with its '!' shortcut", "agents read 'not through the agent' as allowing '!'") + assert.Contains(t, authErr.Hint, "read DeployHQ's setup steps with 'dhq skills show'", "agents rarely run --help, so the first errors point at the skill") + assert.Regexp(t, `dhq signup'[^\n]*not with its '!' shortcut`, authErr.Hint, "the signup line carries the '!' warning too") assert.Contains(t, authErr.Hint, "Settings → Security → API Credentials") assert.Contains(t, authErr.Hint, "DEPLOYHQ_API_KEY") assert.NotContains(t, authErr.Hint, "--api-key", "must not suggest passing the key through the agent") @@ -84,8 +87,8 @@ func TestSignInHint_MentionsSignupForPeopleWithoutAnAccount(t *testing.T) { } func TestNotLoggedInNextStep_MentionsSignup(t *testing.T) { - assert.Contains(t, notLoggedInNextStep, "dhq auth login") - assert.Contains(t, notLoggedInNextStep, "dhq signup") + assert.Contains(t, notLoggedInNextStep(), "dhq auth login") + assert.Contains(t, notLoggedInNextStep(), "dhq signup") // Sign-up just succeeded when the credentials could not be saved: signing // up again is not the fix. assert.NotContains(t, credentialsNotSavedNextStep, "dhq signup") @@ -303,13 +306,15 @@ func TestNewSignupOutput_JSON(t *testing.T) { // ── Root help ──────────────────────────────────────────────────────────────── func TestRootHelp_PointsAgentsToSetupInstructions(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") cmd := NewRootCmd("test") var stdout bytes.Buffer cmd.SetOut(&stdout) cmd.SetArgs([]string{"--help"}) require.NoError(t, cmd.Execute()) assert.Contains(t, stdout.String(), "https://www.deployhq.com/agents.md") - assert.Contains(t, stdout.String(), "install DeployHQ's skill with 'dhq skills install' and follow it") + assert.Contains(t, stdout.String(), "read DeployHQ's setup steps with 'dhq skills show'") + assert.Contains(t, stdout.String(), "'dhq skills install --agent claude-code'") } func TestDescribeCredentialSource(t *testing.T) { @@ -380,6 +385,9 @@ func TestAuthStatus_JSON_NotLoggedIn(t *testing.T) { assert.NotEmpty(t, res["message"]) assert.Contains(t, res["next_step"], "dhq auth login") assert.Contains(t, res["next_step"], "separate terminal window") + assert.Contains(t, res["next_step"], "not with its '!' shortcut") + assert.Contains(t, res["next_step"], "dhq skills show") + assert.Contains(t, res["next_step"], "dhq skills install --agent") } func TestCheckAuthStatus_NetworkFailureIsUnconfirmed(t *testing.T) { diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 4365e0c..94c5171 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/manifoldco/promptui" @@ -189,7 +190,10 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st case 0: return "", &output.UserError{ Message: "No project specified", - Hint: "This account has no projects yet.\nTo provision and deploy in one step: dhq launch\nOr create a project in the DeployHQ dashboard, then re-run with --project .", + Hint: "This account has no projects yet.\n" + + "To provision and deploy in one step: dhq launch. It deploys straight away, so exclude files that must not be published first: dhq skills show launch\n" + + "Or create a project in the DeployHQ dashboard, then re-run with --project .\n" + + auth.SkillPointer(), } case 1: env.Status("Auto-selected project: %s", projects[0].Name) diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go index eb4058b..da99e52 100644 --- a/internal/commands/deploy_test.go +++ b/internal/commands/deploy_test.go @@ -619,6 +619,8 @@ func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) { assert.Contains(t, msg, "a-id (Alpha)") assert.Contains(t, msg, "b-id (Beta)") assert.Contains(t, msg, "c-id (Gamma)") + // An account with projects is set up already: no setup-steps pointer. + assert.NotContains(t, msg, "dhq skills show") } // TestResolveDeployProject_JSONModeSkipsPicker guards the stdout=data contract: @@ -659,6 +661,10 @@ func TestResolveDeployProject_ZeroProjects(t *testing.T) { assert.Empty(t, id) assert.Contains(t, err.Error(), "No project specified") assert.Contains(t, err.Error(), "no projects yet") + // A new account: dhq launch deploys at once, so the hint says to exclude + // files first, and agents are pointed at the setup steps. + assert.Contains(t, err.Error(), "exclude files that must not be published first: dhq skills show launch") + assert.Contains(t, err.Error(), "read DeployHQ's setup steps with 'dhq skills show'") } func TestResolveDeployProject_APIErrorKeepsHeadline(t *testing.T) { diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index a9ee4fe..c819740 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -2,6 +2,8 @@ package commands import ( "fmt" + "io" + "strings" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" @@ -395,6 +397,11 @@ func newDeploymentsLogsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "logs ", Short: "Show deployment step logs", + Long: `Show a deployment's step logs: each entry's message and, under it, its detail +(command output, the list of excluded files, an error's cause). + +Without --step, the logs of every step are printed as text, also when piped; +add --json for JSON. With --step, piped output is JSON, as for other commands.`, Example: ` # Show logs for every step of a deployment dhq deployments logs dep-abc123 -p my-app @@ -418,23 +425,40 @@ func newDeploymentsLogsCmd() *cobra.Command { if err != nil { return err } - if len(dep.Steps) == 0 { + // Show logs for all steps that have logs. JSON only with an + // explicit --json: this has always printed text, also when + // piped, and scripts grep it. In JSON, always an array (empty + // when there are no steps yet), and a step whose logs couldn't + // be read says so rather than being left out. + asJSON := cliCtx.Envelope.JSONMode && cliCtx.Envelope.WantsJSON() // --table / --quiet still win + all := []stepLogs{} + if len(dep.Steps) == 0 && !asJSON { cliCtx.Envelope.Status("No steps found for deployment %s", args[0]) return nil } - // Show logs for all steps that have logs for _, step := range dep.Steps { if !step.Logs { continue } logs, err := client.GetDeploymentStepLogs(cliCtx.Background(), projectID, args[0], step.Identifier) if err != nil { + if asJSON { + all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, + Logs: []sdk.DeploymentStepLog{}, Error: err.Error()}) + } else { + cliCtx.Envelope.Warn("Could not read the logs of %q: %v", step.Description, err) + } continue } - cliCtx.Envelope.Status("\n--- %s (%s) ---", step.Description, step.Status) - for _, log := range logs { - fmt.Fprintln(cliCtx.Envelope.Stdout, log.Message) //nolint:errcheck + if asJSON { + all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, Logs: logs}) + continue } + cliCtx.Envelope.Status("\n--- %s (%s) ---", step.Description, step.Status) + printStepLogs(cliCtx.Envelope.Stdout, logs) + } + if asJSON { + return cliCtx.Envelope.WriteJSON(output.NewResponse(all, fmt.Sprintf("Logs for %d steps", len(all)))) } return nil } @@ -449,9 +473,7 @@ func newDeploymentsLogsCmd() *cobra.Command { return env.WriteJSON(output.NewResponse(logs, fmt.Sprintf("%d log entries", len(logs)))) } - for _, log := range logs { - fmt.Fprintln(env.Stdout, log.Message) //nolint:errcheck - } + printStepLogs(env.Stdout, logs) return nil }, } @@ -482,3 +504,26 @@ func newDeploymentsWatchCmd() *cobra.Command { }, } } + +// printStepLogs prints each log entry's message and, under it, its detail +// (command output, the list of excluded files, an error's cause), as the +// DeployHQ web app and its plain-text log do. +func printStepLogs(w io.Writer, logs []sdk.DeploymentStepLog) { + for _, log := range logs { + fmt.Fprintln(w, log.Message) //nolint:errcheck + if log.Detail != nil { + if d := strings.TrimRight(*log.Detail, "\n"); strings.TrimSpace(d) != "" { + fmt.Fprintln(w, d) //nolint:errcheck + } + } + } +} + +// stepLogs is one step's logs in 'dhq deployments logs --json' without --step. +type stepLogs struct { + Step string `json:"step"` + Description string `json:"description"` + Status string `json:"status"` + Logs []sdk.DeploymentStepLog `json:"logs"` + Error string `json:"error,omitempty"` // set when the logs couldn't be read +} diff --git a/internal/commands/deployments_logs_detail_test.go b/internal/commands/deployments_logs_detail_test.go new file mode 100644 index 0000000..557900d --- /dev/null +++ b/internal/commands/deployments_logs_detail_test.go @@ -0,0 +1,114 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Each log's detail is printed under its message, as the DeployHQ web app +// shows it: without it, "Excluded files for server ..." showed no files. +func TestPrintStepLogs_PrintsDetail(t *testing.T) { + detail := " - deploy.sh (1 file)\n" + blank := " \n" + var out bytes.Buffer + printStepLogs(&out, []sdk.DeploymentStepLog{ + {Message: "Excluded files for server 'site'", Detail: &detail}, + {Message: "Skipped due to project/account settings"}, + {Message: "Done", Detail: &blank}, + }) + assert.Equal(t, "Excluded files for server 'site'\n - deploy.sh (1 file)\nSkipped due to project/account settings\nDone\n", out.String()) +} + +// fakeDeploymentLogsAPI answers deployment dep1 with the given steps body and +// serves step s1's logs (or a 500 when logsStatus is set). +func fakeDeploymentLogsAPI(t *testing.T, steps string, logsStatus int) { + t.Helper() + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) + http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + status, body := http.StatusOK, `{}` + switch { + case strings.HasSuffix(r.URL.Path, "/projects/my-app/deployments/dep1"): + body = `{"identifier":"dep1","status":"completed","steps":` + steps + `}` + case strings.HasSuffix(r.URL.Path, "/deployments/dep1/steps/s1/logs"): + body = `[{"id":1,"step":"s1","message":"Excluded files for server 'site'","detail":" - deploy.sh (1 file)"}]` + if logsStatus != 0 { + status, body = logsStatus, `{"error":"boom"}` + } + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, Request: r}, nil + }) +} + +const twoSteps = `[ + {"identifier":"s1","description":"Generating manifest","status":"completed","logs":true}, + {"identifier":"s2","description":"Transferring files","status":"completed","logs":false}]` + +// runLogsCapture runs 'dhq deployments logs dep1 ' and returns the JSON +// written (captured through DEPLOYHQ_OUTPUT_FILE). +func runLogsCapture(t *testing.T, args ...string) string { + t.Helper() + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + require.NoError(t, runServersCmd(t, append([]string{"deployments", "logs", "dep1"}, args...)...)) + b, err := os.ReadFile(out) + require.NoError(t, err) + return string(b) +} + +// Without --step, --json returns every step's logs, with their detail, as JSON. +func TestDeploymentsLogs_AllStepsJSON(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, 0) + got := runLogsCapture(t, "--json") + assert.Contains(t, got, `"description":"Generating manifest"`) + assert.Contains(t, got, `deploy.sh (1 file)`) + assert.NotContains(t, got, "Transferring files", "steps without logs are left out") +} + +// Piped without --json (the test's stdout is not a terminal), it still prints +// text, as it always has, so scripts that grep it keep working; the detail is +// now part of that text. +func TestDeploymentsLogs_PipedStaysText(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, 0) + + r, w, err := os.Pipe() + require.NoError(t, err) + origStdout := os.Stdout + os.Stdout = w + runErr := runServersCmd(t, "deployments", "logs", "dep1") + os.Stdout = origStdout + require.NoError(t, w.Close()) + b, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, runErr) + + assert.Equal(t, "Excluded files for server 'site'\n - deploy.sh (1 file)\n", string(b)) +} + +// A deployment with no steps yet (queued) still gives JSON: an empty array. +func TestDeploymentsLogs_NoStepsJSON(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, `[]`, 0) + assert.Contains(t, runLogsCapture(t, "--json"), `"data":[]`) +} + +// A step whose logs can't be read is reported, not silently left out. +func TestDeploymentsLogs_StepLogsErrorReported(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, http.StatusInternalServerError) + got := runLogsCapture(t, "--json") + assert.Contains(t, got, `"step":"s1"`) + assert.Contains(t, got, `"error":`) +} diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 0b2f846..96a13f1 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -20,7 +20,8 @@ func newDoctorCmd() *cobra.Command { // Check 1: Auth creds, err := auth.Load() if err != nil { - checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet."}) + checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet. " + + "When an AI agent runs dhq, the person runs either one themselves " + auth.SeparateTerminal + ". " + auth.SkillPointer()}) } else { checks = append(checks, doctorCheck{"Authentication", "ok", "Logged in as " + creds.Email}) } diff --git a/internal/commands/hello.go b/internal/commands/hello.go index a142550..285b81e 100644 --- a/internal/commands/hello.go +++ b/internal/commands/hello.go @@ -28,7 +28,7 @@ func newHelloCmd() *cobra.Command { if env.NonInteractive { return &output.UserError{ Message: "'dhq hello' is interactive-only and cannot run in non-interactive mode", - Hint: "Ask the person to run 'dhq hello' (or 'dhq auth login') themselves in a separate terminal window, not through the AI agent; never paste an API key into an AI chat. Then set a default project with: dhq config set project ", + Hint: "Ask the person to run 'dhq hello' (or 'dhq auth login') themselves " + auth.SeparateTerminal + "; never paste an API key into an AI chat. Then set a default project with: dhq config set project ", } } if !env.IsTTY { diff --git a/internal/commands/repos.go b/internal/commands/repos.go index 2e5ab97..13ba877 100644 --- a/internal/commands/repos.go +++ b/internal/commands/repos.go @@ -120,10 +120,14 @@ func newReposCreateCmd() *cobra.Command { project, projErr := client.GetProject(cliCtx.Background(), projectID) env := cliCtx.Envelope - if branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "connected", branch, repo); branchErr != nil { + branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "connected", branch, repo) + if branchErr == nil { + branchErr = httpBranchMissingError(cliCtx.Background(), client, projectID, "connected", branch, repo) + } + if branchErr != nil { // The repository is connected, so the deploy key is still // needed once the branch is fixed. - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { if env.WantsJSON() { branchErr.Recovery = append(branchErr.Recovery, deployKeyBreadcrumbs(url, project.PublicKey, projectID)...) } else { @@ -137,14 +141,14 @@ func newReposCreateCmd() *cobra.Command { } if env.WantsJSON() { breadcrumbs := []output.Breadcrumb{} - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { breadcrumbs = append(breadcrumbs, deployKeyBreadcrumbs(url, project.PublicKey, projectID)...) } return env.WriteJSON(output.NewResponse(repo, fmt.Sprintf("Repository created: %s", repo.URL), breadcrumbs...)) } env.Status("Repository created: %s (%s)", repo.URL, repo.ScmType) - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { env.Status("") env.Status("Deploy key (add to your repo for DeployHQ to pull):") env.Status(" %s", project.PublicKey) @@ -185,6 +189,9 @@ func newReposUpdateCmd() *cobra.Command { if branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "updated", branch, repo); branchErr != nil { return branchErr } + if branchErr := httpBranchMissingError(cliCtx.Background(), client, projectID, "updated", branch, repo); branchErr != nil { + return branchErr + } env := cliCtx.Envelope if env.WantsJSON() { @@ -438,16 +445,60 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } } + var branches map[string]string + if b, err := client.ListBranches(ctx, projectID, nil); err == nil { + branches = b + } + return pickBranchError(projectID, + fmt.Sprintf("The repository was %s, but on branch %q: %q isn't in it", done, repo.Branch, requested), + branches) +} + +// httpBranchMissingError reports a requested branch that is not in a git +// repository connected over HTTP(S). DeployHQ checks the branch of an SSH +// repository when it saves it (see branchNotKeptError), but for a git URL +// starting with http it skips that check and saves the branch as given, so +// the first deployment would fail. dhq checks it against the repository's +// branch list instead. When the list can't be read (a private repository +// whose credentials don't work yet, a network error) or is empty, nothing is +// reported: the branch can't be shown to be wrong. +func httpBranchMissingError(ctx context.Context, client *sdk.Client, projectID, done, requested string, repo *sdk.Repository) *output.UserError { + if requested == "" || repo == nil || repo.Branch != requested || + repo.ScmType != "git" || !isHTTPRepoURL(repo.URL) { + return nil + } + branches, err := client.ListBranches(ctx, projectID, nil) + if err != nil || len(branches) == 0 { + return nil + } + if _, ok := branches[requested]; ok { + return nil + } + return pickBranchError(projectID, + fmt.Sprintf("The repository was %s on branch %q, but %q isn't in it", done, requested, requested), + branches) +} + +// pickBranchError is the error for a saved branch that isn't in the +// repository: it lists the branches (at most maxBranchesShown) and gives the +// command to pick one. branches may be nil when the list couldn't be read. +func pickBranchError(projectID, message string, branches map[string]string) *output.UserError { + project := quoteRerunArg(projectID) setBranch := fmt.Sprintf("dhq repos update -p %s --branch ", project) listBranches := fmt.Sprintf("dhq repos branches -p %s", project) hint := "Pick one with: " + setBranch - if branches, err := client.ListBranches(ctx, projectID, nil); err == nil && len(branches) > 0 { + if len(branches) > 0 { names := make([]string, 0, len(branches)) for name := range branches { names = append(names, name) } sort.Strings(names) + // Branch names come from the repository and may hold shell syntax + // (release&hotfix); quote them as they may be pasted into a command. + for i, name := range names { + names[i] = quoteRerunArg(name) + } list := strings.Join(names, ", ") if len(names) > maxBranchesShown { list = strings.Join(names[:maxBranchesShown], ", ") + @@ -459,7 +510,7 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } return &output.UserError{ - Message: fmt.Sprintf("The repository was %s, but on branch %q: %q isn't in it", done, repo.Branch, requested), + Message: message, Hint: hint + "\nNothing has been deployed.", Recovery: []output.Breadcrumb{ {Action: "set_branch", Cmd: setBranch}, @@ -468,6 +519,14 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } } +// isHTTPRepoURL reports whether repoURL is an HTTP(S) URL. Such a repository +// is read with a username and password (or none, when public), never with +// the project's deploy key. +func isHTTPRepoURL(repoURL string) bool { + u := strings.ToLower(repoURL) + return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") +} + // rerunArgSafe matches values that can be pasted into a shell unquoted. var rerunArgSafe = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_-]+$`) diff --git a/internal/commands/repos_branch_test.go b/internal/commands/repos_branch_test.go index e326a7b..078dfba 100644 --- a/internal/commands/repos_branch_test.go +++ b/internal/commands/repos_branch_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -28,6 +30,8 @@ type fakeRepoAPI struct { // sentBranch records the branch the CLI asked for when creating the // repository. sentBranch string + // savedURL is the URL of the saved repository; empty means an SSH URL. + savedURL string } func installFakeRepoAPI(t *testing.T, api *fakeRepoAPI) { @@ -42,7 +46,11 @@ func installFakeRepoAPI(t *testing.T, api *fakeRepoAPI) { Request: r, }, nil } - saved := `{"scm_type":"git","url":"git@github.com:acme/my-app.git","branch":"` + api.savedBranch + `"}` + url := api.savedURL + if url == "" { + url = "git@github.com:acme/my-app.git" + } + saved := `{"scm_type":"git","url":"` + url + `","branch":"` + api.savedBranch + `"}` switch { case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/projects/my-app/repository"): var sent struct { @@ -250,3 +258,140 @@ func TestReposUpdate_ServerErrorWithRepositoryKeepsOriginalError(t *testing.T) { assert.Equal(t, output.ExitInternalError, output.ClassifyError(err)) assert.Contains(t, err.Error(), "500") } + +// DeployHQ doesn't check the branch of a git repository connected over +// HTTP(S): it saves the branch as given. dhq checks it against the branch list. +func TestReposBranch_HTTPSBranchMissingFails(t *testing.T) { + const httpsURL = "https://github.com/acme/my-app.git" + for _, tc := range []struct { + args []string + done string + }{ + {[]string{"repos", "create", "--scm-type", "git", "--url", httpsURL, "--branch", "does-not-exist"}, "connected"}, + {[]string{"repos", "update", "--branch", "does-not-exist"}, "updated"}, + } { + t.Run(tc.done, func(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "does-not-exist", savedURL: httpsURL}) + + err := runServersCmd(t, tc.args...) + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Equal(t, output.ExitUserError, output.ClassifyError(err)) + assert.Equal(t, `The repository was `+tc.done+` on branch "does-not-exist", but "does-not-exist" isn't in it`, uerr.Message) + assert.Contains(t, uerr.Hint, "Branches: develop, main.") + assert.Contains(t, uerr.Hint, "Pick one with: dhq repos update -p my-app --branch ") + + data := output.ErrorResponseFromErr(err).Data.(output.ErrorData) + require.Len(t, data.Recovery, 2, "no deploy key for an HTTPS repository: %+v", data.Recovery) + assert.Equal(t, "set_branch", data.Recovery[0].Action) + assert.Equal(t, "list_branches", data.Recovery[1].Action) + }) + } +} + +func TestReposBranch_HTTPSBranchPresentSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "develop", savedURL: "https://github.com/acme/my-app.git"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--scm-type", "git", + "--url", "https://github.com/acme/my-app.git", "--branch", "develop")) +} + +// When the branch list can't be read (a private repository whose credentials +// don't work yet, a network error), the branch can't be shown to be wrong. +func TestReposBranch_HTTPSBranchListUnavailableSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{ + savedBranch: "does-not-exist", + savedURL: "https://github.com/acme/my-app.git", + branchesStatus: http.StatusInternalServerError, + }) + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "does-not-exist")) +} + +func TestIsHTTPRepoURL(t *testing.T) { + for url, want := range map[string]bool{ + "https://github.com/acme/app.git": true, + "HTTP://git.example.com/app.git": true, + "git@github.com:acme/app.git": false, + "ssh://git@github.com/acme/app.git": false, + "svn+ssh://svn.example.com/app": false, + } { + assert.Equal(t, want, isHTTPRepoURL(url), url) + } +} + +// The HTTP(S) check runs only where DeployHQ didn't check: an SSH repository +// whose branch was kept passed DeployHQ's own check, even if the branch list +// read back differs. +func TestReposBranch_SSHKeptBranchNotRechecked(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "feature"}) // list: main, develop + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "feature")) +} + +func TestReposBranch_HTTPSUppercaseSchemeChecked(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "nope", savedURL: "HTTPS://github.com/acme/my-app.git"}) + + err := runServersCmd(t, "repos", "update", "--branch", "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) +} + +// An empty branch list can't show the branch is wrong. +func TestReposBranch_HTTPSEmptyBranchListSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main", savedURL: "https://github.com/acme/my-app.git", branchesBody: "{}"}) + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "main")) +} + +// Branch names come from the repository and are quoted in the hint, since they +// may be pasted into a command. +func TestReposBranch_ListedBranchNamesAreQuoted(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{ + savedBranch: "nope", savedURL: "https://github.com/acme/my-app.git", + branchesBody: `{"main":"abc","a$(id)":"def"}`, + }) + + err := runServersCmd(t, "repos", "update", "--branch", "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Contains(t, uerr.Hint, "Branches: 'a$(id)', main.") +} + +// An HTTPS repository is never read with the project's deploy key, so a +// successful create doesn't suggest adding it. +func TestReposCreate_HTTPSNoDeployKeySuggestion(t *testing.T) { + withResolvableContext(t) + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main", savedURL: "https://github.com/acme/my-app.git"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--json", "--scm-type", "git", + "--url", "https://github.com/acme/my-app.git", "--branch", "main")) + b, err := os.ReadFile(out) + require.NoError(t, err) + require.Contains(t, string(b), "Repository created") + assert.NotContains(t, string(b), "deploy-key") + assert.NotContains(t, string(b), projectPublicKey) +} + +// The same create over SSH does suggest it: the control for the test above. +func TestReposCreate_SSHDeployKeySuggestion(t *testing.T) { + withResolvableContext(t) + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--json", "--scm-type", "git", + "--url", "git@github.com:acme/my-app.git", "--branch", "main")) + b, err := os.ReadFile(out) + require.NoError(t, err) + assert.Contains(t, string(b), "gh repo deploy-key add") +} diff --git a/internal/commands/root.go b/internal/commands/root.go index b01abcd..ddfc275 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/cli" "github.com/deployhq/deployhq-cli/internal/config" "github.com/deployhq/deployhq-cli/internal/harness" @@ -51,8 +52,9 @@ func NewRootCmd(version string) *cobra.Command { Short: "DeployHQ CLI — deploy from your terminal", Long: `The official DeployHQ command-line interface for managing projects, servers, and deployments. -AI agents: install DeployHQ's skill with 'dhq skills install' and follow it, or read -https://www.deployhq.com/agents.md. +AI agents: read DeployHQ's setup steps with '` + auth.SkillReadCommand + `' and follow them +(install them for future sessions with '` + auth.SkillInstallCommand() + `'), +or read https://www.deployhq.com/agents.md. Feedback & feature requests: https://changelog.deployhq.com Support: support@deployhq.com`, diff --git a/internal/commands/servers_auth.go b/internal/commands/servers_auth.go index c8db389..5eb4962 100644 --- a/internal/commands/servers_auth.go +++ b/internal/commands/servers_auth.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/spf13/cobra" @@ -50,7 +51,7 @@ func serverUsesSSHKeys(protocolType string, useSSHKeys bool) bool { const passwordAgentHint = "Use key-based access instead (--use-ssh-keys) if the server supports it. " + "For password-only servers (FTP/FTPS, or SFTP with a password), the person who owns the password " + "should add the server themselves rather than give the password to an AI agent: either run this " + - "command in their own terminal with --password-stdin (it asks for the password with hidden input), " + + "command " + auth.SeparateTerminal + " with --password-stdin (it asks for the password with hidden input), " + "or add the server in the DeployHQ web app." // readServerPassword resolves the server password for `dhq servers create`. diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 60e43bb..2fb16b6 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -22,8 +22,8 @@ const termsURL = "https://www.deployhq.com/terms" // signupInOwnTerminalHint is shown when sign-up can't prompt (for example when // run by an AI agent). The password must come from the person, typed at a // hidden prompt, never relayed through (or invented by) an agent. -const signupInOwnTerminalHint = "Ask the person to run 'dhq signup' themselves in a separate terminal window " + - "(not through the AI agent): it asks for the password at a hidden prompt and asks them to accept the " + +const signupInOwnTerminalHint = "Ask the person to run 'dhq signup' themselves " + auth.SeparateTerminal + + ": it asks for the password at a hidden prompt and asks them to accept the " + "Terms of Service. Never ask for, or make up, a password in an AI chat." // signupNonInteractiveMessage is the refusal when sign-up can't prompt. It @@ -48,7 +48,8 @@ After signing up, open the verification link DeployHQ emails you: until the email address is verified, DeployHQ refuses API requests. AI agents: do not run this with a password in the command. Ask the person to -run 'dhq signup' themselves in a separate terminal window.`, +run 'dhq signup' themselves in a separate terminal window, not with the agent's +'!' shortcut, which gives dhq no terminal to prompt in.`, RunE: func(cmd *cobra.Command, args []string) error { env := cliCtx.Envelope reader := bufio.NewReader(os.Stdin) diff --git a/internal/commands/skills.go b/internal/commands/skills.go index 9e51a84..6e65f34 100644 --- a/internal/commands/skills.go +++ b/internal/commands/skills.go @@ -2,9 +2,13 @@ package commands import ( "fmt" + "io/fs" + "sort" + "strings" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/internal/skillinstaller" + "github.com/deployhq/deployhq-cli/skills" "github.com/spf13/cobra" ) @@ -16,6 +20,7 @@ func newSkillsCmd() *cobra.Command { (Claude Code, Cursor, etc.). Run during 'dhq hello' or standalone. Examples: + dhq skills show Print the skill, to read it right away dhq skills list Show every supported agent and status dhq skills install Install for detected user-scope agents (Claude Code, Cursor, etc.). Skips @@ -25,6 +30,7 @@ Examples: (writes into the current repo) dhq skills install --agent claude-code Install for any specific agent`, } + cmd.AddCommand(newSkillsShowCmd()) cmd.AddCommand(newSkillsListCmd()) cmd.AddCommand(newSkillsInstallCmd()) return cmd @@ -148,3 +154,82 @@ func newSkillsInstallCmd() *cobra.Command { cmd.Flags().StringVar(&agentFlag, "agent", "", "Install for a specific agent only (e.g. claude-code)") return cmd } + +// skillDir is the embedded skill's root in skills.FS. +const skillDir = "deployhq" + +// skillReferences returns the names of the skill's reference docs (without +// ".md"), sorted. +func skillReferences() []string { + entries, err := fs.ReadDir(skills.FS, skillDir+"/references") + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + names = append(names, strings.TrimSuffix(e.Name(), ".md")) + } + } + sort.Strings(names) + return names +} + +// skillShowOutput is the --json shape of 'dhq skills show'. +type skillShowOutput struct { + Name string `json:"name"` + Content string `json:"content"` + References []string `json:"references"` +} + +// newSkillsShowCmd prints the embedded skill. A skill installed with +// 'dhq skills install' is only loaded by the agent's next session, so an +// agent that is setting DeployHQ up right now reads it this way instead. +func newSkillsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show [reference]", + Short: "Print the DeployHQ skill, so an AI agent can read it right away", + Long: `Print DeployHQ's skill for AI agents: the setup steps, including when to ask +the person. Nothing is installed or changed. + +An agent can read it straight away, in the session it is working in. A skill +installed with 'dhq skills install' is only loaded by the agent's next session. + +With a reference name, print that reference document instead. The names are +listed at the end of the skill.`, + Example: ` # Read the skill + dhq skills show + + # Read one of its references + dhq skills show launch`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + env := cliCtx.Envelope + refs := skillReferences() + + name, path := "SKILL", skillDir+"/SKILL.md" + if len(args) == 1 { + name = strings.TrimSuffix(args[0], ".md") + path = skillDir + "/references/" + name + ".md" + } + data, err := skills.FS.ReadFile(path) + if err != nil { + return &output.UserError{ + Message: fmt.Sprintf("Unknown skill reference: %q", name), + Hint: "References: " + strings.Join(refs, ", "), + } + } + + if env.JSONMode { + return env.WriteJSON(output.NewResponse(skillShowOutput{Name: name, Content: string(data), References: refs}, + fmt.Sprintf("DeployHQ skill: %s", name))) + } + // Plain Markdown even when piped: this is a document to read. + fmt.Fprint(env.Stdout, string(data)) //nolint:errcheck + if len(args) == 0 { + fmt.Fprintf(env.Stdout, "\n---\nThe references linked above are printed with 'dhq skills show ': %s\n", strings.Join(refs, ", ")) //nolint:errcheck + } + return nil + }, + } +} diff --git a/internal/commands/skills_show_test.go b/internal/commands/skills_show_test.go new file mode 100644 index 0000000..fa240b2 --- /dev/null +++ b/internal/commands/skills_show_test.go @@ -0,0 +1,99 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/deployhq/deployhq-cli/internal/cli" + "github.com/deployhq/deployhq-cli/internal/config" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/skills" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runSkillsShow runs 'dhq skills show' with stdout not a terminal, as when an +// agent runs it, and returns stdout. +func runSkillsShow(t *testing.T, jsonMode bool, args ...string) (string, error) { + t.Helper() + var stdout bytes.Buffer + orig := cliCtx + t.Cleanup(func() { cliCtx = orig }) + cliCtx = &cli.Context{ + Config: &config.Config{}, + Envelope: &output.Envelope{ + Stdout: &stdout, Stderr: io.Discard, Logger: &output.Logger{}, + NonInteractive: true, JSONMode: jsonMode, IsTTY: false, + }, + } + cmd := newSkillsShowCmd() + err := cmd.RunE(cmd, args) + return stdout.String(), err +} + +// Piped (as an agent runs it), it prints the skill as Markdown, not JSON: +// it is a document to read. +func TestSkillsShow_PrintsSkillMarkdown(t *testing.T) { + want, err := skills.FS.ReadFile("deployhq/SKILL.md") + require.NoError(t, err) + + out, err := runSkillsShow(t, false) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(out, string(want)), "starts with SKILL.md verbatim") + assert.Contains(t, out, "dhq skills show ") + assert.Contains(t, out, "launch", "lists the references") +} + +func TestSkillsShow_Reference(t *testing.T) { + want, err := skills.FS.ReadFile("deployhq/references/launch.md") + require.NoError(t, err) + + for _, arg := range []string{"launch", "launch.md"} { + out, err := runSkillsShow(t, false, arg) + require.NoError(t, err) + assert.Equal(t, string(want), out, arg) + } +} + +func TestSkillsShow_UnknownReference(t *testing.T) { + _, err := runSkillsShow(t, false, "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Contains(t, uerr.Message, `"nope"`) + assert.Contains(t, uerr.Hint, "launch") + + _, err = runSkillsShow(t, false, "../SKILL") + require.True(t, errors.As(err, &uerr), "only reference documents can be printed: got %T: %v", err, err) + assert.Contains(t, uerr.Message, "Unknown skill reference") +} + +func TestSkillsShow_JSON(t *testing.T) { + out, err := runSkillsShow(t, true) + require.NoError(t, err) + var res struct { + OK bool `json:"ok"` + Data skillShowOutput `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(out), &res)) + assert.True(t, res.OK) + assert.Equal(t, "SKILL", res.Data.Name) + assert.Contains(t, res.Data.Content, "DeployHQ") + assert.Contains(t, res.Data.References, "launch") +} + +// The pre-deploy file check lists the branch DeployHQ deploys (the remote's), +// not the local checkout: `git ls-files` shows the local index, which can +// differ (a staged deletion, unpushed work). +func TestSkill_PreDeployFileCheckUsesRemoteBranch(t *testing.T) { + for _, path := range []string{"deployhq/SKILL.md", "deployhq/references/launch.md"} { + b, err := skills.FS.ReadFile(path) + require.NoError(t, err) + s := strings.Join(strings.Fields(string(b)), " ") + assert.Contains(t, s, "git ls-tree -r --name-only origin/", path) + assert.NotContains(t, s, "git ls-files", path) + } +} diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index f3fba70..ece3013 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -226,6 +226,19 @@ func isRetryable(code, message string) bool { return false } +// readSkillCrumb points an agent at DeployHQ's skill. It is offered on the +// errors an agent meets first in a new setup (not signed in, no project), +// since agents act on these but rarely run 'dhq --help'. It reads the skill +// rather than installing it: an installed skill only loads in a new session. +func readSkillCrumb() Breadcrumb { + return Breadcrumb{Action: "read_setup_skill", Cmd: SkillReadCommand} +} + +// SkillReadCommand prints DeployHQ's skill. An agent reads it in the session +// it is working in: a skill installed mid-session is not loaded until the +// agent's next session. +const SkillReadCommand = "dhq skills show" + // recoveryActions returns suggested next commands for common errors. func recoveryActions(code, message string) []Breadcrumb { msg := strings.ToLower(message) @@ -240,18 +253,22 @@ func recoveryActions(code, message string) []Breadcrumb { {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, } if notSignedIn(msg) { - crumbs = append(crumbs, Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}) + crumbs = append(crumbs, + Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, + readSkillCrumb()) } return append(crumbs, Breadcrumb{Action: "check_status", Cmd: "dhq auth status"}) case notSignedIn(msg): return []Breadcrumb{ {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, {Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, + readSkillCrumb(), } case strings.Contains(msg, "no project"): return []Breadcrumb{ {Action: "list_projects", Cmd: "dhq projects list --json"}, {Action: "set_project", Cmd: "dhq config set project "}, + readSkillCrumb(), } case strings.Contains(msg, "not found"): return []Breadcrumb{ diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index f068ebf..aa28e73 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -125,11 +125,31 @@ func TestErrorResponseFromErr_AuthError(t *testing.T) { } func TestErrorResponseFromErr_NotLoggedIn_OffersSignup(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") data := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData) - require.Len(t, data.Recovery, 2) + require.Len(t, data.Recovery, 3) assert.Equal(t, "dhq auth login", data.Recovery[0].Cmd) assert.Equal(t, "ask_user_to_signup_in_separate_terminal", data.Recovery[1].Action) assert.Equal(t, "dhq signup", data.Recovery[1].Cmd) + assert.Equal(t, Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}, data.Recovery[2]) +} + +// Agents act on these errors but rarely run 'dhq --help', so the first +// errors of a new setup point at DeployHQ's skill. +func TestErrorResponseFromErr_FirstSetupErrorsOfferSkill(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") + skill := Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"} + for name, err := range map[string]error{ + "not logged in": errors.New("auth: not logged in"), + "auth not signed in": &AuthError{Message: "Not logged in"}, + "no project": &UserError{Message: "No project specified"}, + } { + t.Run(name, func(t *testing.T) { + assert.Contains(t, ErrorResponseFromErr(err).Data.(ErrorData).Recovery, skill) + }) + } + // A rejected login is not a new setup: the person is already signed up. + assert.NotContains(t, ErrorResponseFromErr(&AuthError{Message: "Invalid credentials"}).Data.(ErrorData).Recovery, skill) } func TestErrorResponseFromErr_SignedInAuthErrorsDoNotOfferSignup(t *testing.T) { @@ -259,3 +279,18 @@ func TestBreadcrumb_ResourceFields(t *testing.T) { assert.Contains(t, string(data), `"resource":"deployment"`) assert.Contains(t, string(data), `"id":"abc"`) } + +// The crumb reads the skill (it changes nothing) rather than installing it: +// an installed skill only loads in the agent's next session. +func TestErrorResponseFromErr_SkillCrumbReadsNotInstalls(t *testing.T) { + for _, agent := range []string{"claude-code", "codex", ""} { + t.Run(agent, func(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", agent) + rec := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData).Recovery + assert.Contains(t, rec, Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}) + for _, b := range rec { + assert.NotContains(t, b.Cmd, "skills install") + } + }) + } +} diff --git a/internal/skillinstaller/install_command_test.go b/internal/skillinstaller/install_command_test.go new file mode 100644 index 0000000..649aa3d --- /dev/null +++ b/internal/skillinstaller/install_command_test.go @@ -0,0 +1,21 @@ +package skillinstaller + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The command names one agent: a bare 'dhq skills install' would install for +// every AI tool found on the machine. +func TestInstallCommandFor(t *testing.T) { + for name, want := range map[string]string{ + "claude-code": "dhq skills install --agent claude-code", + "cursor": "dhq skills install --agent cursor", + "copilot": "dhq skills install --agent ", // project scope: writes into the repo + "": "dhq skills install --agent ", + "vim": "dhq skills install --agent ", + } { + assert.Equal(t, want, InstallCommandFor(name), name) + } +} diff --git a/internal/skillinstaller/installer.go b/internal/skillinstaller/installer.go index 073b838..c988657 100644 --- a/internal/skillinstaller/installer.go +++ b/internal/skillinstaller/installer.go @@ -160,6 +160,19 @@ func Find(name string) Target { return nil } +// InstallCommandFor returns the command that installs the DeployHQ skill for +// the named agent only. A bare 'dhq skills install' installs for every agent +// found on the machine, which an agent must not do on its own. When the name +// isn't a supported agent, or its skill lives in the current repository +// (project scope, which changes the repo), the agent is left as a placeholder +// for the agent to fill in, with 'dhq skills list' naming the choices. +func InstallCommandFor(agentName string) string { + if t := Find(agentName); t != nil && t.Scope() == ScopeUser { + return "dhq skills install --agent " + agentName + } + return "dhq skills install --agent " +} + // Needed reports whether the skill needs to be (re)installed for this target. // Returns true for StatusAvailable and StatusOutdated; false for // StatusInstalled and StatusNotInstalled. diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index db37cf8..be1713c 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -41,7 +41,7 @@ Check first: `dhq auth status --json`. If it reports a signed-in account, setup If not, the first question is **"Do you already have a DeployHQ account?"** -- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. +- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent, and not with its `!` shortcut, which gives dhq no terminal to prompt in). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. - **No:** with the person's OK, they create an account either on the DeployHQ website or by running `dhq signup` in their own terminal (it prompts for the email and password, hiding the password, asks them to accept the Terms of Service, and saves the new credentials). DeployHQ then sends a verification email; the person verifies before anything else will work. Signing up on the website does not sign in the CLI, so after verifying, that person also runs `dhq auth login` in their own terminal. The agent runs `dhq auth status --json` afterwards. `dhq auth login` and `dhq signup` cannot prompt inside an agent (non-interactive mode), so run there they would need the key or password on the command line. That is why the person runs them. @@ -98,17 +98,17 @@ Small steps, each one something the person can approve on its own: 2. **Existing project?** `dhq projects list --json`, and check for a `.deployhq.toml` in the folder. If a matching project exists, ask whether to reuse it rather than creating a second one. 3. **Where to deploy.** Ask, and wait for the answer. The usual options: - **Their own server** (SSH/SFTP, FTP, S3 and others): most DeployHQ customers deploy this way. - - **DeployHQ hosting**: Static Hosting for static sites, or a Managed VPS. These are beta features that the account has to be enrolled in, and are billed once the beta ends. A Managed VPS needs a payment method on the account; Static Hosting does not (an account that isn't paying yet can have one site, paying accounts get more). + - **DeployHQ hosting**: Static Hosting for static sites, or a Managed VPS. These are beta features that the account has to be enrolled in. They are free during the beta and may be billed once it ends. A Managed VPS needs a payment method on the account; Static Hosting does not (an account that isn't paying yet can have one site, paying accounts get more). 4. **Create the project** (after the person agrees): `dhq projects create --name "" --json`, then `dhq config set project ` so later sessions find it. 5. **Connect the repository** (after the person agrees; for a private repository this gives DeployHQ read access): `dhq repos create -p --scm-type git --url --json`. See [repos.md](references/repos.md) for the access key a private repository needs. 6. **Add the target.** - Own server: DeployHQ connects with the project's access key (`dhq projects show --json=public_key`). Installing that key on the server changes who can log in to it, so the agent shows the key and asks before adding it, or the person adds it themselves. Then `dhq servers create ...` (see [servers.md](references/servers.md)). If the server only accepts a password, the person adds it in the DeployHQ web app so the password never passes through the chat. - DeployHQ hosting: `dhq beta enroll --protocol static_hosting` (needs an account admin) and `dhq servers create -p --protocol-type static_hosting --subdomain ...`, or a `managed_vps` server. Check with the person before each. -7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). +7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). Every file in the repository is uploaded unless it is excluded, so before the first deploy check the files on the branch DeployHQ deploys, which is what the remote repository has, not the local checkout (`git fetch`, then `git ls-tree -r --name-only origin/`), and exclude what must not be published, such as deploy scripts or local config: `dhq excluded-files create -p --path ` (all servers unless `--server`). 8. **First deploy.** Summarise the project, branch and target, ask, then `dhq deploy -p -s --wait --json`, which waits for the deployment to finish (a new one may start as `queued`). 9. **Confirm.** Report success only when the JSON shows `"status": "completed"`. A non-zero exit means the command failed: if a deployment was created, its `status` says how it ended; otherwise read the error. (`dhq deployments watch -p ` is a live view for a person following along in their own terminal, not a step for the agent.) -`dhq launch` does steps 4 to 9 for DeployHQ hosting in a single command. It is an option when the person has chosen DeployHQ hosting and wants it done in one go, not the default; see [launch.md](references/launch.md). +`dhq launch` does steps 4 to 9 for DeployHQ hosting in a single command. It is an option when the person has chosen DeployHQ hosting and wants it done in one go, not the default; see [launch.md](references/launch.md). It deploys straight away, so files that must not be published are excluded first: create the project (`dhq projects create`), add the exclusions, then run `dhq launch --project `. ### "Deploy code" 1. `dhq projects list --json` — find project permalink diff --git a/skills/deployhq/references/launch.md b/skills/deployhq/references/launch.md index ac65fa9..7384adb 100644 --- a/skills/deployhq/references/launch.md +++ b/skills/deployhq/references/launch.md @@ -25,6 +25,13 @@ Each of these is hard to undo, so the person agrees to all of them before the agent runs `dhq launch`. A good sequence: - Run `dhq launch --static --dry-run --json` (or `--vps`) and show the person what it would do. +- **Exclude files before launching.** `dhq launch` deploys at once, and every file in + the repository is uploaded unless it is excluded. Check the files on the branch + DeployHQ deploys, as the remote repository has it (`git fetch`, then + `git ls-tree -r --name-only origin/`); if + any must not be published (a deploy script, local config), create the project first + with `dhq projects create`, add them with `dhq excluded-files create -p --path `, + then run `dhq launch --project `. - Offer the choice: run `dhq launch`, or go step by step (`dhq projects create`, `dhq repos create`, `dhq beta enroll`, `dhq servers create --protocol-type static_hosting`, `dhq deploy`), approving each action on its own. See "Set up DeployHQ for this diff --git a/skills/embed.go b/skills/embed.go index 2d0db4a..28016ad 100644 --- a/skills/embed.go +++ b/skills/embed.go @@ -19,4 +19,4 @@ var FS embed.FS // alongside the installed files so a future `dhq` can detect stale installs // and update without re-prompting. Bump when the skill content changes // meaningfully (new commands, restructured references, etc.). -const Version = "1" +const Version = "2"